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"); + } + } +}
