You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sis.apache.org by de...@apache.org on 2021/01/12 23:22:14 UTC

[sis] branch geoapi-4.0 updated: Add isoline rendering capabilities to `CoverageCanvas` using the information provided by user in a `TableView`.

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 38499ac  Add isoline rendering capabilities to `CoverageCanvas` using the information provided by user in a `TableView`.
38499ac is described below

commit 38499ac799c0ad8e71b2f19b46af2a726e38f400
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Wed Jan 13 00:06:05 2021 +0100

    Add isoline rendering capabilities to `CoverageCanvas` using the information provided by user in a `TableView`.
---
 .../apache/sis/gui/coverage/CoverageCanvas.java    |  68 +++-
 .../apache/sis/gui/coverage/CoverageControls.java  |  13 +-
 .../apache/sis/gui/coverage/IsolineRenderer.java   | 451 +++++++++++++++++++++
 .../org/apache/sis/gui/coverage/RenderingData.java |  16 +
 .../java/org/apache/sis/gui/map/MapCanvas.java     |  23 +-
 .../apache/sis/internal/gui/control/ColorRamp.java |  17 +
 .../sis/internal/gui/control/ValueColorMapper.java |  69 +++-
 .../sis/internal/feature/j2d/EmptyShape.java       |  64 +++
 .../internal/processing/image/IsolineTracer.java   |   4 +-
 .../sis/internal/processing/image/Isolines.java    |   1 +
 10 files changed, 690 insertions(+), 36 deletions(-)

diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageCanvas.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageCanvas.java
index 3ccff0c..66dd5c0 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageCanvas.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageCanvas.java
@@ -26,6 +26,8 @@ import java.awt.Graphics2D;
 import java.awt.Rectangle;
 import java.awt.RenderingHints;
 import java.awt.image.RenderedImage;
+import java.awt.Stroke;
+import java.awt.BasicStroke;
 import java.awt.geom.AffineTransform;
 import java.awt.geom.NoninvertibleTransformException;
 import java.lang.ref.Reference;
@@ -163,7 +165,7 @@ public class CoverageCanvas extends MapCanvasAWT {
      *
      * @see #createPropertyExplorer()
      */
-    private ImagePropertyExplorer imageProperty;
+    private ImagePropertyExplorer propertyExplorer;
 
     /**
      * The status bar associated to this {@code MapCanvas}.
@@ -180,6 +182,14 @@ public class CoverageCanvas extends MapCanvasAWT {
     private Reference<Resource> originator;
 
     /**
+     * Renderer of isolines, or {@code null} if none. The presence of this field in this class may be temporary.
+     * A future version may replace this field by a more complete styling framework. Note that this class holds
+     * references to {@link javafx.scene.control.TableView} list of items, which are the list of isoline levels
+     * with their colors.
+     */
+    IsolineRenderer isolines;
+
+    /**
      * Creates a new two-dimensional canvas for {@link RenderedImage}.
      */
     public CoverageCanvas() {
@@ -211,9 +221,9 @@ public class CoverageCanvas extends MapCanvasAWT {
      * is being shown.
      */
     final ImagePropertyExplorer createPropertyExplorer() {
-        imageProperty = new ImagePropertyExplorer(getLocale(), fixedPane.backgroundProperty());
-        imageProperty.setImage(resampledImage, getVisibleImageBounds());
-        return imageProperty;
+        propertyExplorer = new ImagePropertyExplorer(getLocale(), fixedPane.backgroundProperty());
+        propertyExplorer.setImage(resampledImage, getVisibleImageBounds());
+        return propertyExplorer;
     }
 
     /**
@@ -442,12 +452,16 @@ public class CoverageCanvas extends MapCanvasAWT {
      * without resampling and without color ramp stretching. The call to this method is followed by a
      * a repaint event, which will cause the image to be resampled in a background thread.
      *
-     * <p>All arguments can be {@code null} for clearing the canvas.</p>
+     * <p>All arguments can be {@code null} for clearing the canvas.
+     * This method is invoked in JavaFX thread.</p>
      */
     private void setRawImage(final RenderedImage image, final GridGeometry domain, final List<SampleDimension> ranges) {
         if (TRACE) {
             trace(".setRawImage(%s)", image);
         }
+        if (isolines != null) {
+            isolines.clear();
+        }
         resampledImage = null;
         derivedImages.clear();
         data.setImage(image, domain, ranges);
@@ -564,7 +578,12 @@ public class CoverageCanvas extends MapCanvasAWT {
         private final Reference<Resource> originator;
 
         /**
-         * Creates a new renderer.
+         * Snapshot of information required for rendering isolines, or {@code null} if none.
+         */
+        private IsolineRenderer.Snapshot[] isolines;
+
+        /**
+         * Creates a new renderer. Shall be invoked in JavaFX thread.
          */
         Worker(final CoverageCanvas canvas) {
             originator         = canvas.originator;
@@ -577,6 +596,9 @@ public class CoverageCanvas extends MapCanvasAWT {
             if (data.validateCRS(objectiveCRS)) {
                 resampledImage = canvas.resampledImage;
             }
+            if (canvas.isolines != null) {
+                isolines = canvas.isolines.prepare();
+            }
         }
 
         /**
@@ -642,6 +664,12 @@ public class CoverageCanvas extends MapCanvasAWT {
                     }
                 }
                 prefetchedImage = data.prefetch(resampledImage, resampledToDisplay, displayBounds);
+                /*
+                 * Create isolines if requested.
+                 */
+                if (isolines != null) {
+                    data.complete(isolines);
+                }
             } finally {
                 LogHandler.loadingStop(id);
             }
@@ -656,6 +684,17 @@ public class CoverageCanvas extends MapCanvasAWT {
         protected void paint(final Graphics2D gr) {
             gr.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
             gr.drawRenderedImage(prefetchedImage, resampledToDisplay);
+            if (isolines != null) {
+                AffineTransform at = gr.getTransform();
+                final Stroke st = gr.getStroke();
+                gr.setStroke(new BasicStroke(0));
+                gr.transform((AffineTransform) objectiveToDisplay);     // This cast is safe in PlanarCanvas subclass.
+                for (final IsolineRenderer.Snapshot s : isolines) {
+                    s.paint(gr);
+                }
+                gr.setTransform(at);
+                gr.setStroke(st);
+            }
         }
 
         /**
@@ -665,6 +704,11 @@ public class CoverageCanvas extends MapCanvasAWT {
         @Override
         protected boolean commit(final MapCanvas canvas) {
             ((CoverageCanvas) canvas).cacheRenderingData(this);
+            if (isolines != null) {
+                for (final IsolineRenderer.Snapshot s : isolines) {
+                    s.commit();
+                }
+            }
             return super.commit(canvas);
         }
     }
@@ -684,16 +728,20 @@ public class CoverageCanvas extends MapCanvasAWT {
             trace(": New objective bounds:%n%s", this);
         }
         /*
-         * Notify the "Image properties" tab that the image changed. The `imageProperty` field is non-null
+         * Notify the "Image properties" tab that the image changed. The `propertyExplorer` field is non-null
          * only if the "Properties" section in `CoverageControls` has been shown at least once.
          */
-        if (imageProperty != null) {
-            imageProperty.setImage(resampledImage, worker.getVisibleImageBounds());
+        if (propertyExplorer != null) {
+            propertyExplorer.setImage(resampledImage, worker.getVisibleImageBounds());
             if (TRACE) {
                 trace(": Update image property view with visible area %s.",
-                      imageProperty.getVisibleImageBounds(resampledImage));
+                      propertyExplorer.getVisibleImageBounds(resampledImage));
             }
         }
+        /*
+         * Adjust the accuracy of coordinates shown in the status bar.
+         * The number of fraction digits depend on the zoom factor.
+         */
         if (statusBar != null) {
             final Object value = resampledImage.getProperty(PlanarImage.POSITIONAL_ACCURACY_KEY);
             Quantity<Length> accuracy = null;
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageControls.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageControls.java
index bdc1a43..949193a 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageControls.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageControls.java
@@ -70,6 +70,11 @@ final class CoverageControls extends Controls {
     private final TableView<Category> categoryTable;
 
     /**
+     * The renderer of isolines.
+     */
+    private final IsolineRenderer isolines;
+
+    /**
      * The controls for changing {@link #view}.
      */
     private final Accordion controls;
@@ -144,9 +149,11 @@ final class CoverageControls extends Controls {
          */
         final VBox isolinesPane;
         {   // Block for making variables locale to this scope.
-            final ValueColorMapper table = new ValueColorMapper();
-            final TableView<ValueColorMapper.Step> isolinesTable = table.createIsolineTable(vocabulary);
-            isolinesPane = new VBox(isolinesTable); // TODO: add band selector
+            final ValueColorMapper mapper = new ValueColorMapper();
+            final TableView<ValueColorMapper.Step> table = mapper.createIsolineTable(vocabulary);
+            isolines = new IsolineRenderer(view);
+            isolines.setIsolineTables(java.util.Collections.singletonList(table));
+            isolinesPane = new VBox(table);                         // TODO: add band selector
         }
         /*
          * Put all sections together and have the first one expanded by default.
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/IsolineRenderer.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/IsolineRenderer.java
new file mode 100644
index 0000000..2646c6f
--- /dev/null
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/IsolineRenderer.java
@@ -0,0 +1,451 @@
+/*
+ * 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.Map;
+import java.util.Set;
+import java.util.List;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Arrays;
+import java.awt.Shape;
+import java.awt.Color;
+import java.awt.Graphics2D;
+import java.awt.image.RenderedImage;
+import javafx.application.Platform;
+import javafx.scene.control.TableView;
+import javafx.beans.value.ChangeListener;
+import javafx.beans.value.ObservableValue;
+import javafx.collections.ObservableList;
+import javafx.collections.ListChangeListener;
+import org.opengis.referencing.operation.MathTransform;
+import org.opengis.referencing.operation.TransformException;
+import org.apache.sis.internal.gui.control.ColorRamp;
+import org.apache.sis.internal.processing.image.Isolines;
+import org.apache.sis.internal.coverage.j2d.ImageUtilities;
+import org.apache.sis.internal.gui.control.ValueColorMapper.Step;
+import org.apache.sis.internal.feature.j2d.EmptyShape;
+import org.apache.sis.util.ArraysExt;
+
+
+/**
+ * Caches and draws isoline shapes in a {@link CoverageCanvas}. This class is designed for interactive use
+ * in JavaFX widget; this is not a class for doing symbology e.g. in a web service. Most of the work done
+ * by {@code IsolineRenderer} is about listening to changes in {@link TableView}, managing data exchanges
+ * between JavaFX thread and background thread, and computes only isolines that are new compared to previous
+ * rendering.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+final class IsolineRenderer {
+    /**
+     * The canvas where isolines are drawn.
+     */
+    private final CoverageCanvas canvas;
+
+    /**
+     * The list of isoline values and associated shapes for each band, or {@code null} if none.
+     */
+    private Band[] bands;
+
+    /**
+     * Creates an initially empty set of isolines.
+     *
+     * @param  canvas  the canvas where isolines are drawn.
+     */
+    public IsolineRenderer(final CoverageCanvas canvas) {
+        if (canvas.isolines != null) {
+            throw new IllegalArgumentException();
+        }
+        this.canvas = canvas;
+        canvas.isolines = this;
+    }
+
+    /**
+     * Returns {@code true} if there is no isoline to show.
+     * This method shall be invoked in JavaFX thread.
+     */
+    private boolean isEmpty() {
+        if (bands != null) {
+            for (final Band band : bands) {
+                for (final Step level : band.steps) {
+                    if (level.visible.get()) {
+                        return false;
+                    }
+                }
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Clears the cache. This method shall be invoked when the image used for computing isolines has changed,
+     * or when the {@code gridToCRS} transform has changed. This method shall be invoked in JavaFX thread.
+     */
+    final void clear() {
+        assert Platform.isFxApplicationThread();
+        if (bands != null) {
+            for (final Band band : bands) {
+                band.clear();
+            }
+        }
+    }
+
+    /**
+     * Sets the isoline values for all bands from the content of tables edited by user.
+     * This method registers listener on the given tables for repainting the isolines
+     * when table content changed.
+     */
+    public void setIsolineTables(final List<TableView<Step>> tables) {
+        if (bands != null) {
+            for (final Band band : bands) {
+                band.dispose();
+            }
+        }
+        bands = null;
+        if (tables != null && !tables.isEmpty()) {
+            bands = new Band[tables.size()];
+            for (int i=0; i<bands.length; i++) {
+                bands[i] = new Band(tables.get(i).getItems());
+            }
+        }
+    }
+
+    /**
+     * List of isoline values and associated shapes for a single band.
+     */
+    private final class Band implements ListChangeListener<Step>, ChangeListener<Object> {
+        /**
+         * The isoline levels to draw, together with their color.
+         * Considered as read-only (in JavaFX thread) by this class.
+         */
+        final ObservableList<Step> steps;
+
+        /**
+         * Cache of Java2D shapes for each isoline level. Shall be read in written in JavaFX thread.
+         * New isolines are computed in background thread and results stored later in JavaFX thread.
+         *
+         * @see Snapshot#isolines
+         */
+        private Map<Double,Shape> isolines;
+
+        /**
+         * Creates an initially empty set of isolines.
+         *
+         * @param  steps  the list of isoline levels to render.
+         */
+        @SuppressWarnings("ThisEscapedInObjectConstruction")
+        Band(final ObservableList<Step> steps) {
+            this.steps = steps;
+            addListeners(steps);
+            steps.addListener(this);
+        }
+
+        /**
+         * Clears the cache. This method shall be invoked when the image used for computing isolines has changed,
+         * or when the {@code gridToCRS} transform has changed. This method shall be invoked in JavaFX thread.
+         *
+         * @see IsolineRenderer#clear()
+         */
+        final void clear() {
+            // Force new instance instead of `Map.clear()` because previous instance may be used by `Snapshot`.
+            isolines = null;
+        }
+
+        /**
+         * Unregisters all listeners. This method should be invoked before this {@code Band} instance is discarded.
+         */
+        final void dispose() {
+            clear();
+            removeListeners(steps);
+        }
+
+        /**
+         * Invoked when steps are added or removed from the observable list.
+         * This method is public as an implementation side-effect and should not be invoked directly.
+         *
+         * @param  change  set of steps which have been added or removed.
+         */
+        @Override
+        public void onChanged(final Change<? extends Step> change) {
+            while (change.next()) {
+                if (!change.wasPermutated() && !change.wasUpdated()) {
+                    removeListeners(change.getRemoved());
+                    addListeners(change.getAddedSubList());
+                }
+            }
+            canvas.requestRepaint();
+        }
+
+        /**
+         * Unregisters listeners form all properties in the given list.
+         */
+        private void removeListeners(final List<? extends Step> list) {
+            for (final Step level : list) {
+                level.value  .removeListener(this);
+                level.color  .removeListener(this);
+                level.visible.removeListener(this);
+            }
+        }
+
+        /**
+         * Registers listeners on all properties in the given list.
+         */
+        private void addListeners(final List<? extends Step> list) {
+            for (final Step level : list) {
+                level.value  .addListener(this);
+                level.color  .addListener(this);
+                level.visible.addListener(this);
+            }
+        }
+
+        /**
+         * Invoked when an isoline value, color or visibility has changed.
+         * This method is public as an implementation side-effect and should not be invoked directly.
+         *
+         * @param  property  one of the properties defined in the {@link Step} class.
+         * @param  oldValue  the old property value, or {@code null} if none.
+         * @param  newValue  the new property value, or {@code null} if none.
+         */
+        @Override
+        public void changed(final ObservableValue<?> property, final Object oldValue, final Object newValue) {
+            canvas.requestRepaint();
+        }
+
+        /**
+         * Creates a snapshot of the list of isolines to draw, together with lists of cached shapes
+         * and shapes that need to be computed. This snapshot is created in JavaFX thread and used
+         * in a background thread.
+         *
+         * @param  keep  an empty set used by this method for listing the levels to keep in the cache.
+         * @return a snapshot of current {@code Band} state for use by a background thread.
+         *
+         * @see IsolineRenderer#prepare()
+         */
+        final Snapshot prepare(final Set<Double> keep) {
+            if (isolines == null) {
+                isolines = new HashMap<>();
+            }
+            final Snapshot s = new Snapshot(isolines, steps.size());
+            for (final Step level : steps) {
+                final Double value = level.value.get();
+                if (!value.isNaN()) {
+                    keep.add(value);
+                    if (level.visible.get()) {
+                        final ColorRamp cr = level.color.get();
+                        if (cr != null && !cr.isTransparent()) {
+                            s.add(value, cr.colors[0]);
+                        }
+                    }
+                }
+            }
+            isolines.keySet().retainAll(keep);          // Discard shapes that are no longer in use.
+            keep.clear();
+            return s;
+        }
+    }
+
+    /**
+     * Prepares a list of isolines to draw for all bands, initially populated with shapes that are already available.
+     * This method shall be invoked in JavaFX thread for having consistent information. The snapshots returned by this
+     * method will be completed and used in a background thread.
+     *
+     * @return snapshots of information about isolines in all bands, or {@code null} if none.
+     */
+    final Snapshot[] prepare() {
+        assert Platform.isFxApplicationThread();
+        if (isEmpty()) {
+            return null;
+        }
+        final Snapshot[] snapshots = new Snapshot[bands.length];
+        final Set<Double> keep = new HashSet<>();
+        for (int i=0; i < snapshots.length; i++) {
+            snapshots[i] = bands[i].prepare(keep);
+        }
+        return snapshots;
+    }
+
+    /**
+     * Continues isoline preparation by computing the missing Java2D shapes.
+     * This method shall be invoked in a background thread. After this call,
+     * isolines can be painted with {@link Snapshot#paint(Graphics2D)}.
+     *
+     * @param  snapshots  value of {@link #prepare()}. Shall not be {@code null}.
+     * @param  data       the source of data. Used only if there is new isolines to compute.
+     * @param  gridToCRS  transform from pixel coordinates to geometry coordinates, or {@code null} if none.
+     *                    Integer source coordinates are located at pixel centers.
+     * @return the {@code snapshots} array, potentially with less elements.
+     * @throws TransformException if an interpolated point can not be transformed using the given transform.
+     */
+    static Snapshot[] complete(final Snapshot[] snapshots, final RenderedImage data, final MathTransform gridToCRS)
+            throws TransformException
+    {
+        assert !Platform.isFxApplicationThread();
+        double[][] levels = null;
+        final int numBands = ImageUtilities.getNumBands(data);
+        final int numViews = Math.min(numBands, snapshots.length);
+        for (int i=0; i<numViews; i++) {
+            final Snapshot s = snapshots[i];
+            int n = s.missingLevels.size();
+            if (n != 0) {
+                if (levels == null) {
+                    levels = new double[numBands][];
+                    Arrays.fill(levels, ArraysExt.EMPTY_DOUBLE);
+                }
+                final double[] values = new double[n];
+                for (final Double value : s.missingLevels.keySet()) {
+                    values[--n] = value;
+                }
+                assert n == 0 : n;
+                levels[i] = values;
+            }
+        }
+        /*
+         * Compute only the isolines that are not already available. For performance reasons, we need to process
+         * all bands in one single call to `Isolines.generate(…)`. Results are written in empty slots of `shapes`.
+         */
+        if (levels != null) {
+            final Isolines[] isolines = Isolines.generate(data, levels, gridToCRS);
+            for (int i=0; i<numViews; i++) {
+                snapshots[i].complete(isolines[i]);
+            }
+        }
+        return ArraysExt.resize(snapshots, numViews);
+    }
+
+    /**
+     * Snapshot of {@link Band} information before rendering. Life cycle is:
+     *
+     * <ol>
+     *   <li>Created in JavaFX thread with shapes already available.</li>
+     *   <li>Missing Java2D shapes completed in a background thread.</li>
+     *   <li>Painting done in background thread.</li>
+     *   <li>New shapes cached in JavaFX thread.</li>
+     * </ol>
+     */
+    static final class Snapshot {
+        /**
+         * Isolines available before snapshot, and where to store new isolines after completion.
+         * This map shall be read and written in JavaFX thread only. This is initially the same
+         * reference than {@link Band#isolines}, but may become different if {@link Band#clear()}
+         * is invoked while isolines computation or painting is in progress.
+         *
+         * @see Band#isolines
+         */
+        private final Map<Double,Shape> isolines;
+
+        /**
+         * The isoline levels that are missing in the {@link #isolines} map. This map is populated
+         * in JavaFX thread and consumed in the background thread.
+         */
+        private final Map<Double,Integer> missingLevels;
+
+        /**
+         * Isolines that have been created, or {@code null} if none. They are the shapes to store in
+         * the {@link #isolines} map after the isoline painting is completed. Stored in a separated map
+         * because we must wait to be back to JavaFX thread before we can write in {@link #isolines}.
+         */
+        private Map<Double,Shape> newIsolines;
+
+        /**
+         * The isoline shapes to draw. May contain {@code null} elements if some shapes are missing.
+         * Those missing shapes will be computed in a background thread.
+         */
+        private final Shape[] shapes;
+
+        /**
+         * Shape colors, obtained in JavaFX thread.
+         */
+        private final int[] colors;
+
+        /**
+         * Number of valid elements in {@link #shapes} and {@link #colors} array.
+         */
+        private int count;
+
+        /**
+         * Creates a new snapshot of {@link Band} information.
+         *
+         * @param  isolines  value of {@link Band#isolines} reference.
+         * @param  capacity  maximal amount of isoline levels that can be {@linkplain #add added}.
+         */
+        private Snapshot(final Map<Double,Shape> isolines, final int capacity) {
+            this.isolines = isolines;
+            missingLevels = new HashMap<>();
+            shapes = new Shape[capacity];
+            colors = new int[capacity];
+        }
+
+        /**
+         * Adds an isoline level. This method shall be invoked in JavaFX thread.
+         *
+         * @param  value  the level value.
+         * @param  color  color of the isolines to draw for the specified value.
+         */
+        private void add(final Double value, final int color) {
+            final Shape shape = isolines.putIfAbsent(value, EmptyShape.INSTANCE);
+            if (shape == null) {
+                missingLevels.put(value, count);
+            }
+            shapes[count] = shape;
+            colors[count] = color;
+            count++;
+        }
+
+        /**
+         * Completes the {@link #shapes} array by assigning a shapes to null elements.
+         *
+         * @param  isolines  missing isolines computed for the band of this snapshot.
+         */
+        private void complete(final Isolines isolines) {
+            newIsolines = isolines.polylines();
+            for (final Map.Entry<Double,Shape> entry : newIsolines.entrySet()) {
+                final Integer j = missingLevels.get(entry.getKey());
+                if (j != null) shapes[j] = entry.getValue();
+            }
+        }
+
+        /**
+         * Paints all isolines in the given graphics.
+         * This method should be invoked in a background thread.
+         *
+         * @param  target  where to draw isolines.
+         */
+        final void paint(final Graphics2D target) {
+            for (int i=0; i<count; i++) {
+                final Shape shape = shapes[i];
+                if (shape != null) {
+                    target.setColor(new Color(colors[i], true));
+                    target.draw(shape);
+                }
+            }
+        }
+
+        /**
+         * Invoked in JavaFX thread after successful rendering for caching the new isolines.
+         */
+        final void commit() {
+            assert Platform.isFxApplicationThread();
+            if (newIsolines != null) {
+                isolines.putAll(newIsolines);
+            }
+        }
+    }
+}
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/RenderingData.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/RenderingData.java
index 0078220..9eb2252 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/RenderingData.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/RenderingData.java
@@ -37,6 +37,7 @@ import org.opengis.referencing.crs.CoordinateReferenceSystem;
 import org.apache.sis.coverage.grid.GridCoverage;
 import org.apache.sis.coverage.grid.GridGeometry;
 import org.apache.sis.coverage.grid.GridExtent;
+import org.apache.sis.coverage.grid.PixelTranslation;
 import org.apache.sis.coverage.SampleDimension;
 import org.apache.sis.geometry.AbstractEnvelope;
 import org.apache.sis.geometry.Envelope2D;
@@ -534,6 +535,21 @@ final class RenderingData implements Cloneable {
     }
 
     /**
+     * Prepares isolines by computing the the Java2D shapes that were not already computed in a previous rendering.
+     * This method shall be invoked in a background thread after image rendering has been completed (because this
+     * method uses some image computation results).
+     *
+     * @param  isolines  value of {@link IsolineRenderer#prepare()}. Shall not be {@code null}.
+     * @return the {@code isolines} array, potentially with less elements.
+     * @throws TransformException if an interpolated point can not be transformed using the given transform.
+     */
+    final IsolineRenderer.Snapshot[] complete(final IsolineRenderer.Snapshot[] isolines) throws TransformException {
+        final MathTransform centerToObjective = PixelTranslation.translate(
+                cornerToObjective, PixelInCell.CELL_CORNER, PixelInCell.CELL_CENTER);
+        return IsolineRenderer.complete(isolines, data, centerToObjective);
+    }
+
+    /**
      * Creates new rendering data initialized to a copy of this instance.
      */
     @Override
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvas.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvas.java
index a1fdf82..a787886 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvas.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvas.java
@@ -146,7 +146,7 @@ public abstract class MapCanvas extends PlanarCanvas {
      * It does not apply to the immediate feedback that the user gets from JavaFX affine transforms
      * (an image with lower quality used until the higher quality image become ready).
      */
-    private static final long REPAINT_DELAY = 500;
+    private static final long REPAINT_DELAY = 100;
 
     /**
      * The pane showing the map and any other JavaFX nodes to scale and translate together with the map.
@@ -323,7 +323,7 @@ public abstract class MapCanvas extends PlanarCanvas {
      */
     private void onSizeChanged(final Observable property) {
         sizeChanged = true;
-        repaintLater();
+        requestRepaint();
     }
 
     /**
@@ -374,7 +374,7 @@ public abstract class MapCanvas extends PlanarCanvas {
             final Point2D p = changeInProgress.deltaTransform(tx, ty);
             transformOnNewImage.appendTranslation(p.getX(), p.getY());
             if (!isFinal) {
-                repaintLater();
+                requestRepaint();
             }
         }
         if (isFinal && !transform.isIdentity()) {
@@ -445,7 +445,7 @@ public abstract class MapCanvas extends PlanarCanvas {
                 transform.appendRotation(angle, x, y);
                 transformOnNewImage.appendRotation(angle, p.getX(), p.getY());
             }
-            repaintLater();
+            requestRepaint();
         }
         if (event != null) {
             event.consume();
@@ -828,18 +828,9 @@ public abstract class MapCanvas extends PlanarCanvas {
     /**
      * Requests the map to be rendered again, possibly with new data. Invoking this
      * method does not necessarily causes the repaint process to start immediately.
-     * The request will be queued and executed at an arbitrary time.
-     */
-    protected void requestRepaint() {
-        contentChangeCount++;
-        floatingPane.requestLayout();
-    }
-
-    /**
-     * Invokes {@link #repaint()} after a short delay. This method is used when the
-     * repaint event is caused by some gesture like pan, zoom or resizing the window.
+     * The request will be queued and executed at an arbitrary (short) time later.
      */
-    private void repaintLater() {
+    public final void requestRepaint() {
         contentChangeCount++;
         if (renderingInProgress == null) {
             executeRendering(new Delayed());
@@ -1022,7 +1013,7 @@ public abstract class MapCanvas extends PlanarCanvas {
      * as an idle thread, and it is unlikely that other parts of this JavaFX application need that thread in same
      * time (if it happens, other threads will be created).</div>
      *
-     * @see #repaintLater()
+     * @see #requestRepaint()
      */
     private final class Delayed extends Task<Void> {
         @Override protected Void call() {
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/ColorRamp.java b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/ColorRamp.java
index 5445cca..8081a4c 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/ColorRamp.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/ColorRamp.java
@@ -119,6 +119,23 @@ public final class ColorRamp {
     }
 
     /**
+     * Returns {@code true} if this ramp has no color with a non-zero transparency.
+     * If this method returns {@code false}, then {@link #colors} is guaranteed non-empty.
+     *
+     * @return whether this color ramp is fully transparent.
+     */
+    public final boolean isTransparent() {
+        if (colors != null) {
+            for (final int code : colors) {
+                if ((code & 0xFF000000) != 0) {
+                    return false;
+                }
+            }
+        }
+        return true;
+    }
+
+    /**
      * Returns a solid color to use for filling a rectangle in {@link ColorCell}.
      * If this item has many colors (for example because it uses a gradient),
      * then an arbitrary color is returned.
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/ValueColorMapper.java b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/ValueColorMapper.java
index 4f0d02d..fa8f611 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/ValueColorMapper.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/ValueColorMapper.java
@@ -16,6 +16,7 @@
  */
 package org.apache.sis.internal.gui.control;
 
+import java.util.Objects;
 import java.text.NumberFormat;
 import javafx.beans.property.BooleanProperty;
 import javafx.beans.property.DoubleProperty;
@@ -30,6 +31,7 @@ import javafx.scene.control.TableView;
 import javafx.scene.control.TableColumn;
 import javafx.scene.control.cell.CheckBoxTableCell;
 import org.apache.sis.internal.gui.Styles;
+import org.apache.sis.internal.util.Numerics;
 import org.apache.sis.util.resources.Vocabulary;
 
 
@@ -45,8 +47,13 @@ import org.apache.sis.util.resources.Vocabulary;
 public final class ValueColorMapper extends ColorColumnHandler<ValueColorMapper.Step> {
     /**
      * Colors to associate to a given value.
+     *
+     * <h2>Ordering</h2>
+     * {@code Step} natural ordering is inconsistent with equals.
+     * Natural ordering is based on the {@linkplain #value} only,
+     * while the {@link #equals(Object)} method compares all properties.
      */
-    public static final class Step {
+    public static final class Step implements Comparable<Step> {
         /**
          * The value for which to associate a color. The initial value is {@link Double#NaN},
          * but that value should be used only for the insertion row.
@@ -56,11 +63,11 @@ public final class ValueColorMapper extends ColorColumnHandler<ValueColorMapper.
         /**
          * Color associated to the {@linkplain #value}.
          *
-         * The value type is {@link ColorRamp} for now, but if this property become public in a future version
-         * then the type should be changed to {@link Color} and bidirectionally binded to another property
-         * (package-private) of type {@link ColorRamp}.
+         * The value type is {@link ColorRamp} for now. But if this property become public (i.e. located
+         * in a non-internal package) in a future version then the type should be changed to {@link Color}
+         * and bidirectionally binded to another property (package-private) of type {@link ColorRamp}.
          */
-        final ObjectProperty<ColorRamp> color;
+        public final ObjectProperty<ColorRamp> color;
 
         /**
          * Whether this step should be used. For example if {@code ValueColorMapper} is used as an isoline table,
@@ -86,7 +93,59 @@ public final class ValueColorMapper extends ColorColumnHandler<ValueColorMapper.
             this.color.set(new ColorRamp(color));
             visible.set(true);
         }
+
+        /**
+         * Compares this step value with the given value for order.
+         * The comparison is applied only on the {@linkplain #value}.
+         * The color and visibility state are ignored.
+         *
+         * @param  other  the other value to compare with this step.
+         * @return +1 if the value of this step is higher than value of given step, -1 if smaller or 0 if equal.
+         */
+        @Override
+        public int compareTo(final Step other) {
+            return Double.compare(value.get(), other.value.get());
+        }
+
+        /**
+         * Compares the given object with this value for equality.
+         * This method compares all properties, including visibility and color.
+         *
+         * @param  other  the other object to compare with this step.
+         * @return whether the other object is equals to this step.
+         */
+        @Override
+        public boolean equals(final Object other) {
+            if (other instanceof Step) {
+                final Step that = (Step) other;
+                return Numerics.equals(value.get(), that.value.get()) &&
+                        Objects.equals(color.get(), that.color.get()) &&
+                        visible.get() == that.visible.get();
+            }
+            return false;
+        }
+
+        /**
+         * Returns a hash code value for this step.
+         *
+         * @return a hash code value for this step.
+         */
+        @Override
+        public int hashCode() {
+            return Double.hashCode(value.get()) + Objects.hashCode(color.get()) + Boolean.hashCode(visible.get());
+        }
+
+        /**
+         * Returns a string representation of this step for debugging purposes.
+         *
+         * @return a string representation of this step.
+         */
+        @Override
+        public String toString() {
+            return Double.toString(value.get()) + " = " + Objects.toString(color.get());
+        }
     }
+
     /**
      * The format to use for formatting numerical values.
      * The same instance will be shared by all {@link FormatTableCell}s in this table.
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/j2d/EmptyShape.java b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/j2d/EmptyShape.java
new file mode 100644
index 0000000..8760712
--- /dev/null
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/j2d/EmptyShape.java
@@ -0,0 +1,64 @@
+/*
+ * 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.internal.feature.j2d;
+
+import java.awt.Shape;
+import java.awt.Rectangle;
+import java.awt.geom.Rectangle2D;
+import java.awt.geom.AffineTransform;
+import java.awt.geom.PathIterator;
+import java.awt.geom.Point2D;
+import java.util.NoSuchElementException;
+
+
+/**
+ * An empty shape.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+public final class EmptyShape implements Shape, PathIterator {
+    /**
+     * The unique empty shape instance.
+     */
+    public static final Shape INSTANCE = new EmptyShape();
+
+    /**
+     * For {@link #INSTANCE} construction only.
+     */
+    private EmptyShape() {
+    }
+
+    /** Returns an empty bounds. */
+    @Override public Rectangle    getBounds()                                          {return new Rectangle();}
+    @Override public Rectangle2D  getBounds2D()                                        {return new Rectangle();}
+    @Override public int          getWindingRule()                                     {return WIND_NON_ZERO;}
+    @Override public boolean      contains  (Point2D p)                                {return false;}
+    @Override public boolean      contains  (Rectangle2D r)                            {return false;}
+    @Override public boolean      intersects(Rectangle2D r)                            {return false;}
+    @Override public boolean      contains  (double x, double y)                       {return false;}
+    @Override public boolean      contains  (double x, double y, double w, double h)   {return false;}
+    @Override public boolean      intersects(double x, double y, double w, double h)   {return false;}
+    @Override public PathIterator getPathIterator(AffineTransform at)                  {return this;}
+    @Override public PathIterator getPathIterator(AffineTransform at, double flatness) {return this;}
+    @Override public boolean      isDone()                                             {return true;}
+    @Override public void         next()                                               {throw new NoSuchElementException();}
+    @Override public int          currentSegment( float[] coords)                      {throw new NoSuchElementException();}
+    @Override public int          currentSegment(double[] coords)                      {throw new NoSuchElementException();}
+}
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/processing/image/IsolineTracer.java b/core/sis-feature/src/main/java/org/apache/sis/internal/processing/image/IsolineTracer.java
index ddbe362..5545965 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/processing/image/IsolineTracer.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/processing/image/IsolineTracer.java
@@ -77,7 +77,7 @@ final class IsolineTracer {
     int y;
 
     /**
-     * Final transform to apply on coordinates.
+     * Final transform to apply on coordinates (integer source coordinates at pixel centers).
      */
     private final MathTransform gridToCRS;
 
@@ -86,7 +86,7 @@ final class IsolineTracer {
      *
      * @param  window       the 2×2 window containing pixel values in the 4 corners of current contouring grid cell.
      * @param  pixelStride  increment to the position in {@code window} for reading next sample value.
-     * @param  gridToCRS    final transform to apply on coordinates.
+     * @param  gridToCRS    final transform to apply on coordinates (integer source coordinates at pixel centers).
      */
     IsolineTracer(final double[] window, final int pixelStride, final MathTransform gridToCRS) {
         this.window      = window;
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/processing/image/Isolines.java b/core/sis-feature/src/main/java/org/apache/sis/internal/processing/image/Isolines.java
index 8072396..3be55e0 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/processing/image/Isolines.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/processing/image/Isolines.java
@@ -112,6 +112,7 @@ public final class Isolines {
      *                    If there is more bands than {@code levels.length}, the last array is reused for
      *                    all remaining bands.
      * @param  gridToCRS  transform from pixel coordinates to geometry coordinates, or {@code null} if none.
+     *                    Integer source coordinates are located at pixel centers.
      * @return the isolines for each band in the given image.
      * @throws TransformException if an interpolated point can not be transformed using the given transform.
      */