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 549b647c883efa919537ea123791fdfaebec4996
Author: Martin Desruisseaux <[email protected]>
AuthorDate: Sat Jun 18 17:42:57 2022 +0200

    More immediate feedback to user about the changes in source canvas that are 
replicated in the target canvas.
    The interim JavaFX transform is used without waiting for the background 
thread to complete the re-rendering.
---
 .../org/apache/sis/gui/map/GestureFollower.java    |  87 +++++++++--
 .../java/org/apache/sis/gui/map/MapCanvas.java     |  95 ++++++++++--
 .../org/apache/sis/portrayal/CanvasFollower.java   | 167 +++++++++++++++++----
 .../java/org/apache/sis/portrayal/Observable.java  |  24 +--
 .../org/apache/sis/portrayal/PlanarCanvas.java     |  16 +-
 .../apache/sis/portrayal/TransformChangeEvent.java |  40 ++++-
 6 files changed, 346 insertions(+), 83 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
index 776e3b2798..6f4e97bd09 100644
--- 
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
@@ -34,6 +34,7 @@ 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.TransformChangeEvent;
 import org.apache.sis.portrayal.CanvasFollower;
 import org.apache.sis.internal.system.Modules;
 import org.apache.sis.util.logging.Logging;
@@ -81,7 +82,7 @@ public class GestureFollower extends CanvasFollower 
implements EventHandler<Mous
      * 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);
+    private static final DropShadow CURSOR_EFFECT = new 
DropShadow(BlurType.ONE_PASS_BOX, Color.DEEPPINK, 5, 0, 0, 0);
 
     /**
      * Whether changes in the "objective to display" transforms should be 
propagated from source to target canvas.
@@ -96,9 +97,9 @@ public class GestureFollower extends CanvasFollower 
implements EventHandler<Mous
     public final BooleanProperty cursorEnabled;
 
     /**
-     * Cursor position of the mouse over source canvas, expressed in 
coordinates of the target canvas.
+     * Cursor position of the mouse over source canvas, expressed in 
coordinates of the source and target canvas.
      */
-    private final Point2D.Double cursorPosition;
+    private final Point2D.Double cursorSourcePosition, cursorTargetPosition;
 
     /**
      * The shape used for drawing a cursor on the target canvas. Constructed 
when first requested.
@@ -122,7 +123,8 @@ public class GestureFollower extends CanvasFollower 
implements EventHandler<Mous
     public GestureFollower(final MapCanvas source, final MapCanvas target) {
         super(source, target);
         super.setDisabled(true);
-        cursorPosition   = new Point2D.Double();
+        cursorSourcePosition = new Point2D.Double(Double.NaN, Double.NaN);
+        cursorTargetPosition = new Point2D.Double(Double.NaN, Double.NaN);
         transformEnabled = new SimpleBooleanProperty(this, "transformEnabled");
         cursorEnabled    = new SimpleBooleanProperty(this, "cursorEnabled");
         transformEnabled.addListener((p,o,n) -> setDisabled(!n));
@@ -139,6 +141,7 @@ public class GestureFollower extends CanvasFollower 
implements EventHandler<Mous
         if (enabled) {
             if (cursor == null) {
                 cursor = new Path(CURSOR_SHAPE);
+                cursor.setStrokeWidth(3);
                 cursor.setStroke(Color.LIGHTPINK);
                 cursor.setEffect(CURSOR_EFFECT);
                 cursor.setMouseTransparent(true);
@@ -150,10 +153,12 @@ public class GestureFollower extends CanvasFollower 
implements EventHandler<Mous
             pane.addEventHandler(MouseEvent.MOUSE_ENTERED, this);
             pane.addEventHandler(MouseEvent.MOUSE_EXITED,  this);
             pane.addEventHandler(MouseEvent.MOUSE_MOVED,   this);
+            pane.addEventHandler(MouseEvent.MOUSE_DRAGGED, this);
         } else {
             pane.removeEventHandler(MouseEvent.MOUSE_ENTERED, this);
             pane.removeEventHandler(MouseEvent.MOUSE_EXITED,  this);
             pane.removeEventHandler(MouseEvent.MOUSE_MOVED,   this);
+            pane.removeEventHandler(MouseEvent.MOUSE_DRAGGED, this);
             if (cursor != null) {
                 (((MapCanvas) 
target).floatingPane).getChildren().remove(cursor);
             }
@@ -168,27 +173,77 @@ public class GestureFollower extends CanvasFollower 
implements EventHandler<Mous
      */
     @Override
     public void handle(final MouseEvent event) {
+        cursorSourcePosition.x = event.getX();
+        cursorSourcePosition.y = event.getY();
         final EventType<? extends MouseEvent> type = event.getEventType();
-        if (type == MouseEvent.MOUSE_MOVED || type == 
MouseEvent.MOUSE_ENTERED) {
+        if (type == MouseEvent.MOUSE_MOVED) {
+            updateCursorPosition();
+        } else if (type == MouseEvent.MOUSE_ENTERED) {
+            cursor.setVisible(true);
+            updateCursorPosition();
+        } else if (type == MouseEvent.MOUSE_EXITED) {
+            cursor.setVisible(false);
+        }
+    }
+
+    /**
+     * Sets the cursor location in the target canvas to a position computed 
from current value
+     * of {@link #cursorSourcePosition}.
+     */
+    private void updateCursorPosition() {
+        if (cursor.isVisible()) {
             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);
+                final Point2D  p = tr.transform(cursorSourcePosition, 
cursorTargetPosition);
                 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.
+                cursor.setVisible(false);
             }
-        } else if (type != MouseEvent.MOUSE_EXITED) {
-            return;
         }
-        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#INTERM}.
+     * It allows immediate feedback to users without waiting for the 
background thread to complete rendering.
+     *
+     * @param  event  a transform change event that occurred on the source 
canvas.
+     * @return  whether to replicate that change on the target canvas.
+     */
+    @Override
+    protected boolean filter(final TransformChangeEvent event) {
+        return event.getReason() == TransformChangeEvent.Reason.INTERIM;
+    }
+
+    /**
+     * Invoked after the source "objective to display" transform has been 
updated.
+     *
+     * @hidden
+     */
+    @Override
+    protected void transformedSource(final TransformChangeEvent event) {
+        super.transformedSource(event);
+        if (event.getReason() != TransformChangeEvent.Reason.INTERIM) {
+            event.getDisplayChange2D().ifPresent((change) -> {
+                change.transform(cursorSourcePosition, cursorSourcePosition);
+            });
+        }
+    }
+
+    /**
+     * Invoked after the target "objective to display" transform has been 
updated.
+     * This method recomputes the cursor position.
+     *
+     * @hidden
+     */
+    @Override
+    protected void transformedTarget(final TransformChangeEvent event) {
+        super.transformedTarget(event);
+        if (event.getReason() != TransformChangeEvent.Reason.INTERIM) {
+            updateCursorPosition();
+        }
     }
 
     /**
diff --git 
a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvas.java 
b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvas.java
index 1f0cf49dc1..455062f2b4 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvas.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvas.java
@@ -83,8 +83,10 @@ import org.apache.sis.internal.gui.GUIUtilities;
 import org.apache.sis.internal.gui.MouseDrags;
 import org.apache.sis.internal.gui.Resources;
 import org.apache.sis.internal.referencing.AxisDirections;
+import org.apache.sis.internal.referencing.j2d.AffineTransform2D;
 import org.apache.sis.portrayal.PlanarCanvas;
 import org.apache.sis.portrayal.RenderException;
+import org.apache.sis.portrayal.TransformChangeEvent;
 import org.apache.sis.referencing.IdentifiedObjects;
 
 import static java.util.logging.Logger.getLogger;
@@ -271,6 +273,8 @@ public abstract class MapCanvas extends PlanarCanvas {
      * and the completion of latest {@link #repaint()} event. This is used for 
giving immediate feedback to user
      * while waiting for the new rendering to be ready. Since this transform 
is a member of {@link #floatingPane}
      * {@linkplain Pane#getTransforms() transform list}, changes in this 
transform are immediately visible to user.
+     *
+     * @see #getInterimTransform(boolean)
      */
     private final Affine transform;
 
@@ -506,7 +510,11 @@ public abstract class MapCanvas extends PlanarCanvas {
      */
     private void applyTranslation(final double tx, final double ty, final 
boolean isFinal) {
         if (tx != 0 || ty != 0) {
+            final AffineTransform2D interim = 
getInterimTransformForListeners();
             transform.appendTranslation(tx, ty);
+            if (interim != null) {
+                fireInterimTransform(interim, 
AffineTransform.getTranslateInstance(tx, ty));
+            }
             if (!isFinal) {
                 requestRepaint();
                 return;
@@ -575,12 +583,16 @@ public abstract class MapCanvas extends PlanarCanvas {
                     unexpectedException("onKeyTyped", e);
                 }
             }
+            final AffineTransform2D interim = 
getInterimTransformForListeners();
             if (zoom != 1) {
                 transform.appendScale(zoom, zoom, x, y);
             }
             if (angle != 0) {
                 transform.appendRotation(angle, x, y);
             }
+            if (interim != null) {
+                fireInterimTransform(interim, null);
+            }
             requestRepaint();
         }
         if (event != null) {
@@ -906,10 +918,13 @@ public abstract class MapCanvas extends PlanarCanvas {
      * This method must be invoked in the JavaFX thread. The visual is updated 
immediately by transforming
      * the current image, then a more accurate image is prepared in a 
background thread.
      *
-     * <p>Contrarily to the method defined in the {@link PlanarCanvas} parent 
class,
-     * this method does not guarantee that an {@value 
#OBJECTIVE_TO_DISPLAY_PROPERTY} event is fired immediately.
-     * The event may be fired at an undetermined amount of time after this 
method call.
-     * However the event will always be fired in the JavaFX thread.</p>
+     * <h4>Transform events</h4>
+     * This method fires immediately an {@value 
#OBJECTIVE_TO_DISPLAY_PROPERTY} event with
+     * {@link TransformChangeEvent.Reason#INTERIM}. This event does not yet 
reflect the state of the
+     * {@linkplain #getObjectiveToDisplay() objective to display} transform. 
At some arbitrary time in the future,
+     * another {@value #OBJECTIVE_TO_DISPLAY_PROPERTY} event will occur (still 
in JavaFX thread)
+     * with {@link TransformChangeEvent.Reason#DISPLAY_NAVIGATION} (really 
display, not objective).
+     * That event will consolidate all {@code INTERIM} events that happened 
since the last non-interim event.
      *
      * @param  before  coordinate conversion to apply before the current 
<cite>objective to display</cite> transform.
      *
@@ -918,11 +933,15 @@ public abstract class MapCanvas extends PlanarCanvas {
     @Override
     public void transformObjectiveCoordinates(final AffineTransform before) {
         if (!before.isIdentity()) try {
+            final AffineTransform2D interim = 
getInterimTransformForListeners();
             AffineTransform t = objectiveToDisplay.createInverse();
             t.preConcatenate(before);
             t.preConcatenate(objectiveToDisplay);
             transform.prepend(t.getScaleX(), t.getShearX(), t.getTranslateX(),
                               t.getShearY(), t.getScaleY(), t.getTranslateY());
+            if (interim != null) {
+                fireInterimTransform(interim, null);
+            }
             requestRepaint();
         } catch (NoninvertibleTransformException e) {
             errorOccurred(e);
@@ -934,10 +953,13 @@ public abstract class MapCanvas extends PlanarCanvas {
      * This method must be invoked in the JavaFX thread. The visual is updated 
immediately by transforming
      * the current image, then a more accurate image is prepared in a 
background thread.
      *
-     * <p>Contrarily to the method defined in the {@link PlanarCanvas} parent 
class,
-     * this method does not guarantee that an {@value 
#OBJECTIVE_TO_DISPLAY_PROPERTY} event is fired immediately.
-     * The event may be fired at an undetermined amount of time after this 
method call.
-     * However the event will always be fired in the JavaFX thread.</p>
+     * <h4>Transform events</h4>
+     * This method fires immediately an {@value 
#OBJECTIVE_TO_DISPLAY_PROPERTY} event with
+     * {@link TransformChangeEvent.Reason#INTERIM}. This event does not yet 
reflect the state of the
+     * {@linkplain #getObjectiveToDisplay() objective to display} transform. 
At some arbitrary time in the future,
+     * another {@value #OBJECTIVE_TO_DISPLAY_PROPERTY} event will occur (still 
in JavaFX thread)
+     * with {@link TransformChangeEvent.Reason#DISPLAY_NAVIGATION}. That event 
will consolidate
+     * all {@code INTERIM} events that happened since the last non-interim 
event.
      *
      * @param  after  coordinate conversion to apply after the current 
<cite>objective to display</cite> transform.
      *
@@ -946,12 +968,64 @@ public abstract class MapCanvas extends PlanarCanvas {
     @Override
     public void transformDisplayCoordinates(final AffineTransform after) {
         if (!after.isIdentity()) {
+            final AffineTransform2D interim = 
getInterimTransformForListeners();
             transform.append(after.getScaleX(), after.getShearX(), 
after.getTranslateX(),
                              after.getShearY(), after.getScaleY(), 
after.getTranslateY());
+            if (interim != null) {
+                fireInterimTransform(interim, after);
+            }
             requestRepaint();
         }
     }
 
+    /**
+     * Fires a {@link TransformChangeEvent} for a change in the {@link 
#transform}.
+     * This method needs a modifiable {@code before} instance; it will be 
modified.
+     *
+     * @param before  value of {@link #getInterimTransform(boolean)} before 
the change.
+     * @param change  change in pixel coordinates, or {@code null} for lazy 
computation.
+     */
+    private void fireInterimTransform(final AffineTransform2D before, final 
AffineTransform change) {
+        final AffineTransform2D after = getInterimTransform(true);
+        after .concatenate(objectiveToDisplay); after .freeze();
+        before.concatenate(objectiveToDisplay); before.freeze();
+        firePropertyChange(new TransformChangeEvent(this, before, after, null, 
change,
+                               TransformChangeEvent.Reason.INTERIM));
+    }
+
+    /**
+     * Returns the {@linkplain #getInterimTransform(boolean) interim 
transform} if at least one listener
+     * is registered, or {@code null} otherwise. This method should be used 
with the following pattern:
+     *
+     * {@preformat java
+     *     AffineTransform2D interim = getInterimTransformForListeners();
+     *     transform.something(…);
+     *     if (interim != null) {
+     *         fireInterimTransform(interim, change);
+     *     }
+     * }
+     *
+     * @return a copy of {@link #transform} as a modifiable Java2D object, or 
{@code null} if not needed.
+     */
+    private AffineTransform2D getInterimTransformForListeners() {
+        return hasPropertyChangeListener(OBJECTIVE_TO_DISPLAY_PROPERTY) ? 
getInterimTransform(true) : null;
+    }
+
+    /**
+     * Returns {@link #transform} as a Java2D affine transform. This is the 
change to append to
+     * {@link #objectiveToDisplay} for getting the transform that user 
currently see on screen.
+     * This is a temporary transform, for immediate feedback to user before 
the map is re-rendered.
+     *
+     * @param modifiable  whether the returned transform should be modifiable.
+     *         If true, then it is caller's responsibility to invoke {@link 
AffineTransform2D#freeze()}.
+     * @return a copy of {@link #transform} as a (potentially immutable) 
Java2D object.
+     */
+    private AffineTransform2D getInterimTransform(final boolean modifiable) {
+        return new AffineTransform2D(transform.getMxx(), transform.getMyx(),
+                                     transform.getMxy(), transform.getMyy(),
+                                     transform.getTx(),  transform.getTy(), 
modifiable);
+    }
+
     /**
      * Invoked in JavaFX thread for creating a renderer to be executed in a 
background thread.
      * Subclasses shall copy in this method all {@code MapCanvas} properties 
that the background thread
@@ -1189,10 +1263,7 @@ public abstract class MapCanvas extends PlanarCanvas {
          */
         changeInProgress.setToTransform(transform);
         if (!transform.isIdentity()) {
-            super.transformDisplayCoordinates(new AffineTransform(
-                    transform.getMxx(), transform.getMyx(),
-                    transform.getMxy(), transform.getMyy(),
-                    transform.getTx(),  transform.getTy()));
+            super.transformDisplayCoordinates(getInterimTransform(false));
         }
         /*
          * Invoke `createWorker(…)` only after we finished above 
configuration, because that method
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 b7080ec8d9..a2161d7717 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
@@ -47,13 +47,11 @@ 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.
  *
- * <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>
- *
  * <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.
+ * The listeners registered by this class implement an unidirectional binding:
+ * changes in source are applied on target, but not the converse.
  *
  * <h2>Multi-threading</h2>
  * This class is <strong>not</strong> thread-safe.
@@ -240,6 +238,33 @@ public class CanvasFollower implements 
PropertyChangeListener, Disposable {
         }
     }
 
+    /**
+     * 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.
+     *
+     * <p>The default implementation is as below. Subclasses can override this 
method for
+     * using a different point of interest, for example at the location of 
mouse cursor.</p>
+     *
+     * {@preformat java
+     *     return source.getPointOfInterest(true);
+     * }
+     *
+     * The CRS associated to the position shall be {@link 
PlanarCanvas#getObjectiveCRS()}.
+     * For performance reason, this is not verified by this {@code 
CanvasFollower} class.
+     *
+     * @return objective coordinates in source canvas where displacements, 
zooms and rotations
+     *         applied on the source canvas should be mirrored exactly on the 
target canvas.
+     *
+     * @see PlanarCanvas#getPointOfInterest(boolean)
+     */
+    public DirectPosition getSourceObjectivePOI() {
+        return source.getPointOfInterest(true);
+    }
+
     /**
      * 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
@@ -288,41 +313,58 @@ public class CanvasFollower implements 
PropertyChangeListener, Disposable {
      * If the event is an instance of {@link TransformChangeEvent}, then this 
method applies the same change
      * 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>
+     *
+     * <ul>
+     *   <li>{@link #transformObjectiveCoordinates(TransformChangeEvent, 
AffineTransform)}
+     *        if {@linkplain #getFollowRealWorld() following real world 
coordinates}.</li>
+     *   <li>{@link #transformDisplayCoordinates(TransformChangeEvent, 
AffineTransform)}
+     *        if following pixel coordinates instead of real world.</li>
+     *   <li>{@link #transformedSource(TransformChangeEvent)} after the change 
has been applied on {@linkplain #source}.</li>
+     *   <li>{@link #transformedTarget(TransformChangeEvent)} after the change 
has been applied on {@linkplain #target}.</li>
+     * </ul>
+     *
      * @param  event  a change in the canvas that this listener is tracking.
      */
     @Override
     public void propertyChange(final PropertyChangeEvent event) {
-        if (event instanceof TransformChangeEvent) {
+        if (!changing && event instanceof TransformChangeEvent) try {
             final TransformChangeEvent te = (TransformChangeEvent) event;
             displayTransformStatus = OUTDATED;
-            if (!disabled && !changing && te.isSameSource(source) && 
te.getReason().isNavigation()) try {
-                changing = true;
-                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;
+            changing = true;
+            if (te.isSameSource(source)) {
+                transformedSource(te);
+                if (!disabled && filter(te)) {
+                    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 = getSourceObjectivePOI();
+                                AffineTransform t = 
AffineTransforms2D.castOrCopy(MathTransforms.linear(objectiveTransform, poi));
+                                AffineTransform c = t.createInverse();
+                                c.preConcatenate(before);
+                                c.preConcatenate(t);
+                                before = c;
+                            }
+                            transformObjectiveCoordinates(te, before);
+                            return;
+                        } catch (NullArgumentException | TransformException | 
NoninvertibleTransformException e) {
+                            canNotCompute("propertyChange", e);
                         }
-                        target.transformObjectiveCoordinates(before);
-                        return;
-                    } catch (NullArgumentException | TransformException | 
NoninvertibleTransformException e) {
-                        canNotCompute("propertyChange", e);
                     }
+                    te.getDisplayChange2D().ifPresent((after) -> 
transformDisplayCoordinates(te, after));
                 }
-                
te.getDisplayChange2D().ifPresent(target::transformDisplayCoordinates);
-            } finally {
-                changing = false;
+            } else if (te.isSameSource(target)) {
+                transformedTarget(te);
             }
+        } finally {
+            changing = false;
         } else if 
(PlanarCanvas.OBJECTIVE_CRS_PROPERTY.equals(event.getPropertyName())) {
             displayTransform         = null;
             objectiveTransform       = null;
@@ -331,6 +373,73 @@ public class CanvasFollower implements 
PropertyChangeListener, Disposable {
         }
     }
 
+    /**
+     * Returns {@code true} if this listener should replicate the following 
changes on the target canvas.
+     * The default implementation returns {@code true} if the transform reason 
is
+     * {@link TransformChangeEvent.Reason#OBJECTIVE_NAVIGATION} or
+     * {@link TransformChangeEvent.Reason#DISPLAY_NAVIGATION}.
+     *
+     * @param  event  a transform change event that occurred on the 
{@linkplain #source} canvas.
+     * @return  whether to replicate that change on the {@linkplain #target} 
canvas.
+     */
+    protected boolean filter(final TransformChangeEvent event) {
+        return event.getReason().isNavigation();
+    }
+
+    /**
+     * Invoked by {@link #propertyChange(PropertyChangeEvent)} for updating 
the transform of the target canvas
+     * in units of the objective CRS. The {@linkplain #target} canvas is 
updated by this method as if the given
+     * transform was applied <em>before</em> its current <cite>objective to 
display</cite> transform.
+     *
+     * <p>The default implementation delegates to {@link 
PlanarCanvas#transformObjectiveCoordinates(AffineTransform)}.
+     * Subclasses can override if they need to transform additional data.</p>
+     *
+     * @param  event   the change in the {@linkplain #source} canvas.
+     * @param  before  the change to apply on the {@linkplain #target} canvas, 
in unit of objective CRS.
+     *
+     * @see PlanarCanvas#transformObjectiveCoordinates(AffineTransform)
+     */
+    protected void transformObjectiveCoordinates(final TransformChangeEvent 
event, final AffineTransform before) {
+        target.transformObjectiveCoordinates(before);
+    }
+
+    /**
+     * Invoked by {@link #propertyChange(PropertyChangeEvent)} for updating 
the transform of the target canvas
+     * in display units (typically pixels). The {@linkplain #target} canvas is 
updated by this method as if the
+     * given transform was applied <em>after</em> its current <cite>objective 
to display</cite> transform.
+     *
+     * <p>The default implementation delegates to {@link 
PlanarCanvas#transformDisplayCoordinates(AffineTransform)}.
+     * Subclasses can override if they need to transform additional data.</p>
+     *
+     * @param  event  the change in the {@linkplain #source} canvas.
+     * @param  after  the change to apply on the {@linkplain #target} canvas, 
in display units (typically pixels).
+     *
+     * @see PlanarCanvas#transformDisplayCoordinates(AffineTransform)
+     */
+    protected void transformDisplayCoordinates(final TransformChangeEvent 
event, final AffineTransform after) {
+        target.transformDisplayCoordinates(after);
+    }
+
+    /**
+     * Invoked after the source "objective to display" transform has been 
updated.
+     * The default implementation does nothing.
+     * Subclasses can override if they need to transform additional data.
+     *
+     * @param  event  the change which has been applied on the {@linkplain 
#source} canvas.
+     */
+    protected void transformedSource(TransformChangeEvent event) {
+    }
+
+    /**
+     * Invoked after the target "objective to display" transform has been 
updated.
+     * The default implementation does nothing.
+     * Subclasses can override if they need to transform additional data.
+     *
+     * @param  event  the change which has been applied on the {@linkplain 
#target} canvas.
+     */
+    protected void transformedTarget(TransformChangeEvent event) {
+    }
+
     /**
      * 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
diff --git 
a/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/Observable.java 
b/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/Observable.java
index 5fc33b669c..73a4302026 100644
--- a/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/Observable.java
+++ b/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/Observable.java
@@ -36,7 +36,7 @@ import org.apache.sis.util.ArraysExt;
  *       like the index of the element modified in a list.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.1
+ * @version 1.3
  * @since   1.1
  * @module
  */
@@ -104,6 +104,18 @@ abstract class Observable {
         }
     }
 
+    /**
+     * Returns {@code true} if the given property has at least one listener.
+     *
+     * @param  propertyName  name of the property to test.
+     * @return {@code true} if the given property has at least one listener.
+     *
+     * @since 1.3
+     */
+    protected final boolean hasPropertyChangeListener(final String 
propertyName) {
+        return (listeners != null) && listeners.containsKey(propertyName);
+    }
+
     /**
      * Notifies all registered listeners that a property of the given name 
changed its value.
      * The {@linkplain PropertyChangeEvent#getSource() change event source} 
will be {@code this}.
@@ -149,14 +161,4 @@ abstract class Observable {
             }
         }
     }
-
-    /**
-     * Returns {@code true} if the given property has at least one listener.
-     *
-     * @param  propertyName  name of the property.
-     * @return {@code true} if the given property has at least one listener.
-     */
-    final boolean hasListener(final String propertyName) {
-        return (listeners != null) && listeners.containsKey(propertyName);
-    }
 }
diff --git 
a/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/PlanarCanvas.java 
b/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/PlanarCanvas.java
index a6eae370a3..27ed26af5f 100644
--- 
a/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/PlanarCanvas.java
+++ 
b/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/PlanarCanvas.java
@@ -174,14 +174,12 @@ public abstract class PlanarCanvas extends Canvas {
      */
     public void transformObjectiveCoordinates(final AffineTransform before) {
         if (!before.isIdentity()) {
-            final LinearTransform old = 
hasListener(OBJECTIVE_TO_DISPLAY_PROPERTY) ? getObjectiveToDisplay() : null;
+            final LinearTransform old = 
hasPropertyChangeListener(OBJECTIVE_TO_DISPLAY_PROPERTY) ? 
getObjectiveToDisplay() : null;
             objectiveToDisplay.concatenate(before);
             super.setObjectiveToDisplayImpl(null);
             if (old != null) {
-                final TransformChangeEvent event = new 
TransformChangeEvent(this, old, null,
-                        TransformChangeEvent.Reason.OBJECTIVE_NAVIGATION);
-                event.objectiveChange2D = before;
-                firePropertyChange(event);
+                firePropertyChange(new TransformChangeEvent(this, old, null, 
before, null,
+                                       
TransformChangeEvent.Reason.OBJECTIVE_NAVIGATION));
             }
         }
     }
@@ -203,14 +201,12 @@ public abstract class PlanarCanvas extends Canvas {
      */
     public void transformDisplayCoordinates(final AffineTransform after) {
         if (!after.isIdentity()) {
-            final LinearTransform old = 
hasListener(OBJECTIVE_TO_DISPLAY_PROPERTY) ? getObjectiveToDisplay() : null;
+            final LinearTransform old = 
hasPropertyChangeListener(OBJECTIVE_TO_DISPLAY_PROPERTY) ? 
getObjectiveToDisplay() : null;
             objectiveToDisplay.preConcatenate(after);
             super.setObjectiveToDisplayImpl(null);
             if (old != null) {
-                final TransformChangeEvent event = new 
TransformChangeEvent(this, old, null,
-                        TransformChangeEvent.Reason.DISPLAY_NAVIGATION);
-                event.displayChange2D = after;
-                firePropertyChange(event);
+                firePropertyChange(new TransformChangeEvent(this, old, null, 
null, after,
+                                       
TransformChangeEvent.Reason.DISPLAY_NAVIGATION));
             }
         }
     }
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 d4632fcce0..30aacbcf00 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
@@ -101,7 +101,15 @@ public class TransformChangeEvent extends 
PropertyChangeEvent {
          *
          * @see PlanarCanvas#transformDisplayCoordinates(AffineTransform)
          */
-        DISPLAY_NAVIGATION;
+        DISPLAY_NAVIGATION,
+
+        /**
+         * A relative interim change has been applied but is not yet reflected 
in the "objective to display" transform.
+         * This kind of change is not fired by {@link PlanarCanvas} but may be 
fired by subclasses such as
+         * {@link org.apache.sis.gui.map.MapCanvas}. That class provides 
immediate feedback to users
+         * with a temporary visual change before to perform more expansive 
rendering in background.
+         */
+        INTERIM;
 
         /**
          * Returns {@code true} if the "objective to display" transform 
changed because of a change
@@ -129,8 +137,9 @@ public class TransformChangeEvent extends 
PropertyChangeEvent {
 
     /**
      * Value of {@link #displayChange} or {@link #objectiveChange} precomputed 
by the code that fired this event.
+     * If not precomputed, will be computed when first needed.
      */
-    AffineTransform displayChange2D, objectiveChange2D;
+    private AffineTransform displayChange2D, objectiveChange2D;
 
     /**
      * Non-null if {@link #canNotCompute(String, 
NoninvertibleTransformException)} already reported an error.
@@ -140,11 +149,11 @@ public class TransformChangeEvent extends 
PropertyChangeEvent {
 
     /**
      * Creates a new event for a change of the "objective to display" property.
-     * The old and new transforms should not be null, except for lazy 
computation:
+     * The old and new transforms should not be null, except on initialization 
or for lazy computation:
      * a null {@code newValue} means to take the value from {@link 
Canvas#getObjectiveToDisplay()} when needed.
      *
      * @param  source    the canvas that fired the event.
-     * @param  oldValue  the old "objective to display" transform.
+     * @param  oldValue  the old "objective to display" transform, or {@code 
null} if none.
      * @param  newValue  the new transform, or {@code null} for lazy 
computation.
      * @param  reason    the reason why the "objective to display" transform 
changed..
      * @throws IllegalArgumentException if {@code source} is {@code null}.
@@ -157,6 +166,27 @@ public class TransformChangeEvent extends 
PropertyChangeEvent {
         this.reason = reason;
     }
 
+    /**
+     * Creates a new event for an incremental change of the "objective to 
display" property.
+     * The incremental change can be specified by the {@code objective} and/or 
the {@code display} argument.
+     * Usually only one of those two arguments is non-null.
+     *
+     * @param  source     the canvas that fired the event.
+     * @param  oldValue   the old "objective to display" transform, or {@code 
null} if none.
+     * @param  newValue   the new transform, or {@code null} for lazy 
computation.
+     * @param  objective  the incremental change in objective coordinates, or 
{@code null} for lazy computation.
+     * @param  display    the incremental change in display coordinates, or 
{@code null} for lazy computation.
+     * @param  reason     the reason why the "objective to display" transform 
changed..
+     * @throws IllegalArgumentException if {@code source} is {@code null}.
+     */
+    public TransformChangeEvent(final Canvas source, final LinearTransform 
oldValue, final LinearTransform newValue,
+                                final AffineTransform objective, final 
AffineTransform display, final Reason reason)
+    {
+        this(source, oldValue, newValue, reason);
+        objectiveChange2D = objective;
+        displayChange2D   = display;
+    }
+
     /**
      * Quick and non-overrideable check about whether the specified source is 
the source of this event.
      */
@@ -188,7 +218,7 @@ public class TransformChangeEvent extends 
PropertyChangeEvent {
     /**
      * Gets the old "objective to display" transform.
      *
-     * @return the old "objective to display" transform.
+     * @return the old "objective to display" transform, or {@code null} if 
none.
      */
     @Override
     public LinearTransform getOldValue() {

Reply via email to