/* * ChartUtils.java - Copyright(c) 2013 Joe Pasqua * Provided under the MIT License. See the LICENSE file for details. * Created: Oct 16, 2013 */ package org.noroomattheinn.fxextensions; import com.google.common.collect.Range; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.event.EventHandler; import javafx.event.EventType; import javafx.geometry.Bounds; import javafx.geometry.Point2D; import javafx.scene.Node; import javafx.scene.chart.NumberAxis; import javafx.scene.control.ContextMenu; import javafx.scene.input.MouseButton; import javafx.scene.input.MouseEvent; import javafx.scene.input.ScrollEvent; import org.noroomattheinn.utils.Utils; /** * ChartUtils: A selection of utilities for enhancing a VTLineChart with functions * like scrolling and zooming. * * Notes: There is a tricky piece to this. Scrolling, zooming, etc. are all * based on mouse events. If you put the event filter on the line chart, * the event coords will be in the wrong space. It will be in a coord * system that includes the axes, etc. You want coords relative to * just the chartBackground. HOWEVER, if you attach the eventFilter to the * chart background, you won't receive events if the mouse happens to be over * a line in the chartBackground - it consumes the events! * To deal with this, put the event filter on the chart, but translate * the event coordinates to the chart background. * @author Joe Pasqua <joe at NoRoomAtTheInn dot org> */ public class ChartUtils { /*------------------------------------------------------------------------------ * * Internal State * *----------------------------------------------------------------------------*/ private final VTLineChart lineChart; private final Node chartBackground; private final NumberAxis xAxis, yAxis; private ObjectProperty<Point2D> valueTracker; private ContextMenu contextMenu; /*============================================================================== * ------- ------- * ------- Public Interface To This Class ------- * ------- ------- *============================================================================*/ public ChartUtils(VTLineChart lineChart) { this.lineChart = lineChart; this.chartBackground = lineChart.lookup(".chart-plot-background"); this.xAxis = (NumberAxis)lineChart.getXAxis(); this.yAxis = (NumberAxis)lineChart.getYAxis(); valueTracker = null; contextMenu = null; lineChart.addEventFilter(MouseEvent.ANY, new BasicListener()); } /** * Make this chart scrollable by dragging it with the mouse. */ public void enableScrolling() { lineChart.addEventFilter(MouseEvent.ANY, new ChartScroller()); } /** * Make this chart zoomable using the scrollwheel * @param xRange The allowable zoom range in X * @param yRange The allowable zoom range in Y * @param xTicks Tick units for the x Axis * @param yTicks Tick units for the y Axis */ public void enableZooming(Range<Long> xRange, Range<Double>yRange, int xTicks, int yTicks) { lineChart.addEventFilter(ScrollEvent.ANY, new ChartZoomer(xRange, yRange, xTicks, yTicks)); } /** * Add a context menu & activate it when the user presses the correct mouse button * @param menu The context menu to be displayed */ public void enableContextMenu(ContextMenu menu) { contextMenu = menu; } /** * Get an ObjectProperty corresponding to the x,y values under the mouse * cursor in the chart. Add a ChangeListener to this property to keep * track of changes. * @return ObjectProperty that contains the current x,y values */ public ObjectProperty<Point2D> getValueProperty() { if (valueTracker == null) valueTracker = new SimpleObjectProperty<>(); return valueTracker; } /*------------------------------------------------------------------------------ * * PRIVATE - Utility Methods * *----------------------------------------------------------------------------*/ private Point2D getOffset(Node ancestor, Node leaf) { double xOff = 0; double yOff = 0; Node parent = null; while (parent != ancestor) { Bounds b = leaf.boundsInParentProperty().get(); xOff += b.getMinX(); yOff += b.getMinY(); parent = leaf.getParent(); leaf = parent; } return new Point2D(xOff, yOff); } /*------------------------------------------------------------------------------ * * PRIVATE - Classes that implement the Zooming, Scrolling, and other * mouse based funtions * *----------------------------------------------------------------------------*/ private class BasicListener implements EventHandler<MouseEvent> { @Override public void handle(MouseEvent event) { EventType et = event.getEventType(); Point2D offset = getOffset(lineChart, chartBackground); double x = event.getX() - offset.getX(); double y = event.getY() - offset.getY(); if (et == MouseEvent.MOUSE_MOVED && valueTracker != null) { valueTracker.set(new Point2D(x, y)); } if (contextMenu != null && MouseButton.SECONDARY.equals(event.getButton())) { contextMenu.show(chartBackground, event.getScreenX(), event.getScreenY()); } } } private class ChartScroller implements EventHandler<MouseEvent> { public ObjectProperty<Point2D> hoverValue; private double lastX, lastY = 0; ChartScroller() { this.hoverValue = new SimpleObjectProperty<>(); } private double handle( EventType et, double newVal, double oldVal, NumberAxis axis, double axisSizeInPixels, int scale) { if (et == MouseEvent.MOUSE_PRESSED) { oldVal = newVal; } else if (et == MouseEvent.MOUSE_DRAGGED || et == MouseEvent.MOUSE_MOVED) { if (et == MouseEvent.MOUSE_DRAGGED) { double sizeInValueUnits = axis.getUpperBound() - axis.getLowerBound(); double factor = sizeInValueUnits / axisSizeInPixels; double delta = (newVal - oldVal) * factor * scale; axis.setLowerBound(axis.getLowerBound() - delta); axis.setUpperBound(axis.getUpperBound() - delta); } oldVal = newVal; } return oldVal; } @Override public void handle(MouseEvent event) { EventType et = event.getEventType(); boolean ctrl = event.isControlDown(); boolean shift = event.isShiftDown(); boolean none = !ctrl && !shift; Point2D offset = getOffset(lineChart, chartBackground); double x = event.getX() - offset.getX(); double y = event.getY() - offset.getY(); if (et == MouseEvent.MOUSE_MOVED) { hoverValue.set(new Point2D(x, y)); } if (ctrl || shift) { lastY = handle(et, y, lastY, yAxis, yAxis.getHeight(), -1); } if (none || (shift && !ctrl)) { lastX = handle(et, x, lastX, xAxis, xAxis.getWidth(), 1); } } } private class ChartZoomer implements EventHandler<ScrollEvent> { private final Range<Long>xRange; private final Range<Double>yRange; private final int xTicks, yTicks; ChartZoomer(Range<Long> xRange, Range<Double> yRange, int xTicks, int yTicks) { this.xRange = xRange; this.yRange = yRange; this.xTicks = xTicks; this.yTicks = yTicks; } private void handle( double delta, double mouseLoc, NumberAxis axis, int minorTicks, double min, double max) { if (delta == 0) return; double absDelta = Math.abs(delta); double scalePercent = 1.1; if (absDelta > 10) scalePercent = 1.2; if (absDelta > 100) scalePercent = 1.25; if (absDelta > 200) scalePercent = 1.5; if (delta < 0) scalePercent = 1.0/scalePercent; double current = axis.getValueForDisplay(mouseLoc).doubleValue(); double lowerBound = axis.getLowerBound(); double upperBound = axis.getUpperBound(); double range = upperBound - lowerBound; double newRange = Utils.clamp(range * scalePercent, min, max); double ratio = newRange / range; double newLowerBound = current - ratio * (current - lowerBound); double newUpperBound = newLowerBound + newRange; axis.setAutoRanging(false); axis.setLowerBound(newLowerBound); axis.setUpperBound(newUpperBound); if (minorTicks != 0) axis.setTickUnit((upperBound - lowerBound)/minorTicks); } @Override public void handle(ScrollEvent event) { Point2D offset = getOffset(lineChart, chartBackground); boolean ctrl = event.isControlDown(); boolean shift = event.isShiftDown(); boolean none = !ctrl && ! shift; Bounds b; if (ctrl || shift) { handle(event.getDeltaY(), event.getY()-offset.getY(), yAxis, yTicks, yRange.lowerEndpoint(), yRange.upperEndpoint()); } if (none || (shift && !ctrl)) { handle(event.getDeltaY(), event.getX()-offset.getX(), xAxis, xTicks, xRange.lowerEndpoint(), xRange.upperEndpoint()); } } } }