0

I'm creating a line chart with a large set of data points. This chart has a blue marker line that hovers with the mouse curser. What I'm trying to achieve, is onlick get the nearest data point that the blue line is "marking". I've tried to get the coordinate of the Data nodes but wasn't having luck. I also thought about a percentage of distance from first last but worried about the effects screen/stage resizing would have. Any insight or ideas would be appreciated.

enter image description here

        // Note ****** "frames" is a List<FrameV> that I pass in  *****
        double max = (double)frames.stream().mapToLong(f -> f.getFloatValue(type)).max().getAsLong();
        double min = (double)frames.stream().mapToLong(f -> f.getFloatValue(type)).min().getAsLong();
        double percent5 = (max-min)*0.05;
        max += percent5;
        min -= percent5;
        NumberAxis xAxis = new NumberAxis();
        xAxis.setTickLabelsVisible(false);
        xAxis.setLabel(title);
        NumberAxis yAxis = new NumberAxis();
        yAxis.setTickLabelsVisible(false);
        yAxis.setOpacity(0);
        yAxis.setMaxWidth(5);
        yAxis.setMaxHeight(max);
        yAxis.setMinHeight(min);
        
        // Build Charts
        LineChart lineChart = new LineChart(xAxis, yAxis);
        lineChart.setPrefWidth(1000);
        lineChart.setMaxHeight(100);
        lineChart.setCreateSymbols(false);
        
        // Calc Frame Rate
        double temp = (Math.sqrt((double)frames.size()) / 4);
        int frameRate = temp < 1 ? 1 : (int)Math.round(temp);
        
        // Fill Data
        XYChart.Series dataSeries1 = new XYChart.Series();
        for(int i = 0; i < frames.size(); i+=frameRate) 
        {
            dataSeries1.getData().add(new XYChart.Data( i, frames.get(i).getFloatValue(type)));
        }
        lineChart.getData().add(dataSeries1);
        
        
        // Make Line
        mark = new Rectangle(0,0,1,110);
        mark.setStroke(Color.BLUE);
        mark.setTranslateY(-15);
        mark.setVisible(false);
        
        chartPane = new StackPane(lineChart,mark);
        chartPane.setPadding(new Insets(0,5,0,5));
        chartPane.setAlignment(Pos.CENTER_LEFT);
        
        chartPane.setOnMouseMoved(e -> {
            mark.setTranslateX(e.getX());
        });
        
        chartPane.setOnMouseEntered(e -> {mark.setVisible(true);});
        chartPane.setOnMouseExited(e -> {mark.setVisible(false);});
M. Rogers
  • 367
  • 4
  • 18

1 Answers1

1

An example is presented here, it won't do exactly what you want, but it should be adaptable to your case, as I understand it.

As you move the mouse around the chart, the crosshairs track the current mouse position, and the closest data node is highlighted.

nearest point app

The app places the lines for a crosshair in a Pane above the chart. When the mouse moves, a comparator is invoked which determines the nearest node to the current crosshair position, which is then highlighted. When the mouse leaves the chart area, the crosshairs are removed and the highlight is removed from the highlighted node.

To calculate distances accurately, all coordinates need to be translated to a common co-ordinate system. In the example, I translate to scene coordinates and use those.

NearestPointApp.java

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.chart.XYChart;
import javafx.stage.Stage;

public class NearestPointApp extends Application {

    @Override public void start(Stage stage) {
        final InteractiveChartPane chartPane = new InteractiveChartPane(
                createSeries()
        );

        Scene scene = new Scene(chartPane);
        stage.setScene(scene);
        stage.show();

        chartPane.enableInteractivityInScene(scene);
    }

    private static XYChart.Series<Number, Number> createSeries() {
        XYChart.Series<Number, Number> series = new XYChart.Series<>();

        series.getData().add(new XYChart.Data<>(1, 23));
        series.getData().add(new XYChart.Data<>(2, 14));
        series.getData().add(new XYChart.Data<>(3, 15));
        series.getData().add(new XYChart.Data<>(4, 24));
        series.getData().add(new XYChart.Data<>(5, 34));
        series.getData().add(new XYChart.Data<>(6, 36));
        series.getData().add(new XYChart.Data<>(7, 22));
        series.getData().add(new XYChart.Data<>(8, 45));
        series.getData().add(new XYChart.Data<>(9, 43));
        series.getData().add(new XYChart.Data<>(10, 17));
        series.getData().add(new XYChart.Data<>(11, 29));
        series.getData().add(new XYChart.Data<>(12, 25));

        return series;
    }

    public static void main(String[] args) {
        launch(args);
    }
}

InteractiveChartPane.java

import javafx.geometry.Bounds;
import javafx.geometry.Point2D;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.chart.LineChart;
import javafx.scene.chart.NumberAxis;
import javafx.scene.chart.XYChart;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.scene.robot.Robot;
import javafx.scene.shape.Line;

public class InteractiveChartPane extends Pane {
    // crosshair display in chart.
    private final Line horizontal = createLine();
    private final Line vertical = createLine();

    private final HighlightManager highlightManager = new HighlightManager();

    private final LineChart<Number, Number> chart = new LineChart<>(
            makeSimpleAxis(), makeSimpleAxis()
    );

    public InteractiveChartPane(XYChart.Series<Number, Number> series) {
        chart.getData().add(series);
        chart.setLegendVisible(false);

        getChildren().setAll(
                chart,
                horizontal,
                vertical
        );
    }

    public void enableInteractivityInScene(Scene scene) {
        // size chart to scene,
        // not sure why this doesn't happen automatically,
        // but probably the charts have some
        // default preferred size they like to stick to.
        chart.prefWidthProperty().bind(scene.widthProperty());
        chart.prefHeightProperty().bind(scene.heightProperty());

        // hide the cursor when in the chart
        // as the crosshair overlay will indicate mouse position.
        Node chartArea = chart.lookup(".chart-plot-background");
        chartArea.setCursor(Cursor.NONE);

        // show crosshair, highlight chart and closest node
        // when the mouse is in the chart.
        chart.setOnMouseMoved(e ->
                handleMovementInChart(
                        new Point2D(e.getSceneX(), e.getSceneY())
                )
        );

        // find the initial mouse position and handle chart highlighting for it.
        Point2D mousePositionInScene = findCurrentMousePositionInScene(chart);
        if (mousePositionInScene != null) {
            handleMovementInChart(mousePositionInScene);
        }
    }

    private void handleMovementInChart(
            Point2D mousePositionInScene
    ) {
        Scene scene = getScene();
        XYChart.Series<Number, Number> series = chart.getData().get(0);

        Node chartArea = lookup(".chart-plot-background");
        Bounds chartBoundsInScene = chartArea.localToScene(chartArea.getBoundsInLocal());

        if (chartBoundsInScene.contains(mousePositionInScene)) {
            scene.setCursor(Cursor.NONE);

            chartArea.setStyle("-fx-background-color: azure;");

            horizontal.setVisible(true);
            vertical.setVisible(true);

            horizontal.setStartX(chartBoundsInScene.getMinX());
            horizontal.setEndX(chartBoundsInScene.getMaxX());
            horizontal.setStartY(mousePositionInScene.getY());
            horizontal.setEndY(mousePositionInScene.getY());

            vertical.setStartX(mousePositionInScene.getX());
            vertical.setEndX(mousePositionInScene.getX());
            vertical.setStartY(chartBoundsInScene.getMinY());
            vertical.setEndY(chartBoundsInScene.getMaxY());

            // highlight the closest data node to the mouse.
            series.getData().stream()
                    .min(new DistanceComparator(mousePositionInScene))
                    .ifPresent(closestData ->
                            highlightManager.setHighlightedNode(
                                    closestData.getNode()
                            )
                    );
        } else { // mouse is not in the chart area, remove interactive guides.
            scene.setCursor(Cursor.DEFAULT);
            chartArea.setStyle(null);
            horizontal.setVisible(false);
            vertical.setVisible(false);
            highlightManager.setHighlightedNode(null);
        }
    }

    private static Point2D findCurrentMousePositionInScene(LineChart<Number, Number> chart) {
        Robot robot = new Robot();

        Point2D mousePositionInScreen = robot.getMousePosition();
        Point2D mousePositionInLocal = chart.screenToLocal(mousePositionInScreen);
        if (mousePositionInLocal == null) {
            return null;
        }

        return chart.localToScene(mousePositionInLocal);
    }

    private static Line createLine() {
        Line horizontal = new Line();

        horizontal.setStroke(Color.PLUM);
        horizontal.setMouseTransparent(true);

        return horizontal;
    }

    private static NumberAxis makeSimpleAxis() {
        final NumberAxis xAxis = new NumberAxis();

        xAxis.setTickMarkVisible(false);
        xAxis.setTickLabelsVisible(false);
        xAxis.setMinorTickCount(0);

        return xAxis;
    }
}

DistanceComparator.java

import javafx.geometry.Bounds;
import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.scene.chart.XYChart;

import java.util.Comparator;

// compares data nodes based on the distances of their center from a pivot point in the scene.
public record DistanceComparator(Point2D pivotInScene) implements Comparator<XYChart.Data<Number, Number>> {
    @Override
    public int compare(XYChart.Data<Number, Number> o1, XYChart.Data<Number, Number> o2) {
        Node o1Node = o1.getNode();
        Node o2Node = o2.getNode();

        Bounds o1Bounds = o1Node.localToScene(o1Node.getBoundsInLocal());
        Bounds o2Bounds = o2Node.localToScene(o2Node.getBoundsInLocal());

        Point2D o1Center = new Point2D(o1Bounds.getCenterX(), o1Bounds.getCenterY());
        Point2D o2Center = new Point2D(o2Bounds.getCenterX(), o2Bounds.getCenterY());

        double o1Dist = pivotInScene.distance(o1Center);
            double o2Dist = pivotInScene.distance(o2Center);

        return Double.compare(o1Dist, o2Dist);
    }
}

HighlightManager.java

import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.Node;
import javafx.scene.effect.DropShadow;
import javafx.scene.effect.Effect;
import javafx.scene.effect.Glow;
import javafx.scene.paint.Color;

// utility class for highlighting a node in a chart.
class HighlightManager {
    // highlighted data node in chart.
    private final ObjectProperty<Node> highlightedNode = new SimpleObjectProperty<>();
    private final Effect highlightEffect = createHighlightEffect();

    public HighlightManager() {
        highlightedNode.addListener((observable, oldNode, newNode) -> {
            removeHighlight(oldNode);
            applyHighlight(newNode);
        });
    }

    public void setHighlightedNode(Node highlightedNode) {
        this.highlightedNode.set(highlightedNode);
    }

    private static Effect createHighlightEffect() {
        DropShadow outerShadow = new DropShadow(15, Color.GOLD);

        Glow glow = new Glow();
        glow.setInput(outerShadow);

        return glow;
    }

    private void applyHighlight(Node node) {
        if (node == null) {
            return;
        }

        node.setStyle("-fx-background-color: CHART_COLOR_1, yellow;");
        node.setEffect(highlightEffect);
    }

    private void removeHighlight(Node node) {
        if (node == null) {
            return;
        }

        node.setStyle(null);
        node.setEffect(null);
    }
}

Related Questions

jewelsea
  • 150,031
  • 14
  • 366
  • 406