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

eu.binjr.common.javafx.charts.XYChartCrosshair Maven / Gradle / Ivy

There is a newer version: 3.20.1
Show newest version
/*
 *    Copyright 2016-2020 Frederic Thevenet
 *
 *    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.binjr.common.javafx.charts;

import eu.binjr.common.javafx.bindings.BindingManager;
import eu.binjr.common.logging.Logger;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.Property;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.Point2D;
import javafx.geometry.Rectangle2D;
import javafx.scene.Node;
import javafx.scene.chart.XYChart;
import javafx.scene.control.Label;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Region;
import javafx.scene.paint.Color;
import javafx.scene.shape.Line;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.Shape;
import javafx.scene.shape.StrokeType;
import org.gillius.jfxutils.chart.XYChartInfo;

import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Consumer;
import java.util.function.Function;

import static org.gillius.jfxutils.JFXUtil.getXShift;
import static org.gillius.jfxutils.JFXUtil.getYShift;

/**
 * Draws a crosshair on top of an {@link XYChart} and handles selection of a portion of the chart view.
 *
 * @author Frederic Thevenet
 */
public class XYChartCrosshair {
    private static final Logger logger = Logger.create(XYChartCrosshair.class);
    private static final double SELECTION_OPACITY = 0.5;

    private final Line horizontalMarker = new Line();
    private final Line verticalMarker = new Line();
    private final Label xAxisLabel;
    private final Label yAxisLabel;
    private final LinkedHashMap, Function> charts;
    private final Function xValuesFormatter;
    private final XYChartInfo chartInfo;
    private final BooleanProperty isSelecting = new SimpleBooleanProperty(false);
    private final Pane parent;
    private final Rectangle selection = new Rectangle(0, 0, 0, 0);
    private final BooleanProperty verticalMarkerVisible = new SimpleBooleanProperty();
    private final BooleanProperty horizontalMarkerVisible = new SimpleBooleanProperty();
    private final Map, Property> currentYValues = new HashMap<>();
    private final Property currentXValue = new SimpleObjectProperty<>();
    private final XYChart masterChart;
    private final BooleanProperty isMouseOverChart = new SimpleBooleanProperty(false);
    private final BindingManager bindingManager = new BindingManager();
    private final BooleanProperty displayFullHeightMarker = new SimpleBooleanProperty(false);
    private Point2D selectionStart = new Point2D(-1, -1);
    private Point2D mousePosition = new Point2D(-1, -1);
    private Consumer, XYChartSelection>> selectionDoneEvent;

    /**
     * Initializes a new instance of the {@link XYChartCrosshair} class.
     *
     * @param charts           a map of the  {@link XYChart} to attach and their formatting function of the Y values.
     * @param parent           the parent node of the chart
     * @param xValuesFormatter a function used to format the display of X values as strings
     */
    public XYChartCrosshair(LinkedHashMap, Function> charts, Pane parent, Function xValuesFormatter) {
        this.charts = charts;
        applyStyle(this.verticalMarker);
        applyStyle(this.horizontalMarker);
        applyStyle(this.selection);
        this.xAxisLabel = newAxisLabel();
        this.yAxisLabel = newAxisLabel();
        this.parent = parent;
        parent.getChildren().addAll(xAxisLabel, yAxisLabel, verticalMarker, horizontalMarker, selection);
        this.xValuesFormatter = xValuesFormatter;
        masterChart = charts.keySet().stream().reduce((p, n) -> n).orElseThrow(() -> new IllegalStateException("Could not identify last element in chart linked hash map."));
        this.chartInfo = new XYChartInfo(masterChart, parent);
        masterChart.addEventHandler(MouseEvent.MOUSE_MOVED, bindingManager.registerHandler(this::handleMouseMoved));
        masterChart.addEventHandler(MouseEvent.MOUSE_DRAGGED, bindingManager.registerHandler(this::handleMouseMoved));
        masterChart.setOnMouseReleased(bindingManager.registerHandler(e -> {
            if (isSelecting.get()) {
                fireSelectionDoneEvent();
                drawVerticalMarker();
                drawHorizontalMarker();
            }
            isSelecting.set(false);
        }));
        isSelecting.addListener((observable, oldValue, newValue) -> {
            logger.debug(() -> "observable=" + observable + " oldValue=" + oldValue + " newValue=" + newValue);
            if (!oldValue && newValue) {
                selectionStart = new Point2D(verticalMarker.getStartX(), horizontalMarker.getStartY());
            }
            drawSelection();
            selection.setVisible(newValue);
        });
        horizontalMarkerVisible.addListener((observable, oldValue, newValue) -> {
            drawHorizontalMarker();
            if (!newValue && !verticalMarkerVisible.get()) {
                isSelecting.set(false);
                currentYValues.forEach((key, value) -> value.setValue(null));
            }
        });
        verticalMarkerVisible.addListener((observable, oldValue, newValue) -> {
            drawVerticalMarker();
            if (!newValue && !horizontalMarkerVisible.get()) {
                isSelecting.set(false);
                currentXValue.setValue(null);
            }
        });
        masterChart.setOnMouseExited(bindingManager.registerHandler(event -> isMouseOverChart.set(false)));
        masterChart.setOnMouseEntered(bindingManager.registerHandler(event -> isMouseOverChart.set(true)));
        bindingManager.bind(horizontalMarker.visibleProperty(), horizontalMarkerVisible.and(isMouseOverChart));
        bindingManager.bind(yAxisLabel.visibleProperty(), horizontalMarkerVisible.and(isMouseOverChart));
        bindingManager.bind(verticalMarker.visibleProperty(), verticalMarkerVisible.and(isMouseOverChart));
        bindingManager.bind(xAxisLabel.visibleProperty(), verticalMarkerVisible.and(isMouseOverChart));
    }

    /**
     * Gets the boolean property that tracks the visibility of the vertical marker of the crosshair
     *
     * @return the boolean property that tracks the visibility of the vertical marker of the crosshair
     */
    public BooleanProperty verticalMarkerVisibleProperty() {
        return verticalMarkerVisible;
    }

    /**
     * Gets the boolean property that tracks the visibility of the horizontal marker of the crosshair
     *
     * @return the boolean property that tracks the visibility of the horizontal marker of the crosshair
     */
    public BooleanProperty horizontalMarkerVisibleProperty() {
        return horizontalMarkerVisible;
    }

    /**
     * Returns true if the vertical marker is visible, false otherwise
     *
     * @return true if the vertical marker is visible, false otherwise
     */
    public boolean isVerticalMarkerVisible() {
        return verticalMarkerVisible.get();
    }

    /**
     * Sets the visibility of the  vertical marker
     *
     * @param verticalMarkerVisible the visibility of the  vertical marker
     */
    public void setVerticalMarkerVisible(boolean verticalMarkerVisible) {
        this.verticalMarkerVisible.set(verticalMarkerVisible);
    }

    /**
     * Returns true if the horizontal marker is visible, false otherwise
     *
     * @return true if the horizontal marker is visible, false otherwise
     */
    public boolean isHorizontalMarkerVisible() {
        return horizontalMarkerVisible.get();
    }

    /**
     * Sets the visibility of the  horizontal marker
     *
     * @param horizontalMarkerVisible the visibility of the  horizontal marker
     */
    public void setHorizontalMarkerVisible(boolean horizontalMarkerVisible) {
        this.horizontalMarkerVisible.set(horizontalMarkerVisible);
    }

    /**
     * Sets the action to be triggered when selection is complete
     *
     * @param action the action to be triggered when selection is complete
     */
    public void onSelectionDone(Consumer, XYChartSelection>> action) {
        selectionDoneEvent = action;
    }

    /**
     * Returns the Y value for currently selected set of coordinates for the provided chart.
     *
     * @param chart The chart to retrieve the current Y value from.
     * @return the Y value for currently selected set of coordinates for the provided chart.
     */
    public Y getCurrentYValue(XYChart chart) {
        return currentYValues.get(chart).getValue();
    }

    /**
     * Retusn  the X value for currently selected set of coordinates.
     *
     * @return the X value for currently selected set of coordinates.
     */
    public X getCurrentXValue() {
        return currentXValue.getValue();
    }

    /**
     * The currentXValue property.
     *
     * @return the currentXValue property.
     */
    public Property currentXValueProperty() {
        return currentXValue;
    }

    public boolean isDisplayFullHeightMarker() {
        return displayFullHeightMarker.get();
    }

    public void setDisplayFullHeightMarker(boolean displayFullHeightMarker) {
        this.displayFullHeightMarker.set(displayFullHeightMarker);
    }

    public BooleanProperty displayFullHeightMarkerProperty() {
        return displayFullHeightMarker;
    }

    public void dispose() {
        logger.debug(() -> "Disposing XYChartCrossHair " + toString());
        selectionDoneEvent = null;
        bindingManager.close();
    }

    private void fireSelectionDoneEvent() {
        if (selectionDoneEvent != null && (selection.getWidth() > 0 && selection.getHeight() > 0)) {
            var s = new HashMap, XYChartSelection>();
            var plotArea = chartInfo.getPlotArea();
            charts.forEach((c, f) -> {
                s.put(c, new XYChartSelection<>(
                        getValueFromXcoord(selection.getX()),
                        getValueFromXcoord(selection.getX() + selection.getWidth()),
                        getValueFromYcoord(c, Math.min(plotArea.getMaxY(), (selection.getY() + selection.getHeight()))),
                        getValueFromYcoord(c, Math.max(plotArea.getMinY(), (selection.getY()))),
                        selection.getHeight() != plotArea.getHeight()));
            });
            selectionDoneEvent.accept(s);
        }
    }

    private void drawHorizontalMarker() {
        if (mousePosition.getY() < 0) {
            return;
        }
        var plotArea = chartInfo.getPlotArea();
        horizontalMarker.setStartX(plotArea.getMinX());
        horizontalMarker.setEndX(plotArea.getMaxX());
        horizontalMarker.setStartY(mousePosition.getY());
        horizontalMarker.setEndY(horizontalMarker.getStartY());
        yAxisLabel.setLayoutX(Math.min(parent.getWidth() - yAxisLabel.getWidth(), plotArea.getMaxX() + 5));
        yAxisLabel.setLayoutY(Math.min(mousePosition.getY() + 5, plotArea.getMaxY() - yAxisLabel.getHeight()));

        StringBuilder yAxisText = new StringBuilder();
        charts.forEach((c, f) -> {
            currentYValues.computeIfAbsent(c, (k) -> new SimpleObjectProperty()).setValue(getValueFromYcoord(c, mousePosition.getY()));
            yAxisText.append(c.getYAxis().getLabel())
                    .append(": ")
                    .append(f.apply(currentYValues.get(c).getValue()))
                    .append("\n");
        });
        yAxisLabel.setText(yAxisText.toString());
    }

    private Y getValueFromYcoord(XYChart chart, double yPosition) {
        double yStart = chart.getYAxis().getLocalToParentTransform().getTy();
        double axisYRelativePosition = yPosition - getYShift(masterChart, parent) - (yStart * 1.5);
        return chart.getYAxis().getValueForDisplay(axisYRelativePosition);
    }

    private X getValueFromXcoord(double xPosition) {
        double xStart = masterChart.getXAxis().getLocalToParentTransform().getTx();
        double axisXRelativeMousePosition = xPosition - getXShift(masterChart, parent) - xStart;
        return masterChart.getXAxis().getValueForDisplay(axisXRelativeMousePosition - 5);
    }

    private void drawVerticalMarker() {
        if (mousePosition.getX() < 0) {
            return;
        }
        var plotArea = chartInfo.getPlotArea();
        verticalMarker.setStartX(mousePosition.getX());
        verticalMarker.setEndX(verticalMarker.getStartX());
        if (displayFullHeightMarker.getValue()) {
            verticalMarker.setStartY(2);
            verticalMarker.setEndY(parent.getHeight() - 2);
        } else {
            verticalMarker.setStartY(plotArea.getMinY());
            verticalMarker.setEndY(plotArea.getMaxY());
        }
        xAxisLabel.setLayoutY(plotArea.getMaxY() + 4);
        xAxisLabel.setLayoutX(Math.min(mousePosition.getX() + 4, plotArea.getMaxX() - xAxisLabel.getWidth()));
        currentXValue.setValue(getValueFromXcoord(mousePosition.getX()));
        xAxisLabel.setText(xValuesFormatter.apply(currentXValue.getValue()));
    }


    public static Point2D getShift(Node descendant, Region ancestor) {
        double retX = 0.0;
        double retY = 0.0;
        Node curr = descendant;
        while (curr != ancestor) {
            var t = curr.getLocalToParentTransform();
            retX += ancestor.snapSpaceX(t.getTx());
            retY += ancestor.snapSpaceX(t.getTy());
            curr = curr.getParent();
            if (curr == null)
                throw new IllegalArgumentException("'descendant' Node is not a descendant of 'ancestor");
        }

        return new Point2D(retX, retY);
    }

    private void handleMouseMoved(MouseEvent event) {
        Rectangle2D area = chartInfo.getPlotArea();
        var shift = getShift(masterChart, parent);
        double xPos = parent.snapSpaceX(event.getX()) + shift.getX();
        double yPos = parent.snapSpaceX(event.getY()) + shift.getY();
        mousePosition = new Point2D(Math.max(area.getMinX(), Math.min(area.getMaxX(), xPos)), Math.max(area.getMinY(), Math.min(area.getMaxY(), yPos)));
        if (horizontalMarkerVisible.get()) {
            drawHorizontalMarker();
        }
        if (verticalMarkerVisible.get()) {
            drawVerticalMarker();
        }
        if (event.isPrimaryButtonDown() && (verticalMarkerVisible.get() || horizontalMarkerVisible.get())) {
            isSelecting.set(true);
            drawSelection();
        }
    }

    private void drawSelection() {
        if (selectionStart.getX() < 0 || selectionStart.getY() < 0) {
            return;
        }
        if (horizontalMarkerVisible.get()) {
            double height = horizontalMarker.getStartY() - selectionStart.getY();
            selection.setY(height < 0 ? horizontalMarker.getStartY() : selectionStart.getY());
            selection.setHeight(Math.abs(height));
        } else {
            selection.setY(verticalMarker.getStartY());
            selection.setHeight(verticalMarker.getEndY() - verticalMarker.getStartY());
        }

        if (verticalMarkerVisible.get()) {
            double width = verticalMarker.getStartX() - selectionStart.getX();
            selection.setX(width < 0 ? verticalMarker.getStartX() : selectionStart.getX());
            selection.setWidth(Math.abs(width));
        } else {
            selection.setX(horizontalMarker.getStartX());
            selection.setWidth(horizontalMarker.getEndX() - horizontalMarker.getStartX());
        }
    }

    private Label newAxisLabel() {
        Label label = new Label("");
        label.getStyleClass().add("crosshair-axis-label");
        label.setMinSize(Label.USE_PREF_SIZE, Label.USE_PREF_SIZE);
        label.setMouseTransparent(true);
        label.setVisible(false);
        return label;
    }

    private void applyStyle(Shape shape) {
        shape.setManaged(false);
        shape.setMouseTransparent(true);
        shape.setSmooth(false);
        shape.setStrokeWidth(1.0);
        shape.setVisible(false);
        shape.setStrokeType(StrokeType.CENTERED);
        shape.setStroke(Color.STEELBLUE);
        Color fillColor = Color.LIGHTSTEELBLUE;
        shape.setFill(new Color(
                fillColor.getRed(),
                fillColor.getGreen(),
                fillColor.getBlue(),
                SELECTION_OPACITY));
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy