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 d5ff44456b Fix version of visual indications of which tiles are loaded 
in the JavaFX application.
d5ff44456b is described below

commit d5ff44456b0f844f0a2aa68af9cf7105147d35b2
Author: Martin Desruisseaux <[email protected]>
AuthorDate: Fri Mar 13 06:23:56 2026 +0100

    Fix version of visual indications of which tiles are loaded in the JavaFX 
application.
---
 .../org/apache/sis/coverage/grid/GridGeometry.java |  24 +-
 .../main/org/apache/sis/image/PlanarImage.java     |   2 +-
 .../org/apache/sis/storage/geotiff/DataSubset.java |   1 +
 .../org/apache/sis/storage/event/StoreEvent.java   |   2 +-
 .../apache/sis/storage/event/StoreListeners.java   |  14 +-
 .../org/apache/sis/storage/event/package-info.java |   4 +-
 .../apache/sis/storage/tiling/TileReadEvent.java   | 234 ++++++++++++++++++++
 .../sis/storage/tiling/TiledGridCoverage.java      | 140 ++++++++++--
 .../storage/tiling/TiledGridCoverageResource.java  |  46 +++-
 .../apache/sis/storage/geoheif/FromImageIO.java    |   9 +-
 .../main/org/apache/sis/storage/geoheif/Image.java |   3 +-
 .../apache/sis/gui/coverage/CoverageCanvas.java    | 244 +++++++++++++++++++--
 .../apache/sis/gui/coverage/CoverageControls.java  |   9 +-
 .../apache/sis/gui/internal/BackgroundThreads.java |   2 +-
 .../org/apache/sis/gui/internal/Resources.java     |   5 +
 .../apache/sis/gui/internal/Resources.properties   |   1 +
 .../sis/gui/internal/Resources_fr.properties       |   1 +
 .../apache/sis/gui/internal/ShapeConverter.java    | 153 +++++++++++++
 .../org/apache/sis/gui/map/GestureFollower.java    |  18 +-
 .../main/org/apache/sis/gui/map/MapCanvas.java     |  26 ++-
 .../main/org/apache/sis/gui/map/MapCanvasAWT.java  |  14 +-
 .../main/org/apache/sis/gui/map/package-info.java  |   2 +-
 22 files changed, 884 insertions(+), 70 deletions(-)

diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridGeometry.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridGeometry.java
index cbbfcbe626..ad64df989b 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridGeometry.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridGeometry.java
@@ -142,7 +142,7 @@ import org.opengis.coordinate.MismatchedDimensionException;
  *
  * @author  Martin Desruisseaux (IRD, Geomatys)
  * @author  Johann Sorel (Geomatys)
- * @version 1.6
+ * @version 1.7
  * @since   1.0
  */
 public class GridGeometry implements LenientComparable, Serializable {
@@ -1146,7 +1146,7 @@ public class GridGeometry implements LenientComparable, 
Serializable {
      * Returns the {@link #geographicBBox} value or {@code null} if none.
      * This method computes the box when first needed.
      */
-    private final GeographicBoundingBox geographicBBox() {
+    private GeographicBoundingBox geographicBBox() {
         GeographicBoundingBox bbox = geographicBBox;
         if (bbox == null) {
             if (getCoordinateReferenceSystem(envelope) != null && 
!envelope.isAllNaN()) {
@@ -1881,6 +1881,26 @@ public class GridGeometry implements LenientComparable, 
Serializable {
         }
     }
 
+    /**
+     * Returns a coordinate operation for transforming coordinates from the 
<abbr>CRS</abbr> of this grid geometry
+     * to the given <abbr>CRS</abbr>. The {@linkplain #getGeographicExtent() 
geographic bounding box} of this grid
+     * geometry is used as the desired domain of validity.
+     *
+     * @param  target  the target <abbr>CRS</abbr> of the desired operation.
+     * @return coordinate operation from the <abbr>CRS</abbr> of this grid 
geometry to the given <abbr>CRS</abbr>.
+     * @throws IncompleteGridGeometryException if this grid geometry has no 
<abbr>CRS</abbr>.
+     * @throws TransformException if the coordinate operation cannot be found.
+     *
+     * @since 1.7
+     */
+    public CoordinateOperation createChangeOfCRS(final 
CoordinateReferenceSystem target) throws TransformException {
+        try {
+            return findOperation(getCoordinateReferenceSystem(), target, 
geographicBBox());
+        } catch (FactoryException e) {
+            throw new TransformException(e);
+        }
+    }
+
     /**
      * Creates a transform from cell coordinates in this grid to cell 
coordinates in the given grid.
      * The returned transform handles change of Coordinate Reference System 
and wraparound axes
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/PlanarImage.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/PlanarImage.java
index a914a29bed..a84309840b 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/PlanarImage.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/PlanarImage.java
@@ -137,7 +137,7 @@ public abstract class PlanarImage implements RenderedImage {
      *   <li>The {@linkplain GridGeometry#getDimension() number of grid 
dimensions} is always 2.</li>
      *   <li>The number of {@linkplain 
GridGeometry#getCoordinateReferenceSystem() CRS} dimensions is always 2.</li>
      *   <li>The {@linkplain GridGeometry#getExtent() grid extent} is the 
{@linkplain #getBounds() image bounds}.</li>
-     *   <li>The {@linkplain GridGeometry#getGridToCRS grid to CRS} map pixel 
coordinates "real world" coordinates
+     *   <li>The {@linkplain GridGeometry#getGridToCRS grid to CRS} map pixel 
coordinates to "real world" coordinates
      *       (always two-dimensional).</li>
      * </ul>
      *
diff --git 
a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/DataSubset.java
 
b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/DataSubset.java
index 3570da4926..5179c43c9a 100644
--- 
a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/DataSubset.java
+++ 
b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/DataSubset.java
@@ -404,6 +404,7 @@ class DataSubset extends TiledGridCoverage {
                     for (int i=0; i<numMissings; i++) {
                         final Tile tile = missings[i];
                         if (tile.getRegionInsideTile(lower, upper, 
subsampling, false)) {
+                            tile.fireTileReadStarted();
                             origin.x = tile.originX;
                             origin.y = tile.originY;
                             tile.copyTileInfo(tileOffsets,    offsets,    
includedBanks, numTiles);
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/event/StoreEvent.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/event/StoreEvent.java
index 1d761bf3e3..a68cedc498 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/event/StoreEvent.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/event/StoreEvent.java
@@ -66,7 +66,7 @@ public abstract class StoreEvent extends EventObject 
implements Localized {
 
     /**
      * Returns the resource where the event occurred. It is not necessarily 
the {@linkplain Resource#addListener
-     * resource in which listeners have been registered}; it may be one of the 
resource children.
+     * resource in which listeners have been registered}. It may be one of the 
resource children.
      *
      * @return the resource where the event occurred.
      */
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/event/StoreListeners.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/event/StoreListeners.java
index 246165ab57..81ee54b571 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/event/StoreListeners.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/event/StoreListeners.java
@@ -81,7 +81,7 @@ import org.apache.sis.storage.base.StoreUtilities;
  * from multiple threads.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.4
+ * @version 1.7
  * @since   1.0
  */
 public class StoreListeners implements Localized {
@@ -823,11 +823,15 @@ public class StoreListeners implements Localized {
         // No need to synchronize this method.
         ArgumentChecks.ensureNonNull("listener",  listener);
         ArgumentChecks.ensureNonNull("eventType", eventType);
-        for (ForType<?> e = listeners; e != null; e = e.next) {
-            if (e.type == eventType && e.hasListener(listener)) {
-                return true;
+        StoreListeners m = this;
+        do {
+            for (ForType<?> e = m.listeners; e != null; e = e.next) {
+                if (e.type == eventType && e.hasListener(listener)) {
+                    return true;
+                }
             }
-        }
+            m = m.parent;
+        } while (m != null);
         return false;
     }
 
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/event/package-info.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/event/package-info.java
index 889f77e9a4..f38c736143 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/event/package-info.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/event/package-info.java
@@ -19,7 +19,7 @@
 /**
  * Provides interfaces and classes for dealing with different types of events 
fired by resources.
  * The different types of events are specified by the {@link StoreEvent} 
subclasses.
- * For example if a warning occurred while reading data from a file,
+ * For example, if a warning occurred while reading data from a file,
  * then the {@link org.apache.sis.storage.DataStore} implementation should 
fire a {@link WarningEvent}.
  *
  * <p>Events may occur in the following situations:</p>
@@ -35,7 +35,7 @@
  *
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
- * @since   1.4
+ * @since   1.7
  * @version 1.0
  */
 package org.apache.sis.storage.event;
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TileReadEvent.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TileReadEvent.java
new file mode 100644
index 0000000000..4ff4cd8958
--- /dev/null
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TileReadEvent.java
@@ -0,0 +1,234 @@
+/*
+ * 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.storage.tiling;
+
+import java.io.Serializable;
+import java.awt.Shape;
+import java.awt.Rectangle;
+import java.awt.geom.Rectangle2D;
+import org.opengis.referencing.operation.MathTransform;
+import org.opengis.referencing.operation.MathTransform2D;
+import org.opengis.referencing.operation.TransformException;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.opengis.referencing.operation.CoordinateOperation;
+import org.apache.sis.referencing.CRS;
+import org.apache.sis.referencing.operation.transform.MathTransforms;
+import org.apache.sis.storage.Resource;
+import org.apache.sis.storage.event.StoreEvent;
+import org.apache.sis.coverage.grid.GridExtent;
+import org.apache.sis.coverage.grid.GridGeometry;
+import org.apache.sis.coverage.grid.PixelInCell;
+import org.apache.sis.geometry.Shapes2D;
+import org.apache.sis.util.internal.shared.Strings;
+
+
+/**
+ * Notifies listeners that the process of reading a tile has started.
+ * This event contains the bounding box of the tile in real world coordinates.
+ * Because this event may be sent early in the reading process, the associated
+ * {@link Tile} is generally not known yet.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @since   1.7
+ * @version 1.7
+ */
+public class TileReadEvent extends StoreEvent {
+    /**
+     * For cross-version compatibility.
+     */
+    private static final long serialVersionUID = 4912145530425375808L;
+
+    /**
+     * Contextual information shared by all events emitted by the same tile 
iterator.
+     * Contains the conversion from pixel coordinates to real world 
coordinates.
+     */
+    static final class Context implements Serializable {
+        /**
+         * For cross-version compatibility.
+         */
+        private static final long serialVersionUID = 6873392524154427892L;
+
+        /**
+         * Zero-based index of the pyramid level of the tile which is read.
+         * The level with finest resolution is the level 0.
+         *
+         * @see #getPyramidLevel()
+         * @see #getResolution()
+         */
+        final int pyramidLevel;
+
+        /**
+         * The two-dimensional grid geometry of the slice of the resource 
which is represented as an image.
+         */
+        final GridGeometry sliceGeometry;
+
+        /**
+         * Lowest coordinates of the region which has been requested by the 
user for producing an image.
+         * The pixel coordinates (0,0) correspond to the lowest coordinates of 
the requested extent.
+         */
+        private final long offsetX, offsetY;
+
+        /**
+         * Coordinate operation from the <abbr>CRS</abbr> of the coverage to 
the <abbr>CRS</abbr>
+         * given in the last call to the {@code imageToObjective(…)} method.
+         *
+         * @see #imageToObjective(CoordinateReferenceSystem)
+         */
+        private transient CoordinateOperation crsToObjective;
+
+        /**
+         * Conversion from pixel coordinates to "real world" coordinates in a 
user-specified <abbr>CRS</abbr>.
+         * That user-specified <abbr>CRS</abbr> is called "objective 
<abbr>CRS</abbr>" because it is often the
+         * <abbr>CRS</abbr> using for rendering purposes.
+         */
+        @SuppressWarnings("serial")     // Most SIS implementations are 
serializable.
+        private transient MathTransform2D imageToObjective;
+
+        /**
+         * Creates a new context.
+         *
+         * @param  pyramidLevel  index of the pyramid level of the tile which 
is read, where 0 is the level with finest resolution.
+         * @param  domain        the grid geometry of the coverage of which a 
slice is rendered as an image.
+         * @param  aoi           the coordinates requested by the user.
+         * @param  xDimension    dimension of the grid which is mapped to the 
<var>x</var> axis in rendered images.
+         * @param  yDimension    dimension of the grid which is mapped to the 
<var>y</var> axis in rendered images.
+         * @throws RuntimeException if a slice cannot be created in the given 
dimensions.
+         */
+        Context(final int pyramidLevel, final GridGeometry domain, final 
GridExtent aoi, final int xDimension, final int yDimension) {
+            this.pyramidLevel = pyramidLevel;
+            sliceGeometry = domain.selectDimensions(xDimension, yDimension);
+            offsetX = aoi.getLow(xDimension);
+            offsetY = aoi.getLow(yDimension);
+        }
+
+        /**
+         * Returns the transform from pixel coordinates to real world 
coordinates in the given <abbr>CRS</abbr>.
+         *
+         * @param  crs  the two-dimensional <abbr>CRS</abbr> of the desired 
bounding box.
+         * @return transform from pixel coordinates to real world coordinates 
in the given <abbr>CRS</abbr>.
+         * @throws TransformException if the transform cannot be computed.
+         */
+        final synchronized MathTransform2D imageToObjective(final 
CoordinateReferenceSystem crs) throws TransformException {
+            @SuppressWarnings("LocalVariableHidesMemberVariable")
+            CoordinateOperation crsToObjective = this.crsToObjective;
+            if (crsToObjective == null || 
!CRS.equivalent(crsToObjective.getTargetCRS(), crs)) {
+                crsToObjective = sliceGeometry.createChangeOfCRS(crs);
+                MathTransform tr = MathTransforms.translation(offsetX, 
offsetY);
+                tr = MathTransforms.concatenate(tr, 
sliceGeometry.getGridToCRS(PixelInCell.CELL_CORNER));
+                tr = MathTransforms.concatenate(tr, 
crsToObjective.getMathTransform());
+                imageToObjective = MathTransforms.bidimensional(tr);
+                this.crsToObjective = crsToObjective;   // Store only after 
the rest was successful.
+            }
+            return imageToObjective;
+        }
+    }
+
+    /**
+     * Contextual information shared by all events emitted by the same tile 
iterator.
+     * Contains the conversion from pixel coordinates to real world 
coordinates.
+     * Using a shared instance allow to reuse the cached coordinate operation.
+     */
+    private final Context context;
+
+    /**
+     * Bounds of the tile in pixel coordinates.
+     *
+     * Note: there is no public <abbr>API</abbr> yet for fetching this value
+     * because the pixel coordinates are not necessarily the same as the grid
+     * coordinates of the resource, which may confuse users.
+     */
+    private final Rectangle rasterBounds;
+
+    /**
+     * Creates a new event about a tile which will be read or has been read.
+     *
+     * @param  source        the resource where the event occurred.
+     * @param  context       contextual information shared by all events 
emitted by the same tile iterator.
+     * @param  rasterBounds  bounds of the tile in pixel coordinates.
+     */
+    TileReadEvent(final Resource source, final Context context, final 
Rectangle rasterBounds) {
+        super(source);
+        this.context = context;
+        this.rasterBounds = rasterBounds;
+    }
+
+    /**
+     * Returns the zero-based index of the pyramid level of the tile which is 
read.
+     * This is typically the index in the {@linkplain 
TiledGridCoverageResource#getResolutions() list
+     * of resource's resolution} where the values returned by {@link 
#getResolution()} can be found.
+     * The level with finest resolution is the level 0.
+     *
+     * @return zero-based index of the pyramid level of the tile which is read.
+     *
+     * @see TiledGridCoverageResource.Pyramid#forPyramidLevel(int)
+     */
+    public int getPyramidLevel() {
+        return context.pyramidLevel;
+    }
+
+    /**
+     * Returns the resolution in units of the coverage <abbr>CRS</abbr>.
+     * The length of the returned array should be 2.
+     *
+     * @return the resolution in units of the coverage <abbr>CRS</abbr>.
+     */
+    public double[] getResolution() {
+        return context.sliceGeometry.getResolution(true);
+    }
+
+    /**
+     * Computes the bounds of the tile in the given two-dimensional 
<abbr>CRS</abbr>.
+     * If the use of the given <abbr>CRS</abbr> may change straight lines into 
curves
+     * (as with some map projections), the returned bounding box contains 
fully (on a
+     * best-effort basis) the tile.
+     *
+     * @param  crs  the two-dimensional <abbr>CRS</abbr> of the desired 
bounding box.
+     * @return real world coordinates of the tile expressed in the given 
<abbr>CRS</abbr>.
+     * @throws TransformException if the tile bounds cannot be transformed to 
the given <abbr>CRS</abbr>.
+     */
+    public Rectangle2D bounds(final CoordinateReferenceSystem crs) throws 
TransformException {
+        return Shapes2D.transform(context.imageToObjective(crs), rasterBounds, 
null);
+    }
+
+    /**
+     * Computes the outline of the tile in the given two-dimensional 
<abbr>CRS</abbr>.
+     * The returned shape may have curved lines if the use of the given 
<abbr>CRS</abbr>
+     * implies a map projection.
+     *
+     * @param  crs  the two-dimensional <abbr>CRS</abbr> of the desired 
outline.
+     * @return real world coordinates of the tile expressed in the given 
<abbr>CRS</abbr>.
+     * @throws TransformException if the tile bounds cannot be transformed to 
the given <abbr>CRS</abbr>.
+     */
+    public Shape outline(final CoordinateReferenceSystem crs) throws 
TransformException {
+        return 
context.imageToObjective(crs).createTransformedShape(rasterBounds);
+    }
+
+    /**
+     * Returns a string representation of this event for debugging purposes.
+     *
+     * @return a string representation of this event.
+     */
+    @Override
+    public String toString() {
+        return Strings.toString(getClass(),
+                "x",          rasterBounds.x,
+                "y",          rasterBounds.y,
+                "width",      rasterBounds.width,
+                "height",     rasterBounds.height,
+                "resolution", getResolution());
+    }
+}
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TiledGridCoverage.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TiledGridCoverage.java
index c736b86c9e..e820ce116b 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TiledGridCoverage.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TiledGridCoverage.java
@@ -17,7 +17,6 @@
 package org.apache.sis.storage.tiling;
 
 import java.util.Map;
-import java.util.Locale;
 import java.util.Optional;
 import java.nio.file.Path;
 import java.awt.Point;
@@ -43,6 +42,7 @@ import org.apache.sis.coverage.grid.GridExtent;
 import org.apache.sis.coverage.grid.DisjointExtentException;
 import org.apache.sis.image.internal.shared.DeferredProperty;
 import org.apache.sis.image.internal.shared.TiledImage;
+import org.apache.sis.storage.event.StoreListeners;
 import org.apache.sis.storage.internal.Resources;
 import org.apache.sis.util.ArgumentChecks;
 import org.apache.sis.util.collection.WeakValueHashMap;
@@ -192,6 +192,15 @@ public abstract class TiledGridCoverage extends 
GridCoverage {
      */
     private final long[] subsampling, subsamplingOffsets;
 
+    /**
+     * Zero-based index of the pyramid level of this grid coverage.
+     * This is not used directly by this class, but this information is
+     * stored for providing it to {@link TileReadEvent.Context#pyramidLevel}.
+     *
+     * @see TileReadEvent#getPyramidLevel()
+     */
+    private final int pyramidLevel;
+
     /**
      * Indices of {@link TiledGridCoverageResource} bands which have been 
retained
      * for inclusion in this {@code TiledGridCoverage}, in strictly increasing 
order.
@@ -271,9 +280,16 @@ public abstract class TiledGridCoverage extends 
GridCoverage {
     final boolean deferredTileReading;
 
     /**
-     * The locale for warnings or error messages, or {@code null} for the 
default locale.
+     * The listeners to notify for tile read events.
+     * This is the value of {@link TiledGridCoverageResource#listeners} for 
the resource at level 0.
      */
-    private final Locale locale;
+    private final StoreListeners listeners;
+
+    /**
+     * A flag for avoiding to report the same warning many times when an error 
blocks us from notifying
+     * listeners about tile read events.
+     */
+    private volatile boolean cannotNotifyListeners;
 
     /**
      * Creates a new tiled grid coverage. This constructor does not load any 
tile.
@@ -285,9 +301,10 @@ public abstract class TiledGridCoverage extends 
GridCoverage {
      */
     protected TiledGridCoverage(final TiledGridCoverageResource.Subset subset) 
{
         super(subset.domain, subset.ranges);
-        locale              = subset.getLocale();
+        listeners           = subset.listenersOfLevel0;
         xDimension          = subset.xDimension();
         yDimension          = subset.yDimension();
+        pyramidLevel        = subset.pyramidLevel();
         deferredTileReading = subset.deferredTileReading();     // May be 
shorter than other arrays or the grid geometry.
         readExtent          = subset.readExtent;
         subsampling         = subset.subsampling;
@@ -333,7 +350,7 @@ public abstract class TiledGridCoverage extends 
GridCoverage {
      * Returns the localized resources for error messages.
      */
     private Errors errors() {
-        return Errors.forLocale(locale);
+        return Errors.forLocale(listeners.getLocale());
     };
 
     /**
@@ -591,8 +608,16 @@ public abstract class TiledGridCoverage extends 
GridCoverage {
              * Prepare an iterator over all tiles to read, together with the 
following properties:
              *    - Two-dimensional conversion from pixel coordinates to "real 
world" coordinates.
              */
-            final var iterator = new TileIterator(tileLower, tileUpper, 
offsetAOI, dimension, xDimension, yDimension);
-            final Map<String,Object> properties = 
DeferredProperty.forGridGeometry(gridGeometry, selectedDimensions);
+            TileReadEvent.Context eventContext = null;
+            if (listeners.hasListeners(TileReadEvent.class) && 
!cannotNotifyListeners) try {
+                eventContext = new TileReadEvent.Context(pyramidLevel, 
gridGeometry, sliceExtent, xDimension, yDimension);
+            } catch (RuntimeException e) {
+                cannotNotifyListeners = true;
+                listeners.warning(e);
+                // Leave `eventContext` to null: no event will be fired.
+            }
+            final var iterator = new TileIterator(tileLower, tileUpper, 
offsetAOI, dimension, xDimension, yDimension, eventContext);
+            final Map<String, Object> properties = 
DeferredProperty.forGridGeometry(gridGeometry, selectedDimensions);
             if (deferredTileReading) {
                 image = new TiledDeferredImage(imageSize, tileLower, 
properties, iterator);
             } else {
@@ -608,7 +633,7 @@ public abstract class TiledGridCoverage extends 
GridCoverage {
         } catch (DisjointExtentException | CannotEvaluateException e) {
             throw e;
         } catch (Exception e) {     // Too many exception types for listing 
them all.
-            throw new 
CannotEvaluateException(Resources.forLocale(locale).getString(
+            throw new 
CannotEvaluateException(Resources.forLocale(listeners.getLocale()).getString(
                     Resources.Keys.CanNotRenderImage_1, 
getIdentifier().toFullyQualifiedName()), e);
         }
         return image;
@@ -671,14 +696,37 @@ public abstract class TiledGridCoverage extends 
GridCoverage {
          */
         final int[] uncroppedTileLocation;
 
+        /**
+         * The context of all {@link TileReadEvent}s, or {@code null} if this 
type of event will never be fired.
+         * The context contains information needed for computing the outline 
of the tile that has been read.
+         */
+        final TileReadEvent.Context eventContext;
+
+        /**
+         * Whether to fire a {@code TileReadEvent}. This is {@code true} if 
{@link #eventContext} is non-null
+         * and no event has been fired yet for the current tile. This is reset 
to {@code false} after an event
+         * has been sent for avoiding to sent the event twice for the same 
tile.
+         */
+        boolean fireTileReadEvent;
+
         /**
          * Creates a new area of interest.
+         *
+         * @param xDimension   the dimension of the <var>x</var> axis in 
rendered images.
+         * @param yDimension   the dimension of the <var>y</var> axis in 
rendered images.
+         * @param tmcInSubset  Tile Matrix Coordinates (TMC) relative to the 
enclosing {@link TiledGridCoverage}.
+         * @param uncroppedTileLocation  coordinates (relative to the cropped 
tile) of the upper-left corner of the uncropped tile.
+         * @param eventContext the context of all {@link TileReadEvent}s, or 
{@code null} if this type of event will never be fired.
          */
-        AOI(final int xDimension, final int yDimension, final int[] 
tmcInSubset, final int[] uncroppedTileLocation) {
+        AOI(final int xDimension, final int yDimension, final int[] 
tmcInSubset,
+                final int[] uncroppedTileLocation, final TileReadEvent.Context 
eventContext)
+        {
             this.xDimension  = xDimension;
             this.yDimension  = yDimension;
             this.tmcInSubset = tmcInSubset;
             this.uncroppedTileLocation = uncroppedTileLocation;
+            this.eventContext = eventContext;
+            fireTileReadEvent = (eventContext != null);
         }
 
         /**
@@ -808,33 +856,46 @@ public abstract class TiledGridCoverage extends 
GridCoverage {
          * <p>The raster is <em>not</em> filled with {@link #fillValues}.
          * Filling, if needed, should be done by the caller.</p>
          *
+         * <p>If some {@linkplain TiledGridCoverageResource#listeners 
resource's listeners} have
+         * registered an interest for tile read events, and if these listeners 
have not yet been notified
+         * about the reading of the specified tile, then a {@link 
TileReadEvent} is sent to these listeners.
+         * This policy is based on the fact that this method is typically 
invoked before a tile is read.</p>
+         *
          * @return a newly created, initially empty raster.
          */
         public WritableRaster createRaster() {
             final int x = getTileOrigin(xDimension);
             final int y = getTileOrigin(yDimension);
-            return Raster.createWritableRaster(getCoverage().model, new 
Point(x, y));
+            final WritableRaster tile = 
Raster.createWritableRaster(getCoverage().model, new Point(x, y));
+            if (fireTileReadEvent) {
+                fireTileReadEvent(tile.getBounds());
+            }
+            return tile;
         }
 
         /**
          * Returns the given raster relocated at the current <abbr>AOI</abbr> 
position.
          * This method does not need to be invoked for tiles created by {@link 
#createRaster()},
-         * but may need to be invoked for tiles created by a method external 
to this class.
+         * but may need to be invoked for tiles created by a method external 
to this class,
+         * such as {@link javax.imageio.ImageReader#readTileRaster(int, int, 
int)}.
          * If the given raster is already at the current <abbr>AOI</abbr> 
position,
          * then this method returns that raster directly.
          *
-         * @param  raster  the raster to move at the current <abbr>AOI</abbr> 
position.
+         * @param  tile  the raster to move at the current <abbr>AOI</abbr> 
position.
          * @return the relocated raster (may be {@code raster} itself).
          *
          * @see Raster#createTranslatedChild(int, int)
          */
-        public Raster moveRaster(final Raster raster) {
+        public Raster moveRaster(Raster tile) {
             final int x = getTileOrigin(xDimension);
             final int y = getTileOrigin(yDimension);
-            if (raster.getMinX() == x && raster.getMinY() == y) {
-                return raster;
+            if (tile.getMinX() != x || tile.getMinY() != y) {
+                tile = tile.createTranslatedChild(x, y);
             }
-            return raster.createTranslatedChild(x, y);
+            if (fireTileReadEvent) {
+                fireTileReadEvent(tile.getBounds());
+            }
+            return tile;
         }
 
         /**
@@ -997,6 +1058,19 @@ public abstract class TiledGridCoverage extends 
GridCoverage {
             }
             return true;
         }
+
+        /**
+         * Notifies listeners that a tile is about to be read. This method 
shall be invoked only if
+         * {@link #fireTileReadEvent} is true, otherwise a {@link 
NullPointerException} may occur.
+         *
+         * @param  bounds  bounds of the raster which is about to be read, in 
pixel coordinates.
+         * @throws NullPointerException if {@link #eventContext} is null.
+         */
+        final void fireTileReadEvent(final Rectangle bounds) {
+            fireTileReadEvent = false;
+            final StoreListeners listeners = getCoverage().listeners;
+            listeners.fire(TileReadEvent.class, new 
TileReadEvent(listeners.getSource(), eventContext, bounds));
+        }
     }
 
 
@@ -1053,9 +1127,14 @@ public abstract class TiledGridCoverage extends 
GridCoverage {
                      final int[] offsetAOI,
                      final int dimension,
                      final int xDimension,
-                     final int yDimension)
+                     final int yDimension,
+                     final TileReadEvent.Context eventContext)
         {
-            super(xDimension, yDimension, tileLower.clone(), 
uncroppedTileLocation(tileLower));
+            super(xDimension,
+                  yDimension,
+                  tileLower.clone(),
+                  uncroppedTileLocation(tileLower),
+                  eventContext);
             this.tileLower = tileLower;
             this.tileUpper = tileUpper;
             this.offsetAOI = offsetAOI;
@@ -1114,7 +1193,7 @@ public abstract class TiledGridCoverage extends 
GridCoverage {
             for (int i = Math.min(endTile.length, upper.length); --i >= 0;) {
                 upper[i] = Math.max(lower[i], Math.min(upper[i], endTile[i]));
             }
-            return new TileIterator(lower, upper, offset, offset.length, 
xDimension, yDimension);
+            return new TileIterator(lower, upper, offset, offset.length, 
xDimension, yDimension, eventContext);
         }
 
         /**
@@ -1275,6 +1354,7 @@ public abstract class TiledGridCoverage extends 
GridCoverage {
          */
         public boolean next() {
             if (++indexInResultArray >= tileCountInQuery) {
+                fireTileReadEvent = false;
                 return false;
             }
             /*
@@ -1299,6 +1379,7 @@ public abstract class TiledGridCoverage extends 
GridCoverage {
                 tmcInSubset   [i]  = tileLower[i];
                 tileOffsetFull[i]  = multiplyExact(getSubsampling(i), 
offsetAOI[i]);
             }
+            fireTileReadEvent = (eventContext != null);
             return true;
         }
     }
@@ -1329,7 +1410,11 @@ public abstract class TiledGridCoverage extends 
GridCoverage {
          * @param iterator  the iterator for which to create a snapshot of its 
current position.
          */
         public Snapshot(final AOI iterator) {
-            super(iterator.xDimension, iterator.yDimension, 
iterator.tmcInSubset.clone(), iterator.uncroppedTileLocation);
+            super(iterator.xDimension,
+                  iterator.yDimension,
+                  iterator.tmcInSubset.clone(),
+                  iterator.uncroppedTileLocation,
+                  iterator.eventContext);
             coverage           = iterator.getCoverage();
             indexInResultArray = iterator.indexInResultArray;
             indexInTileVector  = iterator.indexInTileVector;
@@ -1357,6 +1442,21 @@ public abstract class TiledGridCoverage extends 
GridCoverage {
             if (dimension == yDimension) return originY;
             throw new AssertionError(dimension);
         }
+
+        /**
+         * Sends to the listeners a notification that the reading of the tile 
identified by this snapshot started.
+         * If this event has not already been sent for this snapshot, this 
method creates a {@link TileReadEvent}
+         * and gives this event to the {@linkplain 
TiledGridCoverageResource#listeners resource's listeners}.
+         *
+         * <p>This event is sent only once per {@code Snapshot} instance.
+         * If this method is invoked more than once, the extra calls are 
no-operation.</p>
+         */
+        public void fireTileReadStarted() {
+            if (fireTileReadEvent) {
+                final SampleModel model = coverage.model;
+                fireTileReadEvent(new Rectangle(originX, originY, 
model.getWidth(), model.getHeight()));
+            }
+        }
     }
 
     /**
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TiledGridCoverageResource.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TiledGridCoverageResource.java
index aa6d7cc585..d8e6142da1 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TiledGridCoverageResource.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TiledGridCoverageResource.java
@@ -18,7 +18,6 @@ package org.apache.sis.storage.tiling;
 
 import java.util.List;
 import java.util.Arrays;
-import java.util.Locale;
 import java.util.Objects;
 import java.util.Collection;
 import java.util.Spliterator;
@@ -52,6 +51,7 @@ import org.apache.sis.storage.Resource;
 import org.apache.sis.storage.AbstractGridCoverageResource;
 import org.apache.sis.storage.DataStoreException;
 import org.apache.sis.storage.RasterLoadingStrategy;
+import org.apache.sis.storage.event.StoreListeners;
 import org.apache.sis.measure.NumberRange;
 import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.ArgumentChecks;
@@ -161,6 +161,15 @@ public abstract class TiledGridCoverageResource extends 
AbstractGridCoverageReso
      */
     private Collection<TileMatrixSet> tileMatrixSets;
 
+    /**
+     * Zero-based index of the pyramid level of this grid coverage resource.
+     * This is not used directly by this class, but this information is stored
+     * for providing it to {@link TileReadEvent.Context#pyramidLevel}.
+     *
+     * @see TileReadEvent#getPyramidLevel()
+     */
+    private int pyramidLevel;
+
     /**
      * The dimension of the grid which is mapped to the <var>x</var> axis 
(column indexes) in rendered images.
      * This value is used, directly or indirectly, at {@link Subset} creation 
time. The default value is 0.
@@ -601,6 +610,11 @@ check:  if (dataType.isInteger()) {
          */
         final WeakValueHashMap<CacheKey, Raster> cache;
 
+        /**
+         * The listeners of the resource at level 0.
+         */
+        StoreListeners listenersOfLevel0;
+
         /**
          * Creates parameters for the given domain and range.
          *
@@ -741,6 +755,7 @@ check:  if (dataType.isInteger()) {
              * If they read only sub-regions or apply subsampling, then they 
will need their own cache.
              */
             cache = sharedCache ? rasters : new 
WeakValueHashMap<>(CacheKey.class);
+            listenersOfLevel0 = listeners;
         }
 
         /**
@@ -817,11 +832,12 @@ check:  if (dataType.isInteger()) {
         }
 
         /**
-         * Returns the locale for warnings and error messages.
-         * This is often {@code null}, which means to use the default locale.
+         * Returns the zero-based index of the pyramid level of this grid 
coverage resource.
+         *
+         * @see TileReadEvent#getPyramidLevel()
          */
-        final Locale getLocale() {
-            return listeners.getLocale();
+        final int pyramidLevel() {
+            return pyramidLevel;
         }
     }
 
@@ -874,11 +890,11 @@ check:  if (dataType.isInteger()) {
              */
             final Pyramid pyramid = choosePyramid(domain, ranges);
             if (pyramid == null || (bestFit = pyramid.forPyramidLevel(0)) == 
null) {
-                return readAtThisPyramidLevel(domain, ranges);
+                return readAtThisPyramidLevel(domain, ranges, null);
             }
+            int level = 0;
             final double[] request = bestFit.convertResolutionOf(domain);
             if (request != null) {
-                int level = 0;
                 TiledGridCoverageResource c;
                 while ((c = pyramid.forPyramidLevel(level)) != null) {
                     final double[] resolution = 
c.getGridGeometry().getResolution(true);
@@ -889,14 +905,15 @@ check:  if (dataType.isInteger()) {
                 }
             }
             if (bestFit == this) {
-                return readAtThisPyramidLevel(domain, ranges);
+                return readAtThisPyramidLevel(domain, ranges, null);
             }
+            bestFit.pyramidLevel = level;
             bestFit.xDimension = xDimension;
             bestFit.yDimension = yDimension;
             bestFit.loadingStrategy = loadingStrategy;
         }
         // Invoke outside the synchronization lock because the new lock may be 
different.
-        return bestFit.readAtThisPyramidLevel(domain, ranges);
+        return bestFit.readAtThisPyramidLevel(domain, ranges, listeners);
     }
 
     /**
@@ -905,10 +922,13 @@ check:  if (dataType.isInteger()) {
      *
      * @param  domain  desired grid extent and resolution, or {@code null} for 
reading the whole domain.
      * @param  ranges  0-based indices of sample dimensions to read, or {@code 
null} or an empty sequence for reading them all.
+     * @param  listenersOfLevel0  listeners of the resource at level 0, can be 
{@code null} if that resource is {@code this}.
      * @return the grid coverage for the specified domain and ranges.
      * @throws DataStoreException if an error occurred while reading the grid 
coverage data.
      */
-    private GridCoverage readAtThisPyramidLevel(final GridGeometry domain, 
final int... ranges) throws DataStoreException {
+    private GridCoverage readAtThisPyramidLevel(final GridGeometry domain, 
final int[] ranges, final StoreListeners listenersOfLevel0)
+            throws DataStoreException
+    {
         final TiledGridCoverage coverage;
         final GridCoverage loaded;
         final boolean preload;
@@ -918,7 +938,11 @@ check:  if (dataType.isInteger()) {
             preload = (loadingStrategy == null || loadingStrategy == 
RasterLoadingStrategy.AT_READ_TIME);
             startTime = preload ? System.nanoTime() : 0;
             try {
-                coverage = read(new Subset(domain, ranges));
+                final var subset = new Subset(domain, ranges);
+                if (listenersOfLevel0 != null) {
+                    subset.listenersOfLevel0 = listenersOfLevel0;
+                }
+                coverage = read(subset);
                 /*
                  * In theory the following condition is redundant with 
`supportImmediateLoading()`.
                  * We apply it anyway in case the coverage geometry is not 
what was announced.
diff --git 
a/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/geoheif/FromImageIO.java
 
b/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/geoheif/FromImageIO.java
index 2db0d56ebb..b708a0a402 100644
--- 
a/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/geoheif/FromImageIO.java
+++ 
b/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/geoheif/FromImageIO.java
@@ -149,13 +149,16 @@ final class FromImageIO extends Image {
     protected Reader computeByteRanges(final 
ImageResource.Coverage.ReadContext context) throws DataStoreException {
         locator.resolve(0, -1, context);
         return (final ChannelDataInput input) -> {
+            final int tx = Math.toIntExact(context.subTileX);
+            final int ty = Math.toIntExact(context.subTileY);
             final ImageReader reader = context.getReader(provider);
             setReaderInput(reader, input, context);
             final BufferedImage image;
             try {
-                image = reader.readTile(IMAGE_INDEX,
-                        Math.toIntExact(context.subTileX),
-                        Math.toIntExact(context.subTileY));
+                if (reader.canReadRaster()) {
+                    return reader.readTileRaster(IMAGE_INDEX, tx, ty);
+                }
+                image = reader.readTile(IMAGE_INDEX, tx, ty);
             } finally {
                 reader.setInput(null);
             }
diff --git 
a/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/geoheif/Image.java
 
b/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/geoheif/Image.java
index b04b221e54..5db1afde60 100644
--- 
a/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/geoheif/Image.java
+++ 
b/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/geoheif/Image.java
@@ -117,7 +117,7 @@ abstract class Image {
      * Instances are prepared and returned by {@link #computeByteRanges 
computeByteRanges(…)}.
      */
     @FunctionalInterface
-    protected interface Reader {
+    interface Reader {
         /**
          * Reads a single tile from a sequence of bytes in the given input.
          * The implementation is responsible for setting the stream position 
before to start reading bytes.
@@ -127,6 +127,7 @@ abstract class Image {
          *
          * @param  input  a view of the byte sequences as if they were stored 
in one single large extent.
          * @return tile filled with the pixel values read by this method.
+         * @throws Exception any I/O error, arithmetic error or other kinds of 
error.
          */
         Raster readTile(ChannelDataInput input) throws Exception;
     }
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 bb8983ebfc..a6a90d2f23 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
@@ -20,7 +20,9 @@ import java.util.Map;
 import java.util.EnumMap;
 import java.util.List;
 import java.util.Locale;
+import java.util.Queue;
 import java.util.concurrent.Future;
+import java.util.concurrent.ConcurrentLinkedQueue;
 import java.util.logging.LogRecord;
 import java.io.IOException;
 import java.io.InputStream;
@@ -33,16 +35,24 @@ import java.awt.image.RenderedImage;
 import java.awt.geom.AffineTransform;
 import java.awt.geom.NoninvertibleTransformException;
 import java.awt.geom.Rectangle2D;
+import javafx.scene.Node;
+import javafx.scene.image.Image;
+import javafx.scene.paint.Color;
+import javafx.scene.shape.Shape;
 import javafx.scene.layout.Region;
 import javafx.scene.layout.Background;
 import javafx.scene.layout.BackgroundImage;
 import javafx.beans.DefaultProperty;
 import javafx.beans.property.ObjectProperty;
 import javafx.beans.property.SimpleObjectProperty;
+import javafx.animation.FadeTransition;
 import javafx.application.Platform;
+import javafx.collections.ObservableList;
 import javafx.concurrent.Task;
+import javafx.event.ActionEvent;
+import javafx.event.EventHandler;
 import javafx.geometry.Insets;
-import javafx.scene.image.Image;
+import javafx.util.Duration;
 import javax.measure.Quantity;
 import javax.measure.quantity.Length;
 import org.opengis.geometry.Envelope;
@@ -65,16 +75,19 @@ import org.apache.sis.geometry.Shapes2D;
 import org.apache.sis.image.Colorizer;
 import org.apache.sis.image.PlanarImage;
 import org.apache.sis.image.Interpolation;
+import org.apache.sis.image.processing.isoline.Isolines;
+import org.apache.sis.image.internal.shared.TileErrorHandler;
 import org.apache.sis.storage.DataStoreException;
 import org.apache.sis.storage.GridCoverageResource;
+import org.apache.sis.storage.event.StoreListener;
+import org.apache.sis.storage.tiling.TileReadEvent;
 import org.apache.sis.gui.map.MapCanvas;
 import org.apache.sis.gui.map.MapCanvasAWT;
 import org.apache.sis.portrayal.RenderException;
 import org.apache.sis.map.coverage.RenderingWorkaround;
-import org.apache.sis.image.internal.shared.TileErrorHandler;
-import org.apache.sis.image.processing.isoline.Isolines;
 import org.apache.sis.gui.internal.BackgroundThreads;
 import org.apache.sis.gui.internal.ExceptionReporter;
+import org.apache.sis.gui.internal.ShapeConverter;
 import org.apache.sis.gui.internal.GUIUtilities;
 import org.apache.sis.gui.internal.LogHandler;
 import org.apache.sis.util.ArraysExt;
@@ -184,7 +197,7 @@ public class CoverageCanvas extends MapCanvasAWT {
      * This is used for preventing never-ending loop when a change of resource 
causes a change of coverage
      * or conversely.
      *
-     * @see #onPropertySpecified(GridCoverageResource, GridCoverage, 
ObjectProperty, GridGeometry)
+     * @see #onPropertySpecified(GridCoverageResource, GridCoverageResource, 
GridCoverage, ObjectProperty, GridGeometry)
      */
     private boolean isCoverageAdjusting;
 
@@ -224,7 +237,7 @@ public class CoverageCanvas extends MapCanvasAWT {
      * The {@link #data} with different operations applied on them. Currently 
the only supported operation is
      * color ramp stretching. The coordinate system is the one of the original 
image (no resampling applied).
      */
-    private final Map<Stretching,RenderedImage> derivedImages;
+    private final Map<Stretching, RenderedImage> derivedImages;
 
     /**
      * Image resampled to a CRS which can easily be mapped to the {@linkplain 
#getDisplayCRS() display CRS}.
@@ -269,6 +282,12 @@ public class CoverageCanvas extends MapCanvasAWT {
      */
     IsolineRenderer isolines;
 
+    /**
+     * Listener notified when tiles are read, for showing them on top of the 
image as translucent tiles.
+     * This is {@code null} if this effect is not shown.
+     */
+    private TileReadListener tileReadListener;
+
     /**
      * Creates a new two-dimensional canvas for {@link RenderedImage}.
      */
@@ -292,9 +311,9 @@ public class CoverageCanvas extends MapCanvasAWT {
         coverageProperty      = new SimpleObjectProperty<>(this, "coverage");
         sliceExtentProperty   = new SimpleObjectProperty<>(this, 
"sliceExtent");
         interpolationProperty = new SimpleObjectProperty<>(this, 
"interpolation", data.processor.getInterpolation());
-        resourceProperty     .addListener((p,o,n) -> onPropertySpecified(n, 
null, coverageProperty, null));
-        coverageProperty     .addListener((p,o,n) -> onPropertySpecified(null, 
n, resourceProperty, null));
-        sliceExtentProperty  .addListener((p,o,n) -> 
onPropertySpecified(getResource(), getCoverage(), null, null));
+        resourceProperty     .addListener((p,o,n) -> onPropertySpecified(o, n, 
null, coverageProperty, null));
+        coverageProperty     .addListener((p,o,n) -> 
onPropertySpecified(getResource(), null, n, resourceProperty, null));
+        sliceExtentProperty  .addListener((p,o,n) -> 
onPropertySpecified(getResource(), getResource(), getCoverage(), null, null));
         interpolationProperty.addListener((p,o,n) -> 
onInterpolationSpecified(n));
         fixedPane.setBackground(BACKGROUND);
     }
@@ -479,6 +498,29 @@ public class CoverageCanvas extends MapCanvasAWT {
         requestRepaint();
     }
 
+    /**
+     * Sets whether to show for a few seconds a visual indication of which 
tiles were read.
+     * If {@code true}, tiles will be shown by a translucent shape and fade 
away.
+     *
+     * @param  enabled  whether to show for a few seconds a visual indication 
of which tiles were read.
+     */
+    final void showTileReads(final boolean enabled) {
+        final GridCoverageResource resource = getResource();
+        if (enabled) {
+            if (tileReadListener == null) {
+                tileReadListener = new TileReadListener();
+                if (resource != null) {
+                    resource.addListener(TileReadEvent.class, 
tileReadListener);
+                }
+            }
+        } else if (tileReadListener != null) {
+            if (resource != null) {
+                resource.removeListener(TileReadEvent.class, tileReadListener);
+            }
+            tileReadListener = null;
+        }
+    }
+
     /**
      * Sets the Coordinate Reference System in which the coverage is resampled 
before displaying.
      * The new CRS must be compatible with the previous CRS, i.e. a coordinate 
operation between
@@ -546,7 +588,8 @@ public class CoverageCanvas extends MapCanvasAWT {
             sliceExtent = null;
             zoom        = null;
         }
-        if (getResource() != resource || getCoverage() != coverage || 
getSliceExtent() != sliceExtent) {
+        final GridCoverageResource discard = getResource();
+        if (discard != resource || getCoverage() != coverage || 
getSliceExtent() != sliceExtent) {
             final boolean p = isCoverageAdjusting;
             try {
                 isCoverageAdjusting = true;
@@ -556,7 +599,7 @@ public class CoverageCanvas extends MapCanvasAWT {
             } finally {
                 isCoverageAdjusting = p;
             }
-            onPropertySpecified(resource, coverage, null, zoom);
+            onPropertySpecified(discard, resource, coverage, null, zoom);
         }
     }
 
@@ -566,13 +609,18 @@ public class CoverageCanvas extends MapCanvasAWT {
      * Those information will be used for initializing "objective CRS" and 
"objective to display" to new values.
      * Rendering will happen in another background computation.
      *
+     * @param  discard   the old resource, or {@code null} if none.
      * @param  resource  the new resource, or {@code null} if none.
      * @param  coverage  the new coverage, or {@code null} if none.
      * @param  toClear   the property which is an alternative to the property 
that has been set.
      * @param  zoom      initial "objective to display" transform to use, or 
{@code null} for automatic.
      */
-    private void onPropertySpecified(final GridCoverageResource resource, 
final GridCoverage coverage,
-                                     final ObjectProperty<?> toClear, final 
GridGeometry zoom)
+    private void onPropertySpecified(
+            final GridCoverageResource discard,
+            final GridCoverageResource resource,
+            final GridCoverage         coverage,
+            final ObjectProperty<?>    toClear,
+            final GridGeometry         zoom)
     {
         hasCoverageOrResource = (resource != null || coverage != null);
         if (isCoverageAdjusting) {
@@ -584,6 +632,10 @@ public class CoverageCanvas extends MapCanvasAWT {
         } finally {
             isCoverageAdjusting = false;
         }
+        if (discard != resource && tileReadListener != null) {
+            if (discard  != null) discard.removeListener(TileReadEvent.class, 
tileReadListener);
+            if (resource != null) resource.addListener  (TileReadEvent.class, 
tileReadListener);
+        }
         if (resource == null && coverage == null) {
             runAfterRendering(this::clear);
         } else if (controls != null && controls.isAdjustingSlice) {
@@ -960,6 +1012,10 @@ public class CoverageCanvas extends MapCanvasAWT {
                 displayBounds.y      -= top;
                 displayBounds.height += top  + margin.getBottom();
             }
+            /*
+             * Help for auxiliary services. They are special cases for now,
+             * but should be refactored as styling services in a future 
version.
+             */
             if (canvas.isolines != null) try {
                 isolines = canvas.isolines.prepare();
                 objectiveAOI = 
Shapes2D.transform(MathTransforms.bidimensional(objectiveToDisplay.inverse()), 
displayBounds, null);
@@ -1096,7 +1152,16 @@ public class CoverageCanvas extends MapCanvasAWT {
          */
         @Override
         protected boolean commit(final MapCanvas canvas) {
-            ((CoverageCanvas) canvas).cacheRenderingData(this);
+            final var cc = (CoverageCanvas) canvas;
+            cc.cacheRenderingData(this);
+            /*
+             * Help for auxiliary services. They are special cases for now,
+             * but should be refactored as styling services in a future 
version.
+             */
+            final TileReadListener tileReadListener = cc.tileReadListener;
+            if (tileReadListener != null) {
+                tileReadListener.takeSnapshotOfObjectiveCRS();
+            }
             if (isolines != null) {
                 for (final IsolineRenderer.Snapshot s : isolines) {
                     s.commit();
@@ -1217,6 +1282,157 @@ public class CoverageCanvas extends MapCanvasAWT {
         return 
Shapes2D.transform(MathTransforms.bidimensional(getObjectiveToDisplay().inverse()),
 displayBounds, null);
     }
 
+
+
+
+    /**
+     * Object notified when a tile is about to be read. The notifications can 
be sent from any thread,
+     * typically a background thread which is reading the data. The tiles are 
enqueued for processing
+     * in another background thread for avoiding to slow down the thread that 
read the data.
+     */
+    private final class TileReadListener implements 
StoreListener<TileReadEvent>, Runnable, EventHandler<ActionEvent> {
+        /**
+         * Colors of the tiles, using different colors for different 
resolutions (pyramid levels).
+         */
+        private static final Color[] TILE_COLORS = {
+            Color.VIOLET, Color.RED, Color.YELLOW, Color.CYAN, Color.PALEGREEN
+        };
+
+        /**
+         * Same colors, but with transparency.
+         */
+        private static final Color[] FILL_COLORS = new 
Color[TILE_COLORS.length];
+        static {
+            for (int i=0; i<FILL_COLORS.length; i++) {
+                final Color c = TILE_COLORS[i];
+                FILL_COLORS[i] = Color.color(c.getRed(), c.getGreen(), 
c.getBlue(), 0.5);
+            }
+        }
+
+        /**
+         * Time that tiles are visible before they fade away.
+         */
+        private static final Duration DURATION = new Duration(4000);
+
+        /**
+         * The tiles to highlight, as a thread-safe queue.
+         */
+        private final Queue<TileReadEvent> tileEvents;
+
+        /**
+         * The JavaFX shapes (usually rectangles) for highlighting the tiles.
+         * Elements of this queue are derived from {@link #tileEvents}.
+         */
+        private final Queue<FadeTransition> tileShapes;
+
+        /**
+         * The objective <abbr>CRS</abbr> of the canvas.
+         * This information is updated in the JavaFX thread after each 
rendering.
+         *
+         * @see CoverageCanvas#getObjectiveCRS()
+         */
+        private CoordinateReferenceSystem objectiveCRS;
+
+        /**
+         * The transform from objective <abbr>CRS</abbr> to the display 
coordinate system of the canvas.
+         * This information is updated in the JavaFX thread after each 
rendering, so that creations of
+         * JavaFX shapes will use the information that reflects the image 
shown in the canvas.
+         *
+         * @see CoverageCanvas#objectiveToDisplay
+         */
+        private LinearTransform objectiveToDisplay;
+
+        /**
+         * Creates a new listener of tile read events.
+         */
+        TileReadListener() {
+            tileEvents = new ConcurrentLinkedQueue<>();
+            tileShapes = new ConcurrentLinkedQueue<>();
+            takeSnapshotOfObjectiveCRS();
+        }
+
+        /**
+         * Takes a snapshot of the objective <abbr>CRS</abbr> and transform to 
display coordinate system.
+         * This method should be invoked after each rendering, so that 
creations of JavaFX shapes will use
+         * the information that reflects the image shown in the canvas.
+         */
+        final synchronized void takeSnapshotOfObjectiveCRS() {
+            objectiveCRS = getObjectiveCRS();
+            objectiveToDisplay = getObjectiveToDisplay();
+        }
+
+        /**
+         * Invoked when a tile has been read. The specified tile is added to 
the list of tiles to highlight.
+         * Starts a background thread for deriving the JavaFX shapes if such 
thread is not already running.
+         */
+        @Override
+        public void eventOccured(final TileReadEvent event) {
+            tileEvents.add(event);
+            if (TRACE) {
+                trace("TileReadListener.accept(%d)", tileEvents.size());
+            }
+            BackgroundThreads.EXECUTOR.execute(this);
+        }
+
+        /**
+         * Invoked in a background thread for converting the tile events into 
JavaFX shapes.
+         * Usually, the calculation done in this method is faster than the 
reading of tiles,
+         * therefore each invocation of this method usually has only one tile 
to convert.
+         */
+        @Override
+        @SuppressWarnings({"UseSpecificCatch", 
"LocalVariableHidesMemberVariable"})
+        public void run() {
+            Exception error = null;
+            TileReadEvent event;
+            while ((event = tileEvents.poll()) != null) {
+                try {
+                    final CoordinateReferenceSystem objectiveCRS;
+                    final AffineTransform objectiveToDisplay;
+                    synchronized (this) {
+                        objectiveCRS = this.objectiveCRS;
+                        objectiveToDisplay = (AffineTransform) 
this.objectiveToDisplay;
+                    }
+                    final Shape tile = 
ShapeConverter.convert(event.outline(objectiveCRS), objectiveToDisplay);
+                    final int ic = event.getPyramidLevel() % 
TILE_COLORS.length;
+                    tile.setStroke(TILE_COLORS[ic]);
+                    tile.setFill(FILL_COLORS[ic]);
+                    tile.setOpacity(0.5);
+                    final var transition = new FadeTransition(DURATION, tile);
+                    transition.setFromValue(0.5);
+                    transition.setToValue(0);
+                    transition.setOnFinished(this);
+                    tileShapes.add(transition);
+                } catch (Exception e) {
+                    if (error == null) error = e;
+                    else error.addSuppressed(e);
+                }
+            }
+            Platform.runLater(() -> {
+                final ObservableList<Node> children = 
floatingPane.getChildren();
+                FadeTransition transition;
+                while ((transition = tileShapes.poll()) != null) {
+                    children.add(transition.getNode());
+                    transition.play();
+                }
+            });
+            if (error != null) {
+                unexpectedException(error);
+            }
+        }
+
+        /**
+         * Invoked when the animation on a tile is finished.
+         * This method removes the JavaFX geometry object that represented the 
tile outline.
+         */
+        @Override
+        public void handle(final ActionEvent event) {
+            final var transition = (FadeTransition) event.getSource();
+            if (floatingPane.getChildren().remove(transition.getNode()) && 
TRACE) {
+                trace("TileReadListener.removeChild");
+            }
+        }
+    }
+
     /**
      * Invoked when an exception occurred while computing a transform but the 
painting process can continue.
      */
@@ -1255,7 +1471,7 @@ public class CoverageCanvas extends MapCanvasAWT {
     }
 
     /**
-     * Prints {@code "CoverageCanvas"} following by the given message if 
{@link #TRACE} is {@code true}.
+     * Prints {@code "CoverageCanvas"} followed by the given message if {@link 
#TRACE} is {@code true}.
      * This is used for debugging purposes only.
      *
      * @param  format     the {@code printf} format string.
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/CoverageControls.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/CoverageControls.java
index fce775001d..9e39751247 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/CoverageControls.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/CoverageControls.java
@@ -20,6 +20,7 @@ import java.util.List;
 import java.util.Locale;
 import javafx.scene.control.TitledPane;
 import javafx.scene.control.ChoiceBox;
+import javafx.scene.control.CheckBox;
 import javafx.scene.control.Label;
 import javafx.scene.control.TableView;
 import javafx.scene.control.Tooltip;
@@ -128,13 +129,19 @@ final class CoverageControls extends ViewAndControls {
             styling = new CoverageStyling(view);
             categoryTable = styling.createCategoryTable(resources, vocabulary);
             VBox.setVgrow(categoryTable, Priority.ALWAYS);
+            /*
+             * Whether to show a visual indication of which tiles are read.
+             */
+            final var showTileReads = new 
CheckBox(resources.getString(Resources.Keys.ShowTileReadEvents));
+            showTileReads.selectedProperty().addListener((p,o,n) -> 
view.showTileReads(n));
             /*
              * All sections put together.
              */
             displayPane = new VBox(
                     labelOfGroup(vocabulary, Vocabulary.Keys.ReferenceSystem, 
crsControl,    true),  crsControl,
                     labelOfGroup(vocabulary, Vocabulary.Keys.Values,          
valuesControl, false), valuesControl,
-                    labelOfGroup(vocabulary, Vocabulary.Keys.Categories,      
categoryTable, false), categoryTable);
+                    labelOfGroup(vocabulary, Vocabulary.Keys.Categories,      
categoryTable, false), categoryTable,
+                    showTileReads);
         }
         /*
          * "Isolines" section with the following controls:
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/BackgroundThreads.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/BackgroundThreads.java
index 1a2da2649a..32e4a18bb6 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/BackgroundThreads.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/BackgroundThreads.java
@@ -68,7 +68,7 @@ public final class BackgroundThreads extends AtomicInteger 
implements ThreadFact
      * The executor for background tasks. This is actually an {@link 
ExecutorService} instance,
      * but only the {@link Executor} method should be used according JavaFX 
documentation.
      */
-    private static final ExecutorService EXECUTOR = 
Executors.newCachedThreadPool(new BackgroundThreads());
+    public static final ExecutorService EXECUTOR = 
Executors.newCachedThreadPool(new BackgroundThreads());
 
     /**
      * For the singleton {@link #EXECUTOR}.
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/Resources.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/Resources.java
index 542f26fb10..f0224a4976 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/Resources.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/Resources.java
@@ -386,6 +386,11 @@ public class Resources extends IndexedResourceBundle {
          */
         public static final short SendTo = 31;
 
+        /**
+         * Visual indication of tile readings
+         */
+        public static final short ShowTileReadEvents = 77;
+
         /**
          * Size or position
          */
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/Resources.properties
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/Resources.properties
index f723beb139..616e05df42 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/Resources.properties
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/Resources.properties
@@ -86,6 +86,7 @@ SelectCRS              = Select a coordinate reference system
 SelectCrsByContextMenu = For changing the projection, use contextual menu on 
the map.
 SelectParentLogger     = Select parent logger
 SendTo                 = Send to
+ShowTileReadEvents     = Visual indication of tile readings
 SizeOrPosition         = Size or position
 StandardErrorStream    = Standard error stream
 SystemMonitor          = System monitor
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/Resources_fr.properties
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/Resources_fr.properties
index 853f861402..5d24f447dd 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/Resources_fr.properties
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/Resources_fr.properties
@@ -91,6 +91,7 @@ SelectCRS              = Choisir un syst\u00e8me de 
r\u00e9f\u00e9rence des coor
 SelectCrsByContextMenu = Pour changer la projection, utilisez le menu 
contextuel sur la carte.
 SelectParentLogger     = Choisir le journal parent
 SendTo                 = Envoyer vers
+ShowTileReadEvents     = Indication visuelle des lectures de tuiles
 SizeOrPosition         = Taille ou position
 StandardErrorStream    = Flux d\u2019erreur standard
 SystemMonitor          = Moniteur syst\u00e8me
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/ShapeConverter.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/ShapeConverter.java
new file mode 100644
index 0000000000..638731c239
--- /dev/null
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/ShapeConverter.java
@@ -0,0 +1,153 @@
+/*
+ * 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.internal;
+
+import java.util.ArrayList;
+import java.awt.geom.AffineTransform;
+import java.awt.geom.PathIterator;
+import java.awt.geom.Rectangle2D;
+import javafx.scene.shape.ClosePath;
+import javafx.scene.shape.CubicCurveTo;
+import javafx.scene.shape.FillRule;
+import javafx.scene.shape.HLineTo;
+import javafx.scene.shape.LineTo;
+import javafx.scene.shape.MoveTo;
+import javafx.scene.shape.Path;
+import javafx.scene.shape.PathElement;
+import javafx.scene.shape.QuadCurveTo;
+import javafx.scene.shape.Rectangle;
+import javafx.scene.shape.Shape;
+import javafx.scene.shape.VLineTo;
+
+
+/**
+ * Converts a Java2D shape to a JavaFX shape.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ */
+public final class ShapeConverter {
+    /**
+     * Do not allow instantiation of this class.
+     */
+    private ShapeConverter() {
+    }
+
+    /**
+     * Converts the given Java2D shape to a JavaFX shape.
+     * If possible, this method simplifies the shape for example by replacing 
it with a rectangle.
+     *
+     * @param  shape  the shape to convert.
+     * @param  tr  an optional affine transform to apply to the coordinates, 
{@code null} if none.
+     * @return the JavaFX shape.
+     */
+    public static Shape convert(final java.awt.Shape shape, final 
AffineTransform tr) {
+        if (tr == null || (tr.getType() & 
(AffineTransform.TYPE_GENERAL_ROTATION | 
AffineTransform.TYPE_GENERAL_TRANSFORM)) == 0) {
+            if (shape instanceof Rectangle2D) {
+                return convert((Rectangle2D) shape, tr);
+            }
+        }
+        final PathIterator it = shape.getPathIterator(tr);
+        final var elements = new ArrayList<PathElement>();
+        double x = Double.NaN, y = Double.NaN;
+        final double[] coords = new double[6];
+        while (!it.isDone()) {
+            final PathElement e;
+            switch (it.currentSegment(coords)) {
+                /*
+                 * MoveTo with the following optimizations:
+                 *   - Omit "move to" that does not actually move.
+                 *   - If consecutive "move to", keep only the last one.
+                 */
+                case PathIterator.SEG_MOVETO: {
+                    if (x == (x = coords[0])  &  y == (y = coords[1])) 
continue;    // Really &, not &&.
+                    if (!elements.isEmpty() && elements.getLast() instanceof 
MoveTo) {
+                        elements.removeLast();
+                    }
+                    e = new MoveTo(x, y);
+                    break;
+                }
+                /*
+                 * LineTo with the following optimizations:
+                 *   - Omit "line to" that does not actually move.
+                 *   - Horizontal line to if the y coordinate does no change.
+                 *   - Vertical line to if the x coordinate does no change.
+                 */
+                case PathIterator.SEG_LINETO: {
+                    int change = 0;
+                    if (x == (x = coords[0])) change  = 1;
+                    if (y == (y = coords[1])) change |= 2;
+                    switch (change) {
+                        case 1: e = new HLineTo(x); break;
+                        case 2: e = new VLineTo(y); break;
+                        case 3: e = new LineTo(x, y); break;
+                        default: continue;
+                    }
+                    break;
+                }
+                case PathIterator.SEG_QUADTO: {
+                    e = new QuadCurveTo(coords[0], coords[1], x = coords[2], y 
= coords[3]);
+                    break;
+                }
+                case PathIterator.SEG_CUBICTO: {
+                    e = new CubicCurveTo(coords[0], coords[1], coords[2], 
coords[3], x = coords[4], y = coords[5]);
+                    break;
+                }
+                case PathIterator.SEG_CLOSE: {
+                    x = y = Double.NaN;
+                    if (!elements.isEmpty() && elements.getLast() instanceof 
ClosePath) {
+                        continue;   // Avoid repeating `ClosePath`.
+                    }
+                    e = new ClosePath();
+                    break;
+                }
+                default: continue;
+            }
+            elements.add(e);
+            it.next();
+        }
+        final var path = new Path(elements);
+        switch (it.getWindingRule()) {
+            case PathIterator.WIND_EVEN_ODD: 
path.setFillRule(FillRule.EVEN_ODD); break;
+            case PathIterator.WIND_NON_ZERO: 
path.setFillRule(FillRule.NON_ZERO); break;
+        }
+        return path;
+    }
+
+    /**
+     * Converts the given Java2D rectangle to a JavaFX rectangle. The given 
affine transform, if non-null,
+     * should not contain a general rotation or general transform, otherwise 
the returned rectangle will
+     * not be a good description of the returned shape.
+     *
+     * @param  shape  the rectangle to convert.
+     * @param  tr  an optional affine transform to apply to the coordinates, 
{@code null} if none.
+     * @return the JavaFX rectangle.
+     */
+    private static Rectangle convert(final Rectangle2D shape, final 
AffineTransform tr) {
+        final double[] coords = {
+            shape.getMinX(), shape.getMinY(),
+            shape.getMaxX(), shape.getMaxY()
+        };
+        if (tr != null) {
+            tr.transform(coords, 0, coords, 0, 2);
+        }
+        return new Rectangle(
+                Math.min(coords[0],  coords[2]),
+                Math.min(coords[1],  coords[3]),
+                Math.abs(coords[2] - coords[0]),
+                Math.abs(coords[3] - coords[1]));
+    }
+}
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 c26123c944..04323ad9ac 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
@@ -20,6 +20,7 @@ import java.awt.geom.Point2D;
 import java.awt.geom.AffineTransform;
 import java.awt.geom.NoninvertibleTransformException;
 import java.util.Optional;
+import javafx.scene.Node;
 import javafx.scene.layout.Pane;
 import javafx.scene.paint.Color;
 import javafx.scene.shape.Path;
@@ -34,6 +35,7 @@ import javafx.event.EventHandler;
 import javafx.scene.input.MouseEvent;
 import javafx.beans.property.BooleanProperty;
 import javafx.beans.property.SimpleBooleanProperty;
+import javafx.collections.ObservableList;
 import org.opengis.referencing.operation.MathTransform2D;
 import org.opengis.referencing.operation.TransformException;
 import org.apache.sis.portrayal.TransformChangeEvent;
@@ -57,7 +59,7 @@ import static org.apache.sis.gui.internal.LogHandler.LOGGER;
  * All events should be processed in the JavaFX thread.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.4
+ * @version 1.7
  * @since   1.3
  */
 public class GestureFollower extends CanvasFollower implements 
EventHandler<MouseEvent> {
@@ -146,6 +148,8 @@ public class GestureFollower extends CanvasFollower 
implements EventHandler<Mous
      */
     private void followCursor(final boolean enabled) {
         final Pane pane = ((MapCanvas) source).floatingPane;
+        @SuppressWarnings("LocalVariableHidesMemberVariable")
+        Path cursor = this.cursor;
         if (enabled) {
             if (cursor == null) {
                 cursor = new Path(CURSOR_SHAPE);
@@ -156,8 +160,9 @@ public class GestureFollower extends CanvasFollower 
implements EventHandler<Mous
                 cursor.setManaged(false);
                 cursor.setSmooth(false);
                 cursor.setCache(true);
+                this.cursor = cursor;
             }
-            (((MapCanvas) target).floatingPane).getChildren().add(cursor);
+            getChildrenOfTargetPane().add(cursor);
             pane.addEventHandler(MouseEvent.MOUSE_ENTERED, this);
             pane.addEventHandler(MouseEvent.MOUSE_EXITED,  this);
             pane.addEventHandler(MouseEvent.MOUSE_MOVED,   this);
@@ -170,11 +175,18 @@ public class GestureFollower extends CanvasFollower 
implements EventHandler<Mous
             pane.removeEventHandler(MouseEvent.MOUSE_MOVED,   this);
             pane.removeEventHandler(MouseEvent.MOUSE_DRAGGED, this);
             if (cursor != null) {
-                (((MapCanvas) 
target).floatingPane).getChildren().remove(cursor);
+                getChildrenOfTargetPane().remove(cursor);
             }
         }
     }
 
+    /**
+     * Returns the list of children in the target pane where the cursor is 
shown.
+     */
+    private ObservableList<Node> getChildrenOfTargetPane() {
+        return (((MapCanvas) target).floatingPane).getChildren();
+    }
+
     /**
      * Returns the position for the mouse cursor in the source canvas if that 
position is known.
      * This information is used when the source and target canvases do not use 
the same CRS.
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 4f3c69678b..b9ee1bade1 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
@@ -131,7 +131,7 @@ import org.opengis.coordinate.MismatchedDimensionException;
  * </ol>
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.5
+ * @version 1.7
  * @since   1.1
  */
 public abstract class MapCanvas extends PlanarCanvas {
@@ -723,6 +723,7 @@ public abstract class MapCanvas extends PlanarCanvas {
          * Creates and registers a new handler for showing a contextual menu 
in the enclosing canvas.
          * It is caller responsibility to ensure that this method is invoked 
only once.
          */
+        @SuppressWarnings("this-escape")
         MenuHandler(final ContextMenu menu) {
             super(getDisplayCRS());
             this.menu = menu;
@@ -763,6 +764,7 @@ public abstract class MapCanvas extends PlanarCanvas {
          * Invoked when user selected a projection centered on mouse position. 
Those CRS are generated on-the-fly
          * and are generally not on the list of CRS managed by {@link 
RecentReferenceSystems}.
          */
+        @SuppressWarnings("UseSpecificCatch")
         final void createProjectedCRS(final PositionableProjection projection) 
{
             try {
                 DirectPosition2D center = new DirectPosition2D();
@@ -809,7 +811,7 @@ public abstract class MapCanvas extends PlanarCanvas {
      * @param  anchor    the point to keep at fixed display coordinates, or 
{@code null} for default value.
      * @param  property  the property to reset if the operation fails, or 
{@code null} if none.
      */
-    @SuppressWarnings("unchecked")
+    @SuppressWarnings({"unchecked", "UseSpecificCatch"})
     private void setObjectiveCRS(final CoordinateReferenceSystem crs, 
DirectPosition anchor,
                                  final ObservableValue<? extends 
ReferenceSystem> property)
     {
@@ -1016,7 +1018,7 @@ public abstract class MapCanvas extends PlanarCanvas {
 
     /**
      * 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.
+     * {@link #objectiveToDisplay} for getting the transform that user 
currently sees 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.
@@ -1370,6 +1372,15 @@ public abstract class MapCanvas extends PlanarCanvas {
         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);
+            }
+        }
         try {
             changeInProgress.invert();
             transform.append(changeInProgress);
@@ -1408,6 +1419,15 @@ 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.
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 494a6cca37..c5caa6417e 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,6 +33,7 @@ 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;
@@ -53,7 +54,7 @@ import org.apache.sis.system.Configuration;
  * controls by the user.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.4
+ * @version 1.7
  * @since   1.1
  */
 public abstract class MapCanvasAWT extends MapCanvas {
@@ -162,6 +163,17 @@ 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/package-info.java 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/package-info.java
index 1f53783039..e76f704188 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/package-info.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/package-info.java
@@ -22,7 +22,7 @@
  * {@link org.apache.sis.gui.map.MapCanvasAWT} is a specialization for 
painting the map using Java2D.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.5
+ * @version 1.7
  * @since   1.1
  */
 package org.apache.sis.gui.map;

Reply via email to