//package edu.purdue.touch.util;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.text.DecimalFormat;
import javax.swing.JPanel;
/**
*
*
* Title: HeatMap
*
*
*
* Description: HeatMap is a JPanel that displays a 2-dimensional array of data
* using a selected color gradient scheme.
*
*
* For specifying data, the first index into the double[][] array is the x-
* coordinate, and the second index is the y-coordinate. In the constructor and
* updateData method, the 'useGraphicsYAxis' parameter is used to control
* whether the row y=0 is displayed at the top or bottom. Since the usual
* graphics coordinate system has y=0 at the top, setting this parameter to true
* will draw the y=0 row at the top, and setting the parameter to false will
* draw the y=0 row at the bottom, like in a regular, mathematical coordinate
* system. This parameter was added as a solution to the problem of
* "Which coordinate system should we use? Graphics, or mathematical?", and
* allows the user to choose either coordinate system. Because the HeatMap will
* be plotting the data in a graphical manner, using the Java Swing framework
* that uses the standard computer graphics coordinate system, the user's data
* is stored internally with the y=0 row at the top.
*
*
* There are a number of defined gradient types (look at the static fields), but
* you can create any gradient you like by using either of the following
* functions in the Gradient class:
*
* - public static Color[] createMultiGradient(Color[] colors, int numSteps)
* - public static Color[] createGradient(Color one, Color two, int numSteps)
*
* You can then assign an arbitrary Color[] object to the HeatMap as follows:
*
*
* myHeatMap.updateGradient(Gradient.createMultiGradient(new Color[] { Color.red,
* Color.white, Color.blue }, 256));
*
*
*
*
*
* By default, the graph title, axis titles, and axis tick marks are not
* displayed. Be sure to set the appropriate title before enabling them.
*
*
*
*
* Copyright: Copyright (c) 2007, 2008
*
*
*
* HeatMap is free software; you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation; either version 2 of the License, or (at your option) any later
* version.
*
*
*
* HeatMap is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
*
*
* You should have received a copy of the GNU General Public License along with
* HeatMap; if not, write to the Free Software Foundation, Inc., 51 Franklin St,
* Fifth Floor, Boston, MA 02110-1301 USA
*
*
* @author Matthew Beckler (matthew@mbeckler.org)
* @author Josh Hayes-Sheen (grey@grevian.org), Converted to use BufferedImage.
* @author J. Keller (jpaulkeller@gmail.com), Added transparency (alpha)
* support, data ordering bug fix.
* @version 1.6
*/
public class HeatMap extends JPanel {
/**
*
*/
private static final long serialVersionUID = 1L;
private double[][] data;
private int[][] dataColorIndices;
// these four variables are used to print the axis labels
private double xMin;
private double xMax;
private double yMin;
private double yMax;
private String title;
private String xAxis;
private String yAxis;
private boolean drawTitle = false;
private boolean drawXTitle = false;
private boolean drawYTitle = false;
private boolean drawLegend = false;
private boolean drawXTicks = false;
private boolean drawYTicks = false;
private Color[] colors;
private Color bg = Color.white;
private Color fg = Color.black;
private BufferedImage bufferedImage;
private Graphics2D bufferedGraphics;
/**
* @param data
* The data to display, must be a complete array (non-ragged)
* @param useGraphicsYAxis
* If true, the data will be displayed with the y=0 row at the
* top of the screen. If false, the data will be displayed with
* they=0 row at the bottom of the screen.
* @param colors
* A variable of the type Color[]. See also
* {@link #createMultiGradient} and {@link #createGradient}.
*/
public HeatMap(double[][] data, boolean useGraphicsYAxis, Color[] colors) {
super();
updateGradient(colors);
updateData(data, useGraphicsYAxis);
this.setPreferredSize(new Dimension(60 + data.length,
60 + data[0].length));
this.setDoubleBuffered(true);
this.bg = Color.white;
this.fg = Color.black;
// this is the expensive function that draws the data plot into a
// BufferedImage. The data plot is then cheaply drawn to the screen when
// needed, saving us a lot of time in the end.
drawData();
}
/**
* Specify the coordinate bounds for the map. Only used for the axis labels,
* which must be enabled seperately. Calls repaint() when finished.
*
* @param xMin
* The lower bound of x-values, used for axis labels
* @param xMax
* The upper bound of x-values, used for axis labels
*/
public void setCoordinateBounds(double xMin, double xMax, double yMin,
double yMax) {
this.xMin = xMin;
this.xMax = xMax;
this.yMin = yMin;
this.yMax = yMax;
repaint();
}
/**
* Specify the coordinate bounds for the X-range. Only used for the axis
* labels, which must be enabled seperately. Calls repaint() when finished.
*
* @param xMin
* The lower bound of x-values, used for axis labels
* @param xMax
* The upper bound of x-values, used for axis labels
*/
public void setXCoordinateBounds(double xMin, double xMax) {
this.xMin = xMin;
this.xMax = xMax;
repaint();
}
/**
* Specify the coordinate bounds for the X Min. Only used for the axis
* labels, which must be enabled seperately. Calls repaint() when finished.
*
* @param xMin
* The lower bound of x-values, used for axis labels
*/
public void setXMinCoordinateBounds(double xMin) {
this.xMin = xMin;
repaint();
}
/**
* Specify the coordinate bounds for the X Max. Only used for the axis
* labels, which must be enabled seperately. Calls repaint() when finished.
*
* @param xMax
* The upper bound of x-values, used for axis labels
*/
public void setXMaxCoordinateBounds(double xMax) {
this.xMax = xMax;
repaint();
}
/**
* Specify the coordinate bounds for the Y-range. Only used for the axis
* labels, which must be enabled seperately. Calls repaint() when finished.
*
* @param yMin
* The lower bound of y-values, used for axis labels
* @param yMax
* The upper bound of y-values, used for axis labels
*/
public void setYCoordinateBounds(double yMin, double yMax) {
this.yMin = yMin;
this.yMax = yMax;
repaint();
}
/**
* Specify the coordinate bounds for the Y Min. Only used for the axis
* labels, which must be enabled seperately. Calls repaint() when finished.
*
* @param yMin
* The lower bound of Y-values, used for axis labels
*/
public void setYMinCoordinateBounds(double yMin) {
this.yMin = yMin;
repaint();
}
/**
* Specify the coordinate bounds for the Y Max. Only used for the axis
* labels, which must be enabled seperately. Calls repaint() when finished.
*
* @param yMax
* The upper bound of y-values, used for axis labels
*/
public void setYMaxCoordinateBounds(double yMax) {
this.yMax = yMax;
repaint();
}
/**
* Updates the title. Calls repaint() when finished.
*
* @param title
* The new title
*/
public void setTitle(String title) {
this.title = title;
repaint();
}
/**
* Updates the state of the title. Calls repaint() when finished.
*
* @param drawTitle
* Specifies if the title should be drawn
*/
public void setDrawTitle(boolean drawTitle) {
this.drawTitle = drawTitle;
repaint();
}
/**
* Updates the X-Axis title. Calls repaint() when finished.
*
* @param xAxisTitle
* The new X-Axis title
*/
public void setXAxisTitle(String xAxisTitle) {
this.xAxis = xAxisTitle;
repaint();
}
/**
* Updates the state of the X-Axis Title. Calls repaint() when finished.
*
* @param drawXAxisTitle
* Specifies if the X-Axis title should be drawn
*/
public void setDrawXAxisTitle(boolean drawXAxisTitle) {
this.drawXTitle = drawXAxisTitle;
repaint();
}
/**
* Updates the Y-Axis title. Calls repaint() when finished.
*
* @param yAxisTitle
* The new Y-Axis title
*/
public void setYAxisTitle(String yAxisTitle) {
this.yAxis = yAxisTitle;
repaint();
}
/**
* Updates the state of the Y-Axis Title. Calls repaint() when finished.
*
* @param drawYAxisTitle
* Specifies if the Y-Axis title should be drawn
*/
public void setDrawYAxisTitle(boolean drawYAxisTitle) {
this.drawYTitle = drawYAxisTitle;
repaint();
}
/**
* Updates the state of the legend. Calls repaint() when finished.
*
* @param drawLegend
* Specifies if the legend should be drawn
*/
public void setDrawLegend(boolean drawLegend) {
this.drawLegend = drawLegend;
repaint();
}
/**
* Updates the state of the X-Axis ticks. Calls repaint() when finished.
*
* @param drawXTicks
* Specifies if the X-Axis ticks should be drawn
*/
public void setDrawXTicks(boolean drawXTicks) {
this.drawXTicks = drawXTicks;
repaint();
}
/**
* Updates the state of the Y-Axis ticks. Calls repaint() when finished.
*
* @param drawYTicks
* Specifies if the Y-Axis ticks should be drawn
*/
public void setDrawYTicks(boolean drawYTicks) {
this.drawYTicks = drawYTicks;
repaint();
}
/**
* Updates the foreground color. Calls repaint() when finished.
*
* @param fg
* Specifies the desired foreground color
*/
public void setColorForeground(Color fg) {
this.fg = fg;
repaint();
}
/**
* Updates the background color. Calls repaint() when finished.
*
* @param bg
* Specifies the desired background color
*/
public void setColorBackground(Color bg) {
this.bg = bg;
repaint();
}
/**
* Updates the gradient used to display the data. Calls drawData() and
* repaint() when finished.
*
* @param colors
* A variable of type Color[]
*/
public void updateGradient(Color[] colors) {
this.colors = (Color[]) colors.clone();
if (data != null) {
updateDataColors();
drawData();
repaint();
}
}
/**
* This uses the current array of colors that make up the gradient, and
* assigns a color index to each data point, stored in the dataColorIndices
* array, which is used by the drawData() method to plot the points.
*/
private void updateDataColors() {
// We need to find the range of the data values,
// in order to assign proper colors.
double largest = Double.MIN_VALUE;
double smallest = Double.MAX_VALUE;
for (int x = 0; x < data.length; x++) {
for (int y = 0; y < data[0].length; y++) {
largest = Math.max(data[x][y], largest);
smallest = Math.min(data[x][y], smallest);
}
}
double range = largest - smallest;
// dataColorIndices is the same size as the data array
// It stores an int index into the color array
dataColorIndices = new int[data.length][data[0].length];
// assign a Color to each data point
for (int x = 0; x < data.length; x++) {
for (int y = 0; y < data[0].length; y++) {
double norm = (data[x][y] - smallest) / range; // 0 < norm < 1
int colorIndex = (int) Math.floor(norm * (colors.length - 1));
dataColorIndices[x][y] = colorIndex;
}
}
}
/**
* This function generates data that is not vertically-symmetric, which
* makes it very useful for testing which type of vertical axis is being
* used to plot the data. If the graphics Y-axis is used, then the lowest
* values should be displayed at the top of the frame. If the non-graphics
* (mathematical coordinate-system) Y-axis is used, then the lowest values
* should be displayed at the bottom of the frame.
*
* @return double[][] data values of a simple vertical ramp
*/
public static double[][] generateRampTestData() {
double[][] data = new double[10][10];
for (int x = 0; x < 10; x++) {
for (int y = 0; y < 10; y++) {
data[x][y] = y;
}
}
return data;
}
/**
* This function generates an appropriate data array for display. It uses
* the function: z = sin(x)*cos(y). The parameter specifies the number of
* data points in each direction, producing a square matrix.
*
* @param dimension
* Size of each side of the returned array
* @return double[][] calculated values of z = sin(x)*cos(y)
*/
public static double[][] generateSinCosData(int dimension) {
if (dimension % 2 == 0) {
dimension++; // make it better
}
double[][] data = new double[dimension][dimension];
double sX, sY; // s for 'Scaled'
for (int x = 0; x < dimension; x++) {
for (int y = 0; y < dimension; y++) {
sX = 2 * Math.PI * (x / (double) dimension); // 0 < sX < 2 * Pi
sY = 2 * Math.PI * (y / (double) dimension); // 0 < sY < 2 * Pi
data[x][y] = Math.sin(sX) * Math.cos(sY);
}
}
return data;
}
/**
* This function generates an appropriate data array for display. It uses
* the function: z = Math.cos(Math.abs(sX) + Math.abs(sY)). The parameter
* specifies the number of data points in each direction, producing a square
* matrix.
*
* @param dimension
* Size of each side of the returned array
* @return double[][] calculated values of z = Math.cos(Math.abs(sX) +
* Math.abs(sY));
*/
public static double[][] generatePyramidData(int dimension) {
if (dimension % 2 == 0) {
dimension++; // make it better
}
double[][] data = new double[dimension][dimension];
double sX, sY; // s for 'Scaled'
for (int x = 0; x < dimension; x++) {
for (int y = 0; y < dimension; y++) {
sX = 6 * (x / (double) dimension); // 0 < sX < 6
sY = 6 * (y / (double) dimension); // 0 < sY < 6
sX = sX - 3; // -3 < sX < 3
sY = sY - 3; // -3 < sY < 3
data[x][y] = Math.cos(Math.abs(sX) + Math.abs(sY));
}
}
return data;
}
/**
* Updates the data display, calls drawData() to do the expensive re-drawing
* of the data plot, and then calls repaint().
*
* @param data
* The data to display, must be a complete array (non-ragged)
* @param useGraphicsYAxis
* If true, the data will be displayed with the y=0 row at the
* top of the screen. If false, the data will be displayed with
* the y=0 row at the bottom of the screen.
*/
public void updateData(double[][] data, boolean useGraphicsYAxis) {
this.data = new double[data.length][data[0].length];
for (int ix = 0; ix < data.length; ix++) {
for (int iy = 0; iy < data[0].length; iy++) {
// we use the graphics Y-axis internally
if (useGraphicsYAxis) {
this.data[ix][iy] = data[ix][iy];
} else {
this.data[ix][iy] = data[ix][data[0].length - iy - 1];
}
}
}
updateDataColors();
drawData();
repaint();
}
/**
* Creates a BufferedImage of the actual data plot.
*
* After doing some profiling, it was discovered that 90% of the drawing
* time was spend drawing the actual data (not on the axes or tick marks).
* Since the Graphics2D has a drawImage method that can do scaling, we are
* using that instead of scaling it ourselves. We only need to draw the data
* into the bufferedImage on startup, or if the data or gradient changes.
* This saves us an enormous amount of time. Thanks to Josh Hayes-Sheen
* (grey@grevian.org) for the suggestion and initial code to use the
* BufferedImage technique.
*
* Since the scaling of the data plot will be handled by the drawImage in
* paintComponent, we take the easy way out and draw our bufferedImage with
* 1 pixel per data point. Too bad there isn't a setPixel method in the
* Graphics2D class, it seems a bit silly to fill a rectangle just to set a
* single pixel...
*
* This function should be called whenever the data or the gradient changes.
*/
private void drawData() {
bufferedImage = new BufferedImage(data.length, data[0].length,
BufferedImage.TYPE_INT_ARGB);
bufferedGraphics = bufferedImage.createGraphics();
for (int x = 0; x < data.length; x++) {
for (int y = 0; y < data[0].length; y++) {
bufferedGraphics.setColor(colors[dataColorIndices[x][y]]);
bufferedGraphics.fillRect(x, y, 1, 1);
}
}
}
/**
* The overridden painting method, now optimized to simply draw the data
* plot to the screen, letting the drawImage method do the resizing. This
* saves an extreme amount of time.
*/
public void paintComponent(Graphics g) {
super.paintComponent(g);
Graphics2D g2d = (Graphics2D) g;
int width = this.getWidth();
int height = this.getHeight();
this.setOpaque(true);
// clear the panel
g2d.setColor(bg);
g2d.fillRect(0, 0, width, height);
// draw the heat map
if (bufferedImage == null) {
// Ideally, we only to call drawData in the constructor, or if we
// change the data or gradients. We include this just to be safe.
drawData();
}
// The data plot itself is drawn with 1 pixel per data point, and the
// drawImage method scales that up to fit our current window size. This
// is very fast, and is much faster than the previous version, which
// redrew the data plot each time we had to repaint the screen.
g2d.drawImage(bufferedImage, 31, 31, width - 30, height - 30, 0, 0,
bufferedImage.getWidth(), bufferedImage.getHeight(), null);
// border
g2d.setColor(fg);
g2d.drawRect(30, 30, width - 60, height - 60);
// title
if (drawTitle && title != null) {
g2d.drawString(title, (width / 2) - 4 * title.length(), 20);
}
// axis ticks - ticks start even with the bottom left coner, end very
// close to end of line (might not be right on)
int numXTicks = (width - 60) / 50;
int numYTicks = (height - 60) / 50;
String label = "";
DecimalFormat df = new DecimalFormat("##.##");
// Y-Axis ticks
if (drawYTicks) {
int yDist = (int) ((height - 60) / (double) numYTicks); // distance
// between
// ticks
for (int y = 0; y <= numYTicks; y++) {
g2d.drawLine(26, height - 30 - y * yDist, 30, height - 30 - y
* yDist);
label = df.format(((y / (double) numYTicks) * (yMax - yMin))
+ yMin);
int labelY = height - 30 - y * yDist - 4 * label.length();
// to get the text to fit nicely, we need to rotate the graphics
g2d.rotate(Math.PI / 2);
g2d.drawString(label, labelY, -14);
g2d.rotate(-Math.PI / 2);
}
}
// Y-Axis title
if (drawYTitle && yAxis != null) {
// to get the text to fit nicely, we need to rotate the graphics
g2d.rotate(Math.PI / 2);
g2d.drawString(yAxis, (height / 2) - 4 * yAxis.length(), -3);
g2d.rotate(-Math.PI / 2);
}
// X-Axis ticks
if (drawXTicks) {
int xDist = (int) ((width - 60) / (double) numXTicks); // distance
// between
// ticks
for (int x = 0; x <= numXTicks; x++) {
g2d.drawLine(30 + x * xDist, height - 30, 30 + x * xDist,
height - 26);
label = df.format(((x / (double) numXTicks) * (xMax - xMin))
+ xMin);
int labelX = (31 + x * xDist) - 4 * label.length();
g2d.drawString(label, labelX, height - 14);
}
}
// X-Axis title
if (drawXTitle && xAxis != null) {
g2d.drawString(xAxis, (width / 2) - 4 * xAxis.length(), height - 3);
}
// Legend
if (drawLegend) {
g2d.drawRect(width - 20, 30, 10, height - 60);
for (int y = 0; y < height - 61; y++) {
int yStart = height
- 31
- (int) Math.ceil(y
* ((height - 60) / (colors.length * 1.0)));
yStart = height - 31 - y;
g2d.setColor(colors[(int) ((y / (double) (height - 60)) * (colors.length * 1.0))]);
g2d.fillRect(width - 19, yStart, 9, 1);
}
}
}
}