1

I'm writing a rudimentary Candlestick chart class in which the candlesticks are created as Regions and are plotted by setting their layoutX and layoutY values to the getDisplayPosition() of the relevant axis.

For example, to plot a candlestick at value 3 on an X axis, I do this:

candlestick.setLayoutX(xAxis.getDisplayPosition(3));

When the stage resizes or when the axes are zoomed in or out, the candlesticks' layout values have to be reset so that the chart renders correctly. I'm currently handling this via ChangeListeners for resize events and Button.setOnAction()s for zooming.

However, I'd rather bind the candlesticks' layout properties to the axes' display positions than set/reset the layout values, but can't find a "displayPositionProperty" (or similar) for a NumberAxis.

Is it possible to do this? Which NumberAxis property would I bind to? ie.

candlestick.layoutXProperty().bind(xAxis.WHICH_PROPERTY?);

Also, would binding the properties be more efficient than resetting layout positions? Some of the charts could potentially have thousands of candlesticks but I can't test resource usage until I figure out how to code the bind.

I've experimented with scaling the candlesticks to the axes' scale but can't use that approach because scaling a Region affects its border width. For certain types of candlesticks, that can change its meaning.

I've also played with the Ensemble candlestick demo chart. It was useful in giving me a start but is too simplistic for my needs.

Here's a MVCE that demonstrates my approach. Any guidance re binding would be very much appreciated.

I'm using OpenJFX 17.

package test023;

import javafx.application.Application;
import javafx.application.Platform;
import javafx.geometry.Insets;
import javafx.scene.Scene;
import javafx.scene.chart.NumberAxis;
import javafx.scene.control.Button;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

public class Test023 extends Application {

    @Override
    public void start(Stage stage) {    

        NumberAxis xAxis = new NumberAxis(0D, 10D, 1D);
        Pane pChart = new Pane();
        Pane pAxis = new Pane();
        VBox vb = new VBox();
        BorderPane bp = new BorderPane();

        pChart.setPrefHeight(100D);
        pAxis.getChildren().add(xAxis);
        xAxis.prefWidthProperty().bind(pAxis.widthProperty());
        xAxis.setAnimated(false);

        vb.setPadding(new Insets(10D));
        vb.getChildren().addAll(pChart, pAxis);

        Region point = new Region();
        point.setPrefSize(5D, 5D);
        point.setStyle("-fx-background-color: black;");

        pChart.getChildren().add(point);

        //Plot the point in its initial position (value 3 on the axis)
        double pointXValue = 3D;
        plotPoint(point, pointXValue, xAxis);

        //*****************************************************************
        //Can the listeners and button.setOnActions be replaced by binding
        //the point's layout value to the axis display position?
        //*****************************************************************

        //Handle resize events
        pChart.widthProperty().addListener((obs, oldVal, newVal) -> {
            plotPoint(point, pointXValue, xAxis);
        });

        stage.maximizedProperty().addListener((obs, oldVal, newVal) -> {
            plotPoint(point, pointXValue, xAxis);
        });        

        //Handle zooming (hard-coded upper and lower bounds for the 
        //sake of simplicity)
        Button btnZoomIn = new Button("Zoom in");
        btnZoomIn.setOnAction((event) -> {
            xAxis.setLowerBound(2D);
            xAxis.setUpperBound(8D);
            xAxis.layout();
            plotPoint(point, pointXValue, xAxis);
        });

        Button btnZoomOut = new Button("Zoom out");
        btnZoomOut.setOnAction((event) -> {
            xAxis.setLowerBound(0D);
            xAxis.setUpperBound(10D);
            xAxis.layout();
            plotPoint(point, pointXValue, xAxis);
        });

        bp.setCenter(vb);
        bp.setTop(new HBox(btnZoomIn, btnZoomOut));

        stage.setScene(new Scene(bp));
        stage.setTitle("Test bind layoutX");
        stage.setWidth(400D);
        stage.setHeight(200D);
        stage.show();

    }

    private void plotPoint(Region region, double axisPos, NumberAxis axis) {

        Platform.runLater(() -> {
            double posX = axis.getDisplayPosition(axisPos);
            region.setLayoutX(posX);
            region.setLayoutY(80D);
        });

    }

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

}
GreenZebra
  • 362
  • 6
  • 14
  • Also see https://stackoverflow.com/questions/38871202/how-to-add-shapes-on-javafx-linechart for the canonical approach to this. – James_D Oct 21 '21 at 01:58
  • Thank you for the reference. I read it with interest and understood what you were saying. I'll experiment with subclassing the Chart class and overriding the layoutPlotChildren() method. Anything that makes it easier to add things like trend lines and shapes is well worth doing. – GreenZebra Oct 21 '21 at 03:14

2 Answers2

3

Something like this would work

@Override
public void start(Stage stage) {    

    NumberAxis xAxis = new NumberAxis(0D, 10D, 1D);
    Pane pChart = new Pane();
    Pane pAxis = new Pane();
    VBox vb = new VBox();
    BorderPane bp = new BorderPane();

    pChart.setPrefHeight(100D);
    pAxis.getChildren().add(xAxis);
    xAxis.prefWidthProperty().bind(pAxis.widthProperty());
    xAxis.setAnimated(false);

    vb.setPadding(new Insets(10D));
    vb.getChildren().addAll(pChart, pAxis);

    Region point = new Region();
    point.setPrefSize(5D, 5D);
    point.setStyle("-fx-background-color: black;");

    pChart.getChildren().add(point);

    //Plot the point in its initial position (value 3 on the axis)
    double pointXValue = 3D;

    point.setLayoutY(80D);
    
    point.layoutXProperty().bind(Bindings.createDoubleBinding(()-> {
        return xAxis.getDisplayPosition(pointXValue);
    }, xAxis.lowerBoundProperty(), xAxis.upperBoundProperty(), pChart.widthProperty()));
        
    //Handle zooming (hard-coded upper and lower bounds for the 
    //sake of simplicity)
    Button btnZoomIn = new Button("Zoom in");
    btnZoomIn.setOnAction((event) -> {
        xAxis.setLowerBound(2D);
        xAxis.setUpperBound(8D);
        xAxis.layout();
    });

    Button btnZoomOut = new Button("Zoom out");
    btnZoomOut.setOnAction((event) -> {
        xAxis.setLowerBound(0D);
        xAxis.setUpperBound(10D);
        xAxis.layout();
    });

    bp.setCenter(vb);
    bp.setTop(new HBox(btnZoomIn, btnZoomOut));

    stage.setScene(new Scene(bp));
    stage.setTitle("Test bind layoutX");
    stage.setWidth(400D);
    stage.setHeight(200D);
    stage.show();

}

This creates a custom double binding with a function that calculates the value of the binding every time the dependencies are changed, see createDoubleBinding​ for more info.

SDIDSA
  • 894
  • 10
  • 19
  • Thanks for your reply. It works perfectly. I've not used double bindings before and will read up on them. I'll also do some stress tests with large charts and see how it goes. Cheers. – GreenZebra Oct 21 '21 at 00:22
3

+1 for @LukasOwen answer which answer you actual question related to bindings.

But as you are aware that every problem has more than one approach, I am suggesting mine, considering the scalability (adding many points) and too many bindings (for every point).

The key things in this approach are:

  • You add all your points numbers and its node to a map.
  • Every time the xAxis is rendered, you update the all the points position. So this will be implicitly done if you resize, change range, or maximize the window.

Below is the example of the approach:

import javafx.application.Application;
import javafx.geometry.Insets;
import javafx.scene.Scene;
import javafx.scene.chart.NumberAxis;
import javafx.scene.control.Button;
import javafx.scene.layout.*;
import javafx.stage.Stage;
import java.util.HashMap;
import java.util.Map;

public class Test023 extends Application {

    Map<Double, Region> plotPoints = new HashMap<>();
    double yOffset = 80D;
    Pane pChart;

    @Override
    public void start(Stage stage) {
        NumberAxis xAxis = new NumberAxis(0D, 10D, 1D);
        xAxis.needsLayoutProperty().addListener((obs, old, needsLayout) -> {
            if(!needsLayout) {
                plotPoints.forEach((num, point) -> {
                    double posX = xAxis.getDisplayPosition(num);
                    point.setLayoutX(posX);
                    point.setLayoutY(yOffset);
                });
            }
        });
        pChart = new Pane();
        Pane pAxis = new Pane();
        VBox vb = new VBox();
        BorderPane root = new BorderPane();

        pChart.setPrefHeight(100D);
        pAxis.getChildren().add(xAxis);
        xAxis.prefWidthProperty().bind(pAxis.widthProperty());
        xAxis.setAnimated(false);

        vb.setPadding(new Insets(10D));
        vb.getChildren().addAll(pChart, pAxis);

        addPoint(3D, "black");
        addPoint(4D, "red");
        addPoint(5D, "blue");

        //Handle zooming (hard-coded upper and lower bounds for the sake of simplicity)
        Button btnZoomIn = new Button("Zoom in");
        btnZoomIn.setOnAction((event) -> {
            xAxis.setLowerBound(2D);
            xAxis.setUpperBound(8D);
        });

        Button btnZoomOut = new Button("Zoom out");
        btnZoomOut.setOnAction((event) -> {
            xAxis.setLowerBound(0D);
            xAxis.setUpperBound(10D);
        });

        root.setCenter(vb);
        root.setTop(new HBox(btnZoomIn, btnZoomOut));

        stage.setScene(new Scene(root));
        stage.setTitle("Test bind layoutX");
        stage.setWidth(400D);
        stage.setHeight(200D);
        stage.show();
    }

    private void addPoint(double num, String color) {
        Region point = new Region();
        point.setPrefSize(5D, 5D);
        point.setStyle("-fx-background-color: " + color);
        plotPoints.put(num, point);
        pChart.getChildren().add(point);
    }

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

enter image description here

Sai Dandem
  • 8,229
  • 11
  • 26
  • Thank you. I didn't know about ```axis.needsLayoutProperty()```. It works perfectly and is much more elegant than using stage-based listeners. I'm already mapping nodes to points, so your approach would work well with what I'm doing. I'll run some stress tests and see how it goes. BTW, zoom in wouldn't work the first time the button is clicked. I added ```xAxis.layout();``` to the button ```setOnAction```s, which I assume forces the axis to layout() and therefore trigger the listener. Is that the right approach? – GreenZebra Oct 21 '21 at 00:45
  • I dont think you need to call layout method. For me it is working well (JavaFX 8) without calling the layout. And more over the internal code of the ValueAxis.java has the calls to invalidateRange() and requestAxisLayout() when you update the bounds. I am not sure why it is not working for you. – Sai Dandem Oct 21 '21 at 00:53
  • And regarding the needsLayoutProperty() it is the property of a Parent node. So for any node that inherits the Parent has this property and you can listen when the node is fully rendered (a.k.a needsLayout=false) – Sai Dandem Oct 21 '21 at 00:54
  • I just tried it in JavaFX 8 and yes, it does indeed work fine. It must be an OpenJFX 17 thing. I'll have to keep forcing a layout for the zooms. Thanks also for the extra info re the ```needsLayoutProperty()```. That's useful to know. Cheers. – GreenZebra Oct 21 '21 at 01:19