blob: dda38a97e46c36f80df8ba10196453c2d72d7925 [file] [log] [blame]
package com.pivotal.chart;
import java.util.ArrayList;
import java.util.List;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ObjectPropertyBase;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.event.Event;
import javafx.event.EventHandler;
import javafx.event.EventType;
import javafx.geometry.Rectangle2D;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.chart.Axis;
import javafx.scene.chart.ValueAxis;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import javafx.scene.layout.Region;
import javafx.scene.paint.Color;
import javafx.scene.shape.Line;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.StrokeType;
import javafx.util.Duration;
import com.pivotal.com.sun.javafx.charts.LegendItem;
import com.pivotal.javafx.scene.chart.Series;
import com.pivotal.javafx.scene.chart.MultiAxisLineChart;
/**
* Line chart with crosshairs, zooming and panning.
*
* @author jbarrett
*
* @param <X>
* @param <Y>
*/
public class AdvancedLineChart<X extends Number, Y extends Number> extends MultiAxisLineChart<X, Y> {
/** Selected series */
private final ObjectProperty<Series<X, Y>> selectedSeries = new ObjectPropertyBase<Series<X, Y>>() {
{
addListener(new ChangeListener<Series<X, Y>>() {
@Override
public void changed(ObservableValue<? extends Series<X, Y>> observable, Series<X, Y> oldValue, Series<X, Y> newValue) {
if (null != oldValue) {
oldValue.getNode().getStyleClass().remove("advanced-chart-selected-series");
final LegendItem legendItem = oldValue.getLegendItem();
legendItem.getLabel().getStyleClass().remove("advanced-chart-selected-series");
}
if (null != newValue) {
newValue.getNode().toFront();
newValue.getNode().getStyleClass().add("advanced-chart-selected-series");
final LegendItem legendItem = newValue.getLegendItem();
legendItem.getLabel().getStyleClass().add("advanced-chart-selected-series");
}
requestChartLayout();
}
});
}
@Override
public Object getBean() {
return AdvancedLineChart.this;
}
@Override
public String getName() {
return "selectedSeries";
}
};
public final Series<X, Y> getSelectedSeries() {
return selectedSeries.get();
}
public final void setSelectedSeries(Series<X, Y> value) {
selectedSeries.set(value);
}
public final ObjectProperty<Series<X, Y>> selectedSeriesProperty() {
return selectedSeries;
}
private final Node chart;
// BEGIN Crosshair properties
private final DoubleProperty crosshairX = new SimpleDoubleProperty();
private final DoubleProperty crosshairY = new SimpleDoubleProperty();
final Line crosshairLineX;
final Line crosshairLineY;
// END Crosshair properties
// BEGIN Panning properties
private double panningLastX;
private double panningLastY;
private final SimpleBooleanProperty panning = new SimpleBooleanProperty(false);
// END Panning properties
// BEGIN Zooming properties
private final SimpleDoubleProperty rectX = new SimpleDoubleProperty();
private final SimpleDoubleProperty rectY = new SimpleDoubleProperty();
private final SimpleBooleanProperty zooming = new SimpleBooleanProperty(false);
private final DoubleProperty zoomDurationMillis = new SimpleDoubleProperty(700.0);
private final BooleanProperty zoomAnimated = new SimpleBooleanProperty(true);
private final BooleanProperty mouseWheelZoomAllowed = new SimpleBooleanProperty(true);
private static enum ZoomMode {
Horizontal, Vertical, Both
}
private ZoomMode zoomMode;
private final Rectangle selectRect;
private final Timeline zoomAnimation = new Timeline();
// END Zooming properties
public AdvancedLineChart(Axis<X> xAxis, Axis<Y> yAxis) {
super(xAxis, yAxis);
// Get the chart region
chart = getChartChildren().get(0).getParent();
// TODO style
crosshairLineX = new Line(0, 0, this.getWidth(), 0);
crosshairLineX.endXProperty().bind(this.widthProperty());
crosshairLineX.setStroke(Color.ORANGE);
crosshairLineX.setCursor(Cursor.CROSSHAIR);
crosshairLineX.setMouseTransparent(true);
crosshairLineX.translateYProperty().bind(crosshairY);
getPlotChildren().add(crosshairLineX);
crosshairLineY = new Line(0, 0, 0, this.getHeight());
crosshairLineY.endYProperty().bind(this.heightProperty());
crosshairLineY.setStroke(Color.ORANGE);
crosshairLineY.setCursor(Cursor.CROSSHAIR);
crosshairLineY.setMouseTransparent(true);
crosshairLineY.translateXProperty().bind(crosshairX);
getPlotChildren().add(crosshairLineY);
selectRect = new Rectangle(0, 0, 0, 0);
selectRect.setFill(Color.DODGERBLUE);
selectRect.setMouseTransparent(true);
selectRect.setOpacity(0.3);
selectRect.setStroke(Color.rgb(0, 0x29, 0x66));
selectRect.setStrokeType(StrokeType.INSIDE);
selectRect.setStrokeWidth(3.0);
selectRect.widthProperty().bind(rectX.subtract(selectRect.translateXProperty()));
selectRect.heightProperty().bind(rectY.subtract(selectRect.translateYProperty()));
selectRect.visibleProperty().bind(zooming);
getPlotChildren().add(selectRect);
chart.addEventHandler(MouseEvent.MOUSE_MOVED, new EventHandler<MouseEvent>() {
@Override
public void handle(MouseEvent event) {
// TODO change events on transforms?
// TODO bind to transform property because axis change
final double x = event.getX() - getXShift(getXAxis());
final double y = event.getY() - getYShift(getPrimaryYAxis());
// TODO do as properties? Crosshair.visible property?
if (y > 0 && y < getPrimaryYAxis().getHeight() && x > 0 && x < getXAxis().getWidth()) {
crosshairX.set(x);
crosshairY.set(y);
setCursor(Cursor.CROSSHAIR);
crosshairLineX.setVisible(true);
crosshairLineY.setVisible(true);
} else {
setCursor(Cursor.DEFAULT);
crosshairLineX.setVisible(false);
crosshairLineY.setVisible(false);
}
}
});
chart.addEventHandler(MouseEvent.MOUSE_PRESSED, new EventHandler<MouseEvent>() {
@Override
public void handle(MouseEvent mouseEvent) {
if (isZoomEvent(mouseEvent))
setupZooming(mouseEvent.getX(), mouseEvent.getY());
mouseEvent.consume();
}
});
chart.addEventHandler(MouseEvent.DRAG_DETECTED, new EventHandler<MouseEvent>() {
@Override
public void handle(MouseEvent mouseEvent) {
if (isPanEvent(mouseEvent)) {
startPanning(mouseEvent.getX(), mouseEvent.getY());
mouseEvent.consume();
} else if (isZoomEvent(mouseEvent)) {
startZooming();
mouseEvent.consume();
}
}
});
chart.addEventHandler(MouseEvent.MOUSE_DRAGGED, new EventHandler<MouseEvent>() {
@Override
public void handle(MouseEvent mouseEvent) {
if (panning.get()) {
doPanning(mouseEvent.getX(), mouseEvent.getY());
mouseEvent.consume();
} else if (zooming.get()) {
doZomming(mouseEvent.getX(), mouseEvent.getY());
mouseEvent.consume();
}
}
});
chart.addEventHandler(MouseEvent.MOUSE_RELEASED, new EventHandler<MouseEvent>() {
@Override
public void handle(MouseEvent mouseEvent) {
if (panning.get()) {
stopPanning();
mouseEvent.consume();
} else if (zooming.get()) {
stopZooming();
mouseEvent.consume();
}
}
});
chart.addEventHandler(ScrollEvent.ANY, new MouseWheelZoomHandler());
}
public final double getCrosshairX() {
return crosshairX.getValue();
}
public final DoubleProperty crosshairXProperty() {
return crosshairX;
}
public final double getCrosshairY() {
return crosshairY.getValue();
}
public final DoubleProperty crosshairYProperty() {
return crosshairY;
}
@Override
protected void seriesAdded(final Series<X, Y> series, int seriesIndex) {
super.seriesAdded(series, seriesIndex);
series.getNode().addEventHandler(MouseEvent.MOUSE_CLICKED, new EventHandler<MouseEvent>() {
@Override
public void handle(MouseEvent event) {
setSelectedSeries(getSelectedSeries() == series ? null : series);
}
});
}
@Override
protected void updateLegend() {
super.updateLegend();
for (final Series<X, Y> series : getData()) {
series.getLegendItem().getLabel().addEventHandler(MouseEvent.MOUSE_CLICKED, new EventHandler<MouseEvent>() {
@Override
public void handle(MouseEvent event) {
setSelectedSeries(getSelectedSeries() == series ? null : series);
}
});
}
}
@Override
protected void yAxisAdded(Axis<Y> yAxis) {
super.yAxisAdded(yAxis);
yAxis.addEventHandler(MouseEvent.MOUSE_CLICKED, new EventHandler<MouseEvent>() {
@Override
public void handle(MouseEvent event) {
@SuppressWarnings("unchecked")
final Axis<Y> yAxis = (Axis<Y>) event.getSource();
setPrimaryYAxis(yAxis);
}
});
}
@Override
protected void layoutPlotChildren() {
super.layoutPlotChildren();
selectRect.toFront();
crosshairLineX.toFront();
crosshairLineY.toFront();
}
protected boolean isPanEvent(MouseEvent mouseEvent) {
return mouseEvent.getButton() == MouseButton.SECONDARY || (mouseEvent.getButton() == MouseButton.PRIMARY && mouseEvent.isShortcutDown());
}
protected boolean isZoomEvent(MouseEvent mouseEvent) {
return mouseEvent.getButton() == MouseButton.PRIMARY;
}
protected void startPanning(final double fromX, final double fromY) {
panningLastX = fromX;
panningLastY = fromY;
final Axis<X> xAxis = this.getXAxis();
// TODO check or bind to chart animation?
// wasXAnimated = xAxis.getAnimated();
// wasYAnimated = chart.getPrimaryYAxis().getAnimated();
xAxis.setAnimated(false);
xAxis.setAutoRanging(false);
for (Axis<Y> yAxis : getYAxes()) {
yAxis.setAnimated(false);
yAxis.setAutoRanging(false);
}
// TODO property?
panning.set(true);
}
protected void doPanning(final double toX, final double toY) {
// TODO animated?
// TODO do we really need to check this? Filters?
if (!panning.get()) {
return;
}
final ValueAxis<?> xAxis = (ValueAxis<?>) getXAxis();
final double dX = (toX - panningLastX) / -xAxis.getScale();
xAxis.setAutoRanging(false);
xAxis.setLowerBound(xAxis.getLowerBound() + dX);
xAxis.setUpperBound(xAxis.getUpperBound() + dX);
@SuppressWarnings("unchecked")
final List<ValueAxis<?>> yAxes = ((List<ValueAxis<?>>) (List<?>) getYAxes());
for (ValueAxis<?> yAxis : yAxes) {
final double dY = (toY - panningLastY) / -yAxis.getScale();
yAxis.setAutoRanging(false);
yAxis.setLowerBound(yAxis.getLowerBound() + dY);
yAxis.setUpperBound(yAxis.getUpperBound() + dY);
}
panningLastX = toX;
panningLastY = toY;
}
protected void stopPanning() {
if (!panning.get())
return;
panning.set(false);
// TODO should we allow certain axis to not be animated?
getXAxis().setAnimated(getAnimated());
for (Axis<Y> yAxis : getYAxes()) {
yAxis.setAnimated(getAnimated());
}
}
protected void setupZooming(final double x, final double y) {
final Axis<X> xAxis = getXAxis();
final Axis<Y> yAxis = getPrimaryYAxis();
final double xShifted = x - getXShift(xAxis);
final double yShifted = y - getYShift(yAxis);
final double width = xAxis.getWidth();
final double height = yAxis.getHeight();
if (xShifted >= 0 && xShifted <= width &&
yShifted >= 0 && yShifted <= height) {
selectRect.setTranslateX(xShifted);
selectRect.setTranslateY(yShifted);
rectX.set(xShifted);
rectY.set(yShifted);
zoomMode = ZoomMode.Both;
} else if (getComponentArea(xAxis).contains(x, y)) {
selectRect.setTranslateX(xShifted);
selectRect.setTranslateY(0);
rectX.set(xShifted);
rectY.set(height);
zoomMode = ZoomMode.Horizontal;
} else if (getComponentArea(yAxis).contains(x, y)) {
selectRect.setTranslateX(0);
selectRect.setTranslateY(yShifted);
rectX.set(width);
rectY.set(yShifted);
zoomMode = ZoomMode.Vertical;
}
}
protected void startZooming() {
// Don't actually start the selecting process until it's officially a drag
// But, we saved the original coordinates from where we started.
// TODO this??
zooming.set(true);
}
protected void doZomming(double x, double y) {
if (!zooming.get())
return;
final Axis<X> xAxis = getXAxis();
final Axis<Y> yAxis = getPrimaryYAxis();
x -= getXShift(xAxis);
y -= getYShift(yAxis);
if (zoomMode == ZoomMode.Both || zoomMode == ZoomMode.Horizontal) {
// Clamp to the selection start
x = Math.max(x, selectRect.getTranslateX());
// Clamp to plot area
x = Math.min(x, xAxis.getWidth());
rectX.set(x);
}
if (zoomMode == ZoomMode.Both || zoomMode == ZoomMode.Vertical) {
// Clamp to the selection start
y = Math.max(y, selectRect.getTranslateY());
// Clamp to plot area
y = Math.min(y, yAxis.getHeight());
rectY.set(y);
}
}
protected void stopZooming() {
if (!zooming.get())
return;
// Prevent a silly zoom... I'm still undecided about && vs ||
if (selectRect.getWidth() == 0.0 || selectRect.getHeight() == 0.0) {
zooming.set(false);
return;
}
// Rectangle2D zoomWindow = chartInfo.getDataCoordinates(
// selectRect.getTranslateX(), selectRect.getTranslateY(),
// rectX.get(), rectY.get()
// );
double minX = selectRect.getTranslateX();
double minY = selectRect.getTranslateY();
double maxX = rectX.get();
double maxY = rectY.get();
// TODO allow zoom in any x/y direction
if (minX > maxX || minY > maxY) {
throw new IllegalArgumentException("min > max for X and/or Y");
}
@SuppressWarnings("unchecked")
final ValueAxis<X> xAxis = (ValueAxis<X>) getXAxis();
// Axis xAxis = chart.getXAxis();
// Axis yAxis = chart.getPrimaryYAxis();
double minDataX = translatePositionToData(xAxis, minX);
double maxDataX = translatePositionToData(xAxis, maxX);
// The "low" Y data value is actually at the maxY graphical location as Y
// graphical axis gets
// larger as you go down on the screen.
// double minDataY = yAxis.toNumericValue( yAxis.getValueForDisplay( maxY -
// yStart ) );
// double maxDataY = yAxis.toNumericValue( yAxis.getValueForDisplay( minY -
// yStart ) );
//
// return new Rectangle2D( minDataX,
// minDataY,
// maxDataX - minDataX,
// maxDataY - minDataY );
if (zoomMode == ZoomMode.Both || zoomMode == ZoomMode.Horizontal) {
xAxis.setAutoRanging(false);
} else {
// TODO this doesn't work, try to auto range x when only zooming y??
ArrayList<X> d = new ArrayList<>();
getData().forEach(s -> {s.getData().forEach(dy -> {d.add(dy.getXValue());});});
xAxis.invalidateRange(d);
}
@SuppressWarnings("unchecked")
final List<ValueAxis<Y>> yAxes = ((List<ValueAxis<Y>>) (List<?>) getYAxes());
if (zoomMode == ZoomMode.Both || zoomMode == ZoomMode.Vertical) {
for (ValueAxis<Y> yAxis : yAxes) {
yAxis.setAutoRanging(false);
}
} else {
// TODO this doesn't work, try to auto range y when only zooming x
for (ValueAxis<Y> yAxis : yAxes) {
ArrayList<Y> d = new ArrayList<>();
getData().forEach(s -> {s.getData().forEach(dy -> {d.add(dy.getYValue());});});
yAxis.invalidateRange(d);
}
}
if (zoomAnimated.get()) {
zoomAnimation.stop();
final KeyValue[] startFrames = new KeyValue[2 + (yAxes.size() * 2)];
final KeyValue[] stopFrames = new KeyValue[2 + (yAxes.size() * 2)];
int startFramesIndex = 0;
int stopFramesIndex = 0;
if (zoomMode == ZoomMode.Both || zoomMode == ZoomMode.Horizontal) {
startFrames[startFramesIndex++] = new KeyValue(xAxis.lowerBoundProperty(), xAxis.getLowerBound());
startFrames[startFramesIndex++] = new KeyValue(xAxis.upperBoundProperty(), xAxis.getUpperBound());
stopFrames[stopFramesIndex++] = new KeyValue(xAxis.lowerBoundProperty(), minDataX);
stopFrames[stopFramesIndex++] = new KeyValue(xAxis.upperBoundProperty(), maxDataX);
}
if (zoomMode == ZoomMode.Both || zoomMode == ZoomMode.Vertical) {
for (ValueAxis<?> yAxis : yAxes) {
final double minDataY = translatePositionToData(yAxis, maxY);
final double maxDataY = translatePositionToData(yAxis, minY);
startFrames[startFramesIndex++] = new KeyValue(yAxis.lowerBoundProperty(), yAxis.getLowerBound());
startFrames[startFramesIndex++] = new KeyValue(yAxis.upperBoundProperty(), yAxis.getUpperBound());
stopFrames[stopFramesIndex++] = new KeyValue(yAxis.lowerBoundProperty(), minDataY);
stopFrames[stopFramesIndex++] = new KeyValue(yAxis.upperBoundProperty(), maxDataY);
}
}
zoomAnimation.getKeyFrames().setAll(new KeyFrame(Duration.ZERO, startFrames), new KeyFrame(Duration.millis(zoomDurationMillis.get()), stopFrames));
zoomAnimation.play();
} else {
zoomAnimation.stop();
if (zoomMode == ZoomMode.Both || zoomMode == ZoomMode.Horizontal) {
xAxis.setLowerBound(minDataX);
xAxis.setUpperBound(maxDataX);
}
if (zoomMode == ZoomMode.Both || zoomMode == ZoomMode.Vertical) {
for (ValueAxis<?> yAxis : yAxes) {
final double minDataY = translatePositionToData(yAxis, maxY);
final double maxDataY = translatePositionToData(yAxis, minY);
yAxis.setLowerBound(minDataY);
yAxis.setUpperBound(maxDataY);
}
}
}
zooming.set(false);
}
protected Rectangle2D getComponentArea(Region childRegion) {
double xStart = getXShift(childRegion);
double yStart = getYShift(childRegion);
return new Rectangle2D(xStart, yStart, childRegion.getWidth(), childRegion.getHeight());
}
protected static final double getXShift(Node node) {
return node.getLocalToParentTransform().getTx();
}
protected static final double getYShift(Node node) {
return node.getLocalToParentTransform().getTy();
}
@SuppressWarnings({ "rawtypes", "unchecked" })
protected static final double translatePositionToData(ValueAxis axis, double position) {
return axis.toNumericValue(axis.getValueForDisplay(position));
}
protected class MouseWheelZoomHandler implements EventHandler<ScrollEvent> {
// TODO never getting start event
private boolean ignoring = false;
@Override
public void handle(ScrollEvent event) {
EventType<? extends Event> eventType = event.getEventType();
if (eventType == ScrollEvent.SCROLL_STARTED) {
// mouse wheel events never send SCROLL_STARTED, bullshit
ignoring = false;
} else if (eventType == ScrollEvent.SCROLL_FINISHED) {
// end non-mouse wheel event, never comes either
ignoring = false;
} else if (eventType == ScrollEvent.SCROLL &&
// If we are allowing mouse wheel zooming
mouseWheelZoomAllowed.get() &&
// If we aren't between SCROLL_STARTED and SCROLL_FINISHED
!ignoring &&
// inertia from non-wheel gestures might have touch count of 0
// !event.isInertia() &&
// Only care about vertical wheel events
event.getDeltaY() != 0 &&
// mouse wheel always has touch count of 0
event.getTouchCount() == 0) {
// If we are are doing a zoom animation, stop it. Also of note is that
// we don't zoom the
// mouse wheel zooming. Because the mouse wheel can "fly" and generate a
// lot of events,
// animation doesn't work well. Plus, as the mouse wheel changes the
// view a small amount in
// a predictable way, it "looks like" an animation when you roll it.
// We might experiment with mouse wheel zoom animation in the future,
// though.
zoomAnimation.stop();
final ValueAxis<?> xAxis = (ValueAxis<?>) getXAxis();
// If we wheel zoom on either axis, we restrict zooming to that axis
// only, else if anywhere
// else, including the plot area, zoom both axes.
ZoomMode zoomMode;
final double eventX = event.getX();
final double eventY = event.getY();
if (getComponentArea(xAxis).contains(eventX, eventY)) {
zoomMode = ZoomMode.Horizontal;
} else if (getComponentArea(getPrimaryYAxis()).contains(eventX, eventY)) {
// TODO other y axes
zoomMode = ZoomMode.Vertical;
} else {
zoomMode = ZoomMode.Both;
}
// At this point we are a mouse wheel event, based on everything I've
// read
// Point2D dataCoords = chartInfo.getDataCoordinates( eventX, eventY );
// TODO shift properties
final double xStart = getXShift(xAxis);
final double yStart = getYShift(getPrimaryYAxis());
double distance = event.getDeltaY();
// TODO accelerator property
double zoomAmount = 0.0005 * distance;
if (zoomMode == ZoomMode.Both || zoomMode == ZoomMode.Horizontal) {
// Determine the proportion of change to the lower and upper bounds
// based on how far the
// cursor is along the axis.
final double dataX = translatePositionToData(xAxis, eventX - xStart);
final double xZoomBalance = getBalance(dataX, xAxis.getLowerBound(), xAxis.getUpperBound());
final double xZoomDelta = (xAxis.getUpperBound() - xAxis.getLowerBound()) * zoomAmount;
xAxis.setAutoRanging(false);
xAxis.setLowerBound(xAxis.getLowerBound() - xZoomDelta * xZoomBalance);
xAxis.setUpperBound(xAxis.getUpperBound() + xZoomDelta * (1 - xZoomBalance));
}
if (zoomMode == ZoomMode.Both || zoomMode == ZoomMode.Vertical) {
@SuppressWarnings("unchecked")
final List<ValueAxis<?>> yAxes = ((List<ValueAxis<?>>) (List<?>) getYAxes());
for (final ValueAxis<?> yAxis : yAxes) {
// Determine the proportion of change to the lower and upper bounds
// based on how far the
// cursor is along the axis.
final double dataY = translatePositionToData(yAxis, eventY - yStart);
final double yZoomBalance = getBalance(dataY, yAxis.getLowerBound(), yAxis.getUpperBound());
final double yZoomDelta = (yAxis.getUpperBound() - yAxis.getLowerBound()) * zoomAmount;
yAxis.setAutoRanging(false);
yAxis.setLowerBound(yAxis.getLowerBound() - yZoomDelta * yZoomBalance);
yAxis.setUpperBound(yAxis.getUpperBound() + yZoomDelta * (1 - yZoomBalance));
}
}
}
}
}
protected static final double getBalance(double val, double min, double max) {
if (val <= min)
return 0.0;
else if (val >= max)
return 1.0;
return (val - min) / (max - min);
}
}