-1

I want to make a kind of "zoomable graphing calculator" in javafx so basically a coordinate system where you can zoom in with the mouse wheel. Im drawing everything on a canvas but Im not sure how to do the zooming part... I can think of three ways of doing it:

  1. I could apply a transformation matrix to everything with GraphicsContext.transform()
  2. I could make some sort of coordinate transformation method that I pass all my coordinates through, and that moves them to the correct positions on the screen
  3. Do all the maths manually for everything I draw on the canvas (this seems like its gonna be super tedious and very hard to maintain)

What would you guys suggest I do?

EDIT: Also if I go for the first approach or something similar, do I need to worry about elements that are "drawn" outside of the canvas? Also will the lines stay nice and crisp or will they get blurry because of anti-aliasing? (I'd perfer the former)

1 Answers1

4

I updated the graphing solution from this answer to add zooming functionality:

To add interactive zooming I added a handler for the scroll event. In the scroll event handler, I calculate new values for low and high values for axes and plot coordinates, then apply them to the axes and plot.

I use the scroll event handler that works on the mouse scroll wheel or touchpad or touchscreen scroll gestures. But you could also (or instead) use a zoom event handler that utilizes zoom (pinching) gestures on touch surfaces.

When a scroll is detected, I just zoom in or out on a fixed amount (10% of the current zoom factor) up to a min or max zoom value. A more sophisticated solution could query the delta values of the scroll or zoom events to achieve inertial scrolling and greater or less scrolling based on the speed of the scroll event.

To implement the zoom, I recreate the zoomed nodes rather than updating the properties of existing nodes, which is probably not all that efficient. But, in the simple test case I had, performance seemed fine so I didn't think it was worth additional effort to optimize.

This is just one of the numerous potential solution strategies for this question (I won't discuss other potential solutions here). The particular solution offered in this answer appeared to be a good fit for me.

Also, note that this solution does not use a canvas, it is based on a scene graph. I recommend using the scene graph for this task, though you could use a canvas if you wish. With a canvas solution, the solution might be quite different from the one presented here (I don't offer any advice on how to create a canvas-based solution).

Event handler for handling the zoom

This handler is attached to the parent pane which holds the graph node children.

private class ZoomHandler implements EventHandler<ScrollEvent> {
    private static final double MAX_ZOOM = 2;
    private static final double MIN_ZOOM = 0.5;

    private double zoomFactor = 1;

    @Override
    public void handle(ScrollEvent event) {
        if (event.getDeltaY() == 0) {
            return;
        } else if (event.getDeltaY() < 0) {
            zoomFactor = Math.max(MIN_ZOOM, zoomFactor * 0.9);
        } else if (event.getDeltaY() > 0) {
            zoomFactor = Math.min(MAX_ZOOM, zoomFactor * 1.1);
        }

        Plot plot = plotChart(zoomFactor);

        Pane parent = (Pane) event.getSource();
        parent.getChildren().setAll(plot);
    }
}

Sample zoomed chart images

zoomed all the way out

image1

default zoom level

image2

zoomed all the way in

image3

Complete sample solution code

import javafx.application.Application;
import javafx.beans.binding.Bindings;
import javafx.event.EventHandler;
import javafx.geometry.*;
import javafx.scene.Scene;
import javafx.scene.chart.NumberAxis;
import javafx.scene.input.ScrollEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.shape.*;
import javafx.stage.Stage;

import java.util.function.Function;

public class ZoomableCartesianPlot extends Application {

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

    @Override
    public void start(final Stage stage) {
        Plot plot = plotChart(1);

        StackPane layout = new StackPane(
                plot
        );
        layout.setPadding(new Insets(20));
        layout.setStyle("-fx-background-color: rgb(35, 39, 50);");
        layout.setOnScroll(new ZoomHandler());

        stage.setTitle("y = \u00BC(x+4)(x+1)(x-2)");
        stage.setScene(new Scene(layout, Color.rgb(35, 39, 50)));
        stage.show();
    }

    private Plot plotChart(double zoomFactor) {
        Axes axes = new Axes(
                400, 300,
                -8 * zoomFactor, 8 * zoomFactor, 1,
                -6 * zoomFactor, 6 * zoomFactor, 1
        );

        Plot plot = new Plot(
                x -> .25 * (x + 4) * (x + 1) * (x - 2),
                -8 * zoomFactor, 8 * zoomFactor, 0.1,
                axes
        );

        return plot;
    }

    class Axes extends Pane {
        private NumberAxis xAxis;
        private NumberAxis yAxis;

        public Axes(
                int width, int height,
                double xLow, double xHi, double xTickUnit,
                double yLow, double yHi, double yTickUnit
        ) {
            setMinSize(Pane.USE_PREF_SIZE, Pane.USE_PREF_SIZE);
            setPrefSize(width, height);
            setMaxSize(Pane.USE_PREF_SIZE, Pane.USE_PREF_SIZE);

            xAxis = new NumberAxis(xLow, xHi, xTickUnit);
            xAxis.setSide(Side.BOTTOM);
            xAxis.setMinorTickVisible(false);
            xAxis.setPrefWidth(width);
            xAxis.setLayoutY(height / 2);

            yAxis = new NumberAxis(yLow, yHi, yTickUnit);
            yAxis.setSide(Side.LEFT);
            yAxis.setMinorTickVisible(false);
            yAxis.setPrefHeight(height);
            yAxis.layoutXProperty().bind(
                Bindings.subtract(
                    (width / 2) + 1,
                    yAxis.widthProperty()
                )
            );

            getChildren().setAll(xAxis, yAxis);
        }

        public NumberAxis getXAxis() {
            return xAxis;
        }

        public NumberAxis getYAxis() {
            return yAxis;
        }
    }

    class Plot extends Pane {
        public Plot(
                Function<Double, Double> f,
                double xMin, double xMax, double xInc,
                Axes axes
        ) {
            Path path = new Path();
            path.setStroke(Color.ORANGE.deriveColor(0, 1, 1, 0.6));
            path.setStrokeWidth(2);

            path.setClip(
                    new Rectangle(
                            0, 0, 
                            axes.getPrefWidth(), 
                            axes.getPrefHeight()
                    )
            );

            double x = xMin;
            double y = f.apply(x);

            path.getElements().add(
                    new MoveTo(
                            mapX(x, axes), mapY(y, axes)
                    )
            );

            x += xInc;
            while (x < xMax) {
                y = f.apply(x);

                path.getElements().add(
                        new LineTo(
                                mapX(x, axes), mapY(y, axes)
                        )
                );

                x += xInc;
            }

            setMinSize(Pane.USE_PREF_SIZE, Pane.USE_PREF_SIZE);
            setPrefSize(axes.getPrefWidth(), axes.getPrefHeight());
            setMaxSize(Pane.USE_PREF_SIZE, Pane.USE_PREF_SIZE);

            getChildren().setAll(axes, path);
        }

        private double mapX(double x, Axes axes) {
            double tx = axes.getPrefWidth() / 2;
            double sx = axes.getPrefWidth() / 
               (axes.getXAxis().getUpperBound() - 
                axes.getXAxis().getLowerBound());

            return x * sx + tx;
        }

        private double mapY(double y, Axes axes) {
            double ty = axes.getPrefHeight() / 2;
            double sy = axes.getPrefHeight() / 
                (axes.getYAxis().getUpperBound() - 
                 axes.getYAxis().getLowerBound());

            return -y * sy + ty;
        }
    }

    private class ZoomHandler implements EventHandler<ScrollEvent> {
        private static final double MAX_ZOOM = 2;
        private static final double MIN_ZOOM = 0.5;

        private double zoomFactor = 1;

        @Override
        public void handle(ScrollEvent event) {
            if (event.getDeltaY() == 0) {
                return;
            } else if (event.getDeltaY() < 0) {
                zoomFactor = Math.max(MIN_ZOOM, zoomFactor * 0.9);
            } else if (event.getDeltaY() > 0) {
                zoomFactor = Math.min(MAX_ZOOM, zoomFactor * 1.1);
            }

            Plot plot = plotChart(zoomFactor);

            Pane parent = (Pane) event.getSource();
            parent.getChildren().setAll(plot);
        }
    }
}
jewelsea
  • 150,031
  • 14
  • 366
  • 406
  • A very helpful MVCE, thanks very much for posting it. I'm writing a custom candlestick chart and need to handle screen resize events. You mentioned re-creating the nodes vs updating their properties, but not binding. Is it possible to bind the nodes' X and Y layout values to the display positions on their respective axes? I've been trying to figure it out but getting nowhere. I'm using the axes' ```getDisplayPosition()``` methods to plot the candlesticks but can't find a "displayPositionProperty" to bind to. Cheers. – GreenZebra Oct 20 '21 at 08:31
  • @GreenZebra I can’t quite understand exactly what you are asking. I suggest you ask a new question with your own mcve. I can’t guarantee you will get an answer and it will be a tricky question to phrase and ask, but that is your most likely approach to get some useful info. – jewelsea Oct 20 '21 at 09:33
  • OK, thanks, will do. – GreenZebra Oct 20 '21 at 09:52