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

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

commit 348c8c8cd9c097213ec9f1d34eebf4fb6b8603e9
Author: Martin Desruisseaux <martin.desruisse...@geomatys.com>
AuthorDate: Thu Nov 25 11:01:56 2021 +0100

    Add a `CoverageCanvas.resourceProperty` in replacement of previous 
WeakReference to resource "originator".
    Add `MultiResolutionCoverageLoader` class for loading `GridCoverage` from a 
resource at different resolution.
    Together, those changes allow `CoverageCanvas` to use data loaded at a 
pyramid level determined from the zoom level.
---
 .../apache/sis/gui/coverage/CoverageCanvas.java    | 314 ++++++++++++-----
 .../apache/sis/gui/coverage/CoverageControls.java  |  82 ++---
 .../apache/sis/gui/coverage/CoverageExplorer.java  | 283 ++++++++++-----
 .../org/apache/sis/gui/coverage/GridControls.java  |  11 +-
 .../java/org/apache/sis/gui/coverage/GridView.java |   8 +-
 .../org/apache/sis/gui/coverage/ImageRequest.java  |  77 ++--
 .../gui/coverage/MultiResolutionImageLoader.java   | 156 +++++++++
 .../org/apache/sis/gui/coverage/RenderingData.java | 224 +++++++++---
 .../apache/sis/gui/coverage/ViewAndControls.java   |   3 +-
 .../java/org/apache/sis/gui/map/MapCanvas.java     |   6 +-
 .../org/apache/sis/internal/gui/LogHandler.java    |  14 +-
 .../coverage/MultiResolutionCoverageLoader.java    | 388 +++++++++++++++++++++
 .../sis/internal/map/coverage/package-info.java}   |  32 +-
 .../MultiResolutionCoverageLoaderTest.java         | 180 ++++++++++
 .../apache/sis/test/suite/PortrayalTestSuite.java  |   5 +-
 15 files changed, 1400 insertions(+), 383 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 b9b5825..d689e89 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
@@ -33,7 +33,6 @@ import java.awt.BasicStroke;
 import java.awt.geom.AffineTransform;
 import java.awt.geom.NoninvertibleTransformException;
 import java.awt.geom.Rectangle2D;
-import java.lang.ref.Reference;
 import javafx.scene.paint.Color;
 import javafx.scene.layout.Region;
 import javafx.scene.layout.Background;
@@ -48,14 +47,12 @@ import javax.measure.Quantity;
 import javax.measure.quantity.Length;
 import org.opengis.geometry.Envelope;
 import org.opengis.geometry.DirectPosition;
-import org.opengis.util.FactoryException;
 import org.opengis.referencing.operation.TransformException;
 import org.opengis.referencing.crs.CoordinateReferenceSystem;
 import org.apache.sis.coverage.SampleDimension;
 import org.apache.sis.coverage.grid.GridCoverage;
 import org.apache.sis.coverage.grid.GridExtent;
 import org.apache.sis.coverage.grid.GridGeometry;
-import org.apache.sis.coverage.grid.ImageRenderer;
 import org.apache.sis.referencing.operation.matrix.AffineTransforms2D;
 import org.apache.sis.referencing.operation.transform.LinearTransform;
 import org.apache.sis.referencing.operation.transform.MathTransforms;
@@ -66,6 +63,7 @@ import org.apache.sis.geometry.Shapes2D;
 import org.apache.sis.image.PlanarImage;
 import org.apache.sis.image.Interpolation;
 import org.apache.sis.coverage.Category;
+import org.apache.sis.storage.GridCoverageResource;
 import org.apache.sis.gui.map.MapCanvas;
 import org.apache.sis.gui.map.MapCanvasAWT;
 import org.apache.sis.gui.map.StatusBar;
@@ -80,12 +78,13 @@ import org.apache.sis.internal.system.Modules;
 import org.apache.sis.util.logging.Logging;
 import org.apache.sis.io.TableAppender;
 import org.apache.sis.measure.Units;
-import org.apache.sis.storage.Resource;
 import org.apache.sis.util.Debug;
 
 
 /**
- * A canvas for {@link RenderedImage} provided by a {@link GridCoverage}.
+ * A canvas for {@link RenderedImage} provided by a {@link GridCoverage} or a 
{@link GridCoverageResource}.
+ * In the later case where the source of data is specified by {@link 
#resourceProperty}, the grid coverage
+ * instance (given by {@link #coverageProperty}) will change automatically 
according the zoom level.
  *
  * @author  Martin Desruisseaux (Geomatys)
  * @version 1.2
@@ -115,25 +114,61 @@ public class CoverageCanvas extends MapCanvasAWT {
     static final boolean TRACE = false;
 
     /**
-     * The data shown in this canvas. Note that setting this property to a 
non-null value may not
-     * modify the canvas content immediately. Instead, a background process 
will request the tiles.
+     * The source of coverage data shown in this canvas. If this property 
value is non-null,
+     * then {@link #coverageProperty} value will change at any time 
(potentially many times)
+     * depending on the zoom level or other user interaction. Conversely if a 
value is set
+     * explicitly on {@link #coverageProperty}, then this {@code 
resourceProperty} is cleared.
+     *
+     * @see #getResource()
+     * @see #setResource(GridCoverageResource)
+     * @see CoverageExplorer#resourceProperty
+     *
+     * @since 1.2
+     */
+    public final ObjectProperty<GridCoverageResource> resourceProperty;
+
+    /**
+     * The data shown in this canvas. This property value may be set 
implicitly or explicitly:
+     * <ul>
+     *   <li>If the {@link #resourceProperty} value is non-null, then the 
value will change
+     *       automatically at any time (potentially many times) depending on 
user interaction.</li>
+     *   <li>Conversely if an explicit value is set on this property,
+     *       then the {@link #resourceProperty} is cleared.</li>
+     * </ul>
+     *
+     * Note that a change in this property value may not modify the canvas 
content immediately.
+     * Instead, a background process will request the tiles and update the 
canvas content later,
+     * when data are ready.
      *
      * <p>Current implementation is restricted to {@link GridCoverage} 
instances, but a future
      * implementation may generalize to {@link org.opengis.coverage.Coverage} 
instances.</p>
      *
      * @see #getCoverage()
      * @see #setCoverage(GridCoverage)
+     * @see CoverageExplorer#coverageProperty
      */
     public final ObjectProperty<GridCoverage> coverageProperty;
 
     /**
+     * Whether {@link #resourceProperty} or {@link #coverageProperty} is in 
process of being adjusted.
+     * This is used for preventing never-ending loop when a change of resource 
causes a change of coverage
+     * or conversely.
+     *
+     * @see #onPropertySpecified(ObjectProperty)
+     */
+    private boolean isCoverageAdjusting;
+
+    /**
      * A subspace of the grid coverage extent where all dimensions except two 
have a size of 1 cell.
      * May be {@code null} if the grid coverage has only two dimensions with a 
size greater than 1 cell.
      *
      * @see #getSliceExtent()
      * @see #setSliceExtent(GridExtent)
      * @see GridCoverage#render(GridExtent)
+     *
+     * @deprecated We will need a different mechanism for specifying slice 
dimensions.
      */
+    @Deprecated
     public final ObjectProperty<GridExtent> sliceExtentProperty;
 
     /**
@@ -193,14 +228,6 @@ public class CoverageCanvas extends MapCanvasAWT {
     private volatile LogRecord errorReport;
 
     /**
-     * The resource from which the data has been read, or {@code null} if 
unknown.
-     * This is used only for determining a target window for logging records.
-     *
-     * @see #setOriginator(Reference)
-     */
-    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
@@ -224,11 +251,13 @@ public class CoverageCanvas extends MapCanvasAWT {
         super(locale);
         data                  = new RenderingData((report) -> errorReport = 
report.getDescription());
         derivedImages         = new EnumMap<>(Stretching.class);
+        resourceProperty      = new SimpleObjectProperty<>(this, "resource");
         coverageProperty      = new SimpleObjectProperty<>(this, "coverage");
         sliceExtentProperty   = new SimpleObjectProperty<>(this, 
"sliceExtent");
         interpolationProperty = new SimpleObjectProperty<>(this, 
"interpolation", data.processor.getInterpolation());
-        coverageProperty     .addListener((p,o,n) -> onImageSpecified());
-        sliceExtentProperty  .addListener((p,o,n) -> onImageSpecified());
+        resourceProperty     .addListener((p,o,n) -> onPropertySpecified(n, 
null, coverageProperty));
+        coverageProperty     .addListener((p,o,n) -> onPropertySpecified(null, 
n, resourceProperty));
+        sliceExtentProperty  .addListener((p,o,n) -> 
onPropertySpecified(getResource(), getCoverage(), null));
         interpolationProperty.addListener((p,o,n) -> 
onInterpolationSpecified(n));
         imageMargin.set(new Insets(100));
     }
@@ -256,18 +285,41 @@ public class CoverageCanvas extends MapCanvasAWT {
     }
 
     /**
-     * Sets the resource from which the data has been read.
-     * This is used only for determining a target window for logging records.
+     * Returns the source of coverages for this viewer.
+     * This method, like all other methods in this class, shall be invoked 
from the JavaFX thread.
      *
-     * @param  originator  the resource from which the data has been read, or 
{@code null} if unknown.
+     * @return the source of coverages shown in this viewer, or {@code null} 
if none.
+     *
+     * @see #resourceProperty
+     *
+     * @since 1.2
      */
-    final void setOriginator(final Reference<Resource> originator) {
-        this.originator = originator;
+    public final GridCoverageResource getResource() {
+        return resourceProperty.get();
+    }
+
+    /**
+     * Sets the source of coverages shown in this viewer.
+     * This method shall be invoked from JavaFX thread and returns immediately.
+     * The new data are loaded in a background thread and the {@link 
#coverageProperty}
+     * value will be updated after an undetermined amount of time.
+     *
+     * @param  resource  the source of data to show in this viewer, or {@code 
null} if none.
+     *
+     * @see #resourceProperty
+     *
+     * @since 1.2
+     */
+    public final void setResource(final GridCoverageResource resource) {
+        resourceProperty.set(resource);
+        // Will indirectly invoke `onPropertySpecified(…)`.
     }
 
     /**
      * Returns the source of image for this viewer.
      * This method, like all other methods in this class, shall be invoked 
from the JavaFX thread.
+     * Note that this value may change at any time (depending on user 
interaction)
+     * if the {@link #resourceProperty} has a non-null value.
      *
      * @return the coverage shown in this explorer, or {@code null} if none.
      *
@@ -283,13 +335,31 @@ public class CoverageCanvas extends MapCanvasAWT {
      * The new data are loaded in a background thread and will appear after an
      * undetermined amount of time.
      *
+     * <p>Invoking this method sets the {@link #resourceProperty} value to 
{@code null}.</p>
+     *
      * @param  coverage  the data to show in this viewer, or {@code null} if 
none.
      *
      * @see #coverageProperty
      */
     public final void setCoverage(final GridCoverage coverage) {
-        assert Platform.isFxApplicationThread();
         coverageProperty.set(coverage);
+        // Will indirectly invoke `onPropertySpecified(…)`.
+    }
+
+    /**
+     * Sets both resource and coverage properties together. Typically only one 
of those properties is non-null.
+     * If both are non-null, then it is caller's responsibility to ensure that 
they are consistent.
+     */
+    final void setCoverage(final GridCoverageResource resource, final 
GridCoverage coverage) {
+        final boolean p = isCoverageAdjusting;
+        try {
+            isCoverageAdjusting = true;
+            setResource(resource);
+            setCoverage(coverage);
+        } finally {
+            isCoverageAdjusting = p;
+        }
+        onPropertySpecified(resource, coverage, null);
     }
 
     /**
@@ -299,7 +369,10 @@ public class CoverageCanvas extends MapCanvasAWT {
      *
      * @see #sliceExtentProperty
      * @see GridCoverage#render(GridExtent)
+     *
+     * @deprecated We will need a different mechanism for specifying slice 
dimensions.
      */
+    @Deprecated
     public final GridExtent getSliceExtent() {
         return sliceExtentProperty.get();
     }
@@ -311,10 +384,13 @@ public class CoverageCanvas extends MapCanvasAWT {
      *
      * @see #sliceExtentProperty
      * @see GridCoverage#render(GridExtent)
+     *
+     * @deprecated We will need a different mechanism for specifying slice 
dimensions.
      */
+    @Deprecated
     public final void setSliceExtent(final GridExtent sliceExtent) {
-        assert Platform.isFxApplicationThread();
         sliceExtentProperty.set(sliceExtent);
+        // Will indirectly invoke `onPropertySpecified(…)`.
     }
 
     /**
@@ -384,7 +460,7 @@ public class CoverageCanvas extends MapCanvasAWT {
      */
     @Override
     public void setObjectiveCRS(final CoordinateReferenceSystem newValue, 
DirectPosition anchor) throws RenderException {
-        final Long id = LogHandler.loadingStart(originator);
+        final Long id = LogHandler.loadingStart(getResource());
         try {
             super.setObjectiveCRS(newValue, anchor);
         } finally {
@@ -403,7 +479,7 @@ public class CoverageCanvas extends MapCanvasAWT {
      */
     @Override
     public void setGridGeometry(final GridGeometry newValue) throws 
RenderException {
-        final Long id = LogHandler.loadingStart(originator);
+        final Long id = LogHandler.loadingStart(getResource());
         try {
             super.setGridGeometry(newValue);
         } finally {
@@ -413,40 +489,70 @@ public class CoverageCanvas extends MapCanvasAWT {
     }
 
     /**
-     * Invoked when a new coverage has been specified or when the slice extent 
changed.
-     * This method fetches the image (which may imply data loading) in a 
background thread.
+     * Invoked when a new value has been set on {@link #resourceProperty} or 
{@link #coverageProperty}.
+     * This method fetches information such as the grid geometry and sample 
dimensions in a background thread.
+     * Those information will be used for initializing "objective CRS" and 
"objective to display" to new values.
+     * Rendering will happen in another background computation.
+     *
+     * @param  resource  the new resource, or {@code null} if none.
+     * @param  coverage  the new coverage, or {@code null} if none.
+     * @param  toClear   the property which is an alternative to the property 
that has been set.
      */
-    private void onImageSpecified() {
-        final GridCoverage coverage = getCoverage();
-        if (coverage == null) {
+    private void onPropertySpecified(final GridCoverageResource resource, 
final GridCoverage coverage,
+                                     final ObjectProperty<?> toClear)
+    {
+        if (isCoverageAdjusting) {
+            return;
+        }
+        if (toClear != null) try {
+            isCoverageAdjusting = true;
+            toClear.set(null);
+        } finally {
+            isCoverageAdjusting = false;
+        }
+        if (resource == null && coverage == null) {
             runAfterRendering(this::clear);
         } else {
-            final GridExtent sliceExtent = getSliceExtent();
-            BackgroundThreads.execute(new Task<RenderedImage>() {
-                /**
-                 * The coverage geometry reduced to two dimensions and with a 
translation taking in account
-                 * the {@code sliceExtent}. That value will be stored in 
{@link CoverageCanvas#dataGeometry}.
-                 */
-                private GridGeometry imageGeometry;
+            BackgroundThreads.execute(new Task<Envelope>() {
+                /** The coverage geometry reduced to two dimensions. */
+                private GridGeometry domain;
 
-                /**
-                 * Invoked in a background thread for fetching the image and 
computing its geometry. The image
-                 * geometry should be provided by {@value 
PlanarImage#GRID_GEOMETRY_KEY} property. But if that
-                 * property is not provided, {@link ImageRenderer} is used as 
a fallback for computing it.
-                 */
-                @Override protected RenderedImage call() throws 
FactoryException {
-                    final Long id = LogHandler.loadingStart(originator);
+                /** Information about all bands. */
+                private List<SampleDimension> ranges;
+
+                /** Fetch coverage domain, range and geospatial envelope. */
+                @Override protected Envelope call() throws Exception {
+                    final Long id = LogHandler.loadingStart(resource);
                     try {
-                        final RenderedImage image = 
coverage.render(sliceExtent);
-                        final Object value = 
image.getProperty(PlanarImage.GRID_GEOMETRY_KEY);
-                        imageGeometry = (value instanceof GridGeometry) ? 
(GridGeometry) value
-                                      : new ImageRenderer(coverage, 
sliceExtent).getImageGeometry(BIDIMENSIONAL);
-                        return image;
+                        if (coverage != null) {
+                            domain = coverage.getGridGeometry();
+                            ranges = coverage.getSampleDimensions();
+                        } else {
+                            domain = resource.getGridGeometry();
+                            ranges = resource.getSampleDimensions();
+                        }
+                        domain = MultiResolutionImageLoader.slice(domain);
+                        if (domain != null) {
+                            if (domain.isDefined(GridGeometry.ENVELOPE)) {
+                                return domain.getEnvelope();
+                            }
+                            if (domain.isDefined(GridGeometry.EXTENT)) {
+                                final GeneralEnvelope ge = 
domain.getExtent().toEnvelope(MathTransforms.identity(BIDIMENSIONAL));
+                                
ge.setCoordinateReferenceSystem(CommonCRS.Engineering.DISPLAY.crs());
+                                return ge;
+                            }
+                        }
+                        return null;
                     } finally {
                         LogHandler.loadingStop(id);
                     }
                 }
 
+                /** Invoked in JavaFX thread for setting the grid geometry we 
just fetched. */
+                @Override protected void succeeded() {
+                    runAfterRendering(() -> setNewSource(getValue(), domain, 
ranges));
+                }
+
                 /**
                  * Invoked when an error occurred while loading an image or 
processing it.
                  * This method popups the dialog box immediately because it is 
considered
@@ -457,49 +563,32 @@ public class CoverageCanvas extends MapCanvasAWT {
                     errorOccurred(ex);
                     ExceptionReporter.canNotUseResource(fixedPane, ex);
                 }
-
-                /**
-                 * Invoked in JavaFX thread for setting the image to the 
instance we just fetched.
-                 */
-                @Override protected void succeeded() {
-                    final RenderedImage image = getValue();
-                    final GridGeometry    gg  = imageGeometry;
-                    List<SampleDimension> sd  = coverage.getSampleDimensions();
-                    runAfterRendering(() -> setRawImage(image, gg, sd));
-                }
             });
         }
     }
 
     /**
-     * Invoked when a new image has been successfully loaded. The given image 
must be the "raw" image,
-     * 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.
+     * Invoked when a new resource or coverage has been specified.
+     * The call to this method is followed by a repaint event,
+     * which will cause the image to be loaded and resampled in a background 
thread.
      *
      * <p>All arguments can be {@code null} for clearing the canvas.
      * This method is invoked in JavaFX thread.</p>
+     *
+     * @param  bounds  geospatial bounds, or {@code null} if none or unknown.
+     * @param  domain  the two-dimensional grid geometry, or {@code null} if 
there is no data.
+     * @param  ranges  descriptions of bands, or {@code null} if there is no 
data.
      */
-    private void setRawImage(final RenderedImage image, final GridGeometry 
domain, final List<SampleDimension> ranges) {
+    private void setNewSource(final Envelope bounds, final GridGeometry 
domain, final List<SampleDimension> ranges) {
         if (TRACE) {
-            trace("setRawImage(…): the new source of data is:%n\t%s", image);
+            trace("setNewSource(…): the new domain of data is:%n\t%s", domain);
         }
         clearError();
         clearIsolines();
         resampledImage = null;
         derivedImages.clear();
-        data.setImage(image, domain, ranges);
-        Envelope bounds = null;
-        if (domain != null) {
-            if (domain.isDefined(GridGeometry.ENVELOPE)) {
-                bounds = domain.getEnvelope();
-            } else if (domain.isDefined(GridGeometry.EXTENT)) try {
-                final GeneralEnvelope ge = 
domain.getExtent().toEnvelope(MathTransforms.identity(BIDIMENSIONAL));
-                
ge.setCoordinateReferenceSystem(CommonCRS.Engineering.DISPLAY.crs());
-                bounds = ge;
-            } catch (TransformException e) {
-                unexpectedException(e);         // Should never happen.
-            }
-        }
+        data.clear();
+        data.setCoverageSpace(domain, ranges);
         setObjectiveBounds(bounds);
         requestRepaint();                       // Cause `Worker` class to be 
executed.
     }
@@ -540,10 +629,11 @@ public class CoverageCanvas extends MapCanvasAWT {
 
     /**
      * Resample and paint image in the canvas. This class performs some or all 
of the following tasks, in order.
-     * It is possible to skip the first tasks if they are already done, but 
after the work started at some point
-     * all remaining points are executed:
+     * It is possible to skip the two first tasks if they were already done, 
but after the work started at some
+     * point all remaining points are executed:
      *
      * <ol>
+     *   <li>Read a new coverage if zoom as changed more than some threshold 
value.</li>
      *   <li>Compute statistics on sample values (if needed).</li>
      *   <li>Stretch the color ramp (if requested).</li>
      *   <li>Resample the image and convert to integer values.</li>
@@ -552,6 +642,21 @@ public class CoverageCanvas extends MapCanvasAWT {
      */
     private static final class Worker extends Renderer {
         /**
+         * The resource from which the data has been read, or {@code null} if 
unknown.
+         * This is used for loading a coverage if none were explicitly 
specified,
+         * and for determining a target window for logging records.
+         */
+        private final GridCoverageResource resource;
+
+        /**
+         * If a coverage has been loaded from the {@linkplain #resource}, the 
coverage.
+         * Otherwise {@code null} for meaning "no change".
+         */
+        private GridCoverage coverage;
+
+        @Deprecated private final GridExtent sliceExtent;
+
+        /**
          * Value of {@link CoverageCanvas#data} at the time this worker has 
been initialized.
          */
         private final RenderingData data;
@@ -622,12 +727,6 @@ public class CoverageCanvas extends MapCanvasAWT {
         private AffineTransform resampledToDisplay;
 
         /**
-         * The resource from which the data has been read, or {@code null} if 
unknown.
-         * This is used only for determining a target window for logging 
records.
-         */
-        private final Reference<Resource> originator;
-
-        /**
          * Snapshot of information required for rendering isolines, or {@code 
null} if none.
          */
         private IsolineRenderer.Snapshot[] isolines;
@@ -636,8 +735,10 @@ public class CoverageCanvas extends MapCanvasAWT {
          * Creates a new renderer. Shall be invoked in JavaFX thread.
          */
         Worker(final CoverageCanvas canvas) {
-            originator         = canvas.originator;
+            resource           = canvas.getResource();
+            coverage           = canvas.getCoverage();
             data               = canvas.data.clone();
+            sliceExtent        = canvas.getSliceExtent();
             objectiveCRS       = canvas.getObjectiveCRS();
             objectiveToDisplay = canvas.getObjectiveToDisplay();
             displayBounds      = canvas.getDisplayBounds();
@@ -648,10 +749,12 @@ public class CoverageCanvas extends MapCanvasAWT {
             }
             final Insets margin = canvas.imageMargin.get();
             if (margin != null) {
-                displayBounds.x      -= margin.getLeft();
-                displayBounds.width  += margin.getLeft() + margin.getRight();
-                displayBounds.y      -= margin.getTop();
-                displayBounds.height += margin.getTop() + margin.getBottom();
+                final double top  = margin.getTop();
+                final double left = margin.getLeft();
+                displayBounds.x      -= left;
+                displayBounds.width  += left + margin.getRight();
+                displayBounds.y      -= top;
+                displayBounds.height += top  + margin.getBottom();
             }
             if (canvas.isolines != null) try {
                 isolines = canvas.isolines.prepare();
@@ -685,9 +788,20 @@ public class CoverageCanvas extends MapCanvasAWT {
         @Override
         @SuppressWarnings("PointlessBitwiseExpression")
         protected void render() throws Exception {
-            final Long id = LogHandler.loadingStart(originator);
+            final Long id = LogHandler.loadingStart(resource);
             try {
                 /*
+                 * Update the transform from data CRS to objective CRS if that 
transform needs to be computed
+                 * (otherwise do nothing). After the objective CRS is set, we 
can compute the pyramid level.
+                 * It will determine if data needs to be loaded again.
+                 */
+                data.setObjectiveCRS(objectiveCRS);
+                if (resource != null) {
+                    data.coverageLoader = 
MultiResolutionImageLoader.getInstance(resource, data.coverageLoader);
+                    coverage = data.ensureCoverageLoaded(objectiveToDisplay, 
objectivePOI);
+                }
+                data.ensureImageLoaded(coverage, sliceExtent);
+                /*
                  * Find whether resampling to apply is different than the 
resampling used last time that the image
                  * has been rendered, ignoring translations. Translations do 
not require new resampling operations
                  * because we can manage translations by changing 
`RenderedImage` coordinates.
@@ -718,7 +832,7 @@ public class CoverageCanvas extends MapCanvasAWT {
                             trace("render(): recolor by application of %s.", 
data.selectedDerivative);
                         }
                     }
-                    resampledImage = data.resampleAndConvert(recoloredImage, 
objectiveCRS, objectiveToDisplay, objectivePOI);
+                    resampledImage = data.resampleAndConvert(recoloredImage, 
objectiveToDisplay, objectivePOI);
                     resampledToDisplay = data.getTransform(objectiveToDisplay);
                     if (TRACE) {
                         trace("render(): resampling result:%n\t%s", 
resampledImage);
@@ -836,6 +950,18 @@ public class CoverageCanvas extends MapCanvasAWT {
             errorReport = null;
             errorOccurred(report.getThrown());
         }
+        /*
+         * If the coverage changed, notify user. Note that a null coverage 
means "no change".
+         */
+        if (!isCoverageAdjusting) {
+            final GridCoverage coverage = worker.coverage;
+            if (coverage != null) try {
+                isCoverageAdjusting = true;
+                setCoverage(coverage);
+            } finally {
+                isCoverageAdjusting = false;
+            }
+        }
     }
 
     /**
@@ -915,7 +1041,7 @@ public class CoverageCanvas extends MapCanvasAWT {
         if (TRACE) {
             trace("clear()");
         }
-        setRawImage(null, null, null);
+        setNewSource(null, null, null);
         super.clear();
     }
 
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 1f5d9ca..49cff1b 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
@@ -17,7 +17,6 @@
 package org.apache.sis.gui.coverage;
 
 import java.util.Locale;
-import java.lang.ref.WeakReference;
 import javafx.scene.control.Accordion;
 import javafx.scene.control.Control;
 import javafx.scene.control.TitledPane;
@@ -26,19 +25,16 @@ import javafx.scene.layout.GridPane;
 import javafx.scene.layout.Region;
 import javafx.scene.layout.VBox;
 import javafx.collections.ObservableList;
-import javafx.concurrent.Task;
 import javafx.scene.control.Label;
 import javafx.scene.control.TableView;
 import javafx.scene.control.Tooltip;
 import javafx.scene.paint.Color;
 import org.apache.sis.coverage.Category;
 import org.apache.sis.coverage.grid.GridCoverage;
-import org.apache.sis.storage.DataStoreException;
-import org.apache.sis.storage.Resource;
+import org.apache.sis.storage.GridCoverageResource;
 import org.apache.sis.gui.map.MapMenu;
 import org.apache.sis.gui.map.StatusBar;
 import org.apache.sis.internal.gui.control.ValueColorMapper;
-import org.apache.sis.internal.gui.BackgroundThreads;
 import org.apache.sis.internal.gui.Styles;
 import org.apache.sis.internal.gui.Resources;
 import org.apache.sis.util.resources.Vocabulary;
@@ -159,76 +155,54 @@ final class CoverageControls extends ViewAndControls {
         final TitledPane p4 = new 
TitledPane(vocabulary.getString(Vocabulary.Keys.Properties), null);
         controls = new Accordion(p1, p2, p3, p4);
         controls.setExpandedPane(p1);
-        view.coverageProperty.addListener((p,o,n) -> coverageChanged(null, n));
+        /*
+         * Set listeners: changes on `CoverageCanvas` properties are 
propagated to the corresponding
+         * `CoverageExplorer` properties. This constructor does not install 
listeners in the opposite
+         * direction; instead `CoverageExplorer` will invoke 
`load(ImageRequest)`.
+         */
+        view.resourceProperty.addListener((p,o,n) -> onPropertySet(n, null));
+        view.coverageProperty.addListener((p,o,n) -> onPropertySet(null, n));
         p4.expandedProperty().addListener(new PropertyPaneCreator(view, p4));
     }
 
     /**
-     * Invoked in JavaFX thread after {@link 
CoverageCanvas#setCoverage(GridCoverage)}.
-     * This method updates the GUI with new information available.
+     * Invoked in JavaFX thread after {@link CoverageCanvas} resource or 
coverage property value changed.
+     * This method updates the controls GUI with new information available and 
update the corresponding
+     * {@link CoverageExplorer} properties.
      *
-     * @param  source  the new source of coverage, or {@code null} if none.
-     * @param  data    the new coverage, or {@code null} if none.
+     * @param  resource  the new source of coverage, or {@code null} if none.
+     * @param  coverage  the new coverage, or {@code null} if none.
      */
-    private void coverageChanged(final Resource source, final GridCoverage 
data) {
+    private void onPropertySet(final GridCoverageResource resource, final 
GridCoverage coverage) {
         final ObservableList<Category> items = categoryTable.getItems();
-        if (data == null) {
+        if (coverage == null) {
             items.clear();
         } else {
             final int visibleBand = 0;          // TODO: provide a selector 
for the band to show.
-            
items.setAll(data.getSampleDimensions().get(visibleBand).getCategories());
+            
items.setAll(coverage.getSampleDimensions().get(visibleBand).getCategories());
         }
-        owner.coverageChanged(source, data);
+        owner.notifyDataChanged(resource, coverage);
     }
 
     /**
      * Sets the view content to the given coverage.
-     * This method starts a background thread.
+     * This method is invoked when a new source of data (either a resource or 
a coverage) is specified,
+     * or when a previously hidden view is made visible. This implementation 
starts a background thread.
      *
-     * @param  request  the coverage to set, or {@code null} for clearing the 
view.
+     * @param  request  the resource or coverage to set, or {@code null} for 
clearing the view.
      */
     @Override
     final void load(final ImageRequest request) {
-        if (request == null) {
-            view.setOriginator(null);
-            view.setCoverage(null);
+        final GridCoverageResource resource;
+        final GridCoverage coverage;
+        if (request != null) {
+            resource = request.resource;
+            coverage = request.getCoverage().orElse(null);
         } else {
-            view.setOriginator(request.resource != null ? new 
WeakReference<>(request.resource) : null);
-            request.getCoverage().ifPresentOrElse(view::setCoverage,
-                    () -> BackgroundThreads.execute(new Loader(request)));
-        }
-    }
-
-    /**
-     * A task for loading {@link GridCoverage} from a resource in a background 
thread.
-     *
-     * @todo Remove this loader, replace by a {@code resourceProperty} in 
{@link CoverageCanvas}.
-     */
-    private final class Loader extends Task<GridCoverage> {
-        /** The coverage resource together with optional parameters for 
reading only a subset. */
-        private final ImageRequest request;
-
-        /** Creates a new task for loading a coverage from the specified 
resource. */
-        Loader(final ImageRequest request) {
-            this.request = request;
-        }
-
-        /** Invoked in background thread for loading the coverage. */
-        @Override protected GridCoverage call() throws DataStoreException {
-            request.load(this, true, false);
-            return request.getCoverage().orElse(null);
-        }
-
-        /** Invoked in JavaFX thread after successful loading. */
-        @Override protected void succeeded() {
-            view.setCoverage(getValue());
-        }
-
-        /** Invoked in JavaFX thread on failure. */
-        @Override protected void failed() {
-            view.setCoverage(null);
-            request.reportError(imageAndStatus, getException());
+            resource = null;
+            coverage = null;
         }
+        view.setCoverage(resource, coverage);
     }
 
     /**
diff --git 
a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageExplorer.java
 
b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageExplorer.java
index 698945a..6ddd16a 100644
--- 
a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageExplorer.java
+++ 
b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageExplorer.java
@@ -29,6 +29,7 @@ import javafx.scene.layout.Region;
 import javafx.event.ActionEvent;
 import javafx.beans.property.ObjectProperty;
 import javafx.beans.property.SimpleObjectProperty;
+import org.apache.sis.storage.GridCoverageResource;
 import org.apache.sis.coverage.grid.GridCoverage;
 import org.apache.sis.coverage.grid.GridGeometry;
 import org.apache.sis.internal.gui.Resources;
@@ -38,7 +39,6 @@ import org.apache.sis.util.ArgumentChecks;
 import org.apache.sis.gui.referencing.RecentReferenceSystems;
 import org.apache.sis.gui.map.StatusBar;
 import org.apache.sis.gui.Widget;
-import org.apache.sis.storage.Resource;
 
 
 /**
@@ -118,54 +118,93 @@ public class CoverageExplorer extends Widget {
     }
 
     /**
-     * The coverage shown in this explorer. Note that setting this property to 
a non-null value may not
-     * modify the view content immediately. Instead, a background process will 
request the tiles.
+     * The type of view (image or tabular data) shown in this explorer.
+     *
+     * @see #getViewType()
+     * @see #setViewType(View)
+     */
+    public final ObjectProperty<View> viewTypeProperty;
+
+    /**
+     * The source of coverage data shown in this explorer. If this property 
value is non-null,
+     * then {@link #coverageProperty} value will change at any time 
(potentially many times)
+     * depending on the zoom level or other user interaction. Conversely if a 
value is set
+     * explicitly on {@link #coverageProperty}, then this {@code 
resourceProperty} is cleared.
+     *
+     * <h4>Relationship with view properties</h4>
+     * This property is "weakly bound" to {@link 
CoverageCanvas#resourceProperty}:
+     * the two properties generally have the same value but are not 
necessarily updated in same time.
+     * After a value is set on one property, the other property may be updated 
only after some background process
+     * (e.g. loading) finished. If a view is not the {@linkplain 
#getViewType() currently visible view},
+     * its property may be updated only when the view become visible.
+     *
+     * @see #getResource()
+     * @see #setResource(GridCoverageResource)
+     * @see CoverageCanvas#resourceProperty
+     *
+     * @since 1.2
+     */
+    public final ObjectProperty<GridCoverageResource> resourceProperty;
+
+    /**
+     * The data shown in this canvas. This property value may be set 
implicitly or explicitly:
+     * <ul>
+     *   <li>If the {@link #resourceProperty} value is non-null, then the 
value will change
+     *       automatically at any time (potentially many times) depending on 
user interaction.</li>
+     *   <li>Conversely if an explicit value is set on this property,
+     *       then the {@link #resourceProperty} is cleared.</li>
+     * </ul>
+     *
+     * Note that a change in this property value may not modify the canvas 
content immediately.
+     * Instead, a background process will request the tiles and update the 
canvas content later,
+     * when data are ready.
      *
      * <p>Current implementation is restricted to {@link GridCoverage} 
instances, but a future
      * implementation may generalize to {@link org.opengis.coverage.Coverage} 
instances.</p>
      *
+     * <h4>Relationship with view properties</h4>
+     * This property is "weakly bound" to {@link 
CoverageCanvas#coverageProperty}:
+     * the two properties generally have the same value but are not 
necessarily updated in same time.
+     * After a value is set on one property, the other property may be updated 
only after some background process
+     * (e.g. loading) finished. If a view is not the {@linkplain 
#getViewType() currently visible view},
+     * its property may be updated only when the view become visible.
+     *
      * @see #getCoverage()
      * @see #setCoverage(GridCoverage)
      * @see #setCoverage(ImageRequest)
+     * @see CoverageCanvas#coverageProperty
      */
     public final ObjectProperty<GridCoverage> coverageProperty;
 
     /**
-     * The type of view (image or tabular data) shown in this explorer.
-     *
-     * @see #getViewType()
-     * @see #setViewType(View)
-     */
-    public final ObjectProperty<View> viewTypeProperty;
-
-    /**
      * Whether the {@link #coverageProperty} is in process of being set,
      * in which case some listeners should not react.
      */
     private boolean isCoverageAdjusting;
 
     /**
-     * The control that put everything together, created when first requested.
-     * The type of control may change in any future SIS version.
-     *
-     * @see #getView()
-     * @see #onViewTypeSpecified(View)
+     * Handles the {@link javafx.scene.control.ChoiceBox} and menu items for 
selecting a CRS.
      */
-    private SplitPane content;
+    final RecentReferenceSystems referenceSystems;
 
     /**
      * The different views we can provide on {@link #coverageProperty}, 
together with associated controls.
      * Values in this map are initially null and created when first needed.
      * Concrete classes are {@link GridControls} and {@link CoverageControls}.
      *
-     * @see #getControl(View)
+     * @see #getDataView(View)
+     * @see #getControls(View)
      */
     private final EnumMap<View,ViewAndControls> views;
 
     /**
-     * Handles the {@link javafx.scene.control.ChoiceBox} and menu items for 
selecting a CRS.
+     * The control that put everything together, created when first requested.
+     * The type of control may change in any future SIS version.
+     *
+     * @see #getView()
+     * @see #onViewTypeSet(View)
      */
-    final RecentReferenceSystems referenceSystems;
+    private SplitPane content;
 
     /**
      * Creates an initially empty explorer with default view type.
@@ -192,24 +231,22 @@ public class CoverageExplorer extends Widget {
      */
     public CoverageExplorer(final View type) {
         ArgumentChecks.ensureNonNull("type", type);
-        coverageProperty = new SimpleObjectProperty<>(this, "coverage");
+        views            = new EnumMap<>(View.class);
         viewTypeProperty = new NonNullObjectProperty<>(this, "viewType", type);
-        viewTypeProperty.addListener((p,o,n) -> onViewTypeSpecified(n));
-        coverageProperty.addListener((p,o,n) -> onCoverageSpecified(n));
+        resourceProperty = new SimpleObjectProperty<> (this, "resource");
+        coverageProperty = new SimpleObjectProperty<> (this, "coverage");
         referenceSystems = new RecentReferenceSystems();
         referenceSystems.addUserPreferences();
         referenceSystems.addAlternatives("EPSG:4326", "EPSG:3395");         // 
WGS 84 / World Mercator
-        /*
-         * The coverage property may be shown in various ways (tabular data, 
image).
-         * Each visualization way is a value in the `views` map.
-         * Elements will be created when first needed.
-         */
-        views = new EnumMap<>(View.class);
+        viewTypeProperty.addListener((p,o,n) -> onViewTypeSet(n));
+        resourceProperty.addListener((p,o,n) -> onPropertySet(n, null, 
coverageProperty));
+        coverageProperty.addListener((p,o,n) -> onPropertySet(null, n, 
resourceProperty));
     }
 
     /**
      * Returns the view-control pair for the given view type.
      * The view-control pair is created when first needed.
+     * Invoking this method may cause data to be loaded in a background thread.
      *
      * @param  type  type of view to obtain.
      * @param  load  whether to force loading of data in the new type.
@@ -233,9 +270,10 @@ public class CoverageExplorer extends Widget {
          * and became selected (visible).
          */
         if (load) {
+            final GridCoverageResource resource = getResource();
             final GridCoverage coverage = getCoverage();
-            if (coverage != null) {
-                c.load(new ImageRequest(coverage, null));
+            if (resource != null || coverage != null) {
+                c.load(new ImageRequest(resource, coverage));
             }
         }
         return c;
@@ -295,7 +333,7 @@ public class CoverageExplorer extends Widget {
      * in any future version.
      *
      * @param  type  whether to obtain a {@link GridView} or {@link 
CoverageCanvas}.
-     * @return the requested view for the {@link #coverageProperty}.
+     * @return the requested view for the value of {@link #resourceProperty} 
or {@link #coverageProperty}.
      */
     public final Region getDataView(final View type) {
         assert Platform.isFxApplicationThread();
@@ -319,6 +357,7 @@ public class CoverageExplorer extends Widget {
 
     /**
      * The action to execute when the user selects a view.
+     * This is used by the toolbar buttons in the widget created by {@link 
#getView()}.
      */
     private final class Selector extends ToolbarButton {
         /** The view to select when the button is pressed. */
@@ -342,8 +381,83 @@ public class CoverageExplorer extends Widget {
     }
 
     /**
+     * Returns the type of view (image or tabular data) shown in this explorer.
+     * The default value is {@link View#TABLE}.
+     *
+     * @return the way to show coverages in this explorer.
+     *
+     * @see #viewTypeProperty
+     */
+    public final View getViewType() {
+        return viewTypeProperty.get();
+    }
+
+    /**
+     * Sets the type of view to show in this explorer.
+     *
+     * @param  type  the new way to show coverages in this explorer.
+     *
+     * @see #viewTypeProperty
+     */
+    public final void setViewType(final View type) {
+        viewTypeProperty.set(type);
+    }
+
+    /**
+     * Invoked when a new view type has been specified.
+     *
+     * @param  type  the new way to show coverages in this explorer.
+     */
+    private void onViewTypeSet(final View type) {
+        final ViewAndControls c = getViewAndControls(type, true);
+        if (content != null) {
+            content.getItems().setAll(c.controls(), c.view());
+            final Toggle selector = c.selector;
+            if (selector != null) {
+                selector.setSelected(true);
+            }
+        }
+    }
+
+    /**
+     * Returns the source of coverages for this explorer.
+     * This method, like all other methods in this class, shall be invoked 
from the JavaFX thread.
+     *
+     * @return the source of coverages shown in this explorer, or {@code null} 
if none.
+     *
+     * @see #resourceProperty
+     *
+     * @since 1.2
+     */
+    public final GridCoverageResource getResource() {
+        return resourceProperty.get();
+    }
+
+    /**
+     * Sets the source of coverages shown in this explorer.
+     * This method shall be invoked from JavaFX thread and returns immediately.
+     * The new data are loaded in a background thread and the {@link 
#coverageProperty}
+     * value will be updated after an undetermined amount of time.
+     *
+     * @param  resource  the source of data to show in this explorer, or 
{@code null} if none.
+     *
+     * @see #resourceProperty
+     *
+     * @since 1.2
+     */
+    public final void setResource(final GridCoverageResource resource) {
+        resourceProperty.set(resource);
+        /*
+         * `onCoverageSpecified(…)` is indirectly invoked,
+         * which in turn invokes `setCoverage(ImageRequest)`.
+         */
+    }
+
+    /**
      * Returns the source of sample values for this explorer.
      * This method, like all other methods in this class, shall be invoked 
from the JavaFX thread.
+     * Note that this value may change at any time (depending on user 
interaction)
+     * if the {@link #resourceProperty} has a non-null value.
      *
      * @return the coverage shown in this explorer, or {@code null} if none.
      *
@@ -356,11 +470,13 @@ public class CoverageExplorer extends Widget {
     }
 
     /**
-     * Sets the coverage to show in this view.
+     * Sets the coverage to show in this explorer.
      * This method shall be invoked from JavaFX thread and returns immediately.
      * The new data are loaded in a background thread and will appear after an
      * undetermined amount of time.
      *
+     * <p>Invoking this method sets the {@link #resourceProperty} value to 
{@code null}.</p>
+     *
      * @param  coverage  the data to show in this explorer, or {@code null} if 
none.
      *
      * @see #coverageProperty
@@ -369,7 +485,10 @@ public class CoverageExplorer extends Widget {
      */
     public final void setCoverage(final GridCoverage coverage) {
         coverageProperty.set(coverage);
-        // `onCoverageSpecified(…)` is indirectly invoked.
+        /*
+         * `onCoverageSpecified(…)` is indirectly invoked,
+         * which in turn invokes `setCoverage(ImageRequest)`.
+         */
     }
 
     /**
@@ -389,84 +508,68 @@ public class CoverageExplorer extends Widget {
             c.load(c == current ? source : null);
         }
         if (current == null) {
-            coverageChanged(null, null);
+            notifyDataChanged(null, null);
         }
-        // Else `coverageChanged(…)` will be invoked later after background 
thread finishes its work.
+        // Else `notifyDataChanged(…)` will be invoked later after background 
thread finishes its work.
     }
 
     /**
-     * Invoked when a new coverage has been set on the {@link 
#coverageProperty}.
-     * This method notifies the GUI controls about the change then starts 
loading
-     * data in a background thread.
+     * Invoked when a new value has been set on {@link #resourceProperty} or 
{@link #coverageProperty}.
+     * This method sets the resource or coverage on the currently visible 
view, which in turn will load
+     * data in its own background thread.
      *
-     * @param  coverage  the new coverage.
+     * @param  resource  the new resource, or {@code null} if none.
+     * @param  coverage  the new coverage, or {@code null} if none.
+     * @param  toClear   the property which is an alternative to the property 
that has been set.
      */
-    private void onCoverageSpecified(final GridCoverage coverage) {
+    private void onPropertySet(final GridCoverageResource resource, final 
GridCoverage coverage,
+                               final ObjectProperty<?> toClear)
+    {
         if (!isCoverageAdjusting) {
-            setCoverage((coverage != null) ? new ImageRequest(coverage, null) 
: null);
+            isCoverageAdjusting = true;
+            try {
+                toClear.set(null);
+            } finally {
+                isCoverageAdjusting = false;
+            }
+            // Indirectly start a background thread which will invoke 
`notifyDataChanged(…)` later.
+            setCoverage((resource != null || coverage != null) ? new 
ImageRequest(resource, coverage) : null);
         }
     }
 
     /**
-     * Invoked in JavaFX thread after {@link #setCoverage(ImageRequest)} 
completion for notifying controls
-     * about the coverage change. Controls should update the GUI with new 
information available,
-     * in particular the coordinate reference system and the list of sample 
dimensions.
+     * Invoked in JavaFX thread after the current view finished to load the 
new coverage.
+     * It is the responsibility of all {@link ViewAndControls} subclasses to 
listen to change events
+     * emitted by their views ({@link GridView} or {@link CoverageCanvas}) and 
to invoke this method.
+     * This method will then update the properties of this {@code 
CoverageExplorer} for the new data.
+     *
+     * <p>Note that view data may have been changed either by user changing 
directly a {@link GridView}
+     * or {@link CoverageCanvas} property, or indirectly by user changing 
{@link #resourceProperty} or
+     * {@link #coverageProperty}. In the later case, the {@code resource} and 
{@code coverage} arguments
+     * given to this method may be the value that the properties already 
have.</p>
      *
-     * @param  source  the new source of coverage, or {@code null} if none.
-     * @param  data    the new coverage, or {@code null} if none.
+     * @param  resource  the new source of coverage, or {@code null} if none.
+     * @param  coverage  the new coverage, or {@code null} if none.
      */
-    final void coverageChanged(final Resource source, final GridCoverage data) 
{
-        if (data != null) {
-            final GridGeometry gg = data.getGridGeometry();
+    final void notifyDataChanged(final GridCoverageResource resource, final 
GridCoverage coverage) {
+        if (coverage != null) {
+            final GridGeometry gg = coverage.getGridGeometry();
             
referenceSystems.areaOfInterest.set(gg.isDefined(GridGeometry.ENVELOPE) ? 
gg.getEnvelope() : null);
             if (gg.isDefined(GridGeometry.CRS)) {
                 referenceSystems.setPreferred(true, 
gg.getCoordinateReferenceSystem());
             }
         }
+        /*
+         * Following calls will NOT forward the new values to the views 
because this `notifyDataChanged(…)`
+         * method is invoked as a consequence of view properties being 
updated. Those views should already
+         * have the new property values at this moment.
+         */
         isCoverageAdjusting = true;
         try {
-            setCoverage(data);
+            setResource(resource);
+            setCoverage(coverage);
         } finally {
             isCoverageAdjusting = false;
         }
     }
-
-    /**
-     * Returns the type of view (image or tabular data) shown in this explorer.
-     * The default value is {@link View#TABLE}.
-     *
-     * @return the way to show coverages in this explorer.
-     *
-     * @see #viewTypeProperty
-     */
-    public final View getViewType() {
-        return viewTypeProperty.get();
-    }
-
-    /**
-     * Sets the type of view to show in this explorer.
-     *
-     * @param  type  the new way to show coverages in this explorer.
-     *
-     * @see #viewTypeProperty
-     */
-    public final void setViewType(final View type) {
-        viewTypeProperty.set(type);
-    }
-
-    /**
-     * Invoked when a new view type has been specified.
-     *
-     * @param  type  the new way to show coverages in this explorer.
-     */
-    private void onViewTypeSpecified(final View type) {
-        final ViewAndControls c = getViewAndControls(type, true);
-        if (content != null) {
-            content.getItems().setAll(c.controls(), c.view());
-            final Toggle selector = c.selector;
-            if (selector != null) {
-                selector.setSelected(true);
-            }
-        }
-    }
 }
diff --git 
a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridControls.java
 
b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridControls.java
index d0d84b0..b986725 100644
--- 
a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridControls.java
+++ 
b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridControls.java
@@ -27,9 +27,9 @@ import javafx.scene.control.TitledPane;
 import javafx.scene.layout.GridPane;
 import javafx.scene.layout.Region;
 import javafx.scene.layout.VBox;
-import org.apache.sis.storage.Resource;
-import org.apache.sis.coverage.SampleDimension;
+import org.apache.sis.storage.GridCoverageResource;
 import org.apache.sis.coverage.grid.GridCoverage;
+import org.apache.sis.coverage.SampleDimension;
 import org.apache.sis.util.resources.Vocabulary;
 import org.apache.sis.internal.gui.Styles;
 
@@ -125,7 +125,7 @@ final class GridControls extends ViewAndControls {
      * @param  source  the new source of coverage, or {@code null} if none.
      * @param  data    the new coverage, or {@code null} if none.
      */
-    final void coverageChanged(final Resource source, final GridCoverage data) 
{
+    final void notifyDataChanged(final GridCoverageResource source, final 
GridCoverage data) {
         final ObservableList<SampleDimension> items = 
sampleDimensions.getItems();
         if (data != null) {
             items.setAll(data.getSampleDimensions());
@@ -133,12 +133,13 @@ final class GridControls extends ViewAndControls {
         } else {
             items.clear();
         }
-        owner.coverageChanged(source, data);
+        owner.notifyDataChanged(source, data);
     }
 
     /**
      * Sets the view content to the given image.
-     * This method starts a background thread.
+     * This method is invoked when a new source of data (either a resource or 
a coverage) is specified,
+     * or when a previously hidden view is made visible. This implementation 
starts a background thread.
      *
      * @param  request  the image to set, or {@code null} for clearing the 
view.
      */
diff --git 
a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridView.java
 
b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridView.java
index 33ed675..d0ddb60 100644
--- 
a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridView.java
+++ 
b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridView.java
@@ -211,7 +211,7 @@ public class GridView extends Control {
      * This is used only for notifications; a future version may use a more 
generic listener.
      * We use this specific mechanism because there is no {@code 
coverageProperty} in this class.
      *
-     * @see GridControls#coverageChanged(GridCoverage)
+     * @see GridControls#notifyDataChanged(GridCoverageResource, GridCoverage)
      */
     private final GridControls controls;
 
@@ -322,7 +322,7 @@ public class GridView extends Control {
         if (source == null) {
             setImage((RenderedImage) null);
             if (controls != null) {
-                controls.coverageChanged(null, null);
+                controls.notifyDataChanged(null, null);
             }
         } else {
             cancelLoader();
@@ -342,7 +342,7 @@ public class GridView extends Control {
         setImage(image);
         request.configure(statusBar);
         if (controls != null) {
-            controls.coverageChanged(request.resource, 
request.getCoverage().orElse(null));
+            controls.notifyDataChanged(request.resource, 
request.getCoverage().orElse(null));
         }
     }
 
@@ -375,7 +375,7 @@ public class GridView extends Control {
          */
         @Override
         protected RenderedImage call() throws DataStoreException {
-            return request.load(this, true, true);
+            return request.load(this);
         }
 
         /**
diff --git 
a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ImageRequest.java
 
b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ImageRequest.java
index df3566d..93b3f3d 100644
--- 
a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ImageRequest.java
+++ 
b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ImageRequest.java
@@ -21,7 +21,6 @@ import java.util.concurrent.FutureTask;
 import java.awt.image.RenderedImage;
 import javafx.scene.Node;
 import org.apache.sis.storage.GridCoverageResource;
-import org.apache.sis.coverage.grid.GridDerivation;
 import org.apache.sis.coverage.grid.GridCoverage;
 import org.apache.sis.coverage.grid.GridGeometry;
 import org.apache.sis.coverage.grid.GridExtent;
@@ -50,11 +49,6 @@ import org.apache.sis.storage.event.StoreListeners;
  */
 public class ImageRequest {
     /**
-     * The {@value} value, for identifying code that assume two-dimensional 
objects.
-     */
-    private static final int BIDIMENSIONAL = 2;
-
-    /**
      * The source from where to read the image, specified at construction time.
      * May be {@code null} if {@link #coverage} instance was specified at 
construction time.
      */
@@ -96,12 +90,20 @@ public class ImageRequest {
     private GridExtent sliceExtent;
 
     /**
-     * The relative position of slice in dimensions other than the 2 visible 
dimensions,
-     * as a ratio between 0 and 1. This may become configurable in a future 
version.
+     * Creates a new request with both a resource and a coverage. At least one 
argument shall be non-null.
+     * If both arguments are non-null, then {@code data} must be the result of 
reading the given resource.
+     * In the later case we will not actually read data (because they are 
already read) and this instance
+     * is used only for transferring data e.g. from {@link CoverageExplorer} 
to {@link CoverageCanvas}.
      *
-     * @see GridDerivation#sliceByRatio(double, int[])
+     * <p>This constructor is not in public API because users should supply 
only a resource or a coverage,
+     * not both.</p>
      */
-    private static final double SLICE_RATIO = 0;
+    ImageRequest(final GridCoverageResource source, final GridCoverage data) {
+        resource = source;
+        coverage = data;
+        domain   = null;
+        range    = null;
+    }
 
     /**
      * Creates a new request for loading an image from the specified resource.
@@ -241,29 +243,6 @@ public class ImageRequest {
     }
 
     /**
-     * Computes a two dimensional slice of the given grid geometry.
-     * This method selects the two first dimensions having a size greater than 
1 cell.
-     *
-     * @todo Give control to user over which dimensions are selected.
-     *
-     * @param  domain  the grid geometry in which to choose a two-dimensional 
slice.
-     * @return a builder configured for returning the desired two-dimensional 
slice.
-     */
-    private static GridDerivation slice(final GridGeometry domain) {
-        final GridExtent extent = domain.getExtent();
-        final int dimension = extent.getDimension();
-        final int[] sliceDimensions = new int[BIDIMENSIONAL];
-        int k = 0;
-        for (int i=0; i<dimension; i++) {
-            if (extent.getLow(i) != extent.getHigh(i)) {
-                sliceDimensions[k] = i;
-                if (++k >= BIDIMENSIONAL) break;
-            }
-        }
-        return domain.derive().sliceByRatio(ImageRequest.SLICE_RATIO, 
sliceDimensions);
-    }
-
-    /**
      * Loads the image. If the coverage has more than {@value #BIDIMENSIONAL} 
dimensions,
      * only two of them are taken for the image; for all other dimensions, 
only the values
      * at lowest index will be read.
@@ -275,43 +254,29 @@ public class ImageRequest {
      * This class does not need to be thread-safe because it should be used 
only once in a well-defined life cycle.
      * We nevertheless synchronize as a safety (e.g. user could give the same 
{@code ImageRequest} to two different
      * {@link CoverageExplorer} instances). In such case the {@link 
GridCoverage} will be loaded only once,
-     * but no caching is done for the {@link RenderedImage}. Image caching is 
generally not needed because
-     * {@link CoverageCanvas} does its own image rendering (it invokes this 
method with {@code render = false}).
-     * If two image renderings happen anyway, we rely on {@link 
org.apache.sis.storage.DataStore} caching.</p>
+     * but no caching is done for the {@link RenderedImage} (because usually 
not needed).
      *
-     * @param  task       the task invoking this method (for checking for 
cancellation).
-     * @param  converted  {@code true} for a coverage containing converted 
values,
-     *                    or {@code false} for a coverage containing packed 
values.
-     * @param  render     {@code false} if only coverage reading is desired.
+     * @param  task  the task invoking this method (for checking for 
cancellation).
      * @return the image loaded from the source given at construction time, or 
{@code null}
-     *         if the task has been cancelled or if {@code render} is {@code 
false}.
+     *         if the task has been cancelled.
      * @throws DataStoreException if an error occurred while loading the grid 
coverage.
      */
-    final synchronized RenderedImage load(final FutureTask<?> task, final 
boolean converted, final boolean render)
-            throws DataStoreException
-    {
+    final synchronized RenderedImage load(final FutureTask<?> task) throws 
DataStoreException {
         GridCoverage cv = coverage;
         final Long id = LogHandler.loadingStart(resource);
         try {
             if (cv == null) {
-                GridGeometry gg = domain;
-                if (gg == null) {
-                    gg = resource.getGridGeometry();
-                }
-                if (gg != null && gg.getDimension() > BIDIMENSIONAL) {
-                    gg = slice(gg).build();
-                }
-                cv = resource.read(gg, range);
+                cv = MultiResolutionImageLoader.getInstance(resource, 
null).getOrLoad(domain, range);
             }
-            coverage = cv = cv.forConvertedValues(converted);
-            if (!render || task.isCancelled()) {
+            coverage = cv = cv.forConvertedValues(true);
+            if (task.isCancelled()) {
                 return null;
             }
             GridExtent ex = sliceExtent;
             if (ex == null) {
                 final GridGeometry gg = cv.getGridGeometry();
-                if (gg != null && gg.getDimension() > BIDIMENSIONAL) {      // 
Should never be null but we are paranoiac.
-                    ex = slice(gg).getIntersection();
+                if (gg.getDimension() > 
MultiResolutionImageLoader.BIDIMENSIONAL) {
+                    ex = MultiResolutionImageLoader.slice(gg.derive(), 
gg.getExtent()).getIntersection();
                 }
             }
             return cv.render(ex);
diff --git 
a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/MultiResolutionImageLoader.java
 
b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/MultiResolutionImageLoader.java
new file mode 100644
index 0000000..79b25ae
--- /dev/null
+++ 
b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/MultiResolutionImageLoader.java
@@ -0,0 +1,156 @@
+/*
+ * 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.WeakHashMap;
+import org.apache.sis.storage.GridCoverageResource;
+import org.apache.sis.storage.DataStoreException;
+import org.apache.sis.coverage.grid.GridCoverage;
+import org.apache.sis.coverage.grid.GridDerivation;
+import org.apache.sis.coverage.grid.GridExtent;
+import org.apache.sis.coverage.grid.GridGeometry;
+import org.apache.sis.internal.map.coverage.MultiResolutionCoverageLoader;
+
+
+/**
+ * A helper class for reading two-dimensional slices of {@link GridCoverage}.
+ * The same instance may be shared by {@link GridView} and {@link 
CoverageCanvas}.
+ * {@code GridView} uses only level 0, while {@code CoverageCanvas} use any 
level.
+ *
+ * <h2>Multi-threading</h2>
+ * Instances of this class are immutable (except for the cache) and safe for 
use by multiple threads.
+ * The same instance may be shared by many {@link CoverageCanvas} or {@link 
GridView} objects.
+ *
+ * <h2>Limitations (TODO)</h2>
+ * Current implementation reads only the two first dimensions.
+ * We will need to define an API for specifying which dimensions to use for 
the slices.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.2
+ * @since   1.2
+ * @module
+ */
+final class MultiResolutionImageLoader extends MultiResolutionCoverageLoader {
+    /**
+     * The {@value} value, for identifying code that assume two-dimensional 
objects.
+     */
+    static final int BIDIMENSIONAL = 2;
+
+    /**
+     * The relative position of slice in dimensions other than the 2 visible 
dimensions,
+     * as a ratio between 0 and 1. This may become configurable in a future 
version.
+     *
+     * @see GridDerivation#sliceByRatio(double, int[])
+     */
+    private static final double SLICE_RATIO = 0;
+
+    /**
+     * The loaders created for grid coverage resources.
+     */
+    private static final WeakHashMap<GridCoverageResource, 
MultiResolutionImageLoader> CACHE = new WeakHashMap<>();
+
+    /**
+     * Creates a new loader of grid coverages from the given resource. The 
loader assumes a pyramid with
+     * the declared resolutions of given resource if present, or computes 
default resolutions otherwise.
+     *
+     * @param  resource  the resource from which to read grid coverages.
+     * @throws DataStoreException if an error occurred while querying the 
resource for resolutions.
+     */
+    private MultiResolutionImageLoader(final GridCoverageResource resource) 
throws DataStoreException {
+        super(resource, null, null);
+    }
+
+    /**
+     * Gets or creates a new loader of grid coverages from the given resource. 
The loader assumes a pyramid
+     * with the declared resolutions of given resource if present, or computes 
default resolutions otherwise.
+     * This method returns cached instances if available.
+     *
+     * @param  resource  the resource from which to read grid coverages.
+     * @param  cached    a cached instance that may be reused, or {@code null} 
if none.
+     * @return loader for the specified resource (never {@code null}).
+     * @throws DataStoreException if an error occurred while querying the 
resource for resolutions.
+     */
+    static MultiResolutionImageLoader getInstance(final GridCoverageResource 
resource,
+            MultiResolutionImageLoader cached) throws DataStoreException
+    {
+        if (cached == null || cached.resource != resource) {
+            synchronized (CACHE) {
+                cached = CACHE.get(resource);
+            }
+            if (cached == null) {
+                final MultiResolutionImageLoader loader = new 
MultiResolutionImageLoader(resource);
+                synchronized (CACHE) {
+                    cached = CACHE.putIfAbsent(resource, loader);
+                }
+                if (cached == null) {
+                    cached = loader;
+                }
+            }
+        }
+        return cached;
+    }
+
+    /**
+     * Given a {@code GridGeometry} configured with the resolution to read, 
returns an amended domain
+     * for a two-dimensional slice.
+     *
+     * @param  subgrid  a grid geometry with the desired resolution.
+     * @return the domain to read from the {@linkplain #resource resource}.
+     */
+    @Override
+    protected GridGeometry getReadDomain(final GridGeometry subgrid) {
+        return slice(subgrid);
+    }
+
+    /**
+     * Returns the given grid geometry with grid indices narrowed to a two 
dimensional slice.
+     * If more than two dimensions are eligible, this method selects the 2 
first ones.
+     *
+     * @param  gg  the grid geometry to reduce to two dimensions, or {@code 
null}.
+     * @return the given grid geometry reduced to 2 dimensions, or {@code 
null} if the geometry was null.
+     */
+    static GridGeometry slice(GridGeometry gg) {
+        if (gg != null && gg.getDimension() > BIDIMENSIONAL && 
gg.isDefined(GridGeometry.EXTENT)) {
+            gg = slice(gg.derive(), gg.getExtent()).build();
+        }
+        return gg;
+    }
+
+    /**
+     * Configures the given {@link GridDerivation} for applying a 
two-dimensional slice.
+     * This method selects the two first dimensions having a size greater than 
1 cell.
+     *
+     * @param  subgrid  a grid geometry builder pre-configured with the 
desired resolution.
+     * @param  extent   extent of the coverage to read, in units of the finest 
level.
+     * @return the builder configured for returning the desired 
two-dimensional slice.
+     */
+    static GridDerivation slice(final GridDerivation subgrid, final GridExtent 
extent) {
+        final int dimension = extent.getDimension();
+        if (dimension <= BIDIMENSIONAL) {
+            return subgrid;
+        }
+        final int[] sliceDimensions = new int[BIDIMENSIONAL];
+        int k = 0;
+        for (int i=0; i<dimension; i++) {
+            if (extent.getLow(i) != extent.getHigh(i)) {
+                sliceDimensions[k] = i;
+                if (++k >= BIDIMENSIONAL) break;
+            }
+        }
+        return subgrid.sliceByRatio(SLICE_RATIO, sliceDimensions);
+    }
+}
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 859d3bc..57a54b2 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
@@ -31,20 +31,24 @@ import java.awt.geom.AffineTransform;
 import java.awt.geom.NoninvertibleTransformException;
 import org.opengis.util.FactoryException;
 import org.opengis.geometry.DirectPosition;
+import org.opengis.metadata.extent.GeographicBoundingBox;
 import org.opengis.referencing.datum.PixelInCell;
 import org.opengis.referencing.operation.Matrix;
 import org.opengis.referencing.operation.CoordinateOperation;
 import org.opengis.referencing.operation.MathTransform;
 import org.opengis.referencing.operation.TransformException;
 import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.apache.sis.storage.DataStoreException;
 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.ImageRenderer;
 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;
 import org.apache.sis.geometry.Shapes2D;
+import org.apache.sis.image.PlanarImage;
 import org.apache.sis.image.ErrorHandler;
 import org.apache.sis.image.ImageProcessor;
 import org.apache.sis.internal.coverage.SampleDimensions;
@@ -57,7 +61,7 @@ import org.apache.sis.io.TableAppender;
 import org.apache.sis.math.Statistics;
 import org.apache.sis.measure.Quantities;
 import org.apache.sis.measure.Units;
-import org.apache.sis.metadata.iso.extent.DefaultGeographicBoundingBox;
+import org.apache.sis.metadata.iso.extent.Extents;
 import org.apache.sis.referencing.operation.transform.LinearTransform;
 import org.apache.sis.referencing.operation.transform.MathTransforms;
 import org.apache.sis.referencing.operation.matrix.AffineTransforms2D;
@@ -89,8 +93,8 @@ import org.apache.sis.util.logging.Logging;
  * and rely on tiling for reducing actual computations to required tiles. 
Since pan gestures are expressed
  * in pixel coordinates, the translation terms in {@code resampledToDisplay} 
transform should stay integers.
  *
- * @todo This class does not perform a special case for {@link BufferedImage}. 
We wait to see if this class
- *       works well in the general case before doing special cases.
+ * <p>Current version of this class does not perform a special case for {@link 
BufferedImage}.
+ * We wait to see if this class works well in the general case before doing 
special cases.</p>
  *
  * @author  Martin Desruisseaux (Geomatys)
  * @version 1.2
@@ -107,6 +111,18 @@ final class RenderingData implements Cloneable {
     private static final boolean CREATE_INDEX_COLOR_MODEL = true;
 
     /**
+     * Loader for reading and caching coverages at various resolutions. Used 
if no image has been
+     * explicitly assigned to {@link #data}, or if the image may vary 
depending on the resolution.
+     * The same instance may be shared by many {@link RenderingData} objects.
+     */
+    MultiResolutionImageLoader coverageLoader;
+
+    /**
+     * The pyramid level of {@linkplain #data} loaded by the {@linkplain 
#coverageLoader}.
+     */
+    private int currentPyramidLevel;
+
+    /**
      * The data fetched from {@link GridCoverage#render(GridExtent)} for 
current {@code sliceExtent}.
      * This rendered image may be tiled and fetching those tiles may require 
computations to be performed
      * in background threads. Pixels in this {@code data} image are mapped to 
pixels in the display
@@ -118,9 +134,12 @@ final class RenderingData implements Cloneable {
      *   <li>{@link CoverageCanvas#getObjectiveToDisplay()}</li>
      * </ol>
      *
+     * This field is initially {@code null}.
+     *
      * @see #dataGeometry
      * @see #dataRanges
-     * @see #setImage(RenderedImage, GridGeometry, List)
+     * @see #isEmpty()
+     * @see #loadIfNeeded(LinearTransform, DirectPosition, GridExtent)
      */
     private RenderedImage data;
 
@@ -133,7 +152,7 @@ final class RenderingData implements Cloneable {
      *
      * @see #data
      * @see #dataRanges
-     * @see #setImage(RenderedImage, GridGeometry, List)
+     * @see #setCoverageSpace(GridGeometry, List)
      */
     private GridGeometry dataGeometry;
 
@@ -143,7 +162,7 @@ final class RenderingData implements Cloneable {
      *
      * @see #data
      * @see #dataGeometry
-     * @see #setImage(RenderedImage, GridGeometry, List)
+     * @see #setCoverageSpace(GridGeometry, List)
      */
     private List<SampleDimension> dataRanges;
 
@@ -216,9 +235,11 @@ final class RenderingData implements Cloneable {
 
     /**
      * Returns {@code true} if this object has no data.
+     * If {@code true}, then {@link CoverageCanvas} should paint
+     * an empty space without starting a background worker thread.
      */
     final boolean isEmpty() {
-        return data == null;
+        return data == null && dataGeometry == null && dataRanges == null;
     }
 
     /**
@@ -246,16 +267,36 @@ final class RenderingData implements Cloneable {
     }
 
     /**
-     * Sets the data to given image, which can be {@code null}.
+     * Clears this renderer. This method should be invoked when the source of 
data (resource or coverage) changed.
+     * The {@link #displayToObjective} transform will be recomputed from 
scratch when first needed.
      */
-    @SuppressWarnings("AssignmentToCollectionOrArrayFieldFromParameter")
-    final void setImage(final RenderedImage data, final GridGeometry domain, 
final List<SampleDimension> ranges) {
+    final void clear() {
         clearCRS();
         displayToObjective = null;
         statistics         = null;
-        this.data          = data;
-        this.dataGeometry  = domain;
-        this.dataRanges    = ranges;        // Not cloned because already an 
unmodifiable list.
+        data               = null;
+        dataRanges         = null;
+        dataGeometry       = null;
+    }
+
+    /**
+     * Sets the input space (domain) and output space (ranges) of the coverage 
to be rendered.
+     * It should be followed by a call to {@link 
#ensureImageLoaded(GridCoverage, GridExtent)}.
+     *
+     * @param  data    the new image, or {@code null} if not yet known (i.e. 
loading may have been deferred).
+     * @param  domain  the two-dimensional grid geometry, or {@code null} if 
there is no data.
+     * @param  ranges  descriptions of bands, or {@code null} if there is no 
data.
+     *
+     * @see #isEmpty()
+     */
+    @SuppressWarnings("AssignmentToCollectionOrArrayFieldFromParameter")
+    final void setCoverageSpace(final GridGeometry domain, final 
List<SampleDimension> ranges) {
+        dataRanges   = ranges;      // Not cloned because already an 
unmodifiable list.
+        dataGeometry = domain;
+        /*
+         * If the grid geometry does not define a "grid to CRS" transform, set 
it to an identity transform.
+         * We do that because this class needs a complete `GridGeometry` as 
much as possible.
+         */
         if (domain != null && !domain.isDefined(GridGeometry.GRID_TO_CRS)
                            &&  domain.isDefined(GridGeometry.EXTENT))
         {
@@ -270,6 +311,106 @@ final class RenderingData implements Cloneable {
     }
 
     /**
+     * Loads a new grid coverage if {@linkplain #data} is null or if the 
pyramid level changed.
+     * It is caller's responsibility to ensure that {@link #coverageLoader} 
has a non-null value
+     * and is using the right resource before to invoke this method.
+     *
+     * <p>Caller should invoke {@link #ensureImageLoaded(GridCoverage, 
GridExtent)}
+     * after this method (this is not done automatically).</p>
+     *
+     * @param  objectiveToDisplay  transform used for rendering the coverage 
on screen.
+     * @param  objectivePOI        point where to compute resolution, in 
coordinates of objective CRS.
+     * @return the loaded grid coverage, or {@code null} if no loading has 
been done
+     *         (which means that the coverage is unchanged, not that it does 
not exist).
+     *
+     * @see #setCoverageSpace(GridGeometry, List)
+     */
+    final GridCoverage ensureCoverageLoaded(final LinearTransform 
objectiveToDisplay, final DirectPosition objectivePOI)
+            throws TransformException, DataStoreException
+    {
+        final MathTransform dataToObjective = (changeOfCRS != null) ? 
changeOfCRS.getMathTransform() : null;
+        final int level = coverageLoader.findPyramidLevel(dataToObjective, 
objectiveToDisplay, objectivePOI);
+        if (data != null && level == currentPyramidLevel) {
+            return null;
+        }
+        data = null;
+        currentPyramidLevel = level;
+        return coverageLoader.getOrLoad(level).forConvertedValues(true);
+    }
+
+    /**
+     * Fetches the rendered image if {@linkplain #data} is null. This method 
needs to be invoked at least
+     * once after {@link #setCoverageSpace(GridGeometry, List)}. The {@code 
coverage} given in argument
+     * may be the value returned by {@link 
#ensureCoverageLoaded(LinearTransform, DirectPosition)}.
+     *
+     * @param  coverage  the coverage from which to read data, or {@code null} 
if the coverage did not changed.
+     */
+    final void ensureImageLoaded(final GridCoverage coverage, @Deprecated 
final GridExtent sliceExtent)
+            throws FactoryException, TransformException
+    {
+        if (data != null || coverage == null) {
+            return;
+        }
+        final GridGeometry old = dataGeometry;
+        final List<SampleDimension> ranges = coverage.getSampleDimensions();
+        final RenderedImage image = coverage.render(sliceExtent);
+        final Object value = image.getProperty(PlanarImage.GRID_GEOMETRY_KEY);
+        final GridGeometry domain = (value instanceof GridGeometry) ? 
(GridGeometry) value
+                : new ImageRenderer(coverage, 
sliceExtent).getImageGeometry(MultiResolutionImageLoader.BIDIMENSIONAL);
+        setCoverageSpace(domain, ranges);
+        data = image;
+        /*
+         * Update the transforms in a way that preserve the current zoom 
level, translation, etc.
+         * We compute the change in the "data grid to objective CRS" 
transforms caused by the change
+         * in data grid geometry, then we concatenate that change to the 
existing transforms.
+         * That way, the objective CRS is kept unchanged.
+         */
+        if (old != null && cornerToObjective != null && objectiveToCenter != 
null) {
+            MathTransform toNew = null, toOld = null;
+            if (old.isDefined(GridGeometry.CRS) && 
domain.isDefined(GridGeometry.CRS)) {
+                final CoordinateReferenceSystem oldCRS = 
old.getCoordinateReferenceSystem();
+                final CoordinateReferenceSystem newCRS = 
dataGeometry.getCoordinateReferenceSystem();
+                if (newCRS != oldCRS) {             // Quick check for the 
vast majority of cases.
+                    /*
+                     * Transform computed below should always be the identity 
transform,
+                     * but we check anyway as a safety. A non-identity 
transform would be
+                     * a pyramid where the CRS changes according the pyramid 
level.
+                     */
+                    final GeographicBoundingBox areaOfInterest = Extents.union(
+                            dataGeometry.getGeographicExtent().orElse(null),
+                            old.getGeographicExtent().orElse(null));
+                    toNew = CRS.findOperation(oldCRS, newCRS, 
areaOfInterest).getMathTransform();
+                    toOld = toNew.inverse();
+                }
+            }
+            final MathTransform forward = concatenate(PixelInCell.CELL_CORNER, 
dataGeometry, old, toOld);
+            final MathTransform inverse = concatenate(PixelInCell.CELL_CENTER, 
old, dataGeometry, toNew);
+            cornerToObjective = MathTransforms.concatenate(forward, 
cornerToObjective);
+            objectiveToCenter = MathTransforms.concatenate(objectiveToCenter, 
inverse);
+        }
+    }
+
+    /**
+     * Computes the transform that represent a change of "data grid to 
objective" transform
+     *
+     * @param  anchor       the cell part to map (center or corner).
+     * @param  toCRS        the grid geometry for which to use the "grid to 
CRS" transform.
+     * @param  toGrid       the grid geometry for which to use the "CRS to 
grid" transform.
+     * @param  changeOfCRS  transform from CRS of {@code toCRS} to CRS of 
{@code toGrid}.
+     */
+    private static MathTransform concatenate(final PixelInCell anchor, final 
GridGeometry toCRS,
+            final GridGeometry toGrid, final MathTransform changeOfCRS) throws 
TransformException
+    {
+        final MathTransform forward = toCRS .getGridToCRS(anchor);
+        final MathTransform inverse = toGrid.getGridToCRS(anchor).inverse();
+        if (changeOfCRS != null) {
+            return MathTransforms.concatenate(forward, changeOfCRS, inverse);
+        } else {
+            return MathTransforms.concatenate(forward, inverse);
+        }
+    }
+
+    /**
      * Returns the position at the center of source data, or {@code null} if 
none.
      */
     private DirectPosition getSourceMedian() {
@@ -305,43 +446,48 @@ final class RenderingData implements Cloneable {
     }
 
     /**
+     * Sets the coordinate reference system of the display. This method does 
nothing if the CRS was already set.
+     * <em>It does not verify if CRS is the same</em>, it is caller 
responsibility to clear {@link #changeOfCRS}
+     * before to invoke this method for forcing a change of CRS.
+     *
+     * <p>This method updates the following fields only:</p>
+     * <ul>
+     *   <li>{@link #changeOfCRS}</li>
+     *   <li>{@link #processor} positional accuracy hint</li>
+     * </ul>
+     *
+     * @param  objectiveCRS  value of {@link CoverageCanvas#getObjectiveCRS()}.
+     */
+    final void setObjectiveCRS(final CoordinateReferenceSystem objectiveCRS) 
throws TransformException {
+        if (changeOfCRS == null && objectiveCRS != null && 
dataGeometry.isDefined(GridGeometry.CRS)) try {
+            changeOfCRS = 
CRS.findOperation(dataGeometry.getCoordinateReferenceSystem(), objectiveCRS,
+                                            
dataGeometry.getGeographicExtent().orElse(null));
+            final double accuracy = CRS.getLinearAccuracy(changeOfCRS);
+            processor.setPositionalAccuracyHints(
+//                  TODO: uncomment after 
https://issues.apache.org/jira/browse/SIS-497 is fixed.
+//                  Quantities.create(0.25, Units.PIXEL),
+                    (accuracy > 0) ? Quantities.create(accuracy, Units.METRE) 
: null);
+        } catch (FactoryException e) {
+            recoverableException(e);
+            // Leave `changeOfCRS` to null.
+        }
+    }
+
+    /**
      * Creates the resampled image, then optionally applies an index color 
model.
      * This method will compute the {@link MathTransform} steps from image 
coordinate system
      * to display coordinate system if those steps have not already been 
computed.
      *
      * @param  recoloredImage      the image computed by {@link #recolor()}.
-     * @param  objectiveCRS        value of {@link 
CoverageCanvas#getObjectiveCRS()}.
      * @param  objectiveToDisplay  value of {@link 
CoverageCanvas#getObjectiveToDisplay()}.
      * @param  objectivePOI        value of {@link 
CoverageCanvas#getPointOfInterest(boolean)} in objective CRS.
      * @return image with operation applied and color ramp stretched.
      */
-    final RenderedImage resampleAndConvert(final RenderedImage             
recoloredImage,
-                                           final CoordinateReferenceSystem 
objectiveCRS,
-                                           final LinearTransform           
objectiveToDisplay,
-                                           final DirectPosition            
objectivePOI)
+    final RenderedImage resampleAndConvert(final RenderedImage   
recoloredImage,
+                                           final LinearTransform 
objectiveToDisplay,
+                                           final DirectPosition  objectivePOI)
             throws TransformException
     {
-        if (changeOfCRS == null && objectiveCRS != null && 
dataGeometry.isDefined(GridGeometry.CRS)) {
-            DefaultGeographicBoundingBox areaOfInterest = null;
-            if (dataGeometry.isDefined(GridGeometry.ENVELOPE)) try {
-                areaOfInterest = new DefaultGeographicBoundingBox();
-                areaOfInterest.setBounds(dataGeometry.getEnvelope());
-            } catch (TransformException e) {
-                recoverableException(e);
-                // Leave `areaOfInterest` to null.
-            }
-            try {
-                changeOfCRS = 
CRS.findOperation(dataGeometry.getCoordinateReferenceSystem(), objectiveCRS, 
areaOfInterest);
-                final double accuracy = CRS.getLinearAccuracy(changeOfCRS);
-                processor.setPositionalAccuracyHints(
-//                      TODO: uncomment after 
https://issues.apache.org/jira/browse/SIS-497 is fixed.
-//                      Quantities.create(0.25, Units.PIXEL),
-                        (accuracy > 0) ? Quantities.create(accuracy, 
Units.METRE) : null);
-            } catch (FactoryException e) {
-                recoverableException(e);
-                // Leave `changeOfCRS` to null.
-            }
-        }
         /*
          * Following transforms are computed when first needed after the new 
data have been specified,
          * or after the objective CRS changed. If non-null, 
`objToCenterNoWrap` is the same transform
diff --git 
a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ViewAndControls.java
 
b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ViewAndControls.java
index 795eaa4..9205b98 100644
--- 
a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ViewAndControls.java
+++ 
b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ViewAndControls.java
@@ -143,7 +143,8 @@ abstract class ViewAndControls {
 
     /**
      * Sets the view content to the given resource, coverage or image.
-     * This method may start a background thread.
+     * This method is invoked when a new source of data (either a resource or 
a coverage) is specified,
+     * or when a previously hidden view is made visible. Implementations may 
start a background thread.
      *
      * @param  request  the resource, coverage or image to set, or {@code 
null} for clearing the view.
      */
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 edd6cca..81d94ff 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
@@ -783,7 +783,7 @@ public abstract class MapCanvas extends PlanarCanvas {
      * @see #setObjectiveCRS(CoordinateReferenceSystem, DirectPosition)
      */
     protected void setObjectiveBounds(final Envelope visibleArea) {
-        ArgumentChecks.ensureDimensionMatches("bounds", BIDIMENSIONAL, 
visibleArea);
+        ArgumentChecks.ensureDimensionMatches("visibleArea", BIDIMENSIONAL, 
visibleArea);
         objectiveBounds = ImmutableEnvelope.castOrCopy(visibleArea);
         invalidObjectiveToDisplay = true;
     }
@@ -1263,6 +1263,10 @@ public abstract class MapCanvas extends PlanarCanvas {
      * processing outside the {@link Renderer}. It does not need to be invoked 
if the error occurred
      * during the rendering process.
      *
+     * <p>If the error property already has a value, then the new error will 
be to the current error
+     * as a {@linkplain Throwable#addSuppressed(Throwable) suppressed 
exception}. The error property
+     * is cleared when a rendering operation completed successfully.</p>
+     *
      * @param  ex  the exception that occurred (can not be null).
      */
     protected void errorOccurred(final Throwable ex) {
diff --git 
a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/LogHandler.java
 
b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/LogHandler.java
index 396fea7..71c1389 100644
--- 
a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/LogHandler.java
+++ 
b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/LogHandler.java
@@ -16,7 +16,6 @@
  */
 package org.apache.sis.internal.gui;
 
-import java.lang.ref.Reference;
 import java.util.WeakHashMap;
 import java.util.concurrent.ConcurrentMap;
 import java.util.concurrent.ConcurrentHashMap;
@@ -37,7 +36,7 @@ import org.apache.sis.storage.event.WarningEvent;
  * This class maintains both a global (system) list and a list of log records 
specific to each resource.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.1
+ * @version 1.2
  * @since   1.1
  * @module
  */
@@ -117,17 +116,6 @@ public final class LogHandler extends Handler implements 
StoreListener<WarningEv
      * @param  source  the resource on which an operation is about to start in 
current thread. May be {@code null}.
      * @return key to use in call to {@link #loadingStop(Long)} when the 
operation is finished. May be {@code null}.
      */
-    public static Long loadingStart(final Reference<Resource> source) {
-        return loadingStart(source != null ? source.get() : null);
-    }
-
-    /**
-     * Notifies this {@code LogHandler} that an operation is about to start on 
the given resource.
-     * Call to this method must be followed by call to {@link 
#loadingStop(Long)} in a {@code finally} block.
-     *
-     * @param  source  the resource on which an operation is about to start in 
current thread. May be {@code null}.
-     * @return key to use in call to {@link #loadingStop(Long)} when the 
operation is finished. May be {@code null}.
-     */
     public static Long loadingStart(final Resource source) {
         if (source == null) return null;
         final Long id = Thread.currentThread().getId();
diff --git 
a/core/sis-portrayal/src/main/java/org/apache/sis/internal/map/coverage/MultiResolutionCoverageLoader.java
 
b/core/sis-portrayal/src/main/java/org/apache/sis/internal/map/coverage/MultiResolutionCoverageLoader.java
new file mode 100644
index 0000000..7c478a1
--- /dev/null
+++ 
b/core/sis-portrayal/src/main/java/org/apache/sis/internal/map/coverage/MultiResolutionCoverageLoader.java
@@ -0,0 +1,388 @@
+/*
+ * 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.map.coverage;
+
+import java.util.Arrays;
+import java.lang.ref.Reference;
+import java.lang.ref.SoftReference;
+import java.text.NumberFormat;
+import org.opengis.geometry.Envelope;
+import org.opengis.geometry.DirectPosition;
+import org.opengis.referencing.datum.PixelInCell;
+import org.opengis.referencing.operation.Matrix;
+import org.opengis.referencing.operation.MathTransform;
+import org.opengis.referencing.operation.TransformException;
+import org.apache.sis.referencing.operation.transform.MathTransforms;
+import org.apache.sis.referencing.operation.transform.LinearTransform;
+import org.apache.sis.storage.GridCoverageResource;
+import org.apache.sis.storage.DataStoreException;
+import org.apache.sis.coverage.grid.GridGeometry;
+import org.apache.sis.coverage.grid.GridCoverage;
+import org.apache.sis.coverage.grid.GridDerivation;
+import org.apache.sis.coverage.grid.GridRoundingMode;
+import org.apache.sis.internal.util.CollectionsExt;
+import org.apache.sis.math.DecimalFunctions;
+import org.apache.sis.io.TableAppender;
+import org.apache.sis.util.ArraysExt;
+
+
+/**
+ * A helper class for reading {@link GridCoverage} instances at various 
resolution.
+ * The resolution are inferred from {@link 
GridCoverageResource#getResolutions()},
+ * using default values if necessary. The objective CRS does not need to be 
the same
+ * than the coverage CRS, in which case transformations are applied at the 
point in
+ * the center of the display bounds.
+ *
+ * <h2>Multi-threading</h2>
+ * Instances of this class are immutable (except for the cache) and safe for 
use by multiple threads.
+ * However it assumes that the {@link GridCoverageResource} given to the 
constructor is also thread-safe;
+ * this class does not synchronize accesses to the resource (because it may be 
used outside this class anyway).
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.2
+ * @since   1.2
+ * @module
+ */
+public class MultiResolutionCoverageLoader {
+    /**
+     * Approximate size in pixels of the pyramid level having coarsest 
resolution.
+     * This is used by {@link #defaultResolutions(GridGeometry, double[])} 
when no
+     * resolution levels are explicitly given by the {@linkplain #resource}.
+     */
+    private static final int DEFAULT_SIZE = 512;
+
+    /**
+     * Value of log₂(rₙ₊₁/rₙ) where rₙ is the resolution at a level and rₙ₊₁ 
is the resolution at the coarser level.
+     * The usual value is 1, which means that there is a scale factor of 2 
between each level. We use a higher value
+     * because if the {@linkplain #resource} did not declared a pyramid, 
reading coverages at low resolution may be
+     * as costly as high resolution. in that case, we want to reduce the 
number of read operations.
+     */
+    private static final int DEFAULT_SCALE_LOG = 3;         // Scale factor of 
2³ = 8.
+
+    /**
+     * Arbitrary number of levels if we can not compute it from {@link 
#DEFAULT_SIZE} and {@link #DEFAULT_SCALE_LOG}.
+     * Reminder: the multiplication factor between two levels is 2^{@value 
#DEFAULT_SCALE_LOG}, so the resolution
+     * goes down very fast.
+     */
+    private static final int DEFAULT_NUM_LEVELS = 4;
+
+    /**
+     * The resource from which to read grid coverages.
+     */
+    protected final GridCoverageResource resource;
+
+    /**
+     * Squares of resolution at each pyramid level, from finest (smaller 
numbers) to coarsest (largest numbers).
+     * Note that this is the reverse order of {@link 
GridCoverageResource#getResolutions()}. For a given level,
+     * the array {@code resolutionSquared[level]} gives the squares of the 
resolution for each CRS dimension.
+     */
+    private final double[][] resolutionSquared;
+
+    /**
+     * The weak or soft references to coverages for each pyramid level.
+     * The array length is at least 1, even if {@link #resolutionSquared} is 
empty.
+     * Accesses to this array should be synchronized on {@code coverages}.
+     */
+    private final Reference<GridCoverage>[] coverages;
+
+    /**
+     * The area of interest in any CRS (transformations will be applied as 
needed),
+     * or {@code null} for not restricting the coverage to a sub-area.
+     */
+    private final Envelope areaOfInterest;
+
+    /**
+     * 0-based indices of sample dimensions to read, or {@code null} or an 
empty sequence for reading them all.
+     */
+    private final int[] readRanges;
+
+    /**
+     * Creates a new loader of grid coverages from the given resource. The 
loader assumes a pyramid with
+     * resolutions declared by the given resource if present, or computes 
default resolutions otherwise.
+     *
+     * @param  resource  the resource from which to read grid coverages. 
Should be thread-safe.
+     * @param  domain    desired spatiotemporal region in any CRS, or {@code 
null} for no sub-area.
+     * @param  range     0-based indices of sample dimensions to read, or 
{@code null} for all.
+     * @throws DataStoreException if an error occurred while querying the 
resource for resolutions.
+     */
+    @SuppressWarnings({"unchecked","rawtypes"})         // Generic array 
creation.
+    public MultiResolutionCoverageLoader(final GridCoverageResource resource, 
final Envelope domain,
+                                         final int[] range) throws 
DataStoreException
+    {
+        this.resource  = resource;
+        areaOfInterest = domain;
+        readRanges     = range;
+        double[][] resolutions = 
CollectionsExt.toArray(resource.getResolutions(), double[].class);
+        ArraysExt.reverse(resolutions);                     // From finest to 
coarsest resolution.
+        if (resolutions.length <= 1) {
+            final GridGeometry gg = resource.getGridGeometry();
+            if (resolutions.length != 0) {
+                resolutions = defaultResolutions(gg, resolutions[0]);
+            } else if (gg.isDefined(GridGeometry.RESOLUTION)) {
+                resolutions = defaultResolutions(gg, gg.getResolution(true));
+            }
+        }
+        resolutionSquared = resolutions;
+        for (final double[] r : resolutions) {
+            for (int i=0; i<r.length; i++) {
+                r[i] *= r[i];
+            }
+        }
+        coverages = new Reference[Math.max(resolutions.length, 1)];
+    }
+
+    /**
+     * Computes default resolutions starting from the given finest level.
+     * This method uses a scale factor determined by {@link 
#DEFAULT_SCALE_LOG} between each level.
+     * The coarsest level will have a size of approximately {@value 
#DEFAULT_SIZE} pixels.
+     *
+     * @param  envelope  bounding box of the coverage in units of the coverage 
CRS.
+     * @param  base      resolution of the finest level.
+     * @return default resolutions from finest to coarsest. The first element 
is always {@code base}.
+     */
+    private static double[][] defaultResolutions(final GridGeometry gg, 
double[] base) {
+        /*
+         * Estimate the number of levels in a pyramid starting from a level 
with `base` resolution
+         * up to a level having approximately `DEFAULT_SIZE` pixels, assuming 
that the resolution
+         * at each level is 8× the resolution at previous level.
+         */
+        int numLevels = 1;
+        if (gg.isDefined(GridGeometry.ENVELOPE)) {
+            final Envelope envelope = gg.getEnvelope();
+            for (int i = envelope.getDimension(); --i >= 0;) {
+                // Multiplication factor from finest resolution to coarsest 
one.
+                final double f = envelope.getSpan(i) / (DEFAULT_SIZE * 
base[i]);
+                int n = Math.getExponent(f);                    // 
floor(log₂(f))
+                if (n < Double.MAX_EXPONENT && (n /= DEFAULT_SCALE_LOG) > 
numLevels) {
+                    numLevels = n;
+                }
+            }
+        } else {
+            numLevels = DEFAULT_NUM_LEVELS;     // Arbitrary number of levels 
if we can not compute it.
+        }
+        /*
+         * Build the arrays of resolutions from finest to coarsest.
+         * The `base` array is cloned then updated to become the base of next 
level.
+         */
+        final double[][] resolutions = new double[numLevels][];
+        resolutions[0] = base;
+        for (int j=1; j<numLevels; j++) {
+            resolutions[j] = base = base.clone();
+            for (int i=0; i<base.length; i++) {
+                base[i] *= (1 << DEFAULT_SCALE_LOG);
+            }
+        }
+        return resolutions;
+    }
+
+    /**
+     * Returns the pyramid level for a zoom defined by the given "objective to 
display" transform.
+     * Only the scale factors of the given transform will be considered; 
translations are ignored.
+     *
+     * @param  dataToObjective     transform from data CRS to the CRS for 
rendering, or {@code null} if none.
+     * @param  objectiveToDisplay  transform used for rendering the coverage 
on screen.
+     * @param  objectivePOI        point where to compute resolution, in 
coordinates of objective CRS.
+     *                             Can be null if {@code dataToObjective} is 
null or linear.
+     * @return pyramid level for the zoom determined by the given transform. 
Finest level is 0.
+     * @throws TransformException if an error occurred while computing 
resolution from given transforms.
+     */
+    public final int findPyramidLevel(final MathTransform dataToObjective, 
final LinearTransform objectiveToDisplay,
+                                      final DirectPosition objectivePOI) 
throws TransformException
+    {
+        int level = Math.max(resolutionSquared.length - 1, 0);
+        if (level != 0) {
+            final LinearTransform displayToObjective = 
objectiveToDisplay.inverse();
+            final Matrix m = displayToObjective.getMatrix();
+            final Matrix d;
+            if (dataToObjective != null && !dataToObjective.isIdentity()) {
+                d = dataToObjective.inverse().derivative(objectivePOI);
+            } else {
+                d = null;
+            }
+            final int srcDim = m.getNumCol() - 1;
+            final int objDim = m.getNumRow() - 1;                       // -1 
for ignoring the translation column.
+            final int tgtDim = (d != null) ? d.getNumRow() : objDim;    // No 
-1 because `d` is not a transform.
+dimensions: for (int j=0; j<tgtDim; j++) {
+                double sum = 0;
+                for (int i=0; i<srcDim; i++) {
+                    double e;
+                    if (d == null) {
+                        e = m.getElement(j,i);
+                    } else {
+                        /*
+                         * Compute the value of `(d × m).getElement(j,i)` 
where (d × m) is "display to objective"
+                         * transform followed by "objective to data". We do 
the multiplication inline here instead
+                         * of invoking `d.multiply(m)` because the two 
matrices do not have compatible size:
+                         * `m` is an affine transform (including translations) 
while `d` is a Jacobian matrix.
+                         * It also allows to skip some calculations if `level` 
become 0 early.
+                         */
+                        e = 0;
+                        for (int k=0; k<objDim; k++) {
+                            e += d.getElement(j,k) * m.getElement(k,i);     // 
TODO: use Math.fma(…) with JDK9.
+                        }
+                    }
+                    sum += e * e;
+                }
+                /*
+                 * Can not use `Arrays.binarySearch(…)` because elements are 
not guaranteed to be sorted.
+                 * Even if `GridCoverageResource.getResolutions()` contract 
said "coarsest to finest",
+                 * it may not be possible to respect this condition on all 
dimensions in same time.
+                 * The main goal is to have a `level` value as high as 
possible while having a resolution
+                 * equals or better than `sum`.
+                 */
+                int levelOfMin = level;
+                double minimum = Double.POSITIVE_INFINITY, r;
+                while ((r = resolutionSquared[level][j]) > sum) {
+                    if (r < minimum) {
+                        minimum = r;
+                        levelOfMin = level;
+                    }
+                    if (level == 0) {
+                        level = levelOfMin;
+                        break dimensions;
+                    }
+                    level--;
+                }
+            }
+        }
+        return level;
+    }
+
+    /**
+     * Returns the coverage at the given level if it is present in the cache, 
or loads and caches it otherwise.
+     *
+     * @param  level  pyramid level of the desired coverage.
+     * @return the coverage at the specified level (never null).
+     * @throws DataStoreException if an error occurred while loading the 
coverage.
+     */
+    public final GridCoverage getOrLoad(final int level) throws 
DataStoreException {
+        synchronized (coverages) {
+            final Reference<GridCoverage> ref = coverages[level];
+            if (ref != null) {
+                final GridCoverage coverage = ref.get();
+                if (coverage != null) return coverage;
+                coverages[level] = null;
+            }
+        }
+        GridGeometry domain = null;
+        if (resolutionSquared.length != 0) {
+            final double[] resolutions = resolutionSquared[level].clone();
+            for (int i=0; i<resolutions.length; i++) {
+                resolutions[i] = Math.sqrt(resolutions[i]);
+            }
+            final MathTransform gridToCRS = MathTransforms.scale(resolutions);
+            domain = new GridGeometry(PixelInCell.CELL_CORNER, gridToCRS, 
areaOfInterest, GridRoundingMode.ENCLOSING);
+        }
+        final GridCoverage coverage = resource.read(getReadDomain(domain), 
readRanges);
+        /*
+         * Cache and return the coverage. The returned coverage may be a 
different instance
+         * if another coverage has been cached concurrently for the same level.
+         */
+        synchronized (coverages) {
+            final Reference<GridCoverage> ref = coverages[level];
+            if (ref != null) {
+                final GridCoverage c = ref.get();
+                if (c != null) return c;
+            }
+            coverages[level] = new SoftReference<>(coverage);
+        }
+        return coverage;
+    }
+
+    /**
+     * If the a grid coverage for the given domain and range is in the cache, 
returns that coverage.
+     * Otherwise loads the coverage and eventually caches it. The caching 
happens only if the given
+     * domain and range and managed by this loader.
+     *
+     * @param  domain  desired grid extent and resolution, or {@code null} for 
reading the whole domain.
+     * @param  range   0-based indices of sample dimensions to read, or {@code 
null} or an empty sequence for reading them all.
+     * @return the grid coverage for the specified domain and range.
+     * @throws DataStoreException if an error occurred while reading the grid 
coverage data.
+     */
+    public final GridCoverage getOrLoad(GridGeometry domain, final int[] 
range) throws DataStoreException {
+        if (domain == null && areaOfInterest == null && 
Arrays.equals(readRanges, range)) {
+            /*
+             * Fot now we leverage the cache only at level 0.
+             * Future versions of this class may try to use the cache at other 
levels too.
+             */
+            return getOrLoad(0);
+        }
+        if (domain == null) {
+            domain = resource.getGridGeometry();
+        }
+        return resource.read(getReadDomain(domain), readRanges);
+    }
+
+    /**
+     * Given a {@code GridGeometry} configured with the resolution to read, 
returns an amended domain.
+     * The default implementation returns {@code domain} unchanged.
+     * Subclasses can override typically for selecting a two-dimensional slice.
+     *
+     * <p>This method is invoked by {@link #getOrLoad(int)} default 
implementation before to read a coverage.</p>
+     *
+     * @param  domain  a grid geometry with the desired resolution.
+     * @return the domain to read from the {@linkplain #resource}.
+     *
+     * @see GridDerivation#slice(DirectPosition)
+     * @see GridDerivation#sliceByRatio(double, int...)
+     */
+    protected GridGeometry getReadDomain(final GridGeometry domain) {
+        return domain;
+    }
+
+    /**
+     * Returns a string representation of this loader for debugging purpose.
+     * Default implementation formats the resolution thresholds in a table
+     * with "cached" word after the level having a cached coverage.
+     *
+     * @return a string representation of this loader.
+     */
+    @Override
+    public String toString() {
+        final int count = resolutionSquared.length - 1;
+        double delta = magnitude(0);
+        if (count != 0) {
+            delta = (magnitude(count) - delta) / count;
+        }
+        final int n = 
Math.max(Math.min(DecimalFunctions.fractionDigitsForDelta(delta, false), 6), 0);
+        final NumberFormat f = NumberFormat.getInstance();
+        f.setMinimumFractionDigits(n);
+        f.setMaximumFractionDigits(n);
+        final TableAppender table = new TableAppender("  ");
+        table.setCellAlignment(TableAppender.ALIGN_RIGHT);
+        for (int level=0; level <= count; level++) {
+            final double[] rs = resolutionSquared[level];
+            for (final double r : rs) {
+                table.append(f.format(Math.sqrt(r)));
+                table.nextColumn();
+            }
+            final Reference<GridCoverage> ref = coverages[level];
+            if (ref != null && ref.get() != null) {     // TODO: use 
!refersTo(null) in JDK16.
+                table.append("cached");
+            }
+            table.nextLine();
+        }
+        return table.toString();
+    }
+
+    /**
+     * Returns the magnitude of resolution at the given level.
+     */
+    private double magnitude(final int level) {
+        return 
Math.sqrt(Arrays.stream(resolutionSquared[level]).average().orElse(1));
+    }
+}
diff --git 
a/core/sis-portrayal/src/test/java/org/apache/sis/test/suite/PortrayalTestSuite.java
 
b/core/sis-portrayal/src/main/java/org/apache/sis/internal/map/coverage/package-info.java
similarity index 50%
copy from 
core/sis-portrayal/src/test/java/org/apache/sis/test/suite/PortrayalTestSuite.java
copy to 
core/sis-portrayal/src/main/java/org/apache/sis/internal/map/coverage/package-info.java
index e22488f..ab1e628 100644
--- 
a/core/sis-portrayal/src/test/java/org/apache/sis/test/suite/PortrayalTestSuite.java
+++ 
b/core/sis-portrayal/src/main/java/org/apache/sis/internal/map/coverage/package-info.java
@@ -14,33 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.sis.test.suite;
-
-import org.apache.sis.test.TestSuite;
-import org.junit.BeforeClass;
-import org.junit.runners.Suite;
 
 
 /**
- * All tests from the {@code sis-portrayal} module, in rough dependency order.
+ * Helper classes for the rendering of grid coverages.
+ *
+ * This package is for internal use by SIS only. Classes in this package
+ * may change in incompatible ways in any future version without notice.
  *
- * @author  Johann Sorel (Geomatys)
- * @version 2.0
- * @since   2.0
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.2
+ * @since   1.2
  * @module
  */
-@Suite.SuiteClasses({
-    org.apache.sis.portrayal.MapLayersTest.class,
-    org.apache.sis.internal.map.SEPortrayerTest.class,
-})
-public final strictfp class PortrayalTestSuite extends TestSuite {
-    /**
-     * Verifies the list of tests before to run the suite.
-     * See {@link #verifyTestList(Class, Class[])} for more information.
-     */
-    @BeforeClass
-    public static void verifyTestList() {
-        assertNoMissingTest(PortrayalTestSuite.class);
-        verifyTestList(PortrayalTestSuite.class);
-    }
-}
+package org.apache.sis.internal.map.coverage;
diff --git 
a/core/sis-portrayal/src/test/java/org/apache/sis/internal/map/coverage/MultiResolutionCoverageLoaderTest.java
 
b/core/sis-portrayal/src/test/java/org/apache/sis/internal/map/coverage/MultiResolutionCoverageLoaderTest.java
new file mode 100644
index 0000000..1be2007
--- /dev/null
+++ 
b/core/sis-portrayal/src/test/java/org/apache/sis/internal/map/coverage/MultiResolutionCoverageLoaderTest.java
@@ -0,0 +1,180 @@
+/*
+ * 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.map.coverage;
+
+import java.util.List;
+import java.util.Arrays;
+import java.util.Collections;
+import java.awt.image.RenderedImage;
+import org.opengis.geometry.DirectPosition;
+import org.opengis.referencing.datum.PixelInCell;
+import org.opengis.referencing.operation.MathTransform;
+import org.opengis.referencing.operation.TransformException;
+import org.apache.sis.referencing.operation.transform.LinearTransform;
+import org.apache.sis.coverage.SampleDimension;
+import org.apache.sis.coverage.grid.GridExtent;
+import org.apache.sis.coverage.grid.GridGeometry;
+import org.apache.sis.coverage.grid.GridCoverage;
+import org.apache.sis.internal.storage.AbstractGridResource;
+import org.apache.sis.referencing.operation.transform.MathTransforms;
+import org.apache.sis.storage.DataStoreException;
+import org.apache.sis.test.TestCase;
+import org.apache.sis.util.iso.Names;
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+
+
+/**
+ * Test {@link MultiResolutionCoverageLoader}.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.2
+ * @since   1.2
+ * @module
+ */
+public final strictfp class MultiResolutionCoverageLoaderTest extends TestCase 
{
+    /**
+     * The loader being tested.
+     */
+    private final MultiResolutionCoverageLoader loader;
+
+    /**
+     * Transform from data CRS to the CRS for rendering, or {@code null} if 
none.
+     */
+    private MathTransform dataToObjective;
+
+    /**
+     * Point where to compute resolution, in coordinates of objective CRS.
+     * Can be null if {@link #dataToObjective} is null or linear.
+     */
+    private DirectPosition objectivePOI;
+
+    /**
+     * Verifies that a transform with the given scale factors result in the 
given level to be found.
+     */
+    private void assertLevelEquals(final double sx, final double sy, final 
double sz, final int expected)
+            throws TransformException
+    {
+        final LinearTransform objectiveToDisplay = MathTransforms.scale(1/sx, 
1/sy, 1/sz);
+        final int level = loader.findPyramidLevel(dataToObjective, 
objectiveToDisplay, objectivePOI);
+        assertEquals(expected, level);
+    }
+
+    /**
+     * Verifies that loading a coverage at the specified level result in a 
grid coverage
+     * with the given scale factors.
+     */
+    private void assertLoadEquals(final int level, final double sx, final 
double sy, final double sz)
+            throws DataStoreException
+    {
+        final GridCoverage coverage = loader.getOrLoad(level);
+        final MathTransform gridToCRS = 
coverage.getGridGeometry().getGridToCRS(PixelInCell.CELL_CORNER);
+        final MathTransform expected = MathTransforms.scale(sx, sy, sz);
+        assertEquals(expected, gridToCRS);
+        assertSame(coverage, loader.getOrLoad(level));
+    }
+
+    /**
+     * Creates a new test case with a loader for a dummy resource.
+     *
+     * @throws DataStoreException if an error occurred while querying the 
dummy resource.
+     */
+    public MultiResolutionCoverageLoaderTest() throws DataStoreException {
+        loader = new MultiResolutionCoverageLoader(new DummyResource(), null, 
null);
+    }
+
+    /**
+     * A dummy resource with arbitrary resolutions for testing purpose.
+     * Resolutions are ordered from coarsest (largest numbers) to finest 
(smallest numbers).
+     */
+    private static final class DummyResource extends AbstractGridResource {
+        /** Creates a dummy resource. */
+        DummyResource() {
+            super(null);
+        }
+
+        /** Returns the preferred resolutions in units of CRS axes. */
+        @Override public List<double[]> getResolutions() {
+            return Arrays.asList(
+                    new double[] {8, 9, 5},
+                    new double[] {4, 4, 3},
+                    new double[] {2, 3, 1});
+        }
+
+        /** Returns a grid geometry with the resolution of finest level. */
+        @Override public GridGeometry getGridGeometry() {
+            return new GridGeometry(new GridExtent(null, null, new long[] {10, 
10, 10}, true),
+                                PixelInCell.CELL_CORNER, 
MathTransforms.scale(2, 3, 1), null);
+        }
+
+        /** Not needed for this test. */
+        @Override public List<SampleDimension> getSampleDimensions() {
+            throw new UnsupportedOperationException();
+        }
+
+        /** Returns a dummy value (will not be used by this test). */
+        @Override public GridCoverage read(final GridGeometry domain, final 
int... range) {
+            final SampleDimension band = new 
SampleDimension(Names.createLocalName(null, null, "dummy"), null, 
Collections.emptyList());
+            return new GridCoverage(domain, Collections.singletonList(band)) {
+                @Override public RenderedImage render(GridExtent sliceExtent) {
+                    throw new UnsupportedOperationException();                 
     // Not needed by this test.
+                }
+            };
+        }
+    }
+
+    /**
+     * Tests {@link 
MultiResolutionCoverageLoader#findPyramidLevel(MathTransform, LinearTransform, 
DirectPosition)}
+     * with no "data to objective" transform.
+     *
+     * @throws TransformException if an error occurred while computing the 
resolution from a transform.
+     */
+    @Test
+    public void testFindPyramidLevel() throws TransformException {
+        assertLevelEquals(3, 2, 2, 0);
+        assertLevelEquals(4, 5, 2, 0);
+        assertLevelEquals(4, 5, 4, 1);
+        assertLevelEquals(9, 9, 5, 2);
+        assertLevelEquals(9, 8, 5, 1);
+    }
+
+    /**
+     * Tests {@link 
MultiResolutionCoverageLoader#findPyramidLevel(MathTransform, LinearTransform, 
DirectPosition)}
+     * with a "data to objective" transform set to a translation. Because 
translation has no effect on scale factors,
+     * the result should be identical to {@link #testFindPyramidLevel()}.
+     *
+     * @throws TransformException if an error occurred while computing the 
resolution from a transform.
+     */
+    @Test
+    public void testFindWithTranslation() throws TransformException {
+        dataToObjective = MathTransforms.translation(-5, 7, 3);
+        testFindPyramidLevel();
+    }
+
+    /**
+     * Tests {@link MultiResolutionCoverageLoader#getOrLoad(int)}.
+     *
+     * @throws DataStoreException if an error occurred while querying the 
dummy resource.
+     */
+    @Test
+    public void testGetOrLoad() throws DataStoreException {
+        assertLoadEquals(2, 8, 9, 5);
+        assertLoadEquals(0, 2, 3, 1);
+        assertLoadEquals(1, 4, 4, 3);
+    }
+}
diff --git 
a/core/sis-portrayal/src/test/java/org/apache/sis/test/suite/PortrayalTestSuite.java
 
b/core/sis-portrayal/src/test/java/org/apache/sis/test/suite/PortrayalTestSuite.java
index e22488f..8824566 100644
--- 
a/core/sis-portrayal/src/test/java/org/apache/sis/test/suite/PortrayalTestSuite.java
+++ 
b/core/sis-portrayal/src/test/java/org/apache/sis/test/suite/PortrayalTestSuite.java
@@ -25,13 +25,14 @@ import org.junit.runners.Suite;
  * All tests from the {@code sis-portrayal} module, in rough dependency order.
  *
  * @author  Johann Sorel (Geomatys)
- * @version 2.0
- * @since   2.0
+ * @version 1.2
+ * @since   1.2
  * @module
  */
 @Suite.SuiteClasses({
     org.apache.sis.portrayal.MapLayersTest.class,
     org.apache.sis.internal.map.SEPortrayerTest.class,
+    
org.apache.sis.internal.map.coverage.MultiResolutionCoverageLoaderTest.class
 })
 public final strictfp class PortrayalTestSuite extends TestSuite {
     /**

Reply via email to