/*
* Copyright (c) 2014 by Gerrit Grunwald
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.hansolo.fx.heatmap;
import javafx.animation.Interpolator;
import javafx.application.Platform;
import javafx.embed.swing.SwingFXUtils;
import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.scene.SnapshotParameters;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.image.PixelReader;
import javafx.scene.image.PixelWriter;
import javafx.scene.image.WritableImage;
import javafx.scene.paint.Color;
import javafx.scene.paint.LinearGradient;
import javafx.scene.paint.Stop;
import javax.imageio.ImageIO;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Created by
* User: hansolo
* Date: 27.12.12
* Time: 05:46
*/
public class HeatMap extends ImageView {
private static final SnapshotParameters SNAPSHOT_PARAMETERS = new SnapshotParameters();
private List<HeatMapEvent> eventList;
private Map<String, Image> eventImages;
private ColorMapping colorMapping;
private LinearGradient mappingGradient;
private boolean fadeColors;
private double radius;
private OpacityDistribution opacityDistribution;
private Image eventImage;
private Canvas monochrome;
private GraphicsContext ctx;
private WritableImage monochromeImage;
private WritableImage heatMap;
// ******************** Constructors **************************************
public HeatMap() {
this(100, 100);
}
public HeatMap(final double WIDTH, final double HEIGHT) {
this(WIDTH, HEIGHT, ColorMapping.LIME_YELLOW_RED);
}
public HeatMap(final double WIDTH, final double HEIGHT, ColorMapping COLOR_MAPPING) {
this(WIDTH, HEIGHT, COLOR_MAPPING, 15.5);
}
public HeatMap(final double WIDTH, final double HEIGHT, ColorMapping COLOR_MAPPING, final double EVENT_RADIUS) {
this(WIDTH, HEIGHT, COLOR_MAPPING, EVENT_RADIUS, true, 0.5, OpacityDistribution.CUSTOM);
}
public HeatMap(final double WIDTH, final double HEIGHT, ColorMapping COLOR_MAPPING, final double EVENT_RADIUS, final boolean FADE_COLORS, final double HEAT_MAP_OPACITY, final OpacityDistribution OPACITY_DISTRIBUTION) {
super();
SNAPSHOT_PARAMETERS.setFill(Color.TRANSPARENT);
eventList = new ArrayList<>();
eventImages = new HashMap<>();
colorMapping = COLOR_MAPPING;
mappingGradient = colorMapping.mapping;
fadeColors = FADE_COLORS;
radius = EVENT_RADIUS;
opacityDistribution = OPACITY_DISTRIBUTION;
eventImage = createEventImage(radius, opacityDistribution);
monochrome = new Canvas(WIDTH, HEIGHT);
ctx = monochrome.getGraphicsContext2D();
monochromeImage = new WritableImage((int) WIDTH, (int) HEIGHT);
setImage(heatMap);
setMouseTransparent(true);
setOpacity(HEAT_MAP_OPACITY);
registerListeners();
}
public void registerListeners() {
fitWidthProperty().addListener(observable -> resize());
fitHeightProperty().addListener(observable -> resize());
}
// ******************** Methods *******************************************
/**
* Add a list of events and update the heatmap after all events
* have been added
* @param EVENTS
*/
public void addEvents(final Point2D... EVENTS) {
for (Point2D event : EVENTS) {
eventList.add(new HeatMapEvent(event.getX(), event.getY(), radius, opacityDistribution));
ctx.drawImage(eventImage, event.getX() - radius, event.getY() - radius);
}
updateHeatMap();
}
/**
* Add a list of events and update the heatmap after all events
* have been added
* @param EVENTS
*/
public void addEvents(final List<Point2D> EVENTS) {
EVENTS.forEach(event -> {
eventList.add(new HeatMapEvent(event.getX(), event.getY(), radius, opacityDistribution));
ctx.drawImage(eventImage, event.getX() - radius, event.getY() - radius);
});
updateHeatMap();
}
/**
* Visualizes an event with the given radius and opacity gradient
* @param X
* @param Y
* @param OFFSET_X
* @param OFFSET_Y
* @param RADIUS
* @param OPACITY_GRADIENT
*/
public void addEvent(final double X, final double Y, final double OFFSET_X, final double OFFSET_Y, final double RADIUS, final OpacityDistribution OPACITY_GRADIENT) {
eventImage = createEventImage(RADIUS, OPACITY_GRADIENT);
addEvent(X, Y, eventImage, OFFSET_X, OFFSET_Y);
}
/**
* Visualizes an event with a given image at the given position and with
* the given offset. So one could use different weighted images for different
* kinds of events (e.g. important events more opaque as unimportant events)
* @param X
* @param Y
* @param EVENT_IMAGE
* @param OFFSET_X
* @param OFFSET_Y
*/
public void addEvent(final double X, final double Y, final Image EVENT_IMAGE, final double OFFSET_X, final double OFFSET_Y) {
eventList.add(new HeatMapEvent(X, Y, radius, opacityDistribution));
ctx.drawImage(EVENT_IMAGE, X - OFFSET_X, Y - OFFSET_Y);
updateHeatMap();
}
/**
* If you don't need to weight events you could use this method which
* will create events that always use the global weight
* @param X
* @param Y
*/
public void addEvent(final double X, final double Y) {
addEvent(X, Y, eventImage, radius, radius);
}
/**
* Calling this method will lead to a clean new heat map without any data
*/
public void clearHeatMap() {
eventList.clear();
ctx.clearRect(0, 0, monochrome.getWidth(), monochrome.getHeight());
monochromeImage = new WritableImage(monochrome.widthProperty().intValue(), monochrome.heightProperty().intValue());
updateHeatMap();
}
/**
* Returns the used color mapping with the gradient that is used
* to visualize the data
* @return
*/
public ColorMapping getColorMapping() {
return colorMapping;
}
/**
* The ColorMapping enum contains some examples for color mappings
* that might be useful to visualize data and here you could set
* the one you like most. Setting another color mapping will recreate
* the heat map automatically.
* @param COLOR_MAPPING
*/
public void setColorMapping(final ColorMapping COLOR_MAPPING) {
colorMapping = COLOR_MAPPING;
mappingGradient = COLOR_MAPPING.mapping;
updateHeatMap();
}
/**
* Returns true if the heat map is used to visualize frequencies (default)
* @return true if the heat map is used to visualize frequencies
*/
public boolean isFadeColors() {
return fadeColors;
}
/**
* If true each event will be visualized by a radial gradient
* with the colors from the given color mapping and decreasing
* opacity from the inside to the outside. If you set it to false
* the color opacity won't fade out but will be opaque. This might
* be handy if you would like to visualize the density instead of
* the frequency
* @param FADE_COLORS
*/
public void setFadeColors(final boolean FADE_COLORS) {
fadeColors = FADE_COLORS;
updateHeatMap();
}
/**
* Returns the radius of the circle that is used to visualize an
* event.
* @return the radius of the circle that is used to visualize an event
*/
public double getEventRadius() {
return radius;
}
/**
* Each event will be visualized by a circle filled with a radial
* gradient with decreasing opacity from the inside to the outside.
* If you have lot's of events it makes sense to set the event radius
* to a smaller value. The default value is 15.5
* @param RADIUS
*/
public void setEventRadius(final double RADIUS) {
radius = RADIUS < 1 ? 1 : RADIUS;
eventImage = createEventImage(radius, opacityDistribution);
}
/**
* Returns the opacity distribution that will be used to visualize
* the events in the monochrome map. If you have lot's of events
* it makes sense to reduce the radius and the set the opacity
* distribution to exponential.
* @return the opacity distribution of events in the monochrome map
*/
public OpacityDistribution getOpacityDistribution() {
return opacityDistribution;
}
/**
* Changing the opacity distribution will affect the smoothing of
* the heat map. If you choose a linear opacity distribution you will
* see bigger colored dots for each event than using the exponential
* opacity distribution (at the same event radius).
* @param OPACITY_DISTRIBUTION
*/
public void setOpacityDistribution(final OpacityDistribution OPACITY_DISTRIBUTION) {
opacityDistribution = OPACITY_DISTRIBUTION;
eventImage = createEventImage(radius, opacityDistribution);
}
/**
* Because the heat map is based on images you have to create a new
* writeable image each time you would like to change the size of
* the heatmap
* @param WIDTH
* @param HEIGHT
*/
public void setSize(final double WIDTH, final double HEIGHT) {
setFitWidth(WIDTH);
setFitHeight(HEIGHT);
}
/**
* Saves the current heat map image as png with the given name to the desktop folder of the current user
* @param FILE_NAME
*/
public void saveAsPng(final String FILE_NAME) {
saveAsPng(this, FILE_NAME + ".png");
}
/**
* Saves the given node as png with the given name to the desktop folder of the current user
* @param NODE
* @param FILE_NAME
*/
public void saveAsPng(final Node NODE, final String FILE_NAME) {
new Thread(() ->
Platform.runLater(() -> {
final String TARGET = System.getProperty("user.home") + "/Desktop/" + FILE_NAME + ".png";
try {
ImageIO.write(SwingFXUtils.fromFXImage(NODE.snapshot(SNAPSHOT_PARAMETERS, null), null), "png", new File(TARGET));
} catch (IOException exception) {
// handle exception here
}
})
).start();
}
/**
* Create an image that contains a circle filled with a
* radial gradient from white to transparent
* @param RADIUS
* @return an image that contains a filled circle
*/
public Image createEventImage(final double RADIUS, final OpacityDistribution OPACITY_DISTRIBUTION) {
Double radius = RADIUS < 1 ? 1 : RADIUS;
if (eventImages.containsKey(OPACITY_DISTRIBUTION.name() + radius)) {
return eventImages.get(OPACITY_DISTRIBUTION.name() + radius);
}
Stop[] stops = new Stop[11];
for (int i = 0; i < 11; i++) {
stops[i] = new Stop(i * 0.1, Color.rgb(255, 255, 255, OPACITY_DISTRIBUTION.distribution[i]));
}
int size = (int) (radius * 2);
WritableImage raster = new WritableImage(size, size);
PixelWriter pixelWriter = raster.getPixelWriter();
double maxDistFactor = 1 / radius;
Color pixelColor;
for (int y = 0; y < size; y++) {
for (int x = 0; x < size; x++) {
double deltaX = radius - x;
double deltaY = radius - y;
double distance = Math.sqrt((deltaX * deltaX) + (deltaY * deltaY));
double fraction = maxDistFactor * distance;
for (int i = 0; i < 10; i++) {
if (Double.compare(fraction, stops[i].getOffset()) >= 0 && Double.compare(fraction, stops[i + 1].getOffset()) <= 0) {
pixelColor = (Color) Interpolator.LINEAR.interpolate(stops[i].getColor(), stops[i + 1].getColor(), (fraction - stops[i].getOffset()) / 0.1);
pixelWriter.setColor(x, y, pixelColor);
break;
}
}
}
}
eventImages.put(OPACITY_DISTRIBUTION.name() + radius, raster);
return raster;
}
/**
* Updates each event in the monochrome map to the given opacity gradient
* which could be useful to reduce oversmoothing
* @param OPACITY_GRADIENT
*/
public void updateMonochromeMap(final OpacityDistribution OPACITY_GRADIENT) {
ctx.clearRect(0, 0, monochrome.getWidth(), monochrome.getHeight());
eventList.forEach(event -> {
event.setOpacityDistribution(OPACITY_GRADIENT);
ctx.drawImage(createEventImage(event.getRadius(), event.getOpacityDistribution()), event.getX() - event.getRadius() * 0.5, event.getY() - event.getRadius() * 0.5);
});
updateHeatMap();
}
/**
* Recreates the heatmap based on the current monochrome map.
* Using this approach makes it easy to change the used color
* mapping.
*/
private void updateHeatMap() {
monochrome.snapshot(SNAPSHOT_PARAMETERS, monochromeImage);
heatMap = new WritableImage(monochromeImage.widthProperty().intValue(), monochromeImage.heightProperty().intValue());
Color colorFromMonoChromeImage;
double brightness;
Color mappedColor;
PixelWriter pixelWriter = heatMap.getPixelWriter();
PixelReader pixelReader = monochromeImage.getPixelReader();
int width = (int) monochromeImage.getWidth();
int height = (int) monochromeImage.getHeight();
for (int y = 0 ; y < height ; y++) {
for (int x = 0 ; x < width ; x++) {
colorFromMonoChromeImage = pixelReader.getColor(x, y);
brightness = colorFromMonoChromeImage.getOpacity();
mappedColor = getColorAt(mappingGradient, brightness);
if (fadeColors) {
pixelWriter.setColor(x, y, Color.color(mappedColor.getRed(), mappedColor.getGreen(), mappedColor.getBlue(), brightness));
} else {
pixelWriter.setColor(x, y, mappedColor);
}
}
}
setImage(heatMap);
}
/**
* Calculates the color in a linear gradient at the given fraction
* @param GRADIENT
* @param FRACTION
* @return the color in a linear gradient at the given fraction
*/
private Color getColorAt(final LinearGradient GRADIENT, final double FRACTION) {
List<Stop> stops = GRADIENT.getStops();
double fraction = FRACTION < 0f ? 0f : (FRACTION > 1 ? 1 : FRACTION);
Stop lowerStop = new Stop(0.0, stops.get(0).getColor());
Stop upperStop = new Stop(1.0, stops.get(stops.size() - 1).getColor());
for (Stop stop : stops) {
double currentFraction = stop.getOffset();
if (Double.compare(currentFraction, fraction) == 0) {
return stop.getColor();
} else if (Double.compare(currentFraction, fraction) < 0) {
lowerStop = new Stop(currentFraction, stop.getColor());
} else {
upperStop = new Stop(currentFraction, stop.getColor());
break;
}
}
double interpolationFraction = (fraction - lowerStop.getOffset()) / (upperStop.getOffset() - lowerStop.getOffset());
return (Color) Interpolator.LINEAR.interpolate(lowerStop.getColor(), upperStop.getColor(), interpolationFraction);
}
private void resize() {
double width = getFitWidth();
double height = getFitHeight();
monochrome.setWidth(width);
monochrome.setHeight(height);
if (width > 0 && height > 0) {
monochromeImage = new WritableImage(monochrome.widthProperty().intValue(), monochrome.heightProperty().intValue());
updateHeatMap();
}
}
}