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


The following commit(s) were added to refs/heads/geoapi-4.0 by this push:
     new 20726edec3 Initial support of gesture synchronization between map 
canvases in the same mosaic. This initial version only adds the visualization 
of the mouse position in other canvases.
20726edec3 is described below

commit 20726edec308e06713bf317255c249599cbccd66
Author: Martin Desruisseaux <[email protected]>
AuthorDate: Wed Apr 8 00:16:56 2026 +0200

    Initial support of gesture synchronization between map canvases in the same 
mosaic.
    This initial version only adds the visualization of the mouse position in 
other canvases.
---
 .../org/apache/sis/portrayal/CanvasFollower.java   |  91 ++++++------
 .../org/apache/sis/portrayal/package-info.java     |   2 +-
 .../apache/sis/gui/coverage/CoverageCanvas.java    |   9 +-
 .../org/apache/sis/gui/dataset/WindowHandler.java  |  16 +-
 .../org/apache/sis/gui/map/EvanescentPane.java     |  91 ++++++++++++
 .../org/apache/sis/gui/map/GestureFollower.java    |  47 ++++--
 .../main/org/apache/sis/gui/map/MapCanvas.java     | 114 ++++++++-------
 .../main/org/apache/sis/gui/map/MapCanvasAWT.java  |  12 --
 .../main/org/apache/sis/gui/map/MapWindows.java    |   8 +-
 .../main/org/apache/sis/gui/map/MultiCanvas.java   | 162 ++++++++++++++++++---
 .../main/org/apache/sis/gui/map/RenderingTask.java |  27 +++-
 11 files changed, 425 insertions(+), 154 deletions(-)

diff --git 
a/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/CanvasFollower.java
 
b/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/CanvasFollower.java
index a624916180..0c06d54a41 100644
--- 
a/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/CanvasFollower.java
+++ 
b/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/CanvasFollower.java
@@ -48,28 +48,28 @@ import 
org.apache.sis.referencing.operation.transform.MathTransforms;
  *
  * <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.
+ * Conversely, the {@link #dispose()} method unregisters all those listeners.
  * The listeners registered by this class implement an unidirectional binding:
- * changes in source are applied on target, but not the converse.
+ * changes in {@linkplain #source} are applied on {@linkplain #target}, but 
not the converse.
  *
  * <h2>Multi-threading</h2>
  * This class is <strong>not</strong> thread-safe.
  * All events should be processed in the same thread.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.3
+ * @version 1.7
  * @since   1.3
  */
 public class CanvasFollower implements PropertyChangeListener, Disposable {
     /**
      * The canvas which is the source of zoom, translation or rotation events.
      */
-    protected final PlanarCanvas source;
+    public final PlanarCanvas source;
 
     /**
      * The canvas on which to apply the change of zoom, translation or 
rotation.
      */
-    protected final PlanarCanvas target;
+    public final PlanarCanvas target;
 
     /**
      * Whether listeners have been registered.
@@ -109,23 +109,21 @@ public class CanvasFollower implements 
PropertyChangeListener, Disposable {
     /**
      * 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 byte displayTransformStatus;
+    private Status displayTransformStatus;
 
     /**
      * 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 #findObjectiveTransform(String)
      */
-    private byte objectiveTransformStatus;
+    private Status objectiveTransformStatus;
 
     /**
      * Enumeration values for {@link #displayTransformStatus} and {@link 
#objectiveTransformStatus}.
      */
-    private static final byte VALID = 0, OUTDATED = 1, UNKNOWN = 2, ERROR = 3;
+    private enum Status {VALID, OUTDATED, UNKNOWN, ERROR}
 
     /**
      * Whether a change is in progress. This is for avoiding never-ending loop
@@ -140,17 +138,21 @@ public class CanvasFollower implements 
PropertyChangeListener, Disposable {
      *
      * <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>
+     * when to start listening to {@code source} change events.</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.
+     * @throws IllegalArgumentException if {@code source} and {@code target} 
are the same map canvas.
      */
     public CanvasFollower(final PlanarCanvas source, final PlanarCanvas 
target) {
         this.source = Objects.requireNonNull(source);
         this.target = Objects.requireNonNull(target);
+        if (source == target) {
+            throw new IllegalArgumentException();
+        }
         followRealWorld = true;
-        displayTransformStatus   = OUTDATED;
-        objectiveTransformStatus = OUTDATED;
+        displayTransformStatus   = Status.OUTDATED;
+        objectiveTransformStatus = Status.OUTDATED;
     }
 
     /**
@@ -229,18 +231,18 @@ public class CanvasFollower implements 
PropertyChangeListener, Disposable {
             followRealWorld          = real;
             displayTransform         = null;
             objectiveTransform       = null;
-            displayTransformStatus   = OUTDATED;
-            objectiveTransformStatus = OUTDATED;
+            displayTransformStatus   = Status.OUTDATED;
+            objectiveTransformStatus = Status.OUTDATED;
         }
     }
 
     /**
-     * Returns the objective coordinates of the Point Of Interest (POI) in 
source canvas.
-     * This information is used when the source and target canvases do not use 
the same CRS.
-     * Changes in "real world" coordinates on the {@linkplain #target} canvas 
are guaranteed
-     * to reflect the changes in "real world" coordinates of the {@linkplain 
#source} canvas
-     * at that location only. At all other locations, the "real world" 
coordinate changes
-     * may differ because of map projection deformations.
+     * Returns the objective coordinates of the Point Of Interest 
(<abbr>POI</abbr>) in source canvas.
+     * This information is used when the source and target canvases do not use 
the same <abbr>CRS</abbr>.
+     * This is the only location where changes in "real world" coordinates on 
the {@linkplain #target} canvas
+     * are guaranteed to reflect the changes in "real world" coordinates of 
the {@linkplain #source} canvas.
+     * At all other locations, the changes in "real world" coordinates may 
differ because of map projection
+     * deformations.
      *
      * <p>The default implementation computes the value from {@link 
#getSourceDisplayPOI()}
      * if present, or fallback on {@code source.getPointOfInterest(true)} 
otherwise.
@@ -283,20 +285,21 @@ public class CanvasFollower implements 
PropertyChangeListener, Disposable {
 
     /**
      * 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,
+     * 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 
prevents 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) {
+        if (displayTransformStatus != Status.VALID) {
+            if (displayTransformStatus != Status.OUTDATED) {
                 return Optional.empty();
             }
-            displayTransformStatus = ERROR;             // Set now in case an 
exception is thrown below.
-            if (objectiveTransformStatus == VALID || 
findObjectiveTransform("getDisplayTransform")) try {
+            displayTransform = null;
+            displayTransformStatus = Status.ERROR;      // Set now in case an 
exception is thrown below.
+            if (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
@@ -317,7 +320,7 @@ public class CanvasFollower implements 
PropertyChangeListener, Disposable {
                             source.getObjectiveToDisplay().inverse(), 
objectiveTransform,
                             target.getObjectiveToDisplay()));
                 }
-                displayTransformStatus = VALID;
+                displayTransformStatus = Status.VALID;
             } catch (NoninvertibleTransformException | 
org.opengis.referencing.operation.NoninvertibleTransformException e) {
                 canNotCompute("getDisplayTransform", e);
             }
@@ -327,8 +330,8 @@ public class CanvasFollower implements 
PropertyChangeListener, Disposable {
 
     /**
      * Invoked when the objective CRS, zoom, translation or rotation changed 
on a map that we are tracking.
-     * If the event is an instance of {@link TransformChangeEvent}, then this 
method applies the same change
-     * on the {@linkplain #target} canvas.
+     * If the event is an instance of {@link TransformChangeEvent} emitted by 
the {@linkplain #source} canvas,
+     * then this method applies the same change in "real world" units on the 
{@linkplain #target} canvas.
      *
      * <p>This method delegates part of its work to the following methods,
      * which can be overridden for altering the changes:</p>
@@ -347,13 +350,13 @@ public class CanvasFollower implements 
PropertyChangeListener, Disposable {
     @Override
     public void propertyChange(final PropertyChangeEvent event) {
         if (!changing && event instanceof TransformChangeEvent) try {
-            final TransformChangeEvent te = (TransformChangeEvent) event;
-            displayTransformStatus = OUTDATED;
+            final var te = (TransformChangeEvent) event;
+            displayTransformStatus = Status.OUTDATED;
             changing = true;
             if (te.isSameSource(source)) {
                 transformedSource(te);
                 if (!disabled && filter(te)) {
-                    if (followRealWorld && (objectiveTransformStatus == VALID 
|| findObjectiveTransform("propertyChange"))) {
+                    if (followRealWorld && 
findObjectiveTransform("propertyChange")) {
                         AffineTransform before = 
te.getObjectiveChange2D().orElse(null);
                         if (before != null) try {
                             /*
@@ -385,8 +388,8 @@ public class CanvasFollower implements 
PropertyChangeListener, Disposable {
         } else if 
(PlanarCanvas.OBJECTIVE_CRS_PROPERTY.equals(event.getPropertyName())) {
             displayTransform         = null;
             objectiveTransform       = null;
-            displayTransformStatus   = OUTDATED;
-            objectiveTransformStatus = OUTDATED;
+            displayTransformStatus   = Status.OUTDATED;
+            objectiveTransformStatus = Status.OUTDATED;
         }
     }
 
@@ -461,18 +464,20 @@ public class CanvasFollower implements 
PropertyChangeListener, Disposable {
 
     /**
      * Finds the transform to use for converting changes from {@linkplain 
#source} canvas to {@linkplain #target} canvas.
-     * 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.
+     * After this method returned, {@link #objectiveTransform} is 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 boolean findObjectiveTransform(final String caller) {
-        if (objectiveTransformStatus == OUTDATED) {
+        if (objectiveTransformStatus == Status.VALID) {
+            return true;
+        }
+        if (objectiveTransformStatus == Status.OUTDATED) {
             displayTransform         = null;
             objectiveTransform       = null;
-            displayTransformStatus   = OUTDATED;
-            objectiveTransformStatus = ERROR;      // If an exception occurs, 
use above setting.
+            displayTransformStatus   = Status.OUTDATED;
+            objectiveTransformStatus = Status.ERROR;      // If an exception 
occurs, use above setting.
             final CoordinateReferenceSystem sourceCRS = 
source.getObjectiveCRS();
             final CoordinateReferenceSystem targetCRS = 
target.getObjectiveCRS();
             if (sourceCRS != null && targetCRS != null) try {
@@ -487,13 +492,13 @@ public class CanvasFollower implements 
PropertyChangeListener, Disposable {
                 if (objectiveTransform.isIdentity()) {
                     objectiveTransform = null;
                 }
-                objectiveTransformStatus = VALID;
+                objectiveTransformStatus = Status.VALID;
                 return true;
             } catch (FactoryException e) {
                 canNotCompute(caller, e);
                 // Stay with "changes in display units" mode.
             } else {
-                objectiveTransformStatus = UNKNOWN;
+                objectiveTransformStatus = Status.UNKNOWN;
             }
         }
         return false;
@@ -502,8 +507,6 @@ public class CanvasFollower implements 
PropertyChangeListener, Disposable {
     /**
      * Invoked when the {@link #objectiveTransform} transform cannot 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.
diff --git 
a/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/package-info.java
 
b/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/package-info.java
index 40c78c92ce..9a6c67d79e 100644
--- 
a/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/package-info.java
+++ 
b/endorsed/src/org.apache.sis.portrayal/main/org/apache/sis/portrayal/package-info.java
@@ -25,7 +25,7 @@
  * Synchronization, if desired, must be done by the caller.
  *
  * @author  Johann Sorel (Geomatys)
- * @version 1.5
+ * @version 1.7
  * @since   1.1
  */
 package org.apache.sis.portrayal;
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/CoverageCanvas.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/CoverageCanvas.java
index b7857cf7e7..73045b52f3 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/CoverageCanvas.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/CoverageCanvas.java
@@ -39,6 +39,7 @@ import javafx.scene.Node;
 import javafx.scene.image.Image;
 import javafx.scene.paint.Color;
 import javafx.scene.shape.Shape;
+import javafx.scene.layout.Pane;
 import javafx.scene.layout.Region;
 import javafx.scene.layout.Background;
 import javafx.scene.layout.BackgroundImage;
@@ -937,7 +938,7 @@ public class CoverageCanvas extends MapCanvasAWT {
 
         /**
          * Value of {@link CoverageCanvas#getObjectiveToDisplay()} at the time 
this worker has been initialized.
-         * This is the conversion from {@link #objectiveCRS} to the canvas 
display CRS.
+         * This is the conversion from {@link #objectiveCRS} to the canvas 
display <abbr>CRS</abbr>.
          * Can be thought as a conversion from "real world" units to pixel 
units
          * and depends on the zoom and translation events that happened before 
rendering.
          */
@@ -1426,7 +1427,7 @@ public class CoverageCanvas extends MapCanvasAWT {
                 }
             }
             Platform.runLater(() -> {
-                final ObservableList<Node> children = 
floatingPane.getChildren();
+                final ObservableList<Node> children = 
getEvanescentPane().getChildren();
                 FadeTransition transition;
                 while ((transition = tileShapes.poll()) != null) {
                     children.add(transition.getNode());
@@ -1445,7 +1446,9 @@ public class CoverageCanvas extends MapCanvasAWT {
         @Override
         public void handle(final ActionEvent event) {
             final var transition = (FadeTransition) event.getSource();
-            if (floatingPane.getChildren().remove(transition.getNode()) && 
TRACE) {
+            final Node node = transition.getNode();
+            final Pane parent = (Pane) node.getParent();
+            if (parent != null && parent.getChildren().remove(node) && TRACE) {
                 trace("TileReadListener.removeChild");
             }
         }
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/WindowHandler.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/WindowHandler.java
index 6331ab20a9..dc3e4d0b79 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/WindowHandler.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/WindowHandler.java
@@ -80,7 +80,7 @@ public abstract class WindowHandler {
      * to retain a direct reference to {@link #window} in listeners, which 
would increase the risk of memory leak.
      */
     private static final ChangeListener<String> TITLE_CHANGED = (p,o,n) -> {
-        final WindowHandler handler = (WindowHandler) ((StringProperty) 
p).getBean();
+        final var handler = (WindowHandler) ((StringProperty) p).getBean();
         handler.window.setTitle(n + " — Apache SIS");
     };
 
@@ -263,9 +263,9 @@ public abstract class WindowHandler {
         @Override public void eventOccured(final CloseEvent event) {
             final Resource resource = event.getSource();
             if (Platform.isFxApplicationThread()) {
-                close(resource, WindowHandler.this);
+                close(resource);
             } else BackgroundThreads.runAndWaitDialog(() -> {
-                close(resource, WindowHandler.this);
+                close(resource);
                 return null;
             });
             // No need to invoke `resource.removeListener(…)`, that work is 
done by `StoreListeners.close()`.
@@ -275,16 +275,16 @@ public abstract class WindowHandler {
     /**
      * Closes all windows (except the main window) which are showing the given 
resource.
      * This is invoked when the resource has been closed in the {@link 
ResourceTree}.
+     * The handler managed by {@code this} is closed unconditionally 
regardless its resource.
      *
      * @param  resource  the resource which has been closed.
-     * @param  force     the handler to close unconditionally regardless its 
resource.
      */
-    private static void close(final Resource resource, final WindowHandler 
force) {
-        final WindowHandler ignore = force.manager.main;
-        final ObservableList<WindowHandler> windows = 
force.manager.modifiableWindowList;
+    private void close(final Resource resource) {
+        final WindowHandler ignore = manager.main;
+        final ObservableList<WindowHandler> windows = 
manager.modifiableWindowList;
         for (int i = windows.size(); --i >= 0;) {
             final WindowHandler handler = windows.get(i);
-            if (handler != ignore && (handler == force || 
handler.getResource() == resource)) {
+            if (handler != ignore && (handler == this || handler.getResource() 
== resource)) {
                 windows.remove(i);
                 if (handler.window != null) {
                     handler.window.setOnHidden(null);       // Because we will 
invoke `dispose()` ourselves.
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/EvanescentPane.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/EvanescentPane.java
new file mode 100644
index 0000000000..b9e77117d3
--- /dev/null
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/EvanescentPane.java
@@ -0,0 +1,91 @@
+/*
+ * 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.util.List;
+import javafx.scene.Node;
+import javafx.scene.layout.Pane;
+import javafx.collections.ObservableList;
+import javafx.collections.ListChangeListener;
+
+
+/**
+ * A pane for evanescent shapes shown in a {@link MapCanvas}. The nodes in 
this pane should use
+ * an animation such as {@link javafx.animation.FadeTransition} and be removed 
after a few seconds.
+ *
+ * <p>This pane is used when it is not worth to update the shapes coordinates 
after user's navigation
+ * (zooms, pans, rotations) because those shapes will disappear soon anyway. 
Instead, a transform will
+ * be added the whole pane when needed.</p>
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ */
+final class EvanescentPane extends Pane implements ListChangeListener<Node> {
+    /**
+     * Whether the pane had at least one child.
+     */
+    private boolean hadChildren;
+
+    /**
+     * Creates an initially empty pane with no shape and no transform.
+     */
+    private EvanescentPane() {
+    }
+
+    /**
+     * Returns a pane with an identity transform from the given list of 
children.
+     * If there is no pane with an identity transform, a new pane is created 
and
+     * added to the list of children.
+     *
+     * <p>Note that the returned pane has an identity transform at the time 
that
+     * this method is invoked, but that transform may become non-identity later
+     * if the user navigates on the map (e.g. zoom or pan events).</p>
+     *
+     * @param  children  the children of {@link MapCanvas#floatingPane}.
+     * @return a pane with an identity transform at the time that this method 
is invoked.
+     */
+    static EvanescentPane getOrCreate(final List<Node> children) {
+        for (int i = children.size(); --i >= 0;) {
+            if (children.get(i) instanceof EvanescentPane pane) {
+                if (pane.getTransforms().isEmpty()) {
+                    return pane;
+                }
+            }
+        }
+        final var pane = new EvanescentPane();
+        pane.getChildren().addListener(pane);
+        children.add(pane);
+        return pane;
+    }
+
+    /**
+     * Invoked when the list of children changed.
+     * If the last child has been removed, removes this pane from the map 
canvas.
+     */
+    @Override
+    public void onChanged(Change<? extends Node> change) {
+        final ObservableList<Node> children = getChildren();
+        if (!children.isEmpty()) {
+            hadChildren = true;
+        } else if (hadChildren) {
+            children.removeListener(this);
+            final Pane parent = (Pane) getParent();
+            if (parent != null) {
+                parent.getChildren().remove(this);
+            }
+        }
+    }
+}
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/GestureFollower.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/GestureFollower.java
index 04323ad9ac..ca849101fc 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/GestureFollower.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/GestureFollower.java
@@ -41,18 +41,20 @@ import org.opengis.referencing.operation.TransformException;
 import org.apache.sis.portrayal.TransformChangeEvent;
 import org.apache.sis.portrayal.CanvasFollower;
 import org.apache.sis.util.logging.Logging;
-import static org.apache.sis.gui.internal.LogHandler.LOGGER;
+import org.apache.sis.gui.internal.LogHandler;
 
 
 /**
  * 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.
+ * at the same "real world" position. These two features are disabled by 
default and can be enabled by setting the
+ * {@link #transformEnabled} and/or {@link #cursorEnabled} flags to {@code 
true}.
  *
  * <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.
+ * Conversely, the {@link #dispose()} method unregisters all those listeners. 
The listeners are unidirectional:
+ * changes in the source canvas are applied on the target canvas, but not the 
converse.
  *
  * <h2>Multi-threading</h2>
  * This class is <strong>not</strong> thread-safe.
@@ -89,13 +91,16 @@ public class GestureFollower extends CanvasFollower 
implements EventHandler<Mous
 
     /**
      * 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.
+     * The default value is {@code false}. This property needs to be enabled 
explicitly by caller if desired.
+     *
+     * @see #isDisabled()
+     * @see #setDisabled(boolean)
      */
     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.
+     * The default value is {@code false}. This property needs to be enabled 
explicitly by caller if desired.
      */
     public final BooleanProperty cursorEnabled;
 
@@ -124,10 +129,11 @@ public class GestureFollower extends CanvasFollower 
implements EventHandler<Mous
      *
      * <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>
+     * when to start listening to {@code source} change events.</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.
+     * @throws IllegalArgumentException if {@code source} and {@code target} 
are the same map canvas.
      */
     @SuppressWarnings("this-escape")    // The invoked method does not store 
`this` and is not overrideable.
     public GestureFollower(final MapCanvas source, final MapCanvas target) {
@@ -231,28 +237,32 @@ public class GestureFollower extends CanvasFollower 
implements EventHandler<Mous
 
     /**
      * Sets the cursor location in the target canvas to a position computed 
from current value
-     * of {@link #cursorSourcePosition}.
+     * of {@link #cursorSourcePosition}. If the position cannot be computed, 
the cursor is hidden
+     * for avoiding to mislead the user with a wrong position.
      */
     private void updateCursorPosition() {
+        @SuppressWarnings("LocalVariableHidesMemberVariable")
+        final Path cursor = this.cursor;
         final MathTransform2D tr = getDisplayTransform().orElse(null);
         if (tr != null) try {
-            final Point2D  p = tr.transform(cursorSourcePosition, 
cursorTargetPosition);
+            final Point2D p = tr.transform(cursorSourcePosition, 
cursorTargetPosition);
             cursor.setTranslateX(p.getX());
             cursor.setTranslateY(p.getY());
+            return;
         } catch (TransformException e) {
-            cursorSourceValid = false;
-            cursor.setVisible(false);
-            Logging.recoverableException(LOGGER, GestureFollower.class, 
"handle", e);
+            canNotCompute("handle", e);
         }
+        cursorSourceValid = false;
+        cursor.setVisible(false);
     }
 
     /**
      * Returns {@code true} if this listener should replicate the following 
changes on the target canvas.
      * This implementation returns {@code true} if the transform reason is 
{@link TransformChangeEvent.Reason#INTERIM}.
-     * It allows immediate feedback to users without waiting for the 
background thread to complete rendering.
+     * It allows immediate feedback to users without waiting for the 
background thread to complete the rendering.
      *
      * @param  event  a transform change event that occurred on the source 
canvas.
-     * @return  whether to replicate that change on the target canvas.
+     * @return whether to replicate that change on the target canvas.
      */
     @Override
     protected boolean filter(final TransformChangeEvent event) {
@@ -285,12 +295,23 @@ public class GestureFollower extends CanvasFollower 
implements EventHandler<Mous
                 change.inverseTransform(cursorSourcePosition, 
cursorSourcePosition);
                 updateCursorPosition();
             } catch (NoninvertibleTransformException e) {
+                canNotCompute("transformedSource", e);
                 cursorSourceValid = false;
                 cursor.setVisible(false);
             }
         }
     }
 
+    /**
+     * Invoked when a transform cannot be computed.
+     *
+     * @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 String caller, final Exception e) {
+        Logging.recoverableException(LogHandler.LOGGER, GestureFollower.class, 
caller, e);
+    }
+
     /**
      * Removes all listeners registered by this {@code GestureFollower} 
instance.
      * This method should be invoked when {@code GestureFollower} is no longer 
needed,
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapCanvas.java 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapCanvas.java
index a54039f8ff..4e8fc1b150 100644
--- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapCanvas.java
+++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapCanvas.java
@@ -50,6 +50,7 @@ import javafx.concurrent.Worker;
 import javafx.scene.control.ContextMenu;
 import javafx.scene.control.ToggleGroup;
 import javafx.scene.transform.Affine;
+import javafx.scene.transform.Transform;
 import javafx.scene.transform.NonInvertibleTransformException;
 import javax.measure.Quantity;
 import javax.measure.quantity.Length;
@@ -190,6 +191,8 @@ public abstract class MapCanvas extends PlanarCanvas {
      * are temporary; they are applied for producing immediate visual feedback 
while the map is recomputed
      * in a background thread. Once calculation is completed and the content 
of this pane has been updated,
      * the {@code floatingPane} {@link Affine} transform is reset to 
identity.</p>
+     *
+     * @see #getEvanescentPane()
      */
     protected final Pane floatingPane;
 
@@ -282,12 +285,6 @@ public abstract class MapCanvas extends PlanarCanvas {
      */
     private final Affine transform;
 
-    /**
-     * The {@link #transform} values at the time the {@link #repaint()} method 
has been invoked.
-     * This is a change applied on {@link #objectiveToDisplay} but not yet 
visible in the map.
-     */
-    private final Affine changeInProgress;
-
     /**
      * Cursor position at the time pan event started.
      * This is used for computing the {@linkplain #floatingPane} translation 
to apply during drag events.
@@ -368,8 +365,7 @@ public abstract class MapCanvas extends PlanarCanvas {
     @SuppressWarnings("this-escape")
     public MapCanvas(final Locale locale) {
         super(locale);
-        transform        = new Affine();
-        changeInProgress = new Affine();
+        transform = new Affine();
         final Pane view = new Pane() {
             @Override protected void layoutChildren() {
                 super.layoutChildren();
@@ -441,6 +437,30 @@ public abstract class MapCanvas extends PlanarCanvas {
         invalidObjectiveToDisplay = true;
     }
 
+    /**
+     * Returns a pane for evanescent shapes shown in this canvas. The shapes 
added in the returned pane should
+     * use an animation effect such as {@link javafx.animation.FadeTransition} 
and be removed after a few seconds.
+     * This method allows to show shapes more easily than adding them directly 
as {@link #floatingPane} children,
+     * but at the expanse of quality. Evanescent panes are easier to use 
because the callers do not need to care
+     * about changes of the {@linkplain #getObjectiveToDisplay() objective to 
display} transform.
+     * Instead, callers can add shapes with coordinate values computed using 
the current transform
+     * at the time that this method is invoked, and ignore the changes that 
may happen afterward.
+     * If the user continues to navigate on the map, the {@linkplain 
Pane#getTransforms() list of transforms}
+     * of the returned pane will be updated.
+     *
+     * <p>While this method allows a much easier strategy than adding shapes 
into {@link #floatingPane}
+     * and tracking <i>objective to display</i> changes, the result is rougher 
(especially after zooms).
+     * For this reason, this method should be used for short-lived shapes such 
as animation effects,
+     * when the high-quality strategy is not worth the effort. Since the 
returned pane is aimed to be short-lived,
+     * it is automatically removed from this {@code MapPane} when its list of 
children become empty.</p>
+     *
+     * @return a pane where to add shapes without the need to track navigation 
events after this method call.
+     * @since 1.7
+     */
+    public Pane getEvanescentPane() {
+        return EvanescentPane.getOrCreate(floatingPane.getChildren());
+    }
+
     /**
      * Returns the bounds of the content in {@link #floatingPane} coordinates, 
or {@code null} if unknown.
      * Some subclasses may compute a larger image than the widget size for 
better visual transition during
@@ -664,23 +684,11 @@ public abstract class MapCanvas extends PlanarCanvas {
         event.consume();
     }
 
-    /**
-     * Removes map content and clears the properties of this canvas.
-     * Invoking this method may help to release memory when the map is no 
longer shown.
-     * The effect of the cleanup action may not be immediate, but may happen 
an arbitrary
-     * time after this method call.
-     *
-     * <p>For overriding this method in subclasses, see {@link #clear()}.</p>
-     *
-     * @since 1.7
-     */
-    public final void clearLater() {
-        runAfterRendering(this::clear);
-    }
-
     /**
      * Resets the map view to its default zoom level and default position with 
no rotation.
      * Contrarily to {@link #clearLater()}, this method does not remove the 
map content.
+     *
+     * @see #clearLater()
      */
     public void reset() {
         invalidObjectiveToDisplay = true;
@@ -1307,12 +1315,12 @@ public abstract class MapCanvas extends PlanarCanvas {
         }
         /*
          * If a temporary zoom, rotation or translation has been applied using 
JavaFX transform API,
-         * replace that temporary transform by a "permanent" adjustment of the 
`objectiveToDisplay`
-         * transform. It allows SIS to get new data for the new visible area 
and resolution.
-         * Do not reset `transform` to identity now; we need to continue 
accumulating gestures
-         * that may happen while the rendering is done in a background thread.
+         * replace that temporary transform by an adjustment of the 
`objectiveToDisplay` transform.
+         * It allows SIS to get new data for the new visible area and 
resolution.
+         *
+         * Note: do not reset `transform` to identity now because we need to 
continue accumulating
+         * gestures that may happen while the rendering is done in a 
background thread.
          */
-        changeInProgress.setToTransform(transform);
         if (!transform.isIdentity()) {
             super.transformDisplayCoordinates(getInterimTransform(false));
         }
@@ -1327,6 +1335,7 @@ public abstract class MapCanvas extends PlanarCanvas {
         if (context != null && context.initialize(floatingPane)) {
             final RenderingTask<?> worker = createWorker(context);
             assert renderingInProgress == null : renderingInProgress;
+            worker.setChangeInProgress(transform);
             BackgroundThreads.execute(worker);
             renderingInProgress = worker;       // Set after we know that the 
task has been scheduled.
             if (!isCursorChangeScheduled) {
@@ -1377,12 +1386,13 @@ public abstract class MapCanvas extends PlanarCanvas {
     /**
      * Invoked after the background thread created by {@link #repaint()} 
finished to update map content.
      * This method should be invoked in all cases: after successful 
completion, failure or cancellation.
-     * The {@link #changeInProgress} is the JavaFX transform at the time the 
repaint event was trigged and
-     * which is now integrated in the map. That transform will be removed from 
{@link #floatingPane} transforms.
-     * The {@link #transform} result is identity if no zoom, rotation or pan 
gesture has been applied since last
-     * rendering.
+     * The {@link RenderingTask#changeInProgress} transform is the JavaFX 
transform at the time that the
+     * repaint event started and which is now integrated in the map.
+     * That transform will be removed from {@link #floatingPane} transforms.
+     * The {@link #transform} result is identity if no zoom, rotation or pan 
gesture has been applied
+     * since last rendering.
      *
-     * <h4>Use case</h4>
+     * <h4>Example</h4>
      * <p>Suppose that the {@link RenderingTask} has been started in response 
to some user gestures.
      * For example, the user has zoomed on the map. The renderer has been 
initialized with a snapshot
      * of this {@code MapCanvas} state at the time when the {@link Renderer} 
has been constructed.
@@ -1414,28 +1424,24 @@ public abstract class MapCanvas extends PlanarCanvas {
          * Display coordinates stored in this `MapCanvas` need to be converted 
to the
          * new display coordinates, as expected by the new "objective to 
display" CRS.
          */
+        final Transform changeInProgress = task.getChangeInProgress();
         final Point2D p = changeInProgress.transform(xPanStart, yPanStart);
         xPanStart = p.getX();
         yPanStart = p.getY();
-        Affine copyOfChanges = null;
         for (final Node child : floatingPane.getChildren()) {
-            if (needsPositionUpdateAfterRepaint(child)) {
-                if (copyOfChanges == null) {
-                    copyOfChanges = new Affine(changeInProgress);
-                }
-                child.getTransforms().add(0, copyOfChanges);
+            if (child instanceof EvanescentPane) {
+                child.getTransforms().addFirst(changeInProgress);
             }
         }
         try {
-            changeInProgress.invert();
-            transform.append(changeInProgress);
+            transform.append(changeInProgress.createInverse());
             /*
              * Note: intuitively one may expect `prepend(…)` instead of 
`append(…)` above.
              * The use of `prepend(…)` would give a `transform` result which 
would be as if
              * the transform was the identity transform at the time that 
rendering started,
              * and all operations on it are gesture events that occurred while 
the renderer
              * was working in background. But actually this is not quite 
correct.
-             * See the zoom-in discussion in "use case" section in method 
javadoc.
+             * See the zoom-in discussion in "example" section of method 
javadoc.
              */
         } catch (NonInvertibleTransformException e) {
             unexpectedException("repaint", e);
@@ -1464,15 +1470,6 @@ public abstract class MapCanvas extends PlanarCanvas {
         }
     }
 
-    /**
-     * Returns whether the given element of the {@link #floatingPane} children 
list needs to have its position
-     * updated after a repaint event. If {@code true}, the position is updated 
with the addition of an affine
-     * transform which contains the zoom changes applied by the repaint event.
-     */
-    boolean needsPositionUpdateAfterRepaint(final Node child) {
-        return true;
-    }
-
     /**
      * A pseudo-rendering task which wait for some delay before to perform the 
real repaint.
      * The intent is to collect some more gesture events (pans, zooms, 
<i>etc.</i>) before consuming CPU time.
@@ -1704,11 +1701,11 @@ public abstract class MapCanvas extends PlanarCanvas {
      * Implementations in subclasses shall invoke {@code super.clear()}.</p>
      *
      * @see #clearLater()
+     * @see #reset()
      */
     protected void clear() {
         assert Platform.isFxApplicationThread();
         transform.setToIdentity();
-        changeInProgress.setToIdentity();
         invalidObjectiveToDisplay = true;
         initialState = null;
         clearError();
@@ -1719,6 +1716,21 @@ public abstract class MapCanvas extends PlanarCanvas {
         requestRepaint();
     }
 
+    /**
+     * Removes map content and clears the properties of this canvas.
+     * Invoking this method may help to release memory when the map is no 
longer shown.
+     * The effect of the cleanup action may not be immediate, but may happen 
an arbitrary
+     * time after this method call.
+     *
+     * <p>For overriding this method in subclasses, see {@link #clear()}.</p>
+     *
+     * @see #reset()
+     * @since 1.7
+     */
+    public final void clearLater() {
+        runAfterRendering(this::clear);
+    }
+
     /**
      * Returns a string representation of this canvas for debugging purposes.
      * This string spans multiple lines.
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapCanvasAWT.java 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapCanvasAWT.java
index a3f7f002f4..72ca3a19b9 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapCanvasAWT.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapCanvasAWT.java
@@ -33,7 +33,6 @@ import javafx.application.Platform;
 import javafx.geometry.Bounds;
 import javafx.geometry.Insets;
 import javafx.geometry.Rectangle2D;
-import javafx.scene.Node;
 import javafx.scene.Scene;
 import javafx.scene.image.ImageView;
 import javafx.scene.image.PixelBuffer;
@@ -163,17 +162,6 @@ public abstract class MapCanvasAWT extends MapCanvas {
         floatingPane.getChildren().add(image);
     }
 
-    /**
-     * Returns whether the given element of the {@link #floatingPane} children 
list needs to have its position
-     * updated after a repaint event. If {@code true}, the position is updated 
with the addition of an affine
-     * transform which contains the zoom changes applied by the repaint event.
-     */
-    @Override
-    final boolean needsPositionUpdateAfterRepaint(final Node child) {
-        // Exclude the image because it already contains the zoom changes 
applied by the repaint event.
-        return child != image;
-    }
-
     /**
      * Returns the image bounds. This is used for determining if a
      * repaint is necessary after {@link MapCanvas} size changed.
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapWindows.java 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapWindows.java
index e6c7bbc5b9..a410b393cd 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapWindows.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapWindows.java
@@ -176,10 +176,10 @@ public class MapWindows implements AutoCloseable {
      */
     @Override
     public void close() {
-        for (final Map.Entry<MultiCanvas, Stage> entry : windows.entrySet()) {
-            entry.getKey().dispose();
-            entry.getValue().close();
-        }
+        windows.forEach((canvas, stage) -> {
+            canvas.dispose();
+            stage.close();
+        });
         windows.clear();
     }
 }
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MultiCanvas.java 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MultiCanvas.java
index ca9c820576..70026ff3a6 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MultiCanvas.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MultiCanvas.java
@@ -16,13 +16,16 @@
  */
 package org.apache.sis.gui.map;
 
+import java.util.ArrayList;
 import java.util.Collection;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.LinkedHashMap;
 import java.util.Locale;
 import java.util.NoSuchElementException;
 import java.util.Objects;
+import java.util.Set;
 import javafx.application.Platform;
 import javafx.beans.InvalidationListener;
 import javafx.beans.Observable;
@@ -48,6 +51,7 @@ import org.apache.sis.gui.internal.BackgroundThreads;
 import org.apache.sis.gui.internal.DataStoreOpener;
 import static org.apache.sis.gui.internal.LogHandler.LOGGER;
 import org.apache.sis.gui.referencing.RecentReferenceSystems;
+import org.apache.sis.io.TableAppender;
 import org.apache.sis.storage.Resource;
 import org.apache.sis.storage.GridCoverageResource;
 import org.apache.sis.storage.DataStoreException;
@@ -132,6 +136,11 @@ final class MultiCanvas extends Widget implements 
Observable {
          */
         final StatusBar status;
 
+        /**
+         * Listeners of mouse displacements and navigation actions such as 
zooms and pans.
+         */
+        final List<GestureFollower> followers;
+
         /**
          * Creates new controls for a new map canvas, then registers the 
listeners.
          * Note that these listeners create cyclic references: most references 
are from {@code canvas} to {@code owner}
@@ -142,6 +151,7 @@ final class MultiCanvas extends Widget implements 
Observable {
          * @param  canvas  the new canvas.
          */
         Controls(final MultiCanvas owner, final MapCanvas canvas) {
+            followers = new ArrayList<>();
             title  = new Label();
             status = new StatusBar(owner.referenceSystems);
             status.track(canvas);
@@ -150,6 +160,45 @@ final class MultiCanvas extends Widget implements 
Observable {
             menu.addCopyOptions(status);
             getView(canvas).addEventHandler(MouseEvent.MOUSE_ENTERED, (event) 
-> owner.showStatusBar(canvas));
         }
+
+        /**
+         * Creates listeners of navigation events in the source canvas, for 
replicating them in the target canvas.
+         * The {@code source} argument <em>shall</em> be the {@code canvas} 
argument given to the constructor.
+         *
+         * @param  source  the {@code canvas} argument given to the 
constructor.
+         * @param  target  the canvas where to replicate the navigation events 
applied on {@code source}.
+         */
+        final void addGestureFollower(final MapCanvas source, final MapCanvas 
target) {
+            final var follower = new GestureFollower(source, target);
+            follower.initialize();
+            follower.cursorEnabled.set(true);
+            followers.add(follower);
+        }
+
+        /**
+         * Removes the listeners which were replicating navigation events to 
the specified target.
+         *
+         * @param  target  the canvas where the navigation events of {@code 
source} were replicated.
+         */
+        final void removeGestureFollower(final MapCanvas target) {
+            for (int i = followers.size(); --i >= 0;) {
+                if (followers.get(i).target == target) {
+                    followers.remove(i).dispose();
+                }
+            }
+        }
+
+        /**
+         * Unregisters the listeners which were following the mouse 
displacements and navigation events.
+         * Also clears the title for preventing it to contribute to {@link 
#getCanvasTitles()}.
+         * Does not unregister the listener for showing the status bar, as 
this listener does not depend
+         * on which data are shown in the canvas. This {@code Controls} may be 
reused for new data.
+         */
+        final void clear() {
+            title.setText(null);
+            followers.forEach(GestureFollower::dispose);
+            followers.clear();
+        }
     }
 
     /**
@@ -225,9 +274,24 @@ final class MultiCanvas extends Widget implements 
Observable {
         return (Region) ((child instanceof BorderPane pane) ? pane.getCenter() 
: child);
     }
 
+    /**
+     * Returns the views of all map canvases that are currently shown in this 
{@code MultiCanvas}.
+     * The returned set is modifiable (callers may remove elements) and 
elements are in no particular order.
+     *
+     * @param  children  value of {@code canvasGrid.getChildren()}.
+     * @return all currently visible map canvases views.
+     */
+    private static Set<Region> getCanvasViews(final List<Node> children) {
+        final Set<Region> views = HashSet.newHashSet(children.size());
+        for (final Node child : children) {
+            views.add(getCanvasView(child));
+        }
+        return views;
+    }
+
     /**
      * Returns the controls of a canvas when only its view is known.
-     * This method is inefficient, but is invoked in contextes where the map 
has only one element.
+     * This method is inefficient, but is invoked in contexts where the map 
has only one element.
      *
      * @param  canvasView  the result of a call to {@link 
#getCanvasView(Node)}.
      * @return the controls for the canvas having the given view.
@@ -258,6 +322,12 @@ final class MultiCanvas extends Widget implements 
Observable {
     @SuppressWarnings("fallthrough")
     private Label addCanvasView(final MapCanvas canvas) {
         final Controls controls = canvasPool.computeIfAbsent(canvas, (key) -> 
new Controls(this, key));
+        /*
+         * If the canvas will be alone in the window, show the view directly 
without title and border.
+         * If more than one canvas will exist, wrap the canvas view in a pane 
with a title and a border
+         * for separating this view from other views. It may require adding 
the title bar and border to
+         * a previously existing canvas view if the latter was alone before 
this method call.
+         */
         Region canvasView = getView(canvas);
         final List<Node> children = canvasGrid.getChildren();
         switch (children.size()) {
@@ -268,6 +338,18 @@ final class MultiCanvas extends Widget implements 
Observable {
                      // Fall through
             default: canvasView = addTitleBar(canvasView, controls.title);
         }
+        /*
+         * Add listeners for replicating navigation events of `canvas` into 
all other visible canvases,
+         * and conversely. Shall be done before the canvas view is added to 
the children list.
+         */
+        final Set<Region> visibles = getCanvasViews(children);
+        for (final Map.Entry<MapCanvas, Controls> entry : 
canvasPool.entrySet()) {
+            final MapCanvas other = entry.getKey();
+            if (visibles.contains(getView(other))) {
+                controls.addGestureFollower(canvas, other);
+                entry.getValue().addGestureFollower(other, canvas);
+            }
+        }
         children.add(canvasView);
         return controls.title;
     }
@@ -278,10 +360,12 @@ final class MultiCanvas extends Widget implements 
Observable {
      * instead of trying to remove entries from the {@link #canvasPool} map, 
current implementation rather just
      * hides unused canvases and may reuse them later.
      *
-     * @param  canvas  the canvas for which to remove the view.
+     * @param  canvas    the canvas for which to remove the view.
+     * @param  controls  value of {@code canvasPool.get(canvas)} (not 
necessarily obtained by that call).
      * @return whether the view has been found and removed.
      */
-    private boolean removeCanvasView(final MapCanvas canvas) {
+    private boolean removeCanvasView(final MapCanvas canvas, final Controls 
controls) {
+        canvasPool.values().forEach((other) -> 
other.removeGestureFollower(canvas));
         boolean changed = false;
         final Region canvasView = getView(canvas);
         final List<Node> children = canvasGrid.getChildren();
@@ -296,7 +380,7 @@ final class MultiCanvas extends Widget implements 
Observable {
             previous = getCanvasView(previous);
             children.add(previous);                 // Hide the title bar and 
the border.
         }
-        clear(canvas);
+        clear(canvas, controls);
         return changed;
     }
 
@@ -307,12 +391,12 @@ final class MultiCanvas extends Widget implements 
Observable {
      *
      * @param  canvasView  view of the canvas to close.
      */
-    private void removeCanvasView(final Region canvasView) {
+    private void closeCanvasView(final Region canvasView) {
         boolean changed = false;
-        for (final MapCanvas canvas : canvasPool.keySet()) {
+        for (final Map.Entry<MapCanvas, Controls> entry : 
canvasPool.entrySet()) {
+            final MapCanvas canvas = entry.getKey();
             if (getView(canvas) == canvasView) {
-                changed |= removeCanvasView(canvas);
-                clear(canvas);
+                changed |= removeCanvasView(canvas, entry.getValue());
                 // Should have only one instance, but continue the loop by 
paranoia.
             }
         }
@@ -330,7 +414,7 @@ final class MultiCanvas extends Widget implements 
Observable {
      */
     private BorderPane addTitleBar(final Region canvasView, final Label title) 
{
         final var close = new Button("❌");
-        close.setOnAction((event) -> removeCanvasView(canvasView));
+        close.setOnAction((event) -> closeCanvasView(canvasView));
         HBox.setHgrow(title, Priority.ALWAYS);
         HBox.setHgrow(close, Priority.NEVER);
         final var bar = new HBox(title, close);
@@ -415,7 +499,7 @@ final class MultiCanvas extends Widget implements 
Observable {
          * Get the collection of `MapCanvas` instances which are not currently 
used.
          * These instances may be recycled for the given resource.
          */
-        final var available = LinkedHashMap.<Node, 
MapCanvas>newLinkedHashMap(canvasPool.size());
+        final Map<Node, MapCanvas> available = 
LinkedHashMap.newLinkedHashMap(canvasPool.size());
         canvasPool.keySet().forEach((canvas) -> available.put(getView(canvas), 
canvas));
         canvasGrid.getChildren().forEach((child) -> 
available.remove(getCanvasView(child)));
         final MapCanvas canvas = createOrReuseCanvas(resource, 
available.values());
@@ -458,9 +542,10 @@ final class MultiCanvas extends Widget implements 
Observable {
         boolean changed = false;
         if (resource != null) {
             resource.removeListener(CloseEvent.class, closer);
-            for (final MapCanvas canvas : canvasPool.keySet()) {
+            for (final Map.Entry<MapCanvas, Controls> entry : 
canvasPool.entrySet()) {
+                final MapCanvas canvas = entry.getKey();
                 if (getResource(canvas) == resource) {
-                    changed |= removeCanvasView(canvas);
+                    changed |= removeCanvasView(canvas, entry.getValue());
                 }
             }
             if (changed) {
@@ -598,16 +683,16 @@ final class MultiCanvas extends Widget implements 
Observable {
      * Clears the content of the given map canvas.
      * It is better to remove the canvas from {@link #canvasGrid} before to 
invoke this method.
      *
-     * @param  canvas  the map canvas to clear.
+     * @param  canvas    the map canvas to clear.
+     * @param  controls  value of {@code canvasPool.get(canvas)} (not 
necessarily obtained by that call).
      */
-    private void clear(final MapCanvas canvas) {
+    private void clear(final MapCanvas canvas, Controls controls) {
         final Resource resource = getResource(canvas);
         if (resource != null) {
             resource.removeListener(CloseEvent.class, closer);
         }
         canvas.clearLater();
-        final Controls controls = canvasPool.get(canvas);       // Should 
never be null, but let be safe.
-        if (controls != null) controls.title.setText(null);
+        controls.clear();
     }
 
     /**
@@ -616,7 +701,7 @@ final class MultiCanvas extends Widget implements 
Observable {
      */
     final void dispose() {
         canvasGrid.getChildren().clear();
-        canvasPool.keySet().forEach(this::clear);
+        canvasPool.forEach(this::clear);
         canvasPool.clear();
     }
 
@@ -641,4 +726,47 @@ final class MultiCanvas extends Widget implements 
Observable {
             });
         }
     }
+
+    /**
+     * Returns a string representation of the state of this {@code 
MultiCanvas} for debugging purposes.
+     * The returned string representation contains a consistency check in the 
last column.
+     *
+     * @return a string representation of the state of this {@code 
MultiCanvas}.
+     */
+    @Override
+    public String toString() {
+        final Set<Region> views = getCanvasViews(canvasGrid.getChildren());
+        final var table = new TableAppender(" │ ");
+        table.appendHorizontalSeparator();
+        table.append("Title").nextColumn();
+        table.append("Shown").nextColumn();
+        table.append("Error").appendHorizontalSeparator();
+        for (final Map.Entry<MapCanvas, Controls> entry : 
canvasPool.entrySet()) {
+            final MapCanvas canvas = entry.getKey();
+            final Controls controls = entry.getValue();
+            table.append(controls.title.getText()).nextColumn();
+            
table.append(Boolean.toString(views.remove(getView(canvas)))).nextColumn();
+            String error = "";
+            for (final GestureFollower follower : controls.followers) {
+                if (follower.source != canvas) {
+                    error = "follower.source";
+                    break;
+                }
+                @SuppressWarnings("element-type-mismatch")
+                final Controls target = canvasPool.get(follower.target);
+                if (target == null) {
+                    error = "follower.target";
+                    break;
+                }
+            }
+            table.append(error).nextLine();
+        }
+        for (final Region orphan : views) {
+            table.append(String.valueOf(orphan)).nextColumn();
+            table.append("true").nextColumn();
+            table.append("orphan").nextLine();
+        }
+        table.appendHorizontalSeparator();
+        return table.toString();
+    }
 }
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/RenderingTask.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/RenderingTask.java
index 1d3753513d..6cb9a6a1f2 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/RenderingTask.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/RenderingTask.java
@@ -17,11 +17,12 @@
 package org.apache.sis.gui.map;
 
 import javafx.concurrent.Task;
+import javafx.scene.transform.Affine;
+import javafx.scene.transform.Transform;
 
 
 /**
  * Base class of tasks executed in background thread for doing rendering.
- * This is currently used only for type safety.
  *
  * @author  Martin Desruisseaux (Geomatys)
  *
@@ -31,9 +32,33 @@ import javafx.concurrent.Task;
  * @see MapCanvas#renderingCompleted(RenderingTask)
  */
 abstract class RenderingTask<V> extends Task<V> {
+    /**
+     * The {@link MapCanvas#transform} values at the time the {@link 
MapCanvas#repaint()} method has been invoked.
+     * This is a change applied on {@link MapCanvas#objectiveToDisplay} but 
not yet visible in the map.
+     */
+    private Transform changeInProgress;
+
     /**
      * Creates a new rendering task.
      */
     RenderingTask() {
     }
+
+    /**
+     * Takes a copy of the given transform.
+     *
+     * @param  transform  value of {@link MapCanvas#transform}.
+     */
+    final void setChangeInProgress(final Affine transform) {
+        // TODO: select a simpler transform implementation when possible.
+        changeInProgress = new Affine(transform);
+    }
+
+    /**
+     * Returns the transform specified by the call to {@link 
#setChangeInProgress(Affine)}.
+     * The returned value is not copied, caller should not modify it.
+     */
+    final Transform getChangeInProgress() {
+        return changeInProgress;
+    }
 }

Reply via email to