/*
* Copyright (C) 2014 TESIS DYNAware GmbH.
* All rights reserved. Use is subject to license terms.
*
* This file is licensed under the Eclipse Public License v1.0, which accompanies this
* distribution and is available at http://www.eclipse.org/legal/epl-v10.html.
*/
package de.tesis.dynaware.javafx.fancychart.zoom;
import javafx.event.EventHandler;
import javafx.geometry.Point2D;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.chart.NumberAxis;
import javafx.scene.chart.XYChart;
import javafx.scene.control.Label;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
/**
* This class adds a zoom functionality to a given XY chart. Zoom means that a user can select a region in the chart
* that should be displayed at a larger scale.
*
*/
public class Zoom {
private static final String INFO_LABEL_ID = "zoomInfoLabel";
private final Pane pane;
private final XYChart<Number, Number> chart;
private final NumberAxis xAxis;
private final NumberAxis yAxis;
private final SelectionRectangle selectionRectangle;
private Label infoLabel;
private Point2D selectionRectangleStart;
private Point2D selectionRectangleEnd;
/**
* Create a new instance of this class with the given chart and pane instances. The {@link Pane} instance is needed
* as a parent for the rectangle that represents the user selection.
*
* @param chart
* the xy chart to which the zoom support should be added
* @param pane
* the pane on which the selection rectangle will be drawn.
*/
public Zoom(XYChart<Number, Number> chart, Pane pane) {
this.pane = pane;
this.chart = chart;
this.xAxis = (NumberAxis) chart.getXAxis();
this.yAxis = (NumberAxis) chart.getYAxis();
selectionRectangle = new SelectionRectangle();
pane.getChildren().add(selectionRectangle);
addDragSelectionMechanism();
addInfoLabel();
}
/**
* The info label shows a short info text that tells the user how to unreset the zoom level.
*/
private void addInfoLabel() {
infoLabel = new Label("Click ESC to reset the zoom level.");
infoLabel.setId(INFO_LABEL_ID);
pane.getChildren().add(infoLabel);
StackPane.setAlignment(infoLabel, Pos.TOP_RIGHT);
infoLabel.setVisible(false);
}
/**
* Adds a mechanism to select an area in the chart that should be displayed at larged scale.
*/
private void addDragSelectionMechanism() {
pane.addEventHandler(MouseEvent.MOUSE_PRESSED, new MousePressedHandler());
pane.addEventHandler(MouseEvent.MOUSE_DRAGGED, new MouseDraggedHandler());
pane.addEventHandler(MouseEvent.MOUSE_RELEASED, new MouseReleasedHandler());
pane.addEventHandler(KeyEvent.KEY_RELEASED, new EscapeKeyHandler());
}
private Point2D computeRectanglePoint(double eventX, double eventY) {
double lowerBoundX = computeOffsetInChart(xAxis, false);
double upperBoundX = lowerBoundX + xAxis.getWidth();
double lowerBoundY = computeOffsetInChart(yAxis, true);
double upperBoundY = lowerBoundY + yAxis.getHeight();
// make sure the rectangle's end point is in the interval defined by the lower and upper bounds for each
// dimension
double x = Math.max(lowerBoundX, Math.min(eventX, upperBoundX));
double y = Math.max(lowerBoundY, Math.min(eventY, upperBoundY));
return new Point2D(x, y);
}
/**
* Computes the pixel offset of the given node inside the chart node.
*
* @param node
* the node for which to compute the pixel offset
* @param vertical
* flag that indicates whether the horizontal or the vertical dimension should be taken into account
* @return the offset inside the chart node
*/
private double computeOffsetInChart(Node node, boolean vertical) {
double offset = 0;
do {
if (vertical) {
offset += node.getLayoutY();
} else {
offset += node.getLayoutX();
}
node = node.getParent();
} while (node != chart);
return offset;
}
/**
*
*/
private final class MousePressedHandler implements EventHandler<MouseEvent> {
@Override
public void handle(final MouseEvent event) {
// do nothing for a right-click
if (event.isSecondaryButtonDown()) {
return;
}
// store position of initial click
selectionRectangleStart = computeRectanglePoint(event.getX(), event.getY());
event.consume();
}
}
/**
*
*/
private final class MouseDraggedHandler implements EventHandler<MouseEvent> {
@Override
public void handle(final MouseEvent event) {
// do nothing for a right-click
if (event.isSecondaryButtonDown()) {
return;
}
// store current cursor position
selectionRectangleEnd = computeRectanglePoint(event.getX(), event.getY());
double x = Math.min(selectionRectangleStart.getX(), selectionRectangleEnd.getX());
double y = Math.min(selectionRectangleStart.getY(), selectionRectangleEnd.getY());
double width = Math.abs(selectionRectangleStart.getX() - selectionRectangleEnd.getX());
double height = Math.abs(selectionRectangleStart.getY() - selectionRectangleEnd.getY());
drawSelectionRectangle(x, y, width, height);
event.consume();
}
/**
* Draws a selection box in the view.
*
* @param x
* the x position of the selection box
* @param y
* the y position of the selection box
* @param width
* the width of the selection box
* @param height
* the height of the selection box
*/
private void drawSelectionRectangle(final double x, final double y, final double width, final double height) {
selectionRectangle.setVisible(true);
selectionRectangle.setX(x);
selectionRectangle.setY(y);
selectionRectangle.setWidth(width);
selectionRectangle.setHeight(height);
}
}
/**
*
*/
private final class MouseReleasedHandler implements EventHandler<MouseEvent> {
/**
* Defines a minimum width for the selected area. If the selected rectangle is not wider than this value, no
* zooming will take place. This helps prevent accidental zooming.
*/
private static final double MIN_RECTANGE_WIDTH = 10;
/**
* Defines a minimum height for the selected area. If the selected rectangle is not wider than this value, no
* zooming will take place. This helps prevent accidental zooming.
*/
private static final double MIN_RECTANGLE_HEIGHT = 10;
@Override
public void handle(final MouseEvent event) {
hideSelectionRectangle();
if (selectionRectangleStart == null || selectionRectangleEnd == null) {
return;
}
if (isRectangleSizeTooSmall()) {
return;
}
setAxisBounds();
showInfo();
selectionRectangleStart = null;
selectionRectangleEnd = null;
// needed for the key event handler to receive events
pane.requestFocus();
event.consume();
}
private boolean isRectangleSizeTooSmall() {
double width = Math.abs(selectionRectangleEnd.getX() - selectionRectangleStart.getX());
double height = Math.abs(selectionRectangleEnd.getY() - selectionRectangleStart.getY());
return width < MIN_RECTANGE_WIDTH || height < MIN_RECTANGLE_HEIGHT;
}
/**
* Hides the selection rectangle.
*/
private void hideSelectionRectangle() {
selectionRectangle.setVisible(false);
}
private void setAxisBounds() {
disableAutoRanging();
// compute new bounds for the chart's x and y axes
double selectionMinX = Math.min(selectionRectangleStart.getX(), selectionRectangleEnd.getX());
double selectionMaxX = Math.max(selectionRectangleStart.getX(), selectionRectangleEnd.getX());
double selectionMinY = Math.min(selectionRectangleStart.getY(), selectionRectangleEnd.getY());
double selectionMaxY = Math.max(selectionRectangleStart.getY(), selectionRectangleEnd.getY());
setHorizontalBounds(selectionMinX, selectionMaxX);
setVerticalBounds(selectionMinY, selectionMaxY);
}
private void disableAutoRanging() {
xAxis.setAutoRanging(false);
yAxis.setAutoRanging(false);
}
private void showInfo() {
infoLabel.setVisible(true);
}
/**
* Sets new bounds for the chart's x axis.
*
* @param minPixelPosition
* the x position of the selection rectangle's left edge (in pixels)
* @param maxPixelPosition
* the x position of the selection rectangle's right edge (in pixels)
*/
private void setHorizontalBounds(double minPixelPosition, double maxPixelPosition) {
double currentLowerBound = xAxis.getLowerBound();
double currentUpperBound = xAxis.getUpperBound();
double offset = computeOffsetInChart(xAxis, false);
setLowerBoundX(minPixelPosition, currentLowerBound, currentUpperBound, offset);
setUpperBoundX(maxPixelPosition, currentLowerBound, currentUpperBound, offset);
}
/**
* Sets new bounds for the chart's y axis.
*
* @param minPixelPosition
* the y position of the selection rectangle's upper edge (in pixels)
* @param maxPixelPosition
* the y position of the selection rectangle's lower edge (in pixels)
*/
private void setVerticalBounds(double minPixelPosition, double maxPixelPosition) {
double currentLowerBound = yAxis.getLowerBound();
double currentUpperBound = yAxis.getUpperBound();
double offset = computeOffsetInChart(yAxis, true);
setLowerBoundY(maxPixelPosition, currentLowerBound, currentUpperBound, offset);
setUpperBoundY(minPixelPosition, currentLowerBound, currentUpperBound, offset);
}
private void setLowerBoundX(double pixelPosition, double currentLowerBound, double currentUpperBound,
double offset) {
double newLowerBound = computeBound(pixelPosition, offset, xAxis.getWidth(), currentLowerBound,
currentUpperBound, false);
xAxis.setLowerBound(newLowerBound);
}
private void setUpperBoundX(double pixelPosition, double currentLowerBound, double currentUpperBound,
double offset) {
double newUpperBound = computeBound(pixelPosition, offset, xAxis.getWidth(), currentLowerBound,
currentUpperBound, false);
xAxis.setUpperBound(newUpperBound);
}
private void setLowerBoundY(double pixelPosition, double currentLowerBound, double currentUpperBound,
double offset) {
double newLowerBound = computeBound(pixelPosition, offset, yAxis.getHeight(), currentLowerBound,
currentUpperBound, true);
yAxis.setLowerBound(newLowerBound);
}
private void setUpperBoundY(double pixelPosition, double currentLowerBound, double currentUpperBound,
double offset) {
double newUpperBound = computeBound(pixelPosition, offset, yAxis.getHeight(), currentLowerBound,
currentUpperBound, true);
yAxis.setUpperBound(newUpperBound);
}
private double computeBound(double pixelPosition, double pixelOffset, double pixelLength, double lowerBound,
double upperBound, boolean axisInverted) {
double pixelPositionWithoutOffset = pixelPosition - pixelOffset;
double relativePosition = pixelPositionWithoutOffset / pixelLength;
double axisLength = upperBound - lowerBound;
// The screen's y axis grows from top to bottom, whereas the chart's y axis goes from bottom to top.
// That's
// why we need to have this distinction here.
double offset = 0;
int sign = 0;
if (axisInverted) {
offset = upperBound;
sign = -1;
} else {
offset = lowerBound;
sign = 1;
}
double newBound = offset + sign * relativePosition * axisLength;
return newBound;
}
}
/**
*
*/
private final class EscapeKeyHandler implements EventHandler<KeyEvent> {
@Override
public void handle(KeyEvent event) {
// the ESCAPE key lets the user reset the zoom level
if (KeyCode.ESCAPE.equals(event.getCode())) {
resetAxisBounds();
hideInfo();
}
}
private void resetAxisBounds() {
xAxis.setAutoRanging(true);
yAxis.setAutoRanging(true);
}
private void hideInfo() {
infoLabel.setVisible(false);
}
}
}