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

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

commit 29c988583b93f016ab6ca6ae515e77aa0e5b0772
Author: Martin Desruisseaux <[email protected]>
AuthorDate: Tue Aug 11 12:59:28 2026 +0200

    Move `TileReadListener` to a separated class and add a safety for reducing 
the risk
    of application freeze when reading a TIFF image made of thousands of thin 
strips.
---
 .../apache/sis/storage/tiling/TileReadEvent.java   |  63 ++++++-
 .../sis/util/collection/WeakValueHashMap.java      |   4 +-
 .../apache/sis/gui/coverage/CoverageCanvas.java    | 137 +-------------
 .../apache/sis/gui/coverage/RectangleMerger.java   | 104 +++++++++++
 .../apache/sis/gui/coverage/TileReadListener.java  | 197 +++++++++++++++++++++
 5 files changed, 367 insertions(+), 138 deletions(-)

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
index ec4132250f..b72132945f 100644
--- 
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
@@ -18,6 +18,7 @@ package org.apache.sis.storage.tiling;
 
 import java.io.Serializable;
 import java.awt.Shape;
+import java.awt.Dimension;
 import java.awt.Rectangle;
 import java.awt.geom.Rectangle2D;
 import org.opengis.util.FactoryException;
@@ -35,6 +36,7 @@ import org.apache.sis.coverage.grid.GridGeometry;
 import org.apache.sis.coverage.grid.IncompleteGridGeometryException;
 import org.apache.sis.coverage.grid.PixelInCell;
 import org.apache.sis.geometry.Shapes2D;
+import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.internal.shared.Strings;
 
 
@@ -82,7 +84,13 @@ public final class TileReadEvent extends StoreEvent {
          * 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;
+        final long offsetX, offsetY;
+
+        /**
+         * Coordinates in dimensions other than <var>x</var> and <var>y</var>.
+         * This is an empty array in the common case of two-dimensional grid.
+         */
+        final long[] sliceCoordinates;
 
         /**
          * Coordinate operation from the <abbr>CRS</abbr> of the coverage to 
the <abbr>CRS</abbr>
@@ -97,7 +105,6 @@ public final class TileReadEvent extends StoreEvent {
          * 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;
 
         /**
@@ -115,6 +122,19 @@ public final class TileReadEvent extends StoreEvent {
             sliceGeometry = domain.selectDimensions(xDimension, yDimension);
             offsetX = aoi.getLow(xDimension);
             offsetY = aoi.getLow(yDimension);
+            final int dimension = aoi.getDimension();
+            if (dimension > TiledGridCoverageResource.BIDIMENSIONAL) {
+                sliceCoordinates = new long[dimension - 
TiledGridCoverageResource.BIDIMENSIONAL];
+                int n = 0;
+                for (int i=0; i<dimension; i++) {
+                    if (i != xDimension && i != yDimension) {
+                        // The low and high coordinates should be the same, 
but ask for the median in case.
+                        sliceCoordinates[n++] = aoi.getMedian(i);
+                    }
+                }
+            } else {
+                sliceCoordinates = ArraysExt.EMPTY_LONG;
+            }
         }
 
         /**
@@ -152,9 +172,7 @@ public final class TileReadEvent extends StoreEvent {
     /**
      * 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.
+     * @see #getTileSize()
      */
     private final Rectangle rasterBounds;
 
@@ -171,6 +189,41 @@ public final class TileReadEvent extends StoreEvent {
         this.rasterBounds = rasterBounds;
     }
 
+    /**
+     * Returns the location of the tile in units of grid coordinates of the 
grid coverage.
+     * The two first elements of the array are the minimum <var>x</var> grid 
coordinate and
+     * the minimum <var>y</var> grid coordinate in that exact order.
+     * All other elements, if any, are the coordinates of the slice in other 
dimensions.
+     *
+     * <p><b>Note:</b> the coordinate order returned by this method is not 
necessarily the same
+     * as the coordinate order of the grid coverage, because this method 
always put <var>x</var>
+     * and <var>y</var> first for making easy to ignore the supplemental 
dimensions.</p>
+     *
+     * @return tile location in an array of length {@value 
TiledGridCoverageResource#BIDIMENSIONAL} or more.
+     */
+    public long[] getTileLocation() {
+        final int n = context.sliceCoordinates.length;
+        final var coordinates = new 
long[TiledGridCoverageResource.BIDIMENSIONAL + n];
+        coordinates[0] = Math.addExact(context.offsetX, rasterBounds.x);
+        coordinates[1] = Math.addExact(context.offsetY, rasterBounds.y);
+        System.arraycopy(context.sliceCoordinates, 0, coordinates, 
TiledGridCoverageResource.BIDIMENSIONAL, n);
+        return coordinates;
+    }
+
+    /**
+     * Returns the width and height of the tile in pixels.
+     *
+     * @return width and height of the tile in pixels.
+     */
+    public Dimension getTileSize() {
+        return rasterBounds.getSize();
+    }
+
+    /*
+     * Note: there is no public method for fetching the (x, y) location 
because the pixel coordinates
+     * are not necessarily the same as the grid coordinates of the resource, 
which may confuse users.
+     */
+
     /**
      * Returns the zero-based index of the pyramid level of the tile which is 
read.
      * This is typically the index in the {@linkplain 
TiledGridCoverageResource#getAvailableResolutions()
diff --git 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/collection/WeakValueHashMap.java
 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/collection/WeakValueHashMap.java
index bd1878a120..56758e7f18 100644
--- 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/collection/WeakValueHashMap.java
+++ 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/collection/WeakValueHashMap.java
@@ -675,7 +675,7 @@ public class WeakValueHashMap<K,V> extends AbstractMap<K,V> 
{
                 return equals = oldValue.equals(other);
             }
         }
-        final Observer observer = new Observer();
+        final var observer = new Observer();
         return intern(key, newValue, observer) != null && observer.equals;
     }
 
@@ -732,7 +732,7 @@ public class WeakValueHashMap<K,V> extends AbstractMap<K,V> 
{
                 final Entry[] table = WeakValueHashMap.this.table;
                 for (Entry el : table) {
                     while (el != null) {
-                        final Map.Entry<K,V> entry = new SimpleEntry<>(el);
+                        final var entry = new SimpleEntry<K,V>(el);
                         if (entry.getValue() != null) {
                             elements[index++] = entry;
                         }
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 6ac0127c15..7e01f68f5e 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,10 +20,8 @@ import java.util.Map;
 import java.util.EnumMap;
 import java.util.List;
 import java.util.Locale;
-import java.util.Queue;
 import java.util.Optional;
 import java.util.concurrent.Future;
-import java.util.concurrent.ConcurrentLinkedQueue;
 import java.util.logging.LogRecord;
 import java.io.IOException;
 import java.io.InputStream;
@@ -36,25 +34,16 @@ 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.Pane;
 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.util.Duration;
 import javax.measure.Quantity;
 import javax.measure.quantity.Length;
 import org.opengis.geometry.Envelope;
@@ -83,7 +72,6 @@ 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.base.StoreUtilities;
-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;
@@ -91,7 +79,6 @@ import org.apache.sis.portrayal.RenderException;
 import org.apache.sis.map.coverage.RenderingWorkaround;
 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;
@@ -549,7 +536,7 @@ public class CoverageCanvas extends MapCanvasAWT {
         final GridCoverageResource resource = getResource();
         if (enabled) {
             if (tileReadListener == null) {
-                tileReadListener = new TileReadListener();
+                tileReadListener = new TileReadListener(this);
                 if (resource != null) {
                     resource.addListener(TileReadEvent.class, 
tileReadListener);
                 }
@@ -758,8 +745,9 @@ public class CoverageCanvas extends MapCanvasAWT {
                                     final double out = (1 - ratio) / 2;      
// Fraction of bounds to take out on each side.
                                     final var zoomArea = new 
GeneralEnvelope(bounds);
                                     for (int i=0; i<dimension; i++) {
-                                        final double margin = 
zoomArea.getSpan(i) * out;
-                                        zoomArea.setRange(i, 
zoomArea.getLower(i) + margin, zoomArea.getUpper(i) - margin);
+                                        final double margin = zoomArea.getSpan 
(i) * out;
+                                        zoomArea.setRange(i,  
zoomArea.getLower(i) + margin,
+                                                              
zoomArea.getUpper(i) - margin);
                                     }
                                     initialArea = new GridGeometry(zoomArea);
                                 }
@@ -1245,7 +1233,7 @@ public class CoverageCanvas extends MapCanvasAWT {
              */
             final TileReadListener tileReadListener = cc.tileReadListener;
             if (tileReadListener != null) {
-                tileReadListener.newStaticGraphics();
+                tileReadListener.newStaticGraphics(cc);
             }
             if (isolines != null) {
                 for (final IsolineController.Snapshot s : isolines) {
@@ -1367,119 +1355,6 @@ public class CoverageCanvas extends MapCanvasAWT {
 
 
 
-    /**
-     * 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>, 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 JavaFX shapes (usually rectangles) for highlighting the tiles.
-         * This queue shall be thread-safe as it is read and written from 
different threads.
-         */
-        private final Queue<FadeTransition> tileShapes;
-
-        /**
-         * 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.
-         */
-        volatile StaticGraphics snapshot;
-
-        /**
-         * Creates a new listener of tile read events.
-         * This constructor must be invoked from the JavaFX thread.
-         */
-        TileReadListener() {
-            tileShapes = new ConcurrentLinkedQueue<>();
-            newStaticGraphics();
-        }
-
-        /**
-         * 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 void newStaticGraphics() {
-            snapshot = usingFixedTransform();
-        }
-
-        /**
-         * Invoked when a tile has been read. This method computes the JavaFX 
shape in a background thread.
-         * One thread is used for each shape (we do not collect the shapes in 
a queue) because that thread
-         * is likely to finish before the next tile has been read anyway.
-         */
-        @Override
-        @SuppressWarnings({"UseSpecificCatch", 
"LocalVariableHidesMemberVariable"})
-        public void eventOccured(final TileReadEvent event) {
-            BackgroundThreads.EXECUTOR.execute(() -> {
-                final StaticGraphics snapshot = TileReadListener.this.snapshot;
-                if (snapshot.objectiveToDisplay instanceof AffineTransform 
objectiveToDisplay) try {
-                    final Shape tile = 
ShapeConverter.convert(event.outline(snapshot.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) {
-                    Logging.recoverableException(LOGGER, 
TileReadListener.class, "eventOccured", e);
-                }
-                Platform.runLater(() -> {
-                    FadeTransition transition = tileShapes.poll();
-                    if (transition != null) {
-                        final ObservableList<Node> children = 
snapshot.getChildren();
-                        do {
-                            children.add(transition.getNode());
-                            transition.play();
-                            transition = tileShapes.poll();
-                        } while (transition != null);
-                    }
-                });
-            });
-        }
-
-        /**
-         * 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();
-            final Node node = transition.getNode();
-            final Pane parent = (Pane) node.getParent();
-            if (parent != null && parent.getChildren().remove(node) && TRACE) {
-                trace("TileReadListener.removeChild");
-            }
-        }
-    }
-
     /**
      * Invoked when an exception occurred while computing a transform but the 
painting process can continue.
      */
@@ -1531,7 +1406,7 @@ public class CoverageCanvas extends MapCanvasAWT {
      */
     @Debug
     @SuppressWarnings("UseOfSystemOutOrSystemErr")
-    private static void trace(final String format, final Object... arguments) {
+    static void trace(final String format, final Object... arguments) {
         if (TRACE) {
             System.out.print("CoverageCanvas.");
             System.out.printf(format, arguments);
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/RectangleMerger.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/RectangleMerger.java
new file mode 100644
index 0000000000..6663144fc6
--- /dev/null
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/RectangleMerger.java
@@ -0,0 +1,104 @@
+/*
+ * 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.coverage;
+
+import java.util.function.Predicate;
+import javafx.animation.FadeTransition;
+import javafx.scene.shape.Rectangle;
+import org.apache.sis.util.internal.shared.Numerics;
+
+
+/**
+ * Merges adjacent rectangles (only for rectangles with no stroke).
+ * This is used for simplifying small tiles into a single larger tile.
+ * The goal is to avoid to put too much pressure on JavaFX when there
+ * is a lot of small tiles.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ */
+final class RectangleMerger implements Predicate<FadeTransition> {
+    /**
+     * Bounds of the rectangle.
+     */
+    private double xmin, xmax, ymin, ymax;
+
+    /**
+     * Tolerance factor on each axis.
+     */
+    private double tolX, tolY;
+
+    /**
+     * Whether a coordinate has been modified.
+     */
+    private boolean modified;
+
+    /**
+     * Creates a new merger starting with the given rectangle.
+     *
+     * @param  r  the initial rectangle.
+     */
+    RectangleMerger(final Rectangle r) {
+        xmin = r.getX();
+        ymin = r.getY();
+        xmax = xmin + (tolX = r.getWidth());
+        ymax = ymin + (tolY = r.getHeight());
+        tolX *= Numerics.COMPARISON_THRESHOLD;
+        tolY *= Numerics.COMPARISON_THRESHOLD;
+    }
+
+    /**
+     * Tries to merge the node of the given transition with the rectangle if 
possible.
+     * If the two rectangle intersect, then merges if either the left and 
right bounds are approximately equal
+     * (merge vertically), or if the top and bottom bounds are approximately 
equal (merge horizontally).
+     *
+     * @param  t  the transition to try to merge.
+     * @return whether the rectangles have been merged.
+     */
+    @Override
+    public boolean test(final FadeTransition t) {
+        if (t.getNode() instanceof Rectangle r) {
+            final double x0 = r.getX();
+            final double y0 = r.getY();
+            final double x1 = x0 + r.getWidth();
+            final double y1 = y0 + r.getHeight();
+            if ((Math.abs(x0 - xmin) <= tolX && Math.abs(x1 - xmax) <= tolX && 
y0 - tolY <= ymax && y1 + tolY >= ymin) ||
+                (Math.abs(y0 - ymin) <= tolY && Math.abs(y1 - ymax) <= tolY && 
x0 - tolX <= xmax && x1 + tolX >= xmin))
+            {
+                if (x0 < xmin) {xmin = x0; modified = true;}
+                if (y0 < ymin) {ymin = y0; modified = true;}
+                if (x1 > xmax) {xmax = x1; modified = true;}
+                if (y1 > ymax) {ymax = y1; modified = true;}
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Stores the coordinates in the given rectangle.
+     *
+     * @param  r  the rectangle to modify.
+     */
+    void copyTo(final Rectangle r) {
+        if (modified) {
+            r.setX(xmin);
+            r.setY(ymin);
+            r.setWidth (xmax - xmin);
+            r.setHeight(ymax - ymin);
+        }
+    }
+}
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/TileReadListener.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/TileReadListener.java
new file mode 100644
index 0000000000..553691f467
--- /dev/null
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/TileReadListener.java
@@ -0,0 +1,197 @@
+/*
+ * 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.coverage;
+
+import java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.awt.Dimension;
+import java.awt.geom.AffineTransform;
+import javafx.scene.Node;
+import javafx.scene.paint.Color;
+import javafx.scene.shape.Shape;
+import javafx.scene.shape.Rectangle;
+import javafx.scene.layout.Pane;
+import javafx.animation.FadeTransition;
+import javafx.application.Platform;
+import javafx.event.ActionEvent;
+import javafx.event.EventHandler;
+import javafx.util.Duration;
+import org.apache.sis.storage.event.StoreListener;
+import org.apache.sis.storage.tiling.TileReadEvent;
+import org.apache.sis.gui.internal.BackgroundThreads;
+import org.apache.sis.gui.internal.ShapeConverter;
+import org.apache.sis.util.logging.Logging;
+import static org.apache.sis.gui.internal.LogHandler.LOGGER;
+
+
+/**
+ * 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.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ */
+final class TileReadListener implements StoreListener<TileReadEvent>, 
EventHandler<ActionEvent>, Runnable {
+    /**
+     * 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);
+        }
+    }
+
+    /**
+     * Minimal size in pixels for showing the stroke. A minimal size is needed 
because if, for example,
+     * the tile height is 1 pixel (as in <abbr>TIFF</abbr> stripped images), 
the stroke fills all the
+     * surface and the tile appears opaque.
+     */
+    private static final int MIN_SIZE = 10;
+
+    /**
+     * Time that tiles are visible before they fade away.
+     */
+    private static final Duration DURATION = new Duration(4000);
+
+    /**
+     * The JavaFX shapes (usually rectangles) for highlighting the tiles.
+     * This queue shall be thread-safe as it is read and written from 
different threads.
+     */
+    private final Queue<FadeTransition> tileShapes;
+
+    /**
+     * 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.
+     */
+    private volatile CoverageCanvas.StaticGraphics snapshot;
+
+    /**
+     * Creates a new listener of tile read events.
+     * This constructor must be invoked from the JavaFX thread.
+     */
+    TileReadListener(final CoverageCanvas canvas) {
+        tileShapes = new ConcurrentLinkedQueue<>();
+        newStaticGraphics(canvas);
+    }
+
+    /**
+     * 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 void newStaticGraphics(final CoverageCanvas canvas) {
+        snapshot = canvas.usingFixedTransform();
+    }
+
+    /**
+     * Invoked when a tile has been read. This method computes the JavaFX 
shape in a background thread.
+     * One thread is used for each shape (we do not collect the shapes in a 
queue) because that thread
+     * is likely to finish before the next tile has been read anyway.
+     */
+    @Override
+    @SuppressWarnings("UseSpecificCatch")
+    public void eventOccured(final TileReadEvent event) {
+        BackgroundThreads.EXECUTOR.execute(() -> {
+            /*
+             * `TileReadListener.snapshot` may change at any time. We can take 
any value,
+             * but it must stay constant for the rest of this method for 
consistency.
+             */
+            @SuppressWarnings("LocalVariableHidesMemberVariable")
+            final CoverageCanvas.StaticGraphics snapshot = 
TileReadListener.this.snapshot;
+            if (snapshot.objectiveToDisplay instanceof AffineTransform 
objectiveToDisplay) try {
+                final Shape tile = 
ShapeConverter.convert(event.outline(snapshot.objectiveCRS), 
objectiveToDisplay);
+                final int ic = event.getPyramidLevel() % TILE_COLORS.length;
+                final Dimension tileSize = event.getTileSize();
+                if (tileSize.width < MIN_SIZE || tileSize.height < MIN_SIZE) {
+                    /*
+                     * If the tiles are very thin, there is a risk of adding 
too many nodes.
+                     * Tries to reduce the number of transitions by merging 
adjacent tile shapes.
+                     * We do that only if there is no stroke, otherwise some 
lines would disappear.
+                     * Note that the `tileShapes` list should be small, 
because it contains only the
+                     * transitions not yet processed by an execution of 
`Platform.runLater(…)` below.
+                     */
+                    if (tile instanceof Rectangle r) {
+                        final var merger = new RectangleMerger(r);
+                        while (tileShapes.removeIf(merger)) {}
+                        merger.copyTo(r);
+                    }
+                } else {
+                    tile.setStroke(TILE_COLORS[ic]);
+                }
+                tile.setFill(FILL_COLORS[ic]);
+                tile.setOpacity(0.5);
+                tile.setUserData(snapshot);
+                final var transition = new FadeTransition(DURATION, tile);
+                transition.setFromValue(0.5);
+                transition.setToValue(0);
+                transition.setOnFinished(this);
+                tileShapes.add(transition);
+            } catch (Exception e) {
+                Logging.recoverableException(LOGGER, TileReadListener.class, 
"eventOccured", e);
+            }
+            Platform.runLater(this);
+        });
+    }
+
+    /**
+     * Invoked in the JavaFX thread for playing the animations that have been 
prepared.
+     * The animation are taken from the {@link #tileShapes} queue, which 
usually contains
+     * exactly one element. But more elements may be present if tiles have 
been read quickly
+     * between two executions of this method by the JavaFX thread.
+     */
+    @Override
+    public void run() {
+        FadeTransition transition;
+        while ((transition = tileShapes.poll()) != null) {
+            final Node node = transition.getNode();
+            /*
+             * We need to use the snapshot at the time when the rectangle was 
created.
+             * This is not necessarily the same snapshot as when this method 
is executed.
+             */
+            @SuppressWarnings("LocalVariableHidesMemberVariable")
+            final var snapshot = (CoverageCanvas.StaticGraphics) 
node.getUserData();
+            snapshot.getChildren().add(node);
+            node.setUserData(null);             // Not needed anymore.
+            transition.play();
+        }
+    }
+
+    /**
+     * Invoked in the JavaFX thread when the animation of a tile is finished.
+     * This method removes the JavaFX geometry object that represented the 
tile outline.
+     */
+    @Override
+    @SuppressWarnings("element-type-mismatch")
+    public void handle(final ActionEvent event) {
+        final var transition = (FadeTransition) event.getSource();
+        final Node node = transition.getNode();
+        final Pane parent = (Pane) node.getParent();
+        if (parent != null && parent.getChildren().remove(node) && 
CoverageCanvas.TRACE) {
+            CoverageCanvas.trace("TileReadListener.removeChild");
+        }
+    }
+}

Reply via email to