This is an automated email from the ASF dual-hosted git repository.

desruisseaux pushed a commit to branch geoapi-4.0
in repository https://gitbox.apache.org/repos/asf/sis.git

commit b99960e228d5098f8031aeb1e941657c6a15738d
Author: Martin Desruisseaux <[email protected]>
AuthorDate: Wed Jun 15 11:21:54 2022 +0200

    Show mouse cursor position in target canvas in addition of following 
zoom/translations/rotations.
---
 .../org/apache/sis/gui/map/GestureFollower.java    | 204 ++++++++++++++
 .../sis/internal/gui/control/SyncWindowList.java   |  65 ++---
 .../org/apache/sis/portrayal/CanvasFollower.java   | 297 +++++++++++++++------
 .../apache/sis/portrayal/TransformChangeEvent.java |   7 +
 4 files changed, 443 insertions(+), 130 deletions(-)

diff --git 
a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/GestureFollower.java
 
b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/GestureFollower.java
new file mode 100644
index 0000000000..776e3b2798
--- /dev/null
+++ 
b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/GestureFollower.java
@@ -0,0 +1,204 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.gui.map;
+
+import java.awt.geom.Point2D;
+import java.util.logging.Logger;
+import javafx.scene.layout.Pane;
+import javafx.scene.paint.Color;
+import javafx.scene.shape.Path;
+import javafx.scene.shape.MoveTo;
+import javafx.scene.shape.HLineTo;
+import javafx.scene.shape.VLineTo;
+import javafx.scene.shape.PathElement;
+import javafx.scene.effect.BlurType;
+import javafx.scene.effect.DropShadow;
+import javafx.event.EventType;
+import javafx.event.EventHandler;
+import javafx.scene.input.MouseEvent;
+import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.SimpleBooleanProperty;
+import org.opengis.referencing.operation.MathTransform2D;
+import org.opengis.referencing.operation.TransformException;
+import org.apache.sis.portrayal.CanvasFollower;
+import org.apache.sis.internal.system.Modules;
+import org.apache.sis.util.logging.Logging;
+
+
+/**
+ * A listener of mouse or keyboard events in a source canvas which can be 
reproduced in a target canvas.
+ * This listener can reproduce the "real world" displacements documented in 
{@linkplain CanvasFollower parent class}.
+ * In addition, this class can also follow mouse movements in source canvas 
and move a cursor in the target canvas
+ * at the same "real world" position.
+ *
+ * <h2>Listeners</h2>
+ * {@code GestureFollower} listeners need to be registered explicitly by a 
call to the {@link #initialize()} method.
+ * The {@link #dispose()} convenience method is provided for unregistering all 
those listeners.
+ *
+ * <h2>Multi-threading</h2>
+ * This class is <strong>not</strong> thread-safe.
+ * All events should be processed in the JavaFX thread.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.3
+ * @since   1.3
+ * @module
+ */
+public class GestureFollower extends CanvasFollower implements 
EventHandler<MouseEvent> {
+    /**
+     * Distance from cursor origin (0,0) to the nearest (inner) or farthest 
(outer) point of the shape
+     * drawing the cursor. Fractional part should be .5 for locating the lines 
at pixel center.
+     */
+    private static final double CURSOR_INNER_RADIUS = 7.5, CURSOR_OUTER_RADIUS 
= 20.5;
+
+    /**
+     * The path elements that describes the shape of the cursor.
+     * The same elements are shared by all {@link #cursor} instances.
+     * The cursor center is at (0.5, 0.5), which maps to a pixel center in 
JavaFX coordinate system.
+     */
+    private static final PathElement[] CURSOR_SHAPE = {
+        new MoveTo(0.5, -CURSOR_OUTER_RADIUS), new 
VLineTo(-CURSOR_INNER_RADIUS),
+        new MoveTo(0.5, +CURSOR_OUTER_RADIUS), new 
VLineTo(+CURSOR_INNER_RADIUS),
+        new MoveTo(-CURSOR_OUTER_RADIUS, 0.5), new 
HLineTo(-CURSOR_INNER_RADIUS),
+        new MoveTo(+CURSOR_OUTER_RADIUS, 0.5), new 
HLineTo(+CURSOR_INNER_RADIUS)
+    };
+
+    /**
+     * The effect applied on the cursor. The intend is to make it more visible 
if the cursor color
+     * is close to the color of features rendered on the map.
+     */
+    private static final DropShadow CURSOR_EFFECT = new 
DropShadow(BlurType.ONE_PASS_BOX, Color.BLACK, 5, 0, 0, 0);
+
+    /**
+     * Whether changes in the "objective to display" transforms should be 
propagated from source to target canvas.
+     * The default value is {@code false}; this property needs to be enabled 
explicitly by caller if desired.
+     */
+    public final BooleanProperty transformEnabled;
+
+    /**
+     * Whether mouse position in source canvas should be shown by a cursor in 
the target canvas.
+     * The default value is {@code false}; this property needs to be enabled 
explicitly by caller if desired.
+     */
+    public final BooleanProperty cursorEnabled;
+
+    /**
+     * Cursor position of the mouse over source canvas, expressed in 
coordinates of the target canvas.
+     */
+    private final Point2D.Double cursorPosition;
+
+    /**
+     * The shape used for drawing a cursor on the target canvas. Constructed 
when first requested.
+     *
+     * @see #followCursor(boolean)
+     */
+    private Path cursor;
+
+    /**
+     * Creates a new listener for synchronizing "objective to display" 
transform changes and cursor position
+     * between the specified canvas. This is a unidirectional binding: changes 
in source are applied on target,
+     * but not the converse.
+     *
+     * <p>Caller needs to register listeners by a call to the {@link 
#initialize()} method.
+     * This is not done automatically by this constructor for allowing users 
to control
+     * when to start listening to changes.</p>
+     *
+     * @param  source  the canvas which is the source of zoom, pan or rotation 
events.
+     * @param  target  the canvas on which to apply the changes of zoom, pan 
or rotation.
+     */
+    public GestureFollower(final MapCanvas source, final MapCanvas target) {
+        super(source, target);
+        super.setDisabled(true);
+        cursorPosition   = new Point2D.Double();
+        transformEnabled = new SimpleBooleanProperty(this, "transformEnabled");
+        cursorEnabled    = new SimpleBooleanProperty(this, "cursorEnabled");
+        transformEnabled.addListener((p,o,n) -> setDisabled(!n));
+        cursorEnabled   .addListener((p,o,n) -> followCursor(n));
+    }
+
+    /**
+     * Invoked when the {@link #cursorEnabled} property value changed.
+     *
+     * @param  enabled  the new property value.
+     */
+    private void followCursor(final boolean enabled) {
+        final Pane pane = ((MapCanvas) source).floatingPane;
+        if (enabled) {
+            if (cursor == null) {
+                cursor = new Path(CURSOR_SHAPE);
+                cursor.setStroke(Color.LIGHTPINK);
+                cursor.setEffect(CURSOR_EFFECT);
+                cursor.setMouseTransparent(true);
+                cursor.setManaged(false);
+                cursor.setSmooth(false);
+                cursor.setCache(true);
+            }
+            (((MapCanvas) target).floatingPane).getChildren().add(cursor);
+            pane.addEventHandler(MouseEvent.MOUSE_ENTERED, this);
+            pane.addEventHandler(MouseEvent.MOUSE_EXITED,  this);
+            pane.addEventHandler(MouseEvent.MOUSE_MOVED,   this);
+        } else {
+            pane.removeEventHandler(MouseEvent.MOUSE_ENTERED, this);
+            pane.removeEventHandler(MouseEvent.MOUSE_EXITED,  this);
+            pane.removeEventHandler(MouseEvent.MOUSE_MOVED,   this);
+            if (cursor != null) {
+                (((MapCanvas) 
target).floatingPane).getChildren().remove(cursor);
+            }
+        }
+    }
+
+    /**
+     * Invoked when the mouse position changed. This method should be invoked 
only if
+     * {@link #cursorEnabled} is {@code true} (this is not verified by this 
method).
+     *
+     * @param  event  the enter, exit or move event.
+     */
+    @Override
+    public void handle(final MouseEvent event) {
+        final EventType<? extends MouseEvent> type = event.getEventType();
+        if (type == MouseEvent.MOUSE_MOVED || type == 
MouseEvent.MOUSE_ENTERED) {
+            final MathTransform2D tr = getDisplayTransform().orElse(null);
+            if (tr != null) try {
+                cursorPosition.x = event.getX();
+                cursorPosition.y = event.getY();
+                final Point2D  p = tr.transform(cursorPosition, 
cursorPosition);
+                cursor.setTranslateX(p.getX());
+                cursor.setTranslateY(p.getY());
+                if (type == MouseEvent.MOUSE_ENTERED) {
+                    cursor.setVisible(true);
+                }
+                return;
+            } catch (TransformException e) {
+                
Logging.recoverableException(Logger.getLogger(Modules.APPLICATION), 
GestureFollower.class, "handle", e);
+                // Handle as a mouse exit.
+            }
+        } else if (type != MouseEvent.MOUSE_EXITED) {
+            return;
+        }
+        cursor.setVisible(false);
+    }
+
+    /**
+     * Removes all listeners registered by this {@code GestureFollower} 
instance.
+     * This method should be invoked when {@code GestureFollower} is no longer 
needed,
+     * in order to avoid memory leak.
+     */
+    @Override
+    public void dispose() {
+        followCursor(false);
+        super.dispose();
+    }
+}
diff --git 
a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/SyncWindowList.java
 
b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/SyncWindowList.java
index a544a6d542..1c8a824458 100644
--- 
a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/SyncWindowList.java
+++ 
b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/SyncWindowList.java
@@ -17,10 +17,6 @@
 package org.apache.sis.internal.gui.control;
 
 import java.util.List;
-import javafx.beans.property.BooleanProperty;
-import javafx.beans.property.SimpleBooleanProperty;
-import javafx.beans.value.ChangeListener;
-import javafx.beans.value.ObservableValue;
 import javafx.collections.ListChangeListener;
 import javafx.collections.ObservableList;
 import javafx.scene.control.Button;
@@ -34,7 +30,7 @@ import javafx.application.Platform;
 import org.apache.sis.util.resources.Vocabulary;
 import org.apache.sis.gui.dataset.WindowHandler;
 import org.apache.sis.gui.map.MapCanvas;
-import org.apache.sis.portrayal.CanvasFollower;
+import org.apache.sis.gui.map.GestureFollower;
 import org.apache.sis.internal.gui.Resources;
 import org.apache.sis.internal.util.UnmodifiableArrayList;
 
@@ -53,12 +49,7 @@ public final class SyncWindowList extends TabularWidget 
implements ListChangeLis
      * Window containing a {@link MapCanvas} to follow on gesture events.
      * Gestures are followed only if {@link #linked} is {@code true}.
      */
-    private static final class Link extends CanvasFollower implements 
ChangeListener<Boolean> {
-        /**
-         * Whether the "foreigner" {@linkplain #view} should be followed.
-         */
-        public final BooleanProperty linked;
-
+    private static final class Link extends GestureFollower {
         /**
          * The "foreigner" view for which to follow the gesture.
          */
@@ -71,50 +62,42 @@ public final class SyncWindowList extends TabularWidget 
implements ListChangeLis
          * @param  source  the canvas which is the source of zoom, pan or 
rotation events.
          * @param  target  the canvas on which to apply the changes of zoom, 
pan or rotation.
          */
-        @SuppressWarnings("ThisEscapedInObjectConstruction")
         private Link(final WindowHandler view, final MapCanvas source, final 
MapCanvas target) {
             super(source, target);
             this.view = view;
-            linked = new SimpleBooleanProperty(this, "linked");
-            linked.addListener(this);
-            source.addPropertyChangeListener(MapCanvas.OBJECTIVE_CRS_PROPERTY, 
this);
-            target.addPropertyChangeListener(MapCanvas.OBJECTIVE_CRS_PROPERTY, 
this);
         }
 
         /**
          * Converts the given list of handled to a list of table rows.
          *
          * @param  added   list of new items to put in the table.
+         * @param  addTo   where to add the converted items.
          * @param  owner   item to exclude (because the referenced window is 
itself).
          * @param  target  the canvas on which to apply the changes of zoom, 
pan or rotation.
          */
-        static List<Link> wrap(final List<? extends WindowHandler> added, 
final WindowHandler owner, final MapCanvas target) {
+        static void wrap(final List<? extends WindowHandler> added, final 
List<Link> addTo,
+                         final WindowHandler owner, final MapCanvas target)
+        {
             final Link[] items = new Link[added.size()];
             int count = 0;
-            for (final WindowHandler view : added) {
-                if (view != owner) {
-                    final MapCanvas source = view.getCanvas().orElse(null);
-                    if (source != null) {
-                        items[count++] = new Link(view, source, target);
+            try {
+                for (final WindowHandler view : added) {
+                    if (view != owner) {
+                        final MapCanvas source = view.getCanvas().orElse(null);
+                        if (source != null) {
+                            final Link item = new Link(view, source, target);;
+                            items[count++] = item;          // Add now for 
disposing if an exception is thrown.
+                            item.initialize();              // Invoked outside 
constructor for allowing disposal.
+                            item.cursorEnabled.set(true);
+                        }
                     }
                 }
-            }
-            return UnmodifiableArrayList.wrap(items, 0, count);
-        }
-
-        /**
-         * Invoked when the {@link #linked} property value changed.
-         *
-         * @param  property  equivalent to {@link #link}.
-         * @param  oldValue  equivalent to {@code !newValue}.
-         * @param  newValue  the new checkbox value.
-         */
-        @Override
-        public void changed(ObservableValue<? extends Boolean> property, 
Boolean oldValue, Boolean newValue) {
-            if (newValue) {
-                
source.addPropertyChangeListener(MapCanvas.OBJECTIVE_TO_DISPLAY_PROPERTY, this);
-            } else {
-                
source.removePropertyChangeListener(MapCanvas.OBJECTIVE_TO_DISPLAY_PROPERTY, 
this);
+                addTo.addAll(UnmodifiableArrayList.wrap(items, 0, count));
+            } catch (Throwable e) {
+                while (--count >= 0) {
+                    items[--count].dispose();               // Remove 
listeners for avoiding memory leak.
+                }
+                throw e;
             }
         }
     }
@@ -164,7 +147,7 @@ public final class SyncWindowList extends TabularWidget 
implements ListChangeLis
          * The first column contains a checkbox for choosing whether the 
window should be followed or not.
          */
         table.getColumns().setAll(
-                newBooleanColumn(/* 🔗 (link symbol) */  "\uD83D\uDD17",      
(cell) -> cell.getValue().linked),
+                newBooleanColumn(/* 🔗 (link symbol) */  "\uD83D\uDD17",      
(cell) -> cell.getValue().transformEnabled),
                 newStringColumn (vocabulary.getString(Vocabulary.Keys.Title), 
(cell) -> cell.getValue().view.title));
         table.setRowFactory(SyncWindowList::newRow);
         /*
@@ -264,6 +247,6 @@ public final class SyncWindowList extends TabularWidget 
implements ListChangeLis
         if (target == null) {
             target = owner.getCanvas().get();
         }
-        table.getItems().addAll(Link.wrap(windows, owner, target));
+        Link.wrap(windows, table.getItems(), owner, target);
     }
 }
diff --git 
a/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/CanvasFollower.java 
b/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/CanvasFollower.java
index 6ec1374a40..b7080ec8d9 100644
--- 
a/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/CanvasFollower.java
+++ 
b/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/CanvasFollower.java
@@ -16,6 +16,7 @@
  */
 package org.apache.sis.portrayal;
 
+import java.util.Optional;
 import java.util.logging.Logger;
 import java.awt.geom.AffineTransform;
 import java.awt.geom.NoninvertibleTransformException;
@@ -25,13 +26,16 @@ import org.opengis.util.FactoryException;
 import org.opengis.geometry.DirectPosition;
 import org.opengis.metadata.extent.GeographicBoundingBox;
 import org.opengis.referencing.operation.MathTransform;
+import org.opengis.referencing.operation.MathTransform2D;
 import org.opengis.referencing.operation.TransformException;
 import org.opengis.referencing.crs.CoordinateReferenceSystem;
 import org.apache.sis.referencing.CRS;
 import org.apache.sis.util.Disposable;
 import org.apache.sis.util.ArgumentChecks;
+import org.apache.sis.util.NullArgumentException;
 import org.apache.sis.util.logging.Logging;
 import org.apache.sis.internal.system.Modules;
+import org.apache.sis.internal.referencing.j2d.AffineTransform2D;
 import org.apache.sis.referencing.operation.matrix.AffineTransforms2D;
 import org.apache.sis.referencing.operation.transform.MathTransforms;
 
@@ -43,18 +47,13 @@ import 
org.apache.sis.referencing.operation.transform.MathTransforms;
  * account the differences in zoom levels and map projections. For example a 
translation of 10 pixels in one
  * canvas may map to a translation of 20 pixels in the other canvas for 
reproducing the same "real world" translation.
  *
- * <h2>Listeners</h2>
- * This class implements an unidirectional binding: changes in source are 
applied on target, but not the converse.
- * {@code CanvasFollower} listener needs to be registered explicitly as below. 
This is not done automatically for
- * allowing users to control when to listen to changes:
- *
- * {@preformat java
- *     
source.addPropertyChangeListener(PlanarCanvas.OBJECTIVE_TO_DISPLAY_PROPERTY, 
this);
- *     source.addPropertyChangeListener(PlanarCanvas.OBJECTIVE_CRS_PROPERTY, 
this);
- *     target.addPropertyChangeListener(PlanarCanvas.OBJECTIVE_CRS_PROPERTY, 
this);
- * }
+ * <p>This class implements an unidirectional binding: changes in source are 
applied on target, but not the converse.
+ * It is recommended to avoid bidirectional binding in current implementation
+ * (this limitation may be fixed in a future version).</p>
  *
- * The {@link #dispose()} convenience method is provided for unregistering all 
the above.
+ * <h2>Listeners</h2>
+ * {@code CanvasFollower} listeners need to be registered explicitly by a call 
to the {@link #initialize()} method.
+ * The {@link #dispose()} convenience method is provided for unregistering all 
those listeners.
  *
  * <h2>Multi-threading</h2>
  * This class is <strong>not</strong> thread-safe.
@@ -76,6 +75,17 @@ public class CanvasFollower implements 
PropertyChangeListener, Disposable {
      */
     protected final PlanarCanvas target;
 
+    /**
+     * Whether listeners have been registered.
+     */
+    private boolean initialized;
+
+    /**
+     * Whether to disable the following of source canvas. Other events such as 
changes
+     * of objective CRS are still listened in order to update the fields of 
this class.
+     */
+    private boolean disabled;
+
     /**
      * Whether to follow the source canvas in "real world" coordinates.
      * If {@code false}, displacements will be followed in pixel coordinates 
instead.
@@ -83,27 +93,43 @@ public class CanvasFollower implements 
PropertyChangeListener, Disposable {
     private boolean followRealWorld;
 
     /**
-     * The effective value of {@link #followRealWorld}.
-     * May be temporarily set to {@code false} if {@link #sourceToTarget} can 
not be computed.
+     * Conversions from source display coordinates to target display 
coordinates.
+     * Computed when first needed, and recomputed when the objective CRS or the
+     * "display to objective" transform change.
+     *
+     * @see #getDisplayTransform()
      */
-    private boolean effectiveRealWorld;
+    private MathTransform2D displayTransform;
 
     /**
-     * The transform from a change in source canvas to a change in target 
canvas.
+     * Conversions from source objective coordinates to target objective 
coordinates.
      * Computed when first needed, and recomputed when the objective CRS 
changes.
      * A {@code null} value means that no change is needed or can not be done.
      *
-     * @see #findSourceToTarget()
+     * @see #findObjectiveTransform(String)
+     */
+    private MathTransform objectiveTransform;
+
+    /**
+     * Whether an attempt to compute {@link #displayTransform} has already 
been done.
+     * The {@code displayTransform} field may still be null if the attempt 
failed.
+     * Value can be {@link #VALID}, {@link #OUTDATED}, {@link #UNKNOWN} or 
{@link #ERROR}.
      */
-    private MathTransform sourceToTarget;
+    private byte displayTransformStatus;
 
     /**
-     * Whether {@link #sourceToTarget} field is up to date.
-     * Note that the field can be up-to-date and {@code null}.
+     * Whether an attempt to compute {@link #objectiveTransform} has already 
been done.
+     * Note that the {@link #objectiveTransform} field can be up-to-date and 
{@code null}.
+     * Value can be {@link #VALID}, {@link #OUTDATED}, {@link #UNKNOWN} or 
{@link #ERROR}.
      *
-     * @see #findSourceToTarget()
+     * @see #findObjectiveTransform(String)
      */
-    private boolean isTransformUpdated;
+    private byte objectiveTransformStatus;
+
+    /**
+     * Enumeration values for {@link #displayTransformStatus} and {@link 
#objectiveTransformStatus}.
+     */
+    private static final byte VALID = 0, OUTDATED = 1, UNKNOWN = 2, ERROR = 3;
 
     /**
      * Whether a change is in progress. This is for avoiding never-ending loop
@@ -116,14 +142,9 @@ public class CanvasFollower implements 
PropertyChangeListener, Disposable {
      * between the specified canvas. This is a unidirectional binding: changes 
in source
      * are applied on target, but not the converse.
      *
-     * <p>Caller needs to register this listener explicitly as below
-     * (this is not done automatically by this constructor):</p>
-     *
-     * {@preformat java
-     *     
source.addPropertyChangeListener(PlanarCanvas.OBJECTIVE_TO_DISPLAY_PROPERTY, 
this);
-     *     
source.addPropertyChangeListener(PlanarCanvas.OBJECTIVE_CRS_PROPERTY, this);
-     *     
target.addPropertyChangeListener(PlanarCanvas.OBJECTIVE_CRS_PROPERTY, this);
-     * }
+     * <p>Caller needs to register listeners by a call to the {@link 
#initialize()} method.
+     * This is not done automatically by this constructor for allowing users 
to control
+     * when to start listening to changes.</p>
      *
      * @param  source  the canvas which is the source of zoom, pan or rotation 
events.
      * @param  target  the canvas on which to apply the changes of zoom, pan 
or rotation.
@@ -133,7 +154,59 @@ public class CanvasFollower implements 
PropertyChangeListener, Disposable {
         ArgumentChecks.ensureNonNull("target", target);
         this.source = source;
         this.target = target;
-        effectiveRealWorld = followRealWorld = true;
+        followRealWorld = true;
+        displayTransformStatus   = OUTDATED;
+        objectiveTransformStatus = OUTDATED;
+    }
+
+    /**
+     * Registers all listeners needed by this object. This method must be 
invoked at least
+     * once after {@linkplain #CanvasFollower(PlanarCanvas, PlanarCanvas) 
construction},
+     * but not necessarily immediately after (it is okay to defer until first 
needed).
+     * The default implementation registers the following listeners:
+     *
+     * {@preformat java
+     *     
source.addPropertyChangeListener(PlanarCanvas.OBJECTIVE_CRS_PROPERTY, this);
+     *     
target.addPropertyChangeListener(PlanarCanvas.OBJECTIVE_CRS_PROPERTY, this);
+     *     
source.addPropertyChangeListener(PlanarCanvas.OBJECTIVE_TO_DISPLAY_PROPERTY, 
this);
+     *     
target.addPropertyChangeListener(PlanarCanvas.OBJECTIVE_TO_DISPLAY_PROPERTY, 
this);
+     * }
+     *
+     * This method is idempotent (it is okay to invoke it twice).
+     *
+     * @see #dispose()
+     */
+    public void initialize() {
+        if (!initialized) {
+            initialized = true;     // Set first in case an exception is 
thrown below.
+            
source.addPropertyChangeListener(PlanarCanvas.OBJECTIVE_CRS_PROPERTY, this);
+            
target.addPropertyChangeListener(PlanarCanvas.OBJECTIVE_CRS_PROPERTY, this);
+            
source.addPropertyChangeListener(PlanarCanvas.OBJECTIVE_TO_DISPLAY_PROPERTY, 
this);
+            
target.addPropertyChangeListener(PlanarCanvas.OBJECTIVE_TO_DISPLAY_PROPERTY, 
this);
+        }
+    }
+
+    /**
+     * Returns {@code true} if this object stopped to replicate changes from 
source canvas to target canvas.
+     * If {@code true}, this object continues to listen to changes in order to 
keep its state consistent,
+     * but does not replicate those changes on the target canvas.
+     *
+     * <p>A non-{@linkplain #initialize() initialized} object is considered 
disabled.</p>
+     *
+     * @return whether this object stopped to replicate changes from source 
canvas to target canvas.
+     */
+    public boolean isDisabled() {
+        return disabled | !initialized;
+    }
+
+    /**
+     * Sets whether to stop to replicate changes from source canvas to target 
canvas.
+     * It does not stop this object to listen to events, because it is 
necessary for keeping its state consistent.
+     *
+     * @param  stop  {@code true} for stopping to replicate changes from 
source canvas to target canvas.
+     */
+    public void setDisabled(final boolean stop) {
+        disabled = stop;
     }
 
     /**
@@ -159,10 +232,55 @@ public class CanvasFollower implements 
PropertyChangeListener, Disposable {
      */
     public void setFollowRealWorld(final boolean real) {
         if (real != followRealWorld) {
-            effectiveRealWorld = followRealWorld = real;
-            isTransformUpdated = false;
-            sourceToTarget     = null;
+            followRealWorld          = real;
+            displayTransform         = null;
+            objectiveTransform       = null;
+            displayTransformStatus   = OUTDATED;
+            objectiveTransformStatus = OUTDATED;
+        }
+    }
+
+    /**
+     * Returns the transform from source display coordinates to target display 
coordinates.
+     * This transform may change every time that a zoom; translation or 
rotation is applied
+     * on at least one canvas. The transform may be absent if an error prevent 
to compute it,
+     * for example is no coordinate operation has been found between the 
objective CRS of the
+     * source and target canvases.
+     *
+     * @return transform from source display coordinates to target display 
coordinates.
+     */
+    public Optional<MathTransform2D> getDisplayTransform() {
+        if (displayTransformStatus != VALID) {
+            if (displayTransformStatus != OUTDATED) {
+                return Optional.empty();
+            }
+            displayTransformStatus = ERROR;             // Set now in case an 
exception is thrown below.
+            if (objectiveTransformStatus == VALID || 
findObjectiveTransform("getDisplayTransform")) try {
+                /*
+                 * Compute (source display to objective) → (map projection) → 
(target objective to display).
+                 * If we can work directly on `AffineTransform` instances, it 
should be more efficient than
+                 * the generic code. But the two `if … else` branches below 
compute the same thing
+                 * (ignoring rounding errors).
+                 */
+                final MathTransform objectiveTransform = 
this.objectiveTransform;
+                if (objectiveTransform == null || objectiveTransform 
instanceof AffineTransform) {
+                    AffineTransform tr = 
source.objectiveToDisplay.createInverse();
+                    if (objectiveTransform != null) {
+                        tr.preConcatenate((AffineTransform) 
objectiveTransform);
+                    }
+                    tr.preConcatenate(target.objectiveToDisplay);
+                    displayTransform = new AffineTransform2D(tr);
+                } else {
+                    displayTransform = 
MathTransforms.bidimensional(MathTransforms.concatenate(
+                            source.getObjectiveToDisplay().inverse(), 
objectiveTransform,
+                            target.getObjectiveToDisplay()));
+                }
+                displayTransformStatus = VALID;
+            } catch (NoninvertibleTransformException | 
org.opengis.referencing.operation.NoninvertibleTransformException e) {
+                canNotCompute("getDisplayTransform", e);
+            }
         }
+        return Optional.ofNullable(displayTransform);
     }
 
     /**
@@ -176,16 +294,29 @@ public class CanvasFollower implements 
PropertyChangeListener, Disposable {
     public void propertyChange(final PropertyChangeEvent event) {
         if (event instanceof TransformChangeEvent) {
             final TransformChangeEvent te = (TransformChangeEvent) event;
-            if (!changing && te.getReason().isNavigation()) try {
+            displayTransformStatus = OUTDATED;
+            if (!disabled && !changing && te.isSameSource(source) && 
te.getReason().isNavigation()) try {
                 changing = true;
-                if (!isTransformUpdated) {
-                    findSourceToTarget();               // May update the 
`effectiveRealWorld` field.
-                }
-                if (effectiveRealWorld) {
-                    AffineTransform before = 
convertObjectiveChange(te.getObjectiveChange2D().orElse(null));
-                    if (before != null) {
+                if (followRealWorld && (objectiveTransformStatus == VALID || 
findObjectiveTransform("propertyChange"))) {
+                    AffineTransform before = 
te.getObjectiveChange2D().orElse(null);
+                    if (before != null) try {
+                        /*
+                         * Converts a change from units of the source CRS to 
units of the target CRS.
+                         * If that change can not be computed, fallback on a 
change in display units.
+                         * The POI may be null, but this is okay if the 
transform is linear.
+                         */
+                        if (objectiveTransform != null) {
+                            DirectPosition poi = 
target.getPointOfInterest(true);
+                            AffineTransform t = 
AffineTransforms2D.castOrCopy(MathTransforms.linear(objectiveTransform, poi));
+                            AffineTransform c = t.createInverse();
+                            c.preConcatenate(before);
+                            c.preConcatenate(t);
+                            before = c;
+                        }
                         target.transformObjectiveCoordinates(before);
                         return;
+                    } catch (NullArgumentException | TransformException | 
NoninvertibleTransformException e) {
+                        canNotCompute("propertyChange", e);
                     }
                 }
                 
te.getDisplayChange2D().ifPresent(target::transformDisplayCoordinates);
@@ -193,21 +324,27 @@ public class CanvasFollower implements 
PropertyChangeListener, Disposable {
                 changing = false;
             }
         } else if 
(PlanarCanvas.OBJECTIVE_CRS_PROPERTY.equals(event.getPropertyName())) {
-            isTransformUpdated = false;
-            sourceToTarget = null;
+            displayTransform         = null;
+            objectiveTransform       = null;
+            displayTransformStatus   = OUTDATED;
+            objectiveTransformStatus = OUTDATED;
         }
     }
 
     /**
      * Finds the transform to use for converting changes from {@linkplain 
#source} canvas to {@linkplain #target} canvas.
-     * This method should be invoked only if {@link #isTransformUpdated} is 
{@code false}. After this method returned,
-     * {@link #sourceToTarget} contains the transform to use, which may be 
{@code null} if none.
+     * This method should be invoked only if {@link #objectiveTransformStatus} 
is not {@link #VALID}. After this method
+     * returned, {@link #objectiveTransform} contains the transform to use, 
which may be {@code null} if none.
+     *
+     * @param  caller  the public method which is invoked this private method. 
Used only for logging purposes.
+     * @return whether a transform has been computed.
      */
-    private void findSourceToTarget() {
-        sourceToTarget     = null;
-        effectiveRealWorld = false;
-        isTransformUpdated = true;      // If an exception occurs, use above 
setting.
-        if (followRealWorld) {
+    private boolean findObjectiveTransform(final String caller) {
+        if (objectiveTransformStatus == OUTDATED) {
+            displayTransform         = null;
+            objectiveTransform       = null;
+            displayTransformStatus   = OUTDATED;
+            objectiveTransformStatus = ERROR;      // If an exception occurs, 
use above setting.
             final CoordinateReferenceSystem sourceCRS = 
source.getObjectiveCRS();
             final CoordinateReferenceSystem targetCRS = 
target.getObjectiveCRS();
             if (sourceCRS != null && targetCRS != null) try {
@@ -215,68 +352,50 @@ public class CanvasFollower implements 
PropertyChangeListener, Disposable {
                 try {
                     aoi = target.getGeographicArea().orElse(null);
                 } catch (RenderException e) {
-                    canNotCompute(e);
+                    canNotCompute(caller, e);
                     aoi = null;
                 }
-                sourceToTarget = CRS.findOperation(sourceCRS, targetCRS, 
aoi).getMathTransform();
-                if (sourceToTarget.isIdentity()) {
-                    sourceToTarget = null;
+                objectiveTransform = CRS.findOperation(sourceCRS, targetCRS, 
aoi).getMathTransform();
+                if (objectiveTransform.isIdentity()) {
+                    objectiveTransform = null;
                 }
-                effectiveRealWorld = true;
+                objectiveTransformStatus = VALID;
+                return true;
             } catch (FactoryException e) {
-                canNotCompute(e);
+                canNotCompute(caller, e);
                 // Stay with "changes in display units" mode.
+            } else {
+                objectiveTransformStatus = UNKNOWN;
             }
         }
+        return false;
     }
 
     /**
-     * Converts a change from units of the source CRS to units of the target 
CRS.
-     * If that change can not be computed, the caller will fallback on a change
-     * in display units (typically pixels).
-     *
-     * @param  before  the change in units of the {@linkplain #source} 
objective CRS.
-     * @return  the same change but in units of the {@linkplain #target} 
objective CRS,
-     *          or {@code null} if it can not be computed.
-     */
-    private AffineTransform convertObjectiveChange(final AffineTransform 
before) {
-        if (sourceToTarget == null) {
-            return before;
-        }
-        if (before != null) {
-            final DirectPosition poi = target.getPointOfInterest(true);
-            if (poi != null) try {
-                AffineTransform t = 
AffineTransforms2D.castOrCopy(MathTransforms.linear(sourceToTarget, poi));
-                AffineTransform c = t.createInverse();
-                c.preConcatenate(before);
-                c.preConcatenate(t);
-                return c;
-            } catch (TransformException | NoninvertibleTransformException e) {
-                canNotCompute(e);
-            }
-        }
-        return null;
-    }
-
-    /**
-     * Invoked when the {@link #sourceToTarget} transform can not be computed,
+     * Invoked when the {@link #objectiveTransform} transform can not be 
computed,
      * or when an optional information required for that transform is missing.
      * This method assumes that the public caller (possibly indirectly) is
      * {@link #propertyChange(PropertyChangeEvent)}.
+     *
+     * @param  caller  the public method which is invoked this private method. 
Used only for logging purposes.
+     * @param  e  the exception that occurred.
      */
-    private static void canNotCompute(final Exception e) {
-        Logging.recoverableException(Logger.getLogger(Modules.PORTRAYAL), 
CanvasFollower.class, "propertyChange", e);
+    private static void canNotCompute(final String caller, final Exception e) {
+        Logging.recoverableException(Logger.getLogger(Modules.PORTRAYAL), 
CanvasFollower.class, caller, e);
     }
 
     /**
-     * Removes all listeners documented in {@linkplain CanvasFollower class 
javadoc}.
-     * This method should be invoked when {@code CanvasFollower} is no longer 
needed
-     * for avoiding memory leak.
+     * Removes all listeners documented in the {@link #initialize()} method.
+     * This method should be invoked when {@code CanvasFollower} is no longer 
needed, in order to avoid memory leak.
+     *
+     * @see #initialize()
      */
     @Override
     public void dispose() {
         
source.removePropertyChangeListener(PlanarCanvas.OBJECTIVE_TO_DISPLAY_PROPERTY, 
this);
+        
target.removePropertyChangeListener(PlanarCanvas.OBJECTIVE_TO_DISPLAY_PROPERTY, 
this);
         
source.removePropertyChangeListener(PlanarCanvas.OBJECTIVE_CRS_PROPERTY, this);
         
target.removePropertyChangeListener(PlanarCanvas.OBJECTIVE_CRS_PROPERTY, this);
+        initialized = false;
     }
 }
diff --git 
a/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/TransformChangeEvent.java
 
b/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/TransformChangeEvent.java
index 53ac2908f8..d4632fcce0 100644
--- 
a/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/TransformChangeEvent.java
+++ 
b/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/TransformChangeEvent.java
@@ -157,6 +157,13 @@ public class TransformChangeEvent extends 
PropertyChangeEvent {
         this.reason = reason;
     }
 
+    /**
+     * Quick and non-overrideable check about whether the specified source is 
the source of this event.
+     */
+    final boolean isSameSource(final Canvas source) {
+        return super.getSource() == source;
+    }
+
     /**
      * Returns the canvas on which this event initially occurred.
      *

Reply via email to