-2

The ask: How do I get the viewing rectangle in the coordinates of a transformed and scaled node?

The code is attached below, it is based upon the code from this answer: JavaFX 8 Dynamic Node scaling

The details:

I have a simple pane, BigGridPane that contains a collection of squares, all 50x50.

I have it within this PanAndZoomPane construct that was lifted from the answer referenced above. I can not honestly say I fully understand the PanAndZoomPane implementation. For example, it's not clear to me why it needs a ScrollPane at all, but I have not delved in to trying without it.

The PanAndZoomPane lets me pan and zoom my BigGridPane. This works just dandy.

There are 4 Panes involved in this total construct, in this heirarchy: ScrollPane contains PanAndZoomPane which contains Group which contains BigGridPane.

ScrollPane
  PanAndZoomPane
    Group
      BigGridPane

I have put listeners on the boundsInLocalProperty and boundsInParentProperty of all of these, and the only one of these that changes while panning and zooming, is the boundsInParentProperty of the PanAndZoomPane. (For some reason I've seen it trigger on the scroll pane, but all of the values are the same, so I don't include that here).

Along with the boundsInParentProperty changes, the translateX, translateY, and myScale properties of the PanAndZoomPane change as things move around. This is expected, of course. myScale is bound to the scaleX and scaleY properties of the PanAndZoomPane.

This is what it looks like at startup. Example Startup

If I pan the grid as shown, putting 2-2 in the upper left: Pan Example

We can see the properties of the PanAndZoomPane.

panAndZoom in parent: BoundingBox [minX:-99.5, minY:-99.5, minZ:0.0, 
                                   width:501.5, height:501.5, depth:0.0, 
                                   maxX:402.0, maxY:402.0, maxZ:0.0]
paz scale = 1.0 - tx: -99.0 - ty: -99.0

Scale is 1 (no zoom), and we've translated ~100x100. That is, the origin of the BigGridPane is at -100,-100. This all makes complete sense. Similarly, the bounding box shows the same thing. The origin is at -100,-100.

In this scenario, I would like to derive a rectangle that shows me what I'm seeing in the window, in the coordinates of the BigGridPane. That would mean a rectangle of

x:100 y:100 width:250 height:250

Normally, I think, this would be the viewport of the ScrollPane, but since this code isn't actually using the ScrollPane for scrolling (again, I'm not quite exactly what it's role is here), the ScrollPane viewport never changes.

I should note that there are shenanigans happening right now because of the retina display on my mac. If you look at the rectangles, showing 5x5, they're 50x50 rectangles, so we should be seeing 10x10, but because of the retina display on my iMac, everything is doubled. What we're seeing in BigGridPane coordinates is a 250x250 block of 5 squares, offset by 100x100. The fact that this is being showing in a window of 500x500 is a detail (but unlikely one we can ignore).

But to reiterate what my question is, that's what I'm trying to get: that 250x250 square at 100x100.

It's odd that it's offset by 100x100 even though the frame is twice as big (500 vs 250). If I pan to where 1-1 is the upper left, the offset is -50,-50, like it should be.

Now, let's add zooming, and pan again to 2-2.

1 click of the scroll wheel and the scale jumps to 1.5. enter image description here

panAndZoom in parent: BoundingBox [minX:-149.375, minY:-150.375, minZ:0.0,     
                                   width:752.25, height:752.25, depth:0.0, 
                                   maxX:602.875, maxY:601.875, maxZ:0.0]
paz scale = 1.5 - tx: -23.375 - ty: -24.375

What I want, again, in this case, is a rectangle in BigGridPane coordinates. Roughly:

x:100 y:100 w:150 h:150

We see we're offset by 2x2 boxes (100x100) and we see 3+ boxes (150x150).

So. Back to the bounding box. MinX and minY = -150,-150. This is good. 100 x 1.5 = 150. Similarly the width and height are 750. 500 x 1.5 = 750. So, that is good.

The translates are where we go off the rails. -23.375, -24.375. I have no idea where these numbers come from. I can't seem to correlate them to anything in regards to 100, 150, 1.5 zoom, etc.

Worse, if we pan (while still at 1.5 scale) to "0,0", before, at scale=1, tx and ty were both 0. That's good.

panAndZoom in parent: BoundingBox [minX:0.625, minY:0.625, minZ:0.0, 
                                   width:752.25, height:752.25, depth:0.0, 
                                   maxX:752.875, maxY:752.875, maxZ:0.0]
paz scale = 1.5 - tx: 126.625 - ty: 126.625

Now, they're 126.625 (probably should be rounded to 125). I have no idea where those numbers come from.

I've tried all sorts of runs on the numbers to see where these numbers come from.

JavaFX knows what the numbers are! (even if the whole retina thing is kind of messing with my head, I'm going to ignore it for the moment).

And I don't see anything in the transforms of any of the panes.

So, my coordinate systems are all over the map, and I'd like to know what part of my BigGridPane is being shown in my panned and scaled view.

Code:

package pkg;

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.collections.ObservableList;
import javafx.event.EventHandler;
import javafx.geometry.Bounds;
import javafx.geometry.Point2D;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Region;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import javafx.stage.Stage;
import javafx.util.Duration;

public class PanZoomTest extends Application {

    private ScrollPane scrollPane = new ScrollPane();
    private final DoubleProperty zoomProperty = new SimpleDoubleProperty(1.0d);
    private final DoubleProperty deltaY = new SimpleDoubleProperty(0.0d);
    private final Group group = new Group();
    PanAndZoomPane panAndZoomPane = null;
    BigGridPane1 bigGridPane = new BigGridPane1(10, 10, 50);

    @Override
    public void start(Stage primaryStage) throws Exception {

        scrollPane.setPannable(true);
        scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
        scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);

        group.getChildren().add(bigGridPane);

        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();

        addListeners("panAndZoom", panAndZoomPane);

        scrollPane.addEventFilter(MouseEvent.MOUSE_PRESSED, sceneGestures.getOnMousePressedEventHandler());
        scrollPane.addEventFilter(MouseEvent.MOUSE_DRAGGED, sceneGestures.getOnMouseDraggedEventHandler());
        scrollPane.addEventFilter(ScrollEvent.ANY, sceneGestures.getOnScrollEventHandler());

        AnchorPane anchorPane = new AnchorPane();

        anchorPane.getChildren().add(scrollPane);

        anchorPane.setTopAnchor(scrollPane, 1.0d);
        anchorPane.setRightAnchor(scrollPane, 1.0d);
        anchorPane.setBottomAnchor(scrollPane, 1.0d);
        anchorPane.setLeftAnchor(scrollPane, 1.0d);

        BorderPane root = new BorderPane(anchorPane);
        Label label = new Label("Pan and Zoom Test");
        root.setTop(label);

        Scene scene = new Scene(root, 250, 250);
        primaryStage.setScene(scene);
        primaryStage.show();
    }

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

    private void addListeners(String label, Node node) {
        node.boundsInLocalProperty().addListener((o) -> {
            System.out.println(label + " in local: " + node.getBoundsInLocal());
        });

        node.boundsInParentProperty().addListener((o) -> {
            System.out.println(label + " in parent: " + node.getBoundsInParent());
            System.out.println("paz scale = " + panAndZoomPane.getScale() + " - "
                    + panAndZoomPane.getTranslateX() + " - "
                    + panAndZoomPane.getTranslateY());
            System.out.println(group.getTransforms());
        });
    }

    class BigGridPane extends Region {

        int rows;
        int cols;
        int size;

        Font numFont = Font.font("sans-serif", 8);
        FontMetrics numMetrics = new FontMetrics(numFont);

        public BigGridPane(int cols, int rows, int size) {
            this.rows = rows;
            this.cols = cols;
            this.size = size;
            int sizeX = cols * size;
            int sizeY = rows * size;
            setMinSize(sizeX, sizeY);
            setMaxSize(sizeX, sizeY);
            setPrefSize(sizeX, sizeY);
            populate();
        }

        @Override
        protected void layoutChildren() {
            System.out.println("grid layout");
            super.layoutChildren();
        }

        private void populate() {
            ObservableList<Node> children = getChildren();
            children.clear();
            for (int i = 0; i < cols; i++) {
                for (int j = 0; j < rows; j++) {
                    Rectangle r = new Rectangle(i * size, j * size, size, size);
                    r.setFill(null);
                    r.setStroke(Color.BLACK);
                    String label = i + "-" + j;
                    Point2D p = new Point2D(r.getBoundsInLocal().getCenterX(), r.getBoundsInLocal().getCenterY());
                    Text t = new Text(label);
                    t.setX(p.getX() - numMetrics.computeStringWidth(label) / 2);
                    t.setY(p.getY() + numMetrics.getLineHeight() / 2);
                    t.setFont(numFont);
                    children.add(r);
                    children.add(t);
                }
            }
        }
    }

    class PanAndZoomPane extends Pane {

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

        public PanAndZoomPane() {

            this.timeline = new Timeline(30);//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)), //200
                    new KeyFrame(Duration.millis(200), new KeyValue(translateYProperty(), getTranslateY() - y)), //200
                    new KeyFrame(Duration.millis(200), new KeyValue(myScale, scale)) //200
            );
            timeline.play();

        }

        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> 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();

            }
        };
    }

    class FontMetrics {

        final private Text internal;
        public float lineHeight;

        public FontMetrics(Font fnt) {
            internal = new Text();
            internal.setFont(fnt);
            Bounds b = internal.getLayoutBounds();
            lineHeight = (float) b.getHeight();
        }

        public float computeStringWidth(String txt) {
            internal.setText(txt);
            return (float) internal.getLayoutBounds().getWidth();
        }

        public float getLineHeight() {
            return lineHeight;
        }
    }
}

Will Hartung
  • 115,893
  • 19
  • 128
  • 203
  • What is `BigGridPane1`? – James_D Jan 23 '22 at 22:07
  • 2
    And can you make this example more minimal and to the point? There seems to be a lot here that’s not related to your question, such as animations (though that’s not the only thing). – James_D Jan 23 '22 at 23:18
  • 2
    This is complicated and would be difficult to answer. What is this for? Can you describe what you are trying to accomplish? It might be an [xyproblem](https://xyproblem.info/), but it might not. Maybe the scroll pane impl isn’t best for you. You could use a grid view clipped in a group or a controlsfx SpreadsheetView, but I’m not sure they would be any better. Do you need panning and zooming? Also, what do you mean by “viewing rectangle”? Do you really need to measure text to demo this? – jewelsea Jan 23 '22 at 23:26

1 Answers1

1

Generally, you can get the bounds of node1 in the coordinate system of node2 if both are in the same scene using

node2.sceneToLocal(node1.localToScene(node1.getBoundsInLocal()));

I don't understand all the code you posted; I don't really know why you are using a scroll pane when you seem to be implementing all the panning and zooming yourself. Here is a simpler version of a PanZoomPane and then a test which shows how to use the idea above to get the bounds of the viewport in the coordinate system of the panning/zooming content. The "viewport" is just the bounds of the panning/zooming pane in the coordinate system of the content.

If you need the additional functionality in your version of panning and zooming, you should be able to adapt this idea to that; but it would take me too long to understand everything you are doing there.

import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.scene.layout.Region;
import javafx.scene.shape.Rectangle;
import javafx.scene.transform.Affine;
import javafx.scene.transform.Transform;

public class PanZoomPane extends Region {
    
    private final Node content ;
    
    private final Rectangle clip ;
    
    private Affine transform ;
    
    private Point2D mouseDown ;
    
    private static final double SCALE = 1.01 ; // zoom factor per pixel scrolled
    
    public PanZoomPane(Node content) {
        this.content = content ;
        getChildren().add(content);
        clip = new Rectangle();
        setClip(clip);
        transform = Affine.affine(1, 0, 0, 1, 0, 0);
        content.getTransforms().setAll(transform);
        
        content.setOnMousePressed(event -> mouseDown = new Point2D(event.getX(), event.getY()));
        content.setOnMouseDragged(event -> {
            double deltaX = event.getX() - mouseDown.getX();
            double deltaY = event.getY() - mouseDown.getY();
            translate(deltaX, deltaY);
        });
        content.setOnScroll(event -> {
            double pivotX = event.getX();
            double pivotY = event.getY();
            double scale = Math.pow(SCALE, event.getDeltaY());
            scale(pivotX, pivotY, scale);
        });
    }
    
    public Node getContent() {
        return content ;
    }
    
    @Override
    protected void layoutChildren() {
        clip.setWidth(getWidth());
        clip.setHeight(getHeight());        
    }
    
    public void scale(double pivotX, double pivotY, double scale) {
        transform.append(Transform.scale(scale, scale, pivotX, pivotY));
    }
    
    public void translate(double x, double y) {
        transform.append(Transform.translate(x, y));
    }
    
    public void reset() {
        transform.setToIdentity();
    }

}
import javafx.application.Application;
import javafx.beans.binding.Binding;
import javafx.beans.binding.ObjectBinding;
import javafx.geometry.Bounds;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.geometry.VPos;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.ColumnConstraints;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.RowConstraints;
import javafx.scene.paint.Color;
import javafx.stage.Stage;



public class PanZoomTest extends Application {
    
    private Binding<Bounds> viewport ;

    @Override
    public void start(Stage stage) {
        Node content = createContent(50, 50, 50) ;
        PanZoomPane pane = new PanZoomPane(content);
        
        viewport = new ObjectBinding<>() {
            {
                bind(
                    pane.localToSceneTransformProperty(),
                    pane.boundsInLocalProperty(),
                    content.localToSceneTransformProperty()
                );
            }

            @Override
            protected Bounds computeValue() {
                return content.sceneToLocal(pane.localToScene(pane.getBoundsInLocal()));
            }
        };
        
        viewport.addListener((obs, oldViewport, newViewport)  -> System.out.println(newViewport));
        
        BorderPane root = new BorderPane(pane);
        Button reset = new Button("Reset");
        reset.setOnAction(event -> pane.reset());
        
        HBox buttons = new HBox(reset);
        buttons.setAlignment(Pos.CENTER);
        buttons.setPadding(new Insets(10));
        
        root.setTop(buttons);
        
        Scene scene = new Scene(root, 800, 800);
        stage.setScene(scene);
        stage.show();
    }
    
    private Node createContent(int columns, int rows, double cellSize) {
        
        GridPane grid = new GridPane() ;
        ColumnConstraints cc = new ColumnConstraints();
        cc.setMinWidth(cellSize);
        cc.setPrefWidth(cellSize);
        cc.setMaxWidth(cellSize);
        cc.setFillWidth(true);
        cc.setHalignment(HPos.CENTER);
        for (int column = 0 ; column < columns ; column++) {
            grid.getColumnConstraints().add(cc);
        }
        RowConstraints rc = new RowConstraints();
        rc.setMinHeight(cellSize);
        rc.setPrefHeight(cellSize);
        rc.setMaxHeight(cellSize);
        rc.setFillHeight(true);
        rc.setValignment(VPos.CENTER);
        for (int row = 0 ; row < rows ; row++) {
            grid.getRowConstraints().add(rc);
        }
        for (int x = 0 ; x < columns ; x++) {
            for (int y = 0 ; y < rows ; y++) {
                Label label = new Label(String.format("[%d, %d]", x, y));
                label.setBackground(new Background(
                    new BackgroundFill(Color.BLACK, CornerRadii.EMPTY, Insets.EMPTY),
                    new BackgroundFill(Color.WHITE, CornerRadii.EMPTY, new Insets(1,1,0,0))
                ));
                label.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE);
                grid.add(label, x, y);
            }
        }
        return grid ;
    }

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

}
James_D
  • 201,275
  • 16
  • 291
  • 322
  • 1
    Thanks @James_D. This is what I was looking for. The reason I want the viewport is so I can add to and cull the screen graph as I scroll and zoom. Simply consider the example you provide, only make it, essentially, infinite. As the user drags across, we'll need to add elements to fulfill the new blank space, and, while we're at it, may as well remove the once that are clipped out. And, no, it's not a spreadsheet. Next time I'll use circles. The only thing yours doesn't do is if you zoom far enough out, and move your mouse off the grid, it doesn't work. Not a real issue if it's always filled. – Will Hartung Jan 24 '22 at 04:25
  • 1
    @WillHartung it would have been useful to have had the information in your comment in the question. – jewelsea Jan 24 '22 at 06:56
  • @WillHartung there are also alternative implementations for functionality similar to what you describe, for example a [tile engine](https://www.javacodegeeks.com/2013/01/writing-a-tile-engine-in-javafx.html) or [james PanningTiledPane](https://gist.github.com/james-d/a249470377fb3c58784a9349a22641c4) or [ControlsFX GridView](https://github.com/controlsfx/controlsfx/wiki/ControlsFX-Features#gridview) and some other references or solutions if you search StackOverflow. Like this answer don’t expect any of them to be exact matches for your requirements. – jewelsea Jan 24 '22 at 07:11
  • Thanks @jewelsea. I'm not using tiles, I want the scene graph to interact with the individual components on the display. I'm also not using a grid. I used a grid in the example because they're easy to make and to let me easily tell what boxes I'm seeing (2-2 vs 4-10). – Will Hartung Jan 24 '22 at 15:35
  • @WillHartung that also was not clear from the question. I really think it was an xyproblem. – jewelsea Jan 24 '22 at 20:42