All Downloads are FREE. Search and download functionalities are using the official Maven repository.

de.gsi.chart.plugins.Zoomer Maven / Gradle / Ivy

package de.gsi.chart.plugins;

import java.util.ArrayDeque;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Predicate;

import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.event.EventHandler;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.geometry.Orientation;
import javafx.geometry.Point2D;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.canvas.Canvas;
import javafx.scene.control.Button;
import javafx.scene.control.Separator;
import javafx.scene.control.Tooltip;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
import javafx.scene.shape.Rectangle;
import javafx.util.Duration;

import org.controlsfx.control.RangeSlider;
import org.kordamp.ikonli.javafx.FontIcon;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import de.gsi.chart.Chart;
import de.gsi.chart.XYChart;
import de.gsi.chart.axes.Axis;
import de.gsi.chart.axes.AxisMode;
import de.gsi.chart.ui.ObservableDeque;
import de.gsi.chart.ui.geometry.Side;

/**
 * Zoom capabilities along X, Y or both axis. For every zoom-in operation the current X and Y range is remembered and
 * restored upon following zoom-out operation.
 * 
    *
  • zoom-in - triggered on {@link MouseEvent#MOUSE_PRESSED MOUSE_PRESSED} event that is accepted by * {@link #getZoomInMouseFilter() zoom-in filter}. It shows a zooming rectangle determining the zoom window once mouse * button is released.
  • *
  • zoom-out - triggered on {@link MouseEvent#MOUSE_CLICKED MOUSE_CLICKED} event that is accepted by * {@link #getZoomOutMouseFilter() zoom-out filter}. It restores the previous ranges on both axis.
  • *
  • zoom-origin - triggered on {@link MouseEvent#MOUSE_CLICKED MOUSE_CLICKED} event that is accepted by * {@link #getZoomOriginMouseFilter() zoom-origin filter}. It restores the initial ranges on both axis as it was at the * moment of the first zoom-in operation.
  • *
*

* CSS class name of the zoom rectangle: {@value #STYLE_CLASS_ZOOM_RECT}. *

* * @author Grzegorz Kruk * @author rstein - adapted to XYChartPane, corrected some features (mouse zoom events outside canvas, auto-ranging on * zoom-out, scrolling, toolbar) */ public class Zoomer extends ChartPlugin { private static final Logger LOGGER = LoggerFactory.getLogger(Zoomer.class); public static final String ZOOMER_OMIT_AXIS = "OmitAxisZoom"; public static final String STYLE_CLASS_ZOOM_RECT = "chart-zoom-rect"; private static final int ZOOM_RECT_MIN_SIZE = 5; private static final Duration DEFAULT_ZOOM_DURATION = Duration.millis(500); private static final int DEFAULT_AUTO_ZOOM_THRESHOLD = 15; // [pixels] private static final int DEFAULT_FLICKER_THRESHOLD = 3; // [pixels] private static final int FONT_SIZE = 22; private static final String ICON_ZOOM_OUT = "fa-arrows-alt:" + FONT_SIZE; private static final String ICON_ZOOM_HV = "fa-arrows:" + FONT_SIZE; private static final String ICON_ZOOM_H = "fa-arrows-h:" + FONT_SIZE; private static final String ICON_ZOOM_V = "fa-arrows-v:" + FONT_SIZE; /** * Default pan mouse filter passing on left mouse button with {@link MouseEvent#isControlDown() control key down}. */ public static final Predicate DEFAULT_MOUSE_FILTER = MouseEventsHelper::isOnlyMiddleButtonDown; private double panShiftX; private double panShiftY; private Point2D previousMouseLocation; private final BooleanProperty enablePanner = new SimpleBooleanProperty(this, "enablePanner", true); private final BooleanProperty autoZoomEnable = new SimpleBooleanProperty(this, "enableAutoZoom", false); private final IntegerProperty autoZoomThreshold = new SimpleIntegerProperty(this, "autoZoomThreshold", DEFAULT_AUTO_ZOOM_THRESHOLD); private final EventHandler panStartHandler = event -> { if (isPannerEnabled() && DEFAULT_MOUSE_FILTER.test(event)) { panStarted(event); event.consume(); } }; private final EventHandler panDragHandler = event -> { if (panOngoing()) { panDragged(event); event.consume(); } }; private final EventHandler panEndHandler = event -> { if (panOngoing()) { panEnded(); event.consume(); } }; /** * Default zoom-in mouse filter passing on left mouse button (only). */ public final Predicate defaultZoomInMouseFilter = event -> MouseEventsHelper.isOnlyPrimaryButtonDown(event) && MouseEventsHelper.modifierKeysUp(event) && isMouseEventWithinCanvas(event); /** * Default zoom-out mouse filter passing on right mouse button (only). */ public final Predicate defaultZoomOutMouseFilter = event -> MouseEventsHelper.isOnlySecondaryButtonDown(event) && MouseEventsHelper.modifierKeysUp(event) && isMouseEventWithinCanvas(event); /** * Default zoom-origin mouse filter passing on right mouse button with {@link MouseEvent#isControlDown() control key * down}. */ public final Predicate defaultZoomOriginFilter = event -> MouseEventsHelper.isOnlySecondaryButtonDown(event) && MouseEventsHelper.isOnlyCtrlModifierDown(event) && isMouseEventWithinCanvas(event); /** * Default zoom scroll filter with {@link MouseEvent#isControlDown() control key down}. */ public final Predicate defaultScrollFilter = this::isMouseEventWithinCanvas; private Predicate zoomInMouseFilter = defaultZoomInMouseFilter; private Predicate zoomOutMouseFilter = defaultZoomOutMouseFilter; private Predicate zoomOriginMouseFilter = defaultZoomOriginFilter; private Predicate zoomScrollFilter = defaultScrollFilter; private final Rectangle zoomRectangle = new Rectangle(); private Point2D zoomStartPoint; private Point2D zoomEndPoint; private final ObservableDeque> zoomStacks = new ObservableDeque<>(new ArrayDeque<>()); private final HBox zoomButtons = getZoomInteractorBar(); private ZoomRangeSlider xRangeSlider; private boolean xRangeSliderInit; private final ObservableList omitAxisZoom = FXCollections.observableArrayList(); private final ObjectProperty axisMode = new SimpleObjectProperty<>(this, "axisMode", AxisMode.XY) { @Override protected void invalidated() { Objects.requireNonNull(get(), "The " + getName() + " must not be null"); } }; private Cursor originalCursor; private final ObjectProperty dragCursor = new SimpleObjectProperty<>(this, "dragCursor"); private final ObjectProperty zoomCursor = new SimpleObjectProperty<>(this, "zoomCursor"); private final BooleanProperty animated = new SimpleBooleanProperty(this, "animated", false); private final ObjectProperty zoomDuration = new SimpleObjectProperty<>(this, "zoomDuration", DEFAULT_ZOOM_DURATION) { @Override protected void invalidated() { Objects.requireNonNull(get(), "The " + getName() + " must not be null"); } }; private final BooleanProperty updateTickUnit = new SimpleBooleanProperty(this, "updateTickUnit", true); private final BooleanProperty sliderVisible = new SimpleBooleanProperty(this, "sliderVisible", true); private final EventHandler zoomInStartHandler = event -> { if (getZoomInMouseFilter() == null || getZoomInMouseFilter().test(event)) { zoomInStarted(event); event.consume(); } }; private final EventHandler zoomInDragHandler = event -> { if (zoomOngoing()) { zoomInDragged(event); event.consume(); } }; private final EventHandler zoomInEndHandler = event -> { if (zoomOngoing()) { zoomInEnded(); event.consume(); } }; private final EventHandler zoomScrollHandler = event -> { if (getZoomScrollFilter() == null || getZoomScrollFilter().test(event)) { final AxisMode mode = getAxisMode(); if (zoomStacks.isEmpty()) { makeSnapshotOfView(); } for (final Axis axis : getChart().getAxes()) { if (axis.getSide() == null || !(axis.getSide().isHorizontal() ? mode.allowsX() : mode.allowsY()) || isOmitZoomInternal(axis)) { continue; } Zoomer.zoomOnAxis(axis, event); } event.consume(); } }; private final EventHandler zoomOutHandler = event -> { if (getZoomOutMouseFilter() == null || getZoomOutMouseFilter().test(event)) { final boolean zoomOutPerformed = zoomOut(); if (zoomOutPerformed) { event.consume(); } } }; private final EventHandler zoomOriginHandler = event -> { if (getZoomOriginMouseFilter() == null || getZoomOriginMouseFilter().test(event)) { final boolean zoomOutPerformed = zoomOrigin(); if (zoomOutPerformed) { event.consume(); } } }; /** * Creates a new instance of Zoomer with animation disabled and with {@link #axisModeProperty() zoomMode} * initialized to {@link AxisMode#XY}. */ public Zoomer() { this(AxisMode.XY); } /** * Creates a new instance of Zoomer with animation disabled. * * @param zoomMode initial value of {@link #axisModeProperty() zoomMode} property */ public Zoomer(final AxisMode zoomMode) { this(zoomMode, false); } /** * Creates a new instance of Zoomer. * * @param zoomMode initial value of {@link #axisModeProperty() axisMode} property * @param animated initial value of {@link #animatedProperty() animated} property */ public Zoomer(final AxisMode zoomMode, final boolean animated) { super(); setAxisMode(zoomMode); setAnimated(animated); setZoomCursor(Cursor.CROSSHAIR); setDragCursor(Cursor.CLOSED_HAND); zoomRectangle.setManaged(false); zoomRectangle.getStyleClass().add(STYLE_CLASS_ZOOM_RECT); getChartChildren().add(zoomRectangle); registerMouseHandlers(); chartProperty().addListener((change, o, n) -> { if (o != null) { o.getToolBar().getChildren().remove(zoomButtons); o.getPlotArea().setBottom(null); xRangeSlider.prefWidthProperty().unbind(); } if (n != null) { if (isAddButtonsToToolBar()) { n.getToolBar().getChildren().add(zoomButtons); } /* always create the slider, even if not visible at first */ final ZoomRangeSlider slider = new ZoomRangeSlider(n); if (isSliderVisible()) { n.getPlotArea().setBottom(slider); xRangeSlider.prefWidthProperty().bind(n.getCanvasForeground().widthProperty()); } } }); } /** * Creates a new instance of Zoomer with {@link #axisModeProperty() zoomMode} initialized to {@link AxisMode#XY}. * * @param animated initial value of {@link #animatedProperty() animated} property */ public Zoomer(final boolean animated) { this(AxisMode.XY, animated); } /** * When {@code true} zooming will be animated. By default it's {@code false}. * * @return the animated property * @see #zoomDurationProperty() */ public final BooleanProperty animatedProperty() { return animated; } /** * When {@code true} auto-zooming feature is being enabled, ie. more horizontal drags do x-zoom only, more vertical * drags do y-zoom only, and xy-zoom otherwise * * @return the autoZoom property */ public final BooleanProperty autoZoomEnabledProperty() { return autoZoomEnable; } public IntegerProperty autoZoomThresholdProperty() { return autoZoomThreshold; } /** * The mode defining axis along which the zoom can be performed. By default initialised to {@link AxisMode#XY}. * * @return the axis mode property */ public final ObjectProperty axisModeProperty() { return axisMode; } /** * Clears the stack of zoom windows saved during zoom-in operations. */ public void clear() { zoomStacks.clear(); } /** * Clears the stack of zoom states saved during zoom-in operations for a specific given axis. * * @param axis axis zoom history that shall be removed */ public void clear(final Axis axis) { for (Map stackStage : zoomStacks) { stackStage.remove(axis); } } /** * Mouse cursor to be used during drag operation. * * @return the mouse cursor property */ public final ObjectProperty dragCursorProperty() { return dragCursor; } public int getAutoZoomThreshold() { return autoZoomThresholdProperty().get(); } /** * Returns the value of the {@link #axisModeProperty()}. * * @return current mode */ public final AxisMode getAxisMode() { return axisModeProperty().get(); } /** * Returns the value of the {@link #dragCursorProperty()} * * @return the current cursor */ public final Cursor getDragCursor() { return dragCursorProperty().get(); } public RangeSlider getRangeSlider() { return xRangeSlider; } /** * Returns the value of the {@link #zoomCursorProperty()} * * @return the current cursor */ public final Cursor getZoomCursor() { return zoomCursorProperty().get(); } /** * Returns the value of the {@link #zoomDurationProperty()}. * * @return the current zoom duration */ public final Duration getZoomDuration() { return zoomDurationProperty().get(); } /** * Returns zoom-in mouse event filter. * * @return zoom-in mouse event filter * @see #setZoomInMouseFilter(Predicate) */ public Predicate getZoomInMouseFilter() { return zoomInMouseFilter; } public HBox getZoomInteractorBar() { final Separator separator = new Separator(); separator.setOrientation(Orientation.VERTICAL); final HBox buttonBar = new HBox(); buttonBar.setPadding(new Insets(1, 1, 1, 1)); final Button zoomOut = new Button(null, new FontIcon(ICON_ZOOM_OUT)); zoomOut.setPadding(new Insets(3, 3, 3, 3)); zoomOut.setTooltip(new Tooltip("zooms to origin and enables auto-ranging")); final Button zoomModeXY = new Button(null, new FontIcon(ICON_ZOOM_HV)); zoomModeXY.setPadding(new Insets(3, 3, 3, 3)); zoomModeXY.setTooltip(new Tooltip("set zoom-mode to X & Y range (N.B. disables auto-ranging)")); final Button zoomModeX = new Button(null, new FontIcon(ICON_ZOOM_H)); zoomModeX.setPadding(new Insets(3, 3, 3, 3)); zoomModeX.setTooltip(new Tooltip("set zoom-mode to X range (N.B. disables auto-ranging)")); final Button zoomModeY = new Button(null, new FontIcon(ICON_ZOOM_V)); zoomModeY.setPadding(new Insets(3, 3, 3, 3)); zoomModeY.setTooltip(new Tooltip("set zoom-mode to Y range (N.B. disables auto-ranging)")); zoomOut.setOnAction(evt -> { zoomOrigin(); getChart().getAxes().forEach(axis -> axis.setAutoRanging(true)); }); zoomModeXY.setOnAction(evt -> setAxisMode(AxisMode.XY)); zoomModeX.setOnAction(evt -> setAxisMode(AxisMode.X)); zoomModeY.setOnAction(evt -> setAxisMode(AxisMode.Y)); buttonBar.getChildren().addAll(separator, zoomOut, zoomModeXY, zoomModeX, zoomModeY); return buttonBar; } /** * Returns zoom-origin mouse filter. * * @return zoom-origin mouse filter * @see #setZoomOriginMouseFilter(Predicate) */ public Predicate getZoomOriginMouseFilter() { return zoomOriginMouseFilter; } /** * Returns zoom-out mouse filter. * * @return zoom-out mouse filter * @see #setZoomOutMouseFilter(Predicate) */ public Predicate getZoomOutMouseFilter() { return zoomOutMouseFilter; } /** * Returns zoom-scroll filter. * * @return predicate of filter */ public Predicate getZoomScrollFilter() { return zoomScrollFilter; } /** * Returns the value of the {@link #animatedProperty()}. * * @return {@code true} if zoom is animated, {@code false} otherwise * @see #getZoomDuration() */ public final boolean isAnimated() { return animatedProperty().get(); } /** * @return {@code true} if auto-zooming feature is being enabled, ie. more horizontal drags do x-zoom only, more * vertical drags do y-zoom only, and xy-zoom otherwise */ public final boolean isAutoZoomEnabled() { return autoZoomEnabledProperty().get(); } public final boolean isPannerEnabled() { return pannerEnabledProperty().get(); } /** * Returns the value of the {@link #sliderVisibleProperty()}. * * @return {@code true} if horizontal range slider is shown */ public final boolean isSliderVisible() { return sliderVisibleProperty().get(); } /** * Returns the value of the {@link #animatedProperty()}. * * @return {@code true} if zoom is animated, {@code false} otherwise * @see #getZoomDuration() */ public final boolean isUpdateTickUnit() { return updateTickUnitProperty().get(); } /** * @return list of axes that shall be ignored when performing zoom-in or outs */ public final ObservableList omitAxisZoomList() { return omitAxisZoom; } /** * When {@code true} pressing the middle mouse button and dragging pans the plot * * @return the pannerEnabled property */ public final BooleanProperty pannerEnabledProperty() { return enablePanner; } /** * Sets the value of the {@link #animatedProperty()}. * * @param value if {@code true} zoom will be animated * @see #setZoomDuration(Duration) */ public final void setAnimated(final boolean value) { animatedProperty().set(value); } /** * Sets the value of the {@link #autoZoomEnabledProperty()}. * * @param state if {@code true} auto-zooming feature is being enabled, ie. more horizontal drags do x-zoom only, * more vertical drags do y-zoom only, and xy-zoom otherwise */ public final void setAutoZoomEnabled(final boolean state) { autoZoomEnabledProperty().set(state); } public void setAutoZoomThreshold(final int value) { autoZoomThresholdProperty().set(value); } /** * Sets the value of the {@link #axisModeProperty()}. * * @param mode the mode to be used */ public final void setAxisMode(final AxisMode mode) { axisModeProperty().set(mode); } /** * Sets value of the {@link #dragCursorProperty()}. * * @param cursor the cursor to be used by the plugin */ public final void setDragCursor(final Cursor cursor) { dragCursorProperty().set(cursor); } /** * Sets the value of the {@link #pannerEnabledProperty()}. * * @param state if {@code true} the panner (middle mouse button is enabled */ public final void setPannerEnabled(final boolean state) { pannerEnabledProperty().set(state); } /** * Sets the value of the {@link #sliderVisibleProperty()}. * * @param state if {@code true} the horizontal range slider is shown */ public final void setSliderVisible(final boolean state) { sliderVisibleProperty().set(state); } /** * Sets the value of the {@link #animatedProperty()}. * * @param value if {@code true} zoom will be animated * @see #setZoomDuration(Duration) */ public final void setUpdateTickUnit(final boolean value) { updateTickUnitProperty().set(value); } /** * Sets value of the {@link #zoomCursorProperty()}. * * @param cursor the cursor to be used by the plugin */ public final void setZoomCursor(final Cursor cursor) { zoomCursorProperty().set(cursor); } /** * Sets the value of the {@link #zoomDurationProperty()}. * * @param duration duration of the zoom */ public final void setZoomDuration(final Duration duration) { zoomDurationProperty().set(duration); } /** * Sets filter on {@link MouseEvent#DRAG_DETECTED DRAG_DETECTED} events that should start zoom-in operation. * * @param zoomInMouseFilter the filter to accept zoom-in mouse event. If {@code null} then any DRAG_DETECTED event * will start zoom-in operation. By default it's set to {@link #defaultZoomInMouseFilter}. * @see #getZoomInMouseFilter() */ public void setZoomInMouseFilter(final Predicate zoomInMouseFilter) { this.zoomInMouseFilter = zoomInMouseFilter; } /** * Sets filter on {@link MouseEvent#MOUSE_CLICKED MOUSE_CLICKED} events that should trigger zoom-origin operation. * * @param zoomOriginMouseFilter the filter to accept zoom-origin mouse event. If {@code null} then any MOUSE_CLICKED * event will start zoom-origin operation. By default it's set to {@link #defaultZoomOriginFilter}. * @see #getZoomOriginMouseFilter() */ public void setZoomOriginMouseFilter(final Predicate zoomOriginMouseFilter) { this.zoomOriginMouseFilter = zoomOriginMouseFilter; } /** * Sets filter on {@link MouseEvent#MOUSE_CLICKED MOUSE_CLICKED} events that should trigger zoom-out operation. * * @param zoomOutMouseFilter the filter to accept zoom-out mouse event. If {@code null} then any MOUSE_CLICKED event * will start zoom-out operation. By default it's set to {@link #defaultZoomOutMouseFilter}. * @see #getZoomOutMouseFilter() */ public void setZoomOutMouseFilter(final Predicate zoomOutMouseFilter) { this.zoomOutMouseFilter = zoomOutMouseFilter; } /** * Sets filter on {@link MouseEvent#MOUSE_CLICKED MOUSE_CLICKED} events that should trigger zoom-origin operation. * * @param zoomScrollFilter filter */ public void setZoomScrollFilter(final Predicate zoomScrollFilter) { this.zoomScrollFilter = zoomScrollFilter; } /** * When {@code true} an additional horizontal range slider is shown in a HiddeSidesPane at the bottom. By default * it's {@code true}. * * @return the sliderVisible property * @see #getRangeSlider() */ public final BooleanProperty sliderVisibleProperty() { return sliderVisible; } /** * When {@code true} zooming will be animated. By default it's {@code false}. * * @return the animated property * @see #zoomDurationProperty() */ public final BooleanProperty updateTickUnitProperty() { return updateTickUnit; } /** * Mouse cursor to be used during zoom operation. * * @return the mouse cursor property */ public final ObjectProperty zoomCursorProperty() { return zoomCursor; } /** * Duration of the animated zoom (in and out). Used only when {@link #animatedProperty()} is set to {@code true}. By * default initialised to 500ms. * * @return the zoom duration property */ public final ObjectProperty zoomDurationProperty() { return zoomDuration; } public boolean zoomOrigin() { clearZoomStackIfAxisAutoRangingIsEnabled(); final Map zoomWindows = zoomStacks.peekLast(); if (zoomWindows == null || zoomWindows.isEmpty()) { return false; } clear(); performZoom(zoomWindows, false); if (xRangeSlider != null) { xRangeSlider.reset(); } for (Axis axis : getChart().getAxes()) { axis.forceRedraw(); } return true; } /** * @return observable queue (allows to attach ListChangeListener listener) */ public ObservableDeque> zoomStackDeque() { return zoomStacks; } /** * While performing zoom-in on all charts we disable auto-ranging on axes (depending on the axisMode) so if user has * enabled back the auto-ranging - he wants the chart to adapt to the data. Therefore keeping the zoom stack doesn't * make sense - performing zoom-out would again disable auto-ranging and put back ranges saved during the previous * zoom-in operation. Also if user enables auto-ranging between two zoom-in operations, the saved zoom stack becomes * irrelevant. */ private void clearZoomStackIfAxisAutoRangingIsEnabled() { Chart chart = getChart(); if (chart == null) { return; } for (Axis axis : getChart().getAxes()) { if (axis.getSide().isHorizontal()) { if (getAxisMode().allowsX() && (axis.isAutoRanging() || axis.isAutoGrowRanging())) { clear(axis); } } else { if (getAxisMode().allowsY() && (axis.isAutoRanging() || axis.isAutoGrowRanging())) { clear(axis); } } } } private Map getZoomDataWindows() { ConcurrentHashMap axisStateMap = new ConcurrentHashMap<>(); if (getChart() == null) { return axisStateMap; } final double minX = zoomRectangle.getX(); final double minY = zoomRectangle.getY() + zoomRectangle.getHeight(); final double maxX = zoomRectangle.getX() + zoomRectangle.getWidth(); final double maxY = zoomRectangle.getY(); // pixel coordinates w.r.t. plot area final Point2D minPlotCoordinate = getChart().toPlotArea(minX, minY); final Point2D maxPlotCoordinate = getChart().toPlotArea(maxX, maxY); for (Axis axis : getChart().getAxes()) { double dataMin; double dataMax; if (axis.getSide().isVertical()) { dataMin = axis.getValueForDisplay(minPlotCoordinate.getY()); dataMax = axis.getValueForDisplay(maxPlotCoordinate.getY()); } else { dataMin = axis.getValueForDisplay(minPlotCoordinate.getX()); dataMax = axis.getValueForDisplay(maxPlotCoordinate.getX()); } switch (getAxisMode()) { case X: if (axis.getSide().isHorizontal()) { axisStateMap.put(axis, new ZoomState(dataMin, dataMax, axis.isAutoRanging(), axis.isAutoGrowRanging())); } break; case Y: if (axis.getSide().isVertical()) { axisStateMap.put(axis, new ZoomState(dataMin, dataMax, axis.isAutoRanging(), axis.isAutoGrowRanging())); } break; case XY: default: axisStateMap.put(axis, new ZoomState(dataMin, dataMax, axis.isAutoRanging(), axis.isAutoGrowRanging())); break; } } return axisStateMap; } private void installDragCursor() { final Region chart = getChart(); originalCursor = chart.getCursor(); if (getDragCursor() != null) { chart.setCursor(getDragCursor()); } } private void installZoomCursor() { final Region chart = getChart(); originalCursor = chart.getCursor(); if (getDragCursor() != null) { chart.setCursor(getZoomCursor()); } } /** * @param axis the axis to be modified * @return {@code true} if axis is zoomable, {@code false} otherwise */ private boolean isOmitZoomInternal(final Axis axis) { final boolean propertyState = Zoomer.isOmitZoom(axis); return propertyState || omitAxisZoomList().contains(axis); } /** * take a snapshot of present view (needed for scroll zoom interactor */ private void makeSnapshotOfView() { final Bounds bounds = getChart().getBoundsInLocal(); final double minX = bounds.getMinX(); final double minY = bounds.getMinY(); final double maxX = bounds.getMaxX(); final double maxY = bounds.getMaxY(); zoomRectangle.setX(bounds.getMinX()); zoomRectangle.setY(bounds.getMinY()); zoomRectangle.setWidth(maxX - minX); zoomRectangle.setHeight(maxY - minY); pushCurrentZoomWindows(); performZoom(getZoomDataWindows(), true); zoomRectangle.setVisible(false); } private void panChart(final Chart chart, final Point2D mouseLocation) { if (!(chart instanceof XYChart)) { return; } final double oldMouseX = previousMouseLocation.getX(); final double oldMouseY = previousMouseLocation.getY(); final double newMouseX = mouseLocation.getX(); final double newMouseY = mouseLocation.getY(); panShiftX += oldMouseX - newMouseX; panShiftY += oldMouseY - newMouseY; for (final Axis axis : chart.getAxes()) { if (axis.getSide() == null || isOmitZoomInternal(axis)) { continue; } final Side side = axis.getSide(); final double prevData = axis.getValueForDisplay(side.isHorizontal() ? oldMouseX : oldMouseY); final double newData = axis.getValueForDisplay(side.isHorizontal() ? newMouseX : newMouseY); final double offset = prevData - newData; final boolean allowsShift = side.isHorizontal() ? getAxisMode().allowsX() : getAxisMode().allowsY(); if (!hasBoundedRange(axis) && allowsShift) { axis.setAutoRanging(false); // shift bounds axis.set(axis.getMin() + offset, axis.getMax() + offset); } } previousMouseLocation = mouseLocation; } private void panDragged(final MouseEvent event) { final Point2D mouseLocation = getLocationInPlotArea(event); panChart(getChart(), mouseLocation); previousMouseLocation = mouseLocation; } private void panEnded() { Chart chart = getChart(); if (chart == null || panShiftX == 0.0 || panShiftY == 0.0 || previousMouseLocation == null) { return; } for (final Axis axis : chart.getAxes()) { if (axis.getSide() == null || isOmitZoomInternal(axis)) { continue; } final Side side = axis.getSide(); final boolean allowsShift = side.isHorizontal() ? getAxisMode().allowsX() : getAxisMode().allowsY(); if (!hasBoundedRange(axis) && allowsShift) { axis.setAutoRanging(false); } } panShiftX = 0.0; panShiftY = 0.0; previousMouseLocation = null; uninstallCursor(); } protected static boolean hasBoundedRange(Axis axis) { return axis.minProperty().isBound() || axis.maxProperty().isBound(); } private boolean panOngoing() { return previousMouseLocation != null; } private void panStarted(final MouseEvent event) { previousMouseLocation = getLocationInPlotArea(event); panShiftX = 0.0; panShiftY = 0.0; installDragCursor(); clearZoomStackIfAxisAutoRangingIsEnabled(); pushCurrentZoomWindows(); } private void performZoom(Entry zoomStateEntry, final boolean isZoomIn) { ZoomState zoomState = zoomStateEntry.getValue(); if (zoomState.zoomRangeMax - zoomState.zoomRangeMin == 0) { LOGGER.atDebug().log("Cannot zoom in deeper than numerical precision"); return; } Axis axis = zoomStateEntry.getKey(); if (isZoomIn && ((axis.getSide().isHorizontal() && getAxisMode().allowsX()) || (axis.getSide().isVertical() && getAxisMode().allowsY()))) { // perform only zoom-in if axis is horizontal (or vertical) and corresponding horizontal (or vertical) // zooming is allowed axis.setAutoRanging(false); } if (isAnimated()) { if (!hasBoundedRange(axis)) { final Timeline xZoomAnimation = new Timeline(); xZoomAnimation.getKeyFrames().setAll( new KeyFrame(Duration.ZERO, new KeyValue(axis.minProperty(), axis.getMin()), new KeyValue(axis.maxProperty(), axis.getMax())), new KeyFrame(getZoomDuration(), new KeyValue(axis.minProperty(), zoomState.zoomRangeMin), new KeyValue(axis.maxProperty(), zoomState.zoomRangeMax))); xZoomAnimation.play(); } } else { if (!hasBoundedRange(axis)) { // only update if this axis is not bound to another (e.g. auto-range) managed axis) axis.set(zoomState.zoomRangeMin, zoomState.zoomRangeMax); } } if (!isZoomIn) { axis.setAutoRanging(zoomState.wasAutoRanging); axis.setAutoGrowRanging(zoomState.wasAutoGrowRanging); } } private void performZoom(Map zoomWindows, final boolean isZoomIn) { for (final Entry entry : zoomWindows.entrySet()) { if (!isOmitZoomInternal(entry.getKey())) { performZoom(entry, isZoomIn); } } for (Axis a : getChart().getAxes()) { a.forceRedraw(); } } private void performZoomIn() { clearZoomStackIfAxisAutoRangingIsEnabled(); pushCurrentZoomWindows(); performZoom(getZoomDataWindows(), true); } private void pushCurrentZoomWindows() { if (getChart() == null) { return; } ConcurrentHashMap axisStateMap = new ConcurrentHashMap<>(); for (Axis axis : getChart().getAxes()) { switch (getAxisMode()) { case X: if (axis.getSide().isHorizontal()) { axisStateMap.put(axis, new ZoomState(axis.getMin(), axis.getMax(), axis.isAutoRanging(), axis.isAutoGrowRanging())); // NOPMD necessary in-loop instantiation } break; case Y: if (axis.getSide().isVertical()) { axisStateMap.put(axis, new ZoomState(axis.getMin(), axis.getMax(), axis.isAutoRanging(), axis.isAutoGrowRanging())); // NOPMD necessary in-loop instantiation } break; case XY: default: axisStateMap.put(axis, new ZoomState(axis.getMin(), axis.getMax(), axis.isAutoRanging(), axis.isAutoGrowRanging())); // NOPMD // necessary // in-loop // instantiation break; } } if (!axisStateMap.keySet().isEmpty()) { zoomStacks.addFirst(axisStateMap); } } private void registerMouseHandlers() { registerInputEventHandler(MouseEvent.MOUSE_PRESSED, zoomInStartHandler); registerInputEventHandler(MouseEvent.MOUSE_DRAGGED, zoomInDragHandler); registerInputEventHandler(MouseEvent.MOUSE_RELEASED, zoomInEndHandler); registerInputEventHandler(MouseEvent.MOUSE_CLICKED, zoomOutHandler); registerInputEventHandler(MouseEvent.MOUSE_CLICKED, zoomOriginHandler); registerInputEventHandler(ScrollEvent.SCROLL, zoomScrollHandler); registerInputEventHandler(MouseEvent.MOUSE_PRESSED, panStartHandler); registerInputEventHandler(MouseEvent.MOUSE_DRAGGED, panDragHandler); registerInputEventHandler(MouseEvent.MOUSE_RELEASED, panEndHandler); } private void uninstallCursor() { getChart().setCursor(originalCursor); } private void zoomInDragged(final MouseEvent event) { final Bounds plotAreaBounds = getChart().getPlotArea().getBoundsInLocal(); zoomEndPoint = limitToPlotArea(event, plotAreaBounds); double zoomRectX = plotAreaBounds.getMinX(); double zoomRectY = plotAreaBounds.getMinY(); double zoomRectWidth = plotAreaBounds.getWidth(); double zoomRectHeight = plotAreaBounds.getHeight(); if (isAutoZoomEnabled()) { final double diffX = zoomEndPoint.getX() - zoomStartPoint.getX(); final double diffY = zoomEndPoint.getY() - zoomStartPoint.getY(); final int limit = Math.abs(getAutoZoomThreshold()); // pixel distance based algorithm + aspect ratio to prevent flickering when starting selection final boolean isZoomX = Math.abs(diffY) <= limit && Math.abs(diffX) >= limit && Math.abs(diffX / diffY) > DEFAULT_FLICKER_THRESHOLD; final boolean isZoomY = Math.abs(diffX) <= limit && Math.abs(diffY) >= limit && Math.abs(diffY / diffX) > DEFAULT_FLICKER_THRESHOLD; // alternate angle-based algorithm // final int angle = (int) Math.toDegrees(Math.atan2(diffY, diffX)); // final boolean isZoomX = Math.abs(angle) <= limit || Math.abs((angle - 180) % 180) <= limit; // final boolean isZoomY = Math.abs((angle - 90) % 180) <= limit || Math.abs((angle - 270) % 180) <= limit; if (isZoomX) { this.setAxisMode(AxisMode.X); } else if (isZoomY) { this.setAxisMode(AxisMode.Y); } else { this.setAxisMode(AxisMode.XY); } } if (getAxisMode().allowsX()) { zoomRectX = Math.min(zoomStartPoint.getX(), zoomEndPoint.getX()); zoomRectWidth = Math.abs(zoomEndPoint.getX() - zoomStartPoint.getX()); } if (getAxisMode().allowsY()) { zoomRectY = Math.min(zoomStartPoint.getY(), zoomEndPoint.getY()); zoomRectHeight = Math.abs(zoomEndPoint.getY() - zoomStartPoint.getY()); } zoomRectangle.setX(zoomRectX); zoomRectangle.setY(zoomRectY); zoomRectangle.setWidth(zoomRectWidth); zoomRectangle.setHeight(zoomRectHeight); } private void zoomInEnded() { zoomRectangle.setVisible(false); if (zoomRectangle.getWidth() > ZOOM_RECT_MIN_SIZE && zoomRectangle.getHeight() > ZOOM_RECT_MIN_SIZE) { performZoomIn(); } zoomStartPoint = zoomEndPoint = null; uninstallCursor(); } private void zoomInStarted(final MouseEvent event) { zoomStartPoint = new Point2D(event.getX(), event.getY()); zoomRectangle.setX(zoomStartPoint.getX()); zoomRectangle.setY(zoomStartPoint.getY()); zoomRectangle.setWidth(0); zoomRectangle.setHeight(0); zoomRectangle.setVisible(true); installZoomCursor(); } private boolean zoomOngoing() { return zoomStartPoint != null; } private boolean zoomOut() { clearZoomStackIfAxisAutoRangingIsEnabled(); final Map zoomWindows = zoomStacks.pollFirst(); if (zoomWindows == null || zoomWindows.isEmpty()) { return zoomOrigin(); } performZoom(zoomWindows, false); return true; } /** * @param axis the axis to be modified * @return {@code true} if axis is zoomable, {@code false} otherwise */ public static boolean isOmitZoom(final Axis axis) { return (axis instanceof Node) && ((Node) axis).getProperties().get(ZOOMER_OMIT_AXIS) == Boolean.TRUE; } /** * @param axis the axis to be modified * @param state true: axis is not taken into account when zooming */ public static void setOmitZoom(final Axis axis, final boolean state) { if (!(axis instanceof Node)) { return; } if (state) { ((Node) axis).getProperties().put(ZOOMER_OMIT_AXIS, true); } else { ((Node) axis).getProperties().remove(ZOOMER_OMIT_AXIS); } } /** * limits the mouse event position to the min/max range of the canavs (N.B. event can occur to be * negative/larger/outside than the canvas) This is to avoid zooming outside the visible canvas range * * @param event the mouse event * @param plotBounds of the canvas * @return the clipped mouse location */ private static Point2D limitToPlotArea(final MouseEvent event, final Bounds plotBounds) { final double limitedX = Math.max(Math.min(event.getX() - plotBounds.getMinX(), plotBounds.getMaxX()), plotBounds.getMinX()); final double limitedY = Math.max(Math.min(event.getY() - plotBounds.getMinY(), plotBounds.getMaxY()), plotBounds.getMinY()); return new Point2D(limitedX, limitedY); } private static void zoomOnAxis(final Axis axis, final ScrollEvent event) { if (hasBoundedRange(axis) || event.getDeltaY() == 0) { return; } final boolean isZoomIn = event.getDeltaY() > 0; final boolean isHorizontal = axis.getSide().isHorizontal(); final double mousePos = isHorizontal ? event.getX() : event.getY(); final double posOnAxis = axis.getValueForDisplay(mousePos); final double max = axis.getMax(); final double min = axis.getMin(); final double scaling = isZoomIn ? 0.9 : 1 / 0.9; final double diffHalf1 = scaling * Math.abs(posOnAxis - min); final double diffHalf2 = scaling * Math.abs(max - posOnAxis); axis.set(posOnAxis - diffHalf1, posOnAxis + diffHalf2); axis.forceRedraw(); } /** * small class used to remember whether the autorange axis was on/off to be able to restore the original state on * unzooming */ public static class ZoomState { protected double zoomRangeMin; protected double zoomRangeMax; protected boolean wasAutoRanging; protected boolean wasAutoGrowRanging; private ZoomState(final double zoomRangeMin, final double zoomRangeMax, final boolean isAutoRanging, final boolean isAutoGrowRanging) { this.zoomRangeMin = Math.min(zoomRangeMin, zoomRangeMax); this.zoomRangeMax = Math.max(zoomRangeMin, zoomRangeMax); this.wasAutoRanging = isAutoRanging; this.wasAutoGrowRanging = isAutoGrowRanging; } /** * @return the zoomRangeMax */ public double getZoomRangeMax() { return zoomRangeMax; } /** * @return the zoomRangeMin */ public double getZoomRangeMin() { return zoomRangeMin; } @Override public String toString() { return "ZoomState[zoomRangeMin= " + zoomRangeMin + ", zoomRangeMax= " + zoomRangeMax + ", wasAutoRanging= " + wasAutoRanging + ", wasAutoGrowRanging= " + wasAutoGrowRanging + "]"; } /** * @return the wasAutoGrowRanging */ public boolean wasAutoGrowRanging() { return wasAutoGrowRanging; } /** * @return the wasAutoRanging */ public boolean wasAutoRanging() { return wasAutoRanging; } } private class ZoomRangeSlider extends RangeSlider { private final BooleanProperty invertedSlide = new SimpleBooleanProperty(this, "invertedSlide", false); private boolean isUpdating; private final ChangeListener sliderResetHandler = (ch, o, n) -> resetSlider(n); protected void resetSlider(Boolean n) { if (getChart() == null) { return; } final Axis axis = getChart().getFirstAxis(Orientation.HORIZONTAL); if (Boolean.TRUE.equals(n)) { setMin(axis.getMin()); setMax(axis.getMax()); } } private final ChangeListener rangeChangeListener = (ch, o, n) -> { if (isUpdating) { return; } isUpdating = true; final Axis xAxis = getChart().getFirstAxis(Orientation.HORIZONTAL); xAxis.getMax(); xAxis.getMin(); // add a little bit of margin to allow zoom outside the dataset final double minBound = Math.min(xAxis.getMin(), getMin()); final double maxBound = Math.max(xAxis.getMax(), getMax()); if (xRangeSliderInit) { setMin(minBound); setMax(maxBound); } isUpdating = false; }; private final ChangeListener sliderValueChanged = (ch, o, n) -> { if (!isSliderVisible() || n == null || isUpdating) { return; } isUpdating = true; final Axis xAxis = getChart().getFirstAxis(Orientation.HORIZONTAL); if (xAxis.isAutoRanging() || xAxis.isAutoGrowRanging()) { setMin(xAxis.getMin()); setMax(xAxis.getMax()); isUpdating = false; return; } isUpdating = false; }; private final EventHandler mouseEventHandler = (final MouseEvent event) -> { // disable auto ranging only when the slider interactor was used // by mouse/user // this is a work-around since the ChangeListener interface does // not contain // a event source object if (zoomStacks.isEmpty()) { makeSnapshotOfView(); } final Axis xAxis = getChart().getFirstAxis(Orientation.HORIZONTAL); xAxis.setAutoRanging(false); xAxis.setAutoGrowRanging(false); xAxis.set(getLowValue(), getHighValue()); }; public ZoomRangeSlider(final Chart chart) { super(); final Axis xAxis = chart.getFirstAxis(Orientation.HORIZONTAL); xRangeSlider = this; setPrefWidth(-1); setMaxWidth(Double.MAX_VALUE); xAxis.invertAxisProperty().bindBidirectional(invertedSlide); invertedSlide.addListener((ch, o, n) -> setRotate(Boolean.TRUE.equals(n) ? 180 : 0)); xAxis.autoRangingProperty().addListener(sliderResetHandler); xAxis.autoGrowRangingProperty().addListener(sliderResetHandler); xAxis.minProperty().addListener(rangeChangeListener); xAxis.maxProperty().addListener(rangeChangeListener); // rstein: needed in case of autoranging/sliding xAxis (see // RollingBuffer for example) lowValueProperty().addListener(sliderValueChanged); highValueProperty().addListener(sliderValueChanged); setOnMouseReleased(mouseEventHandler); lowValueProperty().bindBidirectional(xAxis.minProperty()); highValueProperty().bindBidirectional(xAxis.maxProperty()); sliderVisibleProperty().addListener((ch, o, n) -> { if (getChart() == null || n.equals(o) || isUpdating) { return; } isUpdating = true; if (Boolean.TRUE.equals(n)) { getChart().getPlotArea().setBottom(xRangeSlider); prefWidthProperty().bind(getChart().getCanvasForeground().widthProperty()); } else { getChart().getPlotArea().setBottom(null); prefWidthProperty().unbind(); } isUpdating = false; }); addButtonsToToolBarProperty().addListener((ch, o, n) -> { final Chart chartLocal = getChart(); if (chartLocal == null || n.equals(o)) { return; } if (Boolean.TRUE.equals(n)) { chartLocal.getToolBar().getChildren().add(zoomButtons); } else { chartLocal.getToolBar().getChildren().remove(zoomButtons); } }); xRangeSliderInit = true; } public void reset() { resetSlider(true); } } // ZoomRangeSlider }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy