3

I am relatively new to property bindings and I am looking for some high-level advice on how to approach a design problem, which I will try to describe a simple example of here.

Problem description

The goal in this example is to allow the user to specify a box/rectangular region interactively in a pannable and zoomable 2D space. The 2D screen-space in which the box is depicted, maps to a 2D "real-space" (e.g. voltage vs time cartesian space, or GPS, or whatever). The user should be able to zoom/pan his viewport vertically/horizontally at any time, thereby changing the mapping between these two spaces.

screen-space <-------- user-adjustable mapping --------> real-space

The user specifies the rectangle in his viewport by dragging borders/corners, as in this demo:

class InteractiveHandle extends Rectangle {

    private final Cursor hoverCursor;
    private final Cursor activeCursor;
    private final DoubleProperty centerXProperty = new SimpleDoubleProperty();
    private final DoubleProperty centerYProperty = new SimpleDoubleProperty();

    InteractiveHandle(DoubleProperty x, DoubleProperty y, double w, double h) {
        super();
        centerXProperty.bindBidirectional(x);
        centerYProperty.bindBidirectional(y);
        widthProperty().set(w);
        heightProperty().set(h);
        hoverCursor = Cursor.MOVE;
        activeCursor = Cursor.MOVE;
        bindRect();
        enableDrag(true,true);
    }

    InteractiveHandle(DoubleProperty x, ObservableDoubleValue y, double w, ObservableDoubleValue h) {
        super();
        centerXProperty.bindBidirectional(x);
        centerYProperty.bind(y);
        widthProperty().set(w);
        heightProperty().bind(h);
        hoverCursor = Cursor.H_RESIZE;
        activeCursor = Cursor.H_RESIZE;
        bindRect();
        enableDrag(true,false);
    }

    InteractiveHandle(ObservableDoubleValue x, DoubleProperty y, ObservableDoubleValue w, double h) {
        super();
        centerXProperty.bind(x);
        centerYProperty.bindBidirectional(y);
        widthProperty().bind(w);
        heightProperty().set(h);
        hoverCursor = Cursor.V_RESIZE;
        activeCursor = Cursor.V_RESIZE;
        bindRect();
        enableDrag(false,true);
    }

    InteractiveHandle(ObservableDoubleValue x, ObservableDoubleValue y, ObservableDoubleValue w, ObservableDoubleValue h) {
        super();
        centerXProperty.bind(x);
        centerYProperty.bind(y);
        widthProperty().bind(w);
        heightProperty().bind(h);
        hoverCursor = Cursor.DEFAULT;
        activeCursor = Cursor.DEFAULT;
        bindRect();
        enableDrag(false,false);
    }

    private void bindRect(){
        xProperty().bind(centerXProperty.subtract(widthProperty().divide(2)));
        yProperty().bind(centerYProperty.subtract(heightProperty().divide(2)));
    }

//make a node movable by dragging it around with the mouse.
  private void enableDrag(boolean xDraggable, boolean yDraggable) {
    final Delta dragDelta = new Delta();
    setOnMousePressed((MouseEvent mouseEvent) -> {
        // record a delta distance for the drag and drop operation.
        dragDelta.x = centerXProperty.get() - mouseEvent.getX();
        dragDelta.y = centerYProperty.get() - mouseEvent.getY();
        getScene().setCursor(activeCursor);
    });
    setOnMouseReleased((MouseEvent mouseEvent) -> {
        getScene().setCursor(hoverCursor);
    });
    setOnMouseDragged((MouseEvent mouseEvent) -> {
        if(xDraggable){
            double newX = mouseEvent.getX() + dragDelta.x;
            if (newX > 0 && newX < getScene().getWidth()) {
                centerXProperty.set(newX);
            }
        }
        if(yDraggable){
            double newY = mouseEvent.getY() + dragDelta.y;
            if (newY > 0 && newY < getScene().getHeight()) {
                centerYProperty.set(newY);
            }
        }
    });
    setOnMouseEntered((MouseEvent mouseEvent) -> {
        if (!mouseEvent.isPrimaryButtonDown()) {
            getScene().setCursor(hoverCursor);
        }
    });
    setOnMouseExited((MouseEvent mouseEvent) -> {
        if (!mouseEvent.isPrimaryButtonDown()) {
            getScene().setCursor(Cursor.DEFAULT);
        }
    });
  }
//records relative x and y co-ordinates.
  private class Delta { double x, y; }

}

public class InteractiveBox extends Group {

    private static final double sideHandleWidth = 2;
    private static final double cornerHandleSize = 4;
    private static final double minHandleFraction = 0.5;
    private static final double maxCornerClearance = 6;
    private static final double handleInset = 2;

    private final Rectangle rectangle;

    private final InteractiveHandle ihLeft;
    private final InteractiveHandle ihTop;
    private final InteractiveHandle ihRight;
    private final InteractiveHandle ihBottom;

    private final InteractiveHandle ihTopLeft;
    private final InteractiveHandle ihTopRight;
    private final InteractiveHandle ihBottomLeft;
    private final InteractiveHandle ihBottomRight;

    InteractiveBox(DoubleProperty xMin, DoubleProperty yMin, DoubleProperty xMax, DoubleProperty yMax){
        super();

        rectangle = new Rectangle();
        rectangle.widthProperty().bind(xMax.subtract(xMin));
        rectangle.heightProperty().bind(yMax.subtract(yMin));
        rectangle.xProperty().bind(xMin);
        rectangle.yProperty().bind(yMin);

        DoubleBinding xMid = xMin.add(xMax).divide(2);
        DoubleBinding yMid = yMin.add(yMax).divide(2);
        DoubleBinding hx = (DoubleBinding) Bindings.max(
                 rectangle.widthProperty().multiply(minHandleFraction)
                ,rectangle.widthProperty().subtract(maxCornerClearance*2)
        );
        DoubleBinding vx = (DoubleBinding) Bindings.max(
                 rectangle.heightProperty().multiply(minHandleFraction)
                ,rectangle.heightProperty().subtract(maxCornerClearance*2)
        );
        ihTopLeft = new InteractiveHandle(xMin,yMax,cornerHandleSize,cornerHandleSize);
        ihTopRight = new InteractiveHandle(xMax,yMax,cornerHandleSize,cornerHandleSize);
        ihBottomLeft = new InteractiveHandle(xMin,yMin,cornerHandleSize,cornerHandleSize);
        ihBottomRight = new InteractiveHandle(xMax,yMin,cornerHandleSize,cornerHandleSize);
        ihLeft   = new InteractiveHandle(xMin,yMid,sideHandleWidth,vx);
        ihTop    = new InteractiveHandle(xMid,yMax,hx,sideHandleWidth);
        ihRight  = new InteractiveHandle(xMax,yMid,sideHandleWidth,vx);
        ihBottom = new InteractiveHandle(xMid,yMin,hx,sideHandleWidth);

        style(ihLeft);
        style(ihTop);
        style(ihRight);
        style(ihBottom);
        style(ihTopLeft);
        style(ihTopRight);
        style(ihBottomLeft);
        style(ihBottomRight);

        getChildren().addAll(rectangle
                ,ihTopLeft, ihTopRight, ihBottomLeft, ihBottomRight
                ,ihLeft, ihTop, ihRight, ihBottom
        );

        rectangle.setFill(Color.ALICEBLUE);
        rectangle.setStroke(Color.LIGHTGRAY);
        rectangle.setStrokeWidth(2);
        rectangle.setStrokeType(StrokeType.CENTERED);
    }

    private void style(InteractiveHandle ih){
        ih.setStroke(Color.TRANSPARENT);
        ih.setStrokeWidth(handleInset);
        ih.setStrokeType(StrokeType.OUTSIDE);
    }
}   

public class Summoner extends Application {

    DoubleProperty x = new SimpleDoubleProperty(50);
    DoubleProperty y = new SimpleDoubleProperty(50);
    DoubleProperty xMax = new SimpleDoubleProperty(100);
    DoubleProperty yMax = new SimpleDoubleProperty(100);

    @Override
    public void start(Stage primaryStage) {
        InteractiveBox box = new InteractiveBox(x,y,xMax,yMax);
        Pane root = new Pane();
        root.getChildren().add(box);
        Scene scene = new Scene(root, 300, 250);
        primaryStage.setScene(scene);
        primaryStage.show();

    }

    /**
     * @param args the command line arguments
     */
    public static void main(String[] args) {
        launch(args);
    }
}

After the rectangle has been specified by the user, its coordinates (in real-space) are passed on to or read by a different part of the program.

My rationale

My first instinct was to use the built-in scale/translate properties in JavaFX nodes to implement the mapping, but we want borders and handles to have a consistent size/appearance regardless of zoom-state; zooming should only embiggen the conceptual rectangle itself, not thicken the borders or corner-handles.

(In the following, arrows represent causality/influence/dependency. For example, A ---> B could mean property B is bound to property A (or it could mean that event-handler A sets property B), and <-----> could represent a bidirectional binding. A multi-tailed arrow such as --+--> could represent a binding that depends on multiple input observables.)

So my question became: which of the following should I do?

  • real-space-properties ---+--> screen-space-properties
  • real-space-properties <--+--- screen-space properties
  • or something different, using <---->

On the one hand, we have mouse events and the rendered rectangle itself in screen-space. This argues for a self-contained interactive rectangle (whose screen-space position/dimension properties we can observe (as well as manipulate, if we wanted to) externally) as per the demo above.

mouse events -----> screen-space properties ------> depicted rectangle
                          |
                          |
                          --------> real-space properties -----> API

On the other hand, when the user adjusts pan/zoom, we want the rectangle's properties in real-space (not screen-space) to be preserved. This argues for binding the screen-space properties to real-space properties using pan&zoom-state properties:

                  pan/zoom properties
                         |
                         |
real-space properties ---+--> screen-space properties ------> depicted rectangle
        |
        |
        -------> API

If I try to put together both approaches above, I run into a problem:

                                    mouse events
                                         |
                  pan/zoom properties    |
                         |               |
                         |               v
real-space properties <--+--> screen-space properties ------> depicted rectangle
        |             * 
        |
        -------> API

This diagram makes a lot of sense to me, but I don't think the kind of "bidirectional" 3-way binding at * is possible, directly. But is there perhaps a simple way to emulate/work around it? Or should I take an entirely different approach?

Museful
  • 6,711
  • 5
  • 42
  • 68

1 Answers1

0

Here's an example of a rectangle on a zoom & pannable pane with a constant stroke width. You just have to define the scale factor as a property of the pane, bind a it to a property in the calling class and divide that into a property that's bound to the strokewidth of the rectangle.

import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.application.Application;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.event.EventHandler;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.ScrollPane.ScrollBarPolicy;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.StrokeType;
import javafx.stage.Stage;
import javafx.util.Duration;

public class ZoomAndPanExample extends Application {

    private ScrollPane scrollPane = new ScrollPane();

    private final DoubleProperty zoomProperty = new SimpleDoubleProperty(1.0d);
    private final DoubleProperty strokeWidthProperty = new SimpleDoubleProperty(1.0d);
    private final DoubleProperty deltaY = new SimpleDoubleProperty(0.0d);

    private final Group group = new Group();

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

    @Override
    public void start(Stage primaryStage) {

        scrollPane.setPannable(true);
        scrollPane.setHbarPolicy(ScrollBarPolicy.NEVER);
        scrollPane.setVbarPolicy(ScrollBarPolicy.NEVER);
        AnchorPane.setTopAnchor(scrollPane, 10.0d);
        AnchorPane.setRightAnchor(scrollPane, 10.0d);
        AnchorPane.setBottomAnchor(scrollPane, 10.0d);
        AnchorPane.setLeftAnchor(scrollPane, 10.0d);

        AnchorPane root = new AnchorPane();

        Rectangle rect = new Rectangle(80, 60);

        rect.setStroke(Color.NAVY);
        rect.setFill(Color.web("#000080", 0.2));
        rect.setStrokeType(StrokeType.INSIDE);
        rect.strokeWidthProperty().bind(strokeWidthProperty.divide(zoomProperty));

        group.getChildren().add(rect);
        // create canvas
        PanAndZoomPane panAndZoomPane = new PanAndZoomPane();
        zoomProperty.bind(panAndZoomPane.myScale);
        deltaY.bind(panAndZoomPane.deltaY);
        panAndZoomPane.getChildren().add(group);

        SceneGestures sceneGestures = new SceneGestures(panAndZoomPane);

        scrollPane.setContent(panAndZoomPane);
        panAndZoomPane.toBack();
        scrollPane.addEventFilter( MouseEvent.MOUSE_CLICKED, sceneGestures.getOnMouseClickedEventHandler());
        scrollPane.addEventFilter( MouseEvent.MOUSE_PRESSED, sceneGestures.getOnMousePressedEventHandler());
        scrollPane.addEventFilter( MouseEvent.MOUSE_DRAGGED, sceneGestures.getOnMouseDraggedEventHandler());
        scrollPane.addEventFilter( ScrollEvent.ANY, sceneGestures.getOnScrollEventHandler());

        root.getChildren().add(scrollPane);
        Scene scene = new Scene(root, 600, 400);
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    class PanAndZoomPane extends Pane {

        public static final double DEFAULT_DELTA = 1.3d;
        DoubleProperty myScale = new SimpleDoubleProperty(1.0);
        public DoubleProperty deltaY = new SimpleDoubleProperty(0.0);
        private Timeline timeline;


        public PanAndZoomPane() {

            this.timeline = new Timeline(60);

            // add scale transform
            scaleXProperty().bind(myScale);
            scaleYProperty().bind(myScale);
        }


        public double getScale() {
            return myScale.get();
        }

        public void setScale( double scale) {
            myScale.set(scale);
        }

        public void setPivot( double x, double y, double scale) {
            // note: pivot value must be untransformed, i. e. without scaling
            // timeline that scales and moves the node
            timeline.getKeyFrames().clear();
            timeline.getKeyFrames().addAll(
                new KeyFrame(Duration.millis(200), new KeyValue(translateXProperty(), getTranslateX() - x)),
                new KeyFrame(Duration.millis(200), new KeyValue(translateYProperty(), getTranslateY() - y)),
                new KeyFrame(Duration.millis(200), new KeyValue(myScale, scale))
            );
            timeline.play();

        }

    /** 
     * fit the rectangle to the width of the window
     */
        public void fitWidth () {
            double scale = getParent().getLayoutBounds().getMaxX()/getLayoutBounds().getMaxX();
            double oldScale = getScale();

            double f = (scale / oldScale)-1;

            double dx = getTranslateX() - getBoundsInParent().getMinX() - getBoundsInParent().getWidth()/2;
            double dy = getTranslateY() - getBoundsInParent().getMinY() - getBoundsInParent().getHeight()/2;

            double newX = f*dx + getBoundsInParent().getMinX();
            double newY = f*dy + getBoundsInParent().getMinY();

            setPivot(newX, newY, scale);

        }

        public void resetZoom () {
            double scale = 1.0d;

            double x = getTranslateX();
            double y = getTranslateY();

            setPivot(x, y, scale);
        }

        public double getDeltaY() {
            return deltaY.get();
        }
        public void setDeltaY( double dY) {
            deltaY.set(dY);
        }
    }


    /**
     * Mouse drag context used for scene and nodes.
     */
    class DragContext {

        double mouseAnchorX;
        double mouseAnchorY;

        double translateAnchorX;
        double translateAnchorY;

    }

    /**
     * Listeners for making the scene's canvas draggable and zoomable
     */
    public class SceneGestures {

        private DragContext sceneDragContext = new DragContext();

        PanAndZoomPane panAndZoomPane;

        public SceneGestures( PanAndZoomPane canvas) {
            this.panAndZoomPane = canvas;
        }

        public EventHandler<MouseEvent> getOnMouseClickedEventHandler() {
            return onMouseClickedEventHandler;
        }

        public EventHandler<MouseEvent> getOnMousePressedEventHandler() {
            return onMousePressedEventHandler;
        }

        public EventHandler<MouseEvent> getOnMouseDraggedEventHandler() {
            return onMouseDraggedEventHandler;
        }

        public EventHandler<ScrollEvent> getOnScrollEventHandler() {
            return onScrollEventHandler;
        }

        private EventHandler<MouseEvent> onMousePressedEventHandler = new EventHandler<MouseEvent>() {

            public void handle(MouseEvent event) {

                sceneDragContext.mouseAnchorX = event.getX();
                sceneDragContext.mouseAnchorY = event.getY();

                sceneDragContext.translateAnchorX = panAndZoomPane.getTranslateX();
                sceneDragContext.translateAnchorY = panAndZoomPane.getTranslateY();

            }

        };

        private EventHandler<MouseEvent> onMouseDraggedEventHandler = new EventHandler<MouseEvent>() {
            public void handle(MouseEvent event) {

                panAndZoomPane.setTranslateX(sceneDragContext.translateAnchorX + event.getX() - sceneDragContext.mouseAnchorX);
                panAndZoomPane.setTranslateY(sceneDragContext.translateAnchorY + event.getY() - sceneDragContext.mouseAnchorY);

                event.consume();
            }
        };

        /**
         * Mouse wheel handler: zoom to pivot point
         */
        private EventHandler<ScrollEvent> onScrollEventHandler = new EventHandler<ScrollEvent>() {

            @Override
            public void handle(ScrollEvent event) {

                double delta = PanAndZoomPane.DEFAULT_DELTA;

                double scale = panAndZoomPane.getScale(); // currently we only use Y, same value is used for X
                double oldScale = scale;

                panAndZoomPane.setDeltaY(event.getDeltaY()); 
                if (panAndZoomPane.deltaY.get() < 0) {
                    scale /= delta;
                } else {
                    scale *= delta;
                }

                double f = (scale / oldScale)-1;

                double dx = (event.getX() - (panAndZoomPane.getBoundsInParent().getWidth()/2 + panAndZoomPane.getBoundsInParent().getMinX()));
                double dy = (event.getY() - (panAndZoomPane.getBoundsInParent().getHeight()/2 + panAndZoomPane.getBoundsInParent().getMinY()));

                panAndZoomPane.setPivot(f*dx, f*dy, scale);

                event.consume();

            }
        };

        /**
         * Mouse click handler
         */
        private EventHandler<MouseEvent> onMouseClickedEventHandler = new EventHandler<MouseEvent>() {

            @Override
            public void handle(MouseEvent event) {
                if (event.getButton().equals(MouseButton.PRIMARY)) {
                    if (event.getClickCount() == 2) {
                        panAndZoomPane.resetZoom();
                    }
                }
                if (event.getButton().equals(MouseButton.SECONDARY)) {
                    if (event.getClickCount() == 2) {
                        panAndZoomPane.fitWidth();
                    }
                }
            }
        };
    }
}
RonSiven
  • 939
  • 2
  • 10
  • 23