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 f583861e3dd183c67aec1c27aa5ef0343b5c4adc
Author: Martin Desruisseaux <[email protected]>
AuthorDate: Sun Apr 5 18:22:08 2026 +0200

    Initial rewrite of the system for showing data in separated windows.
    The new `MapWindows` class is intended to replace `WindowManager`.
    This first draft can show many `MapCanvas` in a mosaic.
---
 .../sis/storage/base/URIDataStoreProvider.java     |   3 +
 .../main/org/apache/sis/gui/DataViewer.java        |  23 +-
 .../apache/sis/gui/coverage/CoverageCanvas.java    |  40 +-
 .../org/apache/sis/gui/dataset/PathAction.java     |  10 +-
 .../org/apache/sis/gui/dataset/ResourceCell.java   | 122 ++--
 .../apache/sis/gui/dataset/ResourceExplorer.java   |  19 +-
 .../org/apache/sis/gui/dataset/ResourceTree.java   |  23 +-
 .../apache/sis/gui/internal/BackgroundThreads.java |   2 +
 .../apache/sis/gui/internal/ExceptionReporter.java |   4 +-
 .../main/org/apache/sis/gui/internal/FontGIS.java  |   2 +-
 .../org/apache/sis/gui/internal/GUIUtilities.java  |  10 +-
 .../sis/gui/internal/ImmutableObjectProperty.java  |   2 +
 .../org/apache/sis/gui/internal/Resources.java     |  22 +-
 .../main/org/apache/sis/gui/map/MapCanvas.java     | 100 +++-
 .../main/org/apache/sis/gui/map/MapCanvasAWT.java  |  14 +-
 .../main/org/apache/sis/gui/map/MapMenu.java       |   5 +-
 .../main/org/apache/sis/gui/map/MapWindows.java    | 185 ++++++
 .../main/org/apache/sis/gui/map/MultiCanvas.java   | 644 +++++++++++++++++++++
 .../main/org/apache/sis/gui/map/StatusBar.java     |  32 +-
 .../org/apache/sis/gui/map/ValuesUnderCursor.java  |   9 +-
 .../main/org/apache/sis/gui/package-info.java      |   2 +-
 .../apache/sis/gui/coverage/CoverageCanvasApp.java |  27 +-
 .../test/org/apache/sis/gui/map/MapWindowsApp.java | 111 ++++
 23 files changed, 1252 insertions(+), 159 deletions(-)

diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/URIDataStoreProvider.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/URIDataStoreProvider.java
index c34d659644..dfb80acdef 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/URIDataStoreProvider.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/URIDataStoreProvider.java
@@ -151,6 +151,9 @@ public abstract class URIDataStoreProvider extends 
DataStoreProvider {
      * @throws DataStoreException if an error on the file system prevent the 
creation of the path.
      */
     public static Object location(final Resource resource) throws 
DataStoreException {
+        if (resource == null) {
+            return null;
+        }
         if (resource instanceof DataStore) {
             final Optional<ParameterValueGroup> p = ((DataStore) 
resource).getOpenParameters();
             if (p.isPresent()) try {
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/DataViewer.java 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/DataViewer.java
index 8fd1d72eec..bfe2db2034 100644
--- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/DataViewer.java
+++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/DataViewer.java
@@ -49,6 +49,7 @@ import org.apache.sis.gui.internal.ExceptionReporter;
 import org.apache.sis.gui.internal.LogHandler;
 import org.apache.sis.gui.internal.Resources;
 import org.apache.sis.gui.internal.RecentChoices;
+import org.apache.sis.gui.map.MapWindows;
 import org.apache.sis.storage.DataStoreProvider;
 import org.apache.sis.storage.DataStores;
 import org.apache.sis.storage.DataStore;
@@ -65,7 +66,7 @@ import org.apache.sis.util.resources.Vocabulary;
  *
  * @author  Smaniotto Enzo (GSoC)
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.3
+ * @version 1.7
  * @since   1.1
  */
 public class DataViewer extends Application {
@@ -143,7 +144,7 @@ public class DataViewer extends Application {
     @Override
     public void start(final Stage window) {
         this.window = window;
-        content = new ResourceExplorer();
+        content = new ResourceExplorer(new MapWindows(null));
         final Resources  localized  = Resources.getInstance();
         final Vocabulary vocabulary = 
Vocabulary.forLocale(localized.getLocale());
         /*
@@ -186,7 +187,7 @@ public class DataViewer extends Application {
         /*
          * Set the main content and show.
          */
-        final BorderPane pane = new BorderPane();
+        final var pane = new BorderPane();
         pane.setTop(menus);
         pane.setCenter(content.getView());
         final Scene scene = new Scene(pane);
@@ -221,10 +222,10 @@ public class DataViewer extends Application {
      */
     private void createFileFilters() {
         final Resources res = Resources.getInstance();
-        final Set<String>    suffixes = new LinkedHashSet<>();
-        final Set<String> allSuffixes = new LinkedHashSet<>();
-        final List<FileChooser.ExtensionFilter> open = new ArrayList<>();
-        final List<FileChooser.ExtensionFilter> save = new ArrayList<>();
+        final var    suffixes = new LinkedHashSet<String>();
+        final var allSuffixes = new LinkedHashSet<String>();
+        final var open = new ArrayList<FileChooser.ExtensionFilter>();
+        final var save = new ArrayList<FileChooser.ExtensionFilter>();
         /*
          * Add an "All files (*.*)" filter only for the Open action.
          * The Save action will need to specify a specific filter.
@@ -291,7 +292,7 @@ public class DataViewer extends Application {
             createFileFilters();
             lastFilter = openFilters[1];
         }
-        final FileChooser chooser = new FileChooser();
+        final var chooser = new FileChooser();
         chooser.setTitle(Resources.format(Resources.Keys.OpenDataFile));
         chooser.getExtensionFilters().addAll(openFilters);
         chooser.setSelectedExtensionFilter(lastFilter);
@@ -308,8 +309,8 @@ public class DataViewer extends Application {
      * Invoked when the user selects "File" ▶ "Open URL" menu.
      */
     private void showOpenURLDialog() {
-        final TextInputDialog  chooser = new TextInputDialog();
-        final ListView<String> recents = new ListView<>();
+        final var chooser = new TextInputDialog();
+        final var recents = new ListView<String>();
         RecentChoices.getURLs(recents.getItems());
         recents.setPrefWidth (500);
         recents.setPrefHeight(200);
@@ -323,7 +324,7 @@ public class DataViewer extends Application {
         chooser.showAndWait().ifPresent((choice) -> {
             try {
                 final URI url = new URI(choice);
-                final Set<String> save = new LinkedHashSet<>(16);
+                final var save = new LinkedHashSet<String>(16);
                 save.add(url.toString());
                 for (final String old : recents.getItems()) {
                     save.add(old);
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/CoverageCanvas.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/CoverageCanvas.java
index ba2b11181a..b7857cf7e7 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/CoverageCanvas.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/CoverageCanvas.java
@@ -358,7 +358,7 @@ public class CoverageCanvas extends MapCanvasAWT {
     }
 
     /**
-     * Returns the source of coverages for this viewer.
+     * Returns the source of coverages for this viewer, or {@code null} if 
none.
      * This method, like all other methods in this class, shall be invoked 
from the JavaFX thread.
      *
      * @return the source of coverages shown in this viewer, or {@code null} 
if none.
@@ -659,7 +659,7 @@ public class CoverageCanvas extends MapCanvasAWT {
             if (resource != null) resource.addListener  (TileReadEvent.class, 
tileReadListener);
         }
         if (resource == null && coverage == null) {
-            runAfterRendering(this::clear);
+            clearLater();
         } else if (controls != null && controls.isAdjustingSlice) {
             runAfterRendering(() -> {
                 clearRenderedImage();
@@ -1229,21 +1229,18 @@ public class CoverageCanvas extends MapCanvasAWT {
          * Adjust the accuracy of coordinates shown in the status bar.
          * The number of fraction digits depend on the zoom factor.
          */
-        if (controls != null) {
-            final Object value = 
resampledImage.getProperty(PlanarImage.POSITIONAL_ACCURACY_KEY);
-            Quantity<Length> accuracy = null;
-            if (value instanceof Quantity<?>[]) {
-                for (final Quantity<?> q : (Quantity<?>[]) value) {
-                    if (Units.isLinear(q.getUnit())) {
-                        accuracy = q.asType(Length.class);
-                        accuracy = GUIUtilities.shorter(accuracy, 
accuracy.getUnit().getConverterTo(Units.METRE)
-                                                                    
.convert(accuracy.getValue().doubleValue()));
-                        break;
-                    }
+        Quantity<Length> accuracy = null;
+        if (resampledImage.getProperty(PlanarImage.POSITIONAL_ACCURACY_KEY) 
instanceof Quantity<?>[] values) {
+            for (final Quantity<?> q : values) {
+                if (Units.isLinear(q.getUnit())) {
+                    accuracy = q.asType(Length.class);
+                    double m = 
accuracy.getUnit().getConverterTo(Units.METRE).convert(accuracy.getValue().doubleValue());
+                    accuracy = GUIUtilities.shorter(accuracy, m);
+                    break;
                 }
             }
-            controls.status.lowestAccuracy.set(accuracy);
         }
+        setPositionalAccuracy(accuracy);
         /*
          * If error(s) occurred during calls to `RenderedImage.getTile(tx, 
ty)`, reports those errors.
          * The `errorReport` field is reset to `null` in preparation for the 
next rendering operation.
@@ -1469,18 +1466,13 @@ public class CoverageCanvas extends MapCanvasAWT {
     }
 
     /**
-     * Removes the image shown and releases memory.
-     *
-     * <h4>Usage</h4>
-     * Overriding methods in subclasses should invoke {@code super.clear()}.
-     * Other methods should generally not invoke this method directly,
-     * and use the following code instead:
+     * Removes the image which was shown and releases memory.
+     * Invoking this method may help to release memory when the map is no 
longer shown.
      *
-     * {@snippet lang="java" :
-     *     runAfterRendering(this::clear);
-     *     }
+     * <p>Subclasses should override this method for cleaning their fields.
+     * Implementations in subclasses shall invoke {@code super.clear()}.</p>
      *
-     * @see #runAfterRendering(Runnable)
+     * @see #clearLater()
      */
     @Override
     protected void clear() {
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/PathAction.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/PathAction.java
index e9d5e10d9f..0255786d63 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/PathAction.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/PathAction.java
@@ -66,16 +66,16 @@ final class PathAction implements EventHandler<ActionEvent> 
{
     }
 
     /**
-     * Whether the "Open containing folder" operation is disabled.
+     * Whether the "Open containing folder" operation is enabled.
      */
-    private static final boolean isBrowseDisabled = 
!(Desktop.isDesktopSupported() &&
-            Desktop.getDesktop().isSupported(Desktop.Action.BROWSE_FILE_DIR));
+    static final boolean isBrowseEnabled = Desktop.isDesktopSupported() &&
+            Desktop.getDesktop().isSupported(Desktop.Action.BROWSE_FILE_DIR);
 
     /**
      * Returns {@code true} if {@code PathAction} cannot handle the given path 
for browsing.
      */
-    static boolean isBrowseDisabled(final Object path) {
-        return PathAction.isBrowseDisabled || IOUtilities.toPathOrNull(path) 
== null;
+    static boolean isBrowseEnabled(final Object path) {
+        return isBrowseEnabled && IOUtilities.toPathOrNull(path) != null;
     }
 
     /**
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/ResourceCell.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/ResourceCell.java
index 5192d2e5ff..e9fc07f73c 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/ResourceCell.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/ResourceCell.java
@@ -16,7 +16,7 @@
  */
 package org.apache.sis.gui.dataset;
 
-import javafx.collections.ObservableList;
+import java.util.ArrayList;
 import javafx.scene.control.Button;
 import javafx.scene.control.ContextMenu;
 import javafx.scene.control.MenuItem;
@@ -49,10 +49,21 @@ import org.apache.sis.util.resources.Vocabulary;
  */
 final class ResourceCell extends TreeCell<Resource> {
     /**
-     * Position of menu items in the contextual menu built by {@link 
#updateItem(Resource, boolean)}.
-     * Above method assumes that {@link #CLOSE} is the last menu item.
+     * Identifier of menu items in the contextual menu built by {@link 
#updateItem(Resource, boolean)}.
      */
-    private static final int COPY_PATH = 0, OPEN_FOLDER = 1, AGGREGATED = 2, 
CLOSE = 3;
+    private static final String
+            VIEW_IN_MOSAIC = "VIEW_IN_MOSAIC",
+            OPEN_FOLDER    = "OPEN_FOLDER",
+            COPY_PATH      = "COPY_PATH",
+            AGGREGATED     = "AGGREGATED",
+            CLOSE          = "CLOSE";
+
+    /**
+     * Whether this cell is used for showing the node of a data store.
+     * Those nodes are direct children of the tree root node.
+     * This information is kept because the contextual menus are different.
+     */
+    private boolean forRootResource;
 
     /**
      * Creates a new cell with initially no data.
@@ -64,6 +75,13 @@ final class ResourceCell extends TreeCell<Resource> {
     ResourceCell(final TreeView<Resource> tree) {
     }
 
+    /**
+     * Returns the tree where this cell is shown.
+     */
+    private ResourceTree getResourceTree() {
+        return (ResourceTree) getTreeView();
+    }
+
     /**
      * Invoked when a new resource needs to be shown in the tree view.
      * This method sets the text to a label that describes the resource,
@@ -80,7 +98,7 @@ final class ResourceCell extends TreeCell<Resource> {
         Button      more  = null;
         ContextMenu menu  = null;
         if (!empty && getTreeItem() instanceof ResourceItem item) {
-            final var tree = (ResourceTree) getTreeView();
+            final ResourceTree tree = getResourceTree();
             textProperty().bind(item.label);
             if (item.isLoading()) {
                 /*
@@ -109,47 +127,61 @@ final class ResourceCell extends TreeCell<Resource> {
                 }
             }
             /*
-             * Following block is for the contextual menu. In current version,
-             * we provide menu only for "root" resources (usually data stores).
+             * Contextual menu. The menu items depend on whether the resource
+             * is a data store in the root, or a child resource of a data 
store.
              */
-            if (tree.findOrRemove(resource, false) != null) {
-                /*
-                 * "Copy file path" menu item should be enabled only if we can
-                 * get some kind of file path or URI from the specified 
resource.
-                 * "Aggregated view" should be enabled only on supported 
resources.
-                 */
-                Object path;
-                try {
-                    path = URIDataStoreProvider.location(resource);
-                } catch (DataStoreException e) {
-                    path = null;
-                    ResourceTree.unexpectedException("updateItem", e);
+            final boolean isRootResource = tree.findOrRemove(resource, false) 
!= null;
+            final boolean aggregatable = isRootResource && 
item.isViewSelectable(resource, TreeViewType.AGGREGATION);
+            Object path;
+            try {
+                path = URIDataStoreProvider.location(resource);
+            } catch (DataStoreException e) {
+                path = null;
+                ResourceTree.unexpectedException("updateItem", e);
+            }
+            /*
+             * Create (if not already done) and configure contextual menu 
using above information.
+             */
+            menu = getContextMenu();
+            if (menu == null || isRootResource != forRootResource) {
+                menu = new ContextMenu();
+                final Resources localized = tree.localized();
+                final var items = new ArrayList<MenuItem>();
+                if (tree.windows != null) {
+                    items.add(localized.menu(VIEW_IN_MOSAIC, 
Resources.Keys.View, (e) -> {
+                        getResourceTree().windows.addResource(getItem());
+                    }));
                 }
-                final boolean aggregatable = item.isViewSelectable(resource, 
TreeViewType.AGGREGATION);
-                /*
-                 * Create (if not already done) and configure contextual menu 
using above information.
-                 */
-                menu = getContextMenu();
-                if (menu == null) {
-                    menu = new ContextMenu();
-                    final Resources localized = tree.localized();
-                    final MenuItem[] items = new MenuItem[CLOSE + 1];
-                    items[COPY_PATH]   = 
localized.menu(Resources.Keys.CopyFilePath, new PathAction(this, false));
-                    items[OPEN_FOLDER] = 
localized.menu(Resources.Keys.OpenContainingFolder, new PathAction(this, true));
-                    items[AGGREGATED]  = 
localized.menu(Resources.Keys.AggregatedView, false, (p,o,n) -> {
+                if (PathAction.isBrowseEnabled) {
+                    items.add(localized.menu(OPEN_FOLDER, 
Resources.Keys.OpenContainingFolder, new PathAction(this, true)));
+                }
+                items.add(localized.menu(COPY_PATH, 
Resources.Keys.CopyFilePath, new PathAction(this, false)));
+                if (isRootResource) {
+                    items.add(localized.menu(AGGREGATED,  
Resources.Keys.AggregatedView, false, (p,o,n) -> {
                         setView(n ? TreeViewType.AGGREGATION : 
TreeViewType.SOURCE);
-                    });
-                    items[CLOSE] = localized.menu(Resources.Keys.Close, (e) -> 
{
-                        ((ResourceTree) 
getTreeView()).removeAndClose(getItem());
-                    });
-                    menu.getItems().setAll(items);
+                    }));
+                    items.add(localized.menu(CLOSE, Resources.Keys.Close, (e) 
-> {
+                        getResourceTree().removeAndClose(getItem());
+                    }));
+                }
+                menu.getItems().setAll(items);
+                forRootResource = isRootResource;
+            }
+            for (final MenuItem m : menu.getItems()) {
+                final boolean enabled;
+                switch (m.getId()) {
+                    default: continue;
+                    case VIEW_IN_MOSAIC: enabled = 
getResourceTree().windows.isSupported(getItem()); break;
+                    case COPY_PATH:      enabled = 
IOUtilities.isKindOfPath(path); break;
+                    case OPEN_FOLDER:    enabled = 
PathAction.isBrowseEnabled(path); break;
+                    case AGGREGATED:  {
+                        final var aggregated = (CheckMenuItem) m;
+                        enabled = aggregatable;
+                        aggregated.setSelected(aggregatable && 
item.isView(TreeViewType.AGGREGATION));
+                        break;
+                    }
                 }
-                final ObservableList<MenuItem> items = menu.getItems();
-                
items.get(COPY_PATH).setDisable(!IOUtilities.isKindOfPath(path));
-                
items.get(OPEN_FOLDER).setDisable(PathAction.isBrowseDisabled(path));
-                final CheckMenuItem aggregated = (CheckMenuItem) 
items.get(AGGREGATED);
-                aggregated.setDisable(!aggregatable);
-                aggregated.setSelected(aggregatable && 
item.isView(TreeViewType.AGGREGATION));
+                m.setDisable(!enabled);
             }
         } else {
             textProperty().unbind();
@@ -173,7 +205,7 @@ final class ResourceCell extends TreeCell<Resource> {
             Color color = Styles.NORMAL_TEXT;
             if (error != null) {
                 color = Styles.ERROR_TEXT;
-                setGraphic(createErrorDetails((ResourceTree) getTreeView(), 
item, error));
+                setGraphic(createErrorDetails(getResourceTree(), item, error));
             }
             setTextFill(isSelected() ? Styles.SELECTED_TEXT : color);
         }
@@ -183,7 +215,7 @@ final class ResourceCell extends TreeCell<Resource> {
      * Creates a button for providing details about an exception that occurred 
while loading the data.
      * This method also updates the label of the given item with a text that 
summarizes the error.
      *
-     * @param  tree   value of {@link #getTreeView()}.
+     * @param  tree   value of {@link #getResourceTree()}.
      * @param  item   the item in which the error occurred.
      * @param  error  the error that occurred.
      */
@@ -219,6 +251,6 @@ final class ResourceCell extends TreeCell<Resource> {
      * we can create an aggregated view of all components.
      */
     private void setView(final TreeViewType type) {
-        ((ResourceItem) getTreeItem()).setView(this, type, ((ResourceTree) 
getTreeView()).locale);
+        ((ResourceItem) getTreeItem()).setView(this, type, 
getResourceTree().locale);
     }
 }
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/ResourceExplorer.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/ResourceExplorer.java
index 0e3d3170fa..ed1c996477 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/ResourceExplorer.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/ResourceExplorer.java
@@ -45,6 +45,8 @@ import org.apache.sis.storage.DataStore;
 import org.apache.sis.storage.DataStoreProvider;
 import org.apache.sis.storage.DataStoreException;
 import org.apache.sis.storage.tiling.TiledResource;
+import org.apache.sis.util.collection.TreeTable;
+import org.apache.sis.util.resources.Vocabulary;
 import org.apache.sis.gui.Widget;
 import org.apache.sis.gui.metadata.MetadataSummary;
 import org.apache.sis.gui.metadata.MetadataTree;
@@ -52,11 +54,10 @@ import org.apache.sis.gui.metadata.StandardMetadataTree;
 import org.apache.sis.gui.coverage.ImageRequest;
 import org.apache.sis.gui.coverage.CoverageExplorer;
 import org.apache.sis.gui.coverage.TileMatrixSetPane;
-import org.apache.sis.util.collection.TreeTable;
-import org.apache.sis.util.resources.Vocabulary;
 import org.apache.sis.gui.internal.BackgroundThreads;
 import org.apache.sis.gui.internal.ExceptionReporter;
 import org.apache.sis.gui.internal.LogHandler;
+import org.apache.sis.gui.map.MapWindows;
 
 
 /**
@@ -182,11 +183,23 @@ public class ResourceExplorer extends Widget {
      */
     @SuppressWarnings("this-escape")    // `this` appears in a cyclic graph.
     public ResourceExplorer() {
+        this(null);
+    }
+
+    /**
+     * Creates a new panel for exploring resources and viewing them in new 
windows.
+     * If {@code windows} is non-null, the contextual menu will contain a 
"Show in mosaic" menu item.
+     *
+     * @param windows  manager of windows for map canvases, or {@code null} if 
none.
+     * @since 1.7
+     */
+    @SuppressWarnings("this-escape")    // `this` appears in a cyclic graph.
+    public ResourceExplorer(final MapWindows windows) {
         /*
          * Build the controls on the left side, which will initially contain 
only the resource explorer.
          * The various tabs will be next (on the right side).
          */
-        resources  = new ResourceTree();
+        resources  = new ResourceTree(windows);
         
resources.getSelectionModel().getSelectedItems().addListener(this::onResourceSelected);
         resources.setPrefWidth(400);
         final Vocabulary vocabulary = Vocabulary.forLocale(resources.locale);
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/ResourceTree.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/ResourceTree.java
index 4bbe0a74e4..3d71b8e07d 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/ResourceTree.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/ResourceTree.java
@@ -49,6 +49,7 @@ import org.apache.sis.gui.internal.ExceptionReporter;
 import org.apache.sis.gui.internal.Resources;
 import org.apache.sis.util.logging.Logging;
 import static org.apache.sis.gui.internal.LogHandler.LOGGER;
+import org.apache.sis.gui.map.MapWindows;
 
 
 /**
@@ -118,6 +119,14 @@ public class ResourceTree extends TreeView<Resource> {
      */
     private final Queue<ResourceItem.Completer> pendingItems;
 
+    /**
+     * The manager of windows where to show the resources, or {@code null} if 
none.
+     * If non-null, the contextual menu will contain a "Show in mosaic" item.
+     *
+     * @since 1.7
+     */
+    protected final MapWindows windows;
+
     /**
      * Creates a new tree of resources with initially no resource to show.
      * For showing a resource, invoke
@@ -125,8 +134,19 @@ public class ResourceTree extends TreeView<Resource> {
      * {@link #addResource(Resource)} or
      * {@link #loadResource(Object)} after construction.
      */
-    @SuppressWarnings("this-escape")    // `this` appears in a cyclic graph.
     public ResourceTree() {
+        this(null);
+    }
+
+    /**
+     * Creates a new tree of resources which can show the resources using the 
given manager of windows.
+     * If {@code windows} is non-null, the contextual menu will contain a 
"Show in mosaic" menu item.
+     *
+     * @param windows  manager of windows for map canvases, or {@code null} if 
none.
+     * @since 1.7
+     */
+    @SuppressWarnings("this-escape")    // `this` appears in a cyclic graph.
+    public ResourceTree(final MapWindows windows) {
         locale = Locale.getDefault();
         pendingItems = new LinkedList<>();
         setCellFactory(ResourceCell::new);
@@ -134,6 +154,7 @@ public class ResourceTree extends TreeView<Resource> {
         setOnDragDropped(this::onDragDropped);
         onResourceLoaded = new SimpleObjectProperty<>(this, 
"onResourceLoaded");
         onResourceClosed = new SimpleObjectProperty<>(this, 
"onResourceClosed");
+        this.windows = windows;
     }
 
     /**
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/BackgroundThreads.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/BackgroundThreads.java
index 32e4a18bb6..354941cca0 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/BackgroundThreads.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/BackgroundThreads.java
@@ -25,6 +25,7 @@ import java.util.concurrent.ThreadFactory;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.FutureTask;
 import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.atomic.AtomicInteger;
 import javafx.application.Platform;
 import org.apache.sis.gui.DataViewer;
@@ -97,6 +98,7 @@ public final class BackgroundThreads extends AtomicInteger 
implements ThreadFact
      * given task in that thread.
      *
      * @param  task  the task to execute.
+     * @throws RejectedExecutionException if the application is shutting down.
      */
     public static void execute(final Runnable task) {
         if (Platform.isFxApplicationThread()) {
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/ExceptionReporter.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/ExceptionReporter.java
index 94e10f7ffb..be2be4d25c 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/ExceptionReporter.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/ExceptionReporter.java
@@ -104,7 +104,7 @@ public final class ExceptionReporter extends Widget {
         final Resources localized = Resources.getInstance();
         final Menu sendTo = new 
Menu(localized.getString(Resources.Keys.SendTo));
         
sendTo.getItems().add(localized.menu(Resources.Keys.StandardErrorStream, 
this::printStackTrace));
-        final ContextMenu menu = new 
ContextMenu(localized.menu(Resources.Keys.Copy, this::copy), sendTo);
+        final var menu = new ContextMenu(localized.menu(Resources.Keys.Copy, 
this::copy), sendTo);
         pane.setContextMenu(menu);
 
         final Label header = new 
Label(localized.getString(Resources.Keys.ErrorAt));
@@ -321,7 +321,7 @@ public final class ExceptionReporter extends Widget {
             pane.setPrefWidth(650);
             pane.setUserData(content);
         } else {
-            final ExceptionReporter content = (ExceptionReporter) 
alert.getDialogPane().getUserData();
+            final var content = (ExceptionReporter) 
alert.getDialogPane().getUserData();
             content.setException(exception);
         }
         if (title != null) alert.setTitle(title);
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/FontGIS.java 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/FontGIS.java
index c937fad6b0..ac024dc940 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/FontGIS.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/FontGIS.java
@@ -101,7 +101,7 @@ public final class FontGIS {
     }
 
     /**
-     * Creates a taggle button with a Font-GIS glyph for the given code.
+     * Creates a toggle button with a Font-<abbr>GIS</abbr> glyph for the 
given code.
      * The code should be one of the {@link Code} constants.
      * The font size and the padding are set to empirical values.
      *
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/GUIUtilities.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/GUIUtilities.java
index 5398c28e2f..e401730f63 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/GUIUtilities.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/GUIUtilities.java
@@ -121,7 +121,7 @@ public final class GUIUtilities {
      * @param  pane  the pane on which to set the clip.
      */
     public static void setClipToBounds(final Pane pane) {
-        final Rectangle clip = new Rectangle();
+        final var clip = new Rectangle();
         clip.widthProperty() .bind(pane.widthProperty());
         clip.heightProperty().bind(pane.heightProperty());
         pane.setClip(clip);
@@ -192,7 +192,7 @@ walk:   for (final T search : path) {
     }
 
     /**
-     * Sets the selected value or {@code target} to the same item as the 
selected item of {@code source}.
+     * Sets the selected value of {@code target} to the same item as the 
selected item of {@code source}.
      *
      * @param <T>     type of items.
      * @param source  the control from which to copy the selected item.
@@ -365,7 +365,7 @@ walk:   for (final T search : path) {
          * Following loop is the "traceback" procedure: starting from last 
cell, follows
          * the direction where the length decrease.
          */
-        final List<E> lcs = new ArrayList<>(lengths[nx][ny] + prefix.size() + 
suffix.size());
+        final var lcs = new ArrayList<E>(lengths[nx][ny] + prefix.size() + 
suffix.size());
         while (nx > 0 && ny > 0) {
             final int lg = lengths[nx][ny];
             if (lengths[nx-1][ny] >= lg) {
@@ -383,7 +383,7 @@ walk:   for (final T search : path) {
     }
 
     /**
-     * Modify the quantity unit for showing a smaller value.
+     * Modifies the quantity unit for showing a smaller value.
      *
      * @param  quantity  the quantity to modify, or {@code null}.
      * @param  m         the quantity value in metres.
@@ -434,6 +434,6 @@ walk:   for (final T search : path) {
      * Converts a floating point value in the 0 … 1 range to an integer value 
in the 0 … 255 range.
      */
     private static int toByte(final double value) {
-        return (int) Math.round(value * 255);
+        return (int) Math.rint(value * 255);
     }
 }
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/ImmutableObjectProperty.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/ImmutableObjectProperty.java
index f5696d66b7..ce56c7fa58 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/ImmutableObjectProperty.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/ImmutableObjectProperty.java
@@ -23,6 +23,8 @@ import javafx.beans.property.ReadOnlyObjectProperty;
 
 /**
  * A property for a value that never change.
+ * The {@code addListener(…)} methods in this class ignore listeners
+ * because these listeners will never be notified of any change.
  *
  * @author  Martin Desruisseaux (Geomatys)
  *
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/Resources.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/Resources.java
index 0d2fd76e96..d62c85744c 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/Resources.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/Resources.java
@@ -581,21 +581,37 @@ public class Resources extends IndexedResourceBundle {
      * @return the menu item with the specified text and action.
      */
     public MenuItem menu(final short key, final EventHandler<ActionEvent> 
onAction) {
-        final MenuItem item = new MenuItem(getString(key));
+        final var item = new MenuItem(getString(key));
         item.setOnAction(onAction);
         return item;
     }
 
+    /**
+     * Creates a new menu item with a localized text specified by the given 
key.
+     *
+     * @param  id        identifier for finding the menu item in a list, or 
{@code null} if not needed.
+     * @param  key       the key for the text of the menu item.
+     * @param  onAction  action to execute when the menu is selected.
+     * @return the menu item with the specified text and action.
+     */
+    public MenuItem menu(final String id, final short key, final 
EventHandler<ActionEvent> onAction) {
+        final var item = menu(key, onAction);
+        item.setId(id);
+        return item;
+    }
+
     /**
      * Creates a new check menu item with a localized text specified by the 
given key.
      *
+     * @param  id        identifier for finding the menu item in a list, or 
{@code null} if not needed.
      * @param  key       the key for the text of the menu item.
      * @param  selected  initial state of the check menu item.
      * @param  onAction  action to execute when the menu is selected or 
unselected.
      * @return the menu item with the specified text and action.
      */
-    public CheckMenuItem menu(final short key, final boolean selected, final 
ChangeListener<Boolean> onAction) {
-        final CheckMenuItem item = new CheckMenuItem(getString(key));
+    public CheckMenuItem menu(final String id, final short key, final boolean 
selected, final ChangeListener<Boolean> onAction) {
+        final var item = new CheckMenuItem(getString(key));
+        item.setId(id);
         item.setSelected(selected);
         item.selectedProperty().addListener(onAction);
         return item;
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapCanvas.java 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapCanvas.java
index a3e9114511..a54039f8ff 100644
--- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapCanvas.java
+++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapCanvas.java
@@ -51,6 +51,8 @@ import javafx.scene.control.ContextMenu;
 import javafx.scene.control.ToggleGroup;
 import javafx.scene.transform.Affine;
 import javafx.scene.transform.NonInvertibleTransformException;
+import javax.measure.Quantity;
+import javax.measure.quantity.Length;
 import org.opengis.geometry.Envelope;
 import org.opengis.geometry.DirectPosition;
 import org.opengis.referencing.ReferenceSystem;
@@ -63,6 +65,9 @@ import org.apache.sis.referencing.operation.matrix.MatrixSIS;
 import org.apache.sis.referencing.operation.transform.MathTransforms;
 import org.apache.sis.referencing.operation.transform.LinearTransform;
 import org.apache.sis.referencing.cs.CoordinateSystems;
+import org.apache.sis.referencing.internal.shared.AxisDirections;
+import org.apache.sis.referencing.internal.shared.AffineTransform2D;
+import org.apache.sis.measure.Quantities;
 import org.apache.sis.geometry.DirectPosition2D;
 import org.apache.sis.geometry.Envelope2D;
 import org.apache.sis.geometry.AbstractEnvelope;
@@ -70,8 +75,6 @@ import org.apache.sis.geometry.ImmutableEnvelope;
 import org.apache.sis.coverage.grid.GridGeometry;
 import org.apache.sis.coverage.grid.GridExtent;
 import org.apache.sis.coverage.grid.PixelInCell;
-import org.apache.sis.gui.referencing.PositionableProjection;
-import org.apache.sis.gui.referencing.RecentReferenceSystems;
 import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.ArgumentChecks;
 import org.apache.sis.util.logging.Logging;
@@ -83,8 +86,8 @@ import org.apache.sis.gui.internal.ExceptionReporter;
 import org.apache.sis.gui.internal.GUIUtilities;
 import org.apache.sis.gui.internal.MouseDrags;
 import org.apache.sis.gui.internal.Resources;
-import org.apache.sis.referencing.internal.shared.AxisDirections;
-import org.apache.sis.referencing.internal.shared.AffineTransform2D;
+import org.apache.sis.gui.referencing.PositionableProjection;
+import org.apache.sis.gui.referencing.RecentReferenceSystems;
 import org.apache.sis.portrayal.PlanarCanvas;
 import org.apache.sis.portrayal.RenderException;
 import org.apache.sis.portrayal.TransformChangeEvent;
@@ -321,6 +324,14 @@ public abstract class MapCanvas extends PlanarCanvas {
      */
     private final ReadOnlyBooleanWrapper isRendering;
 
+    /**
+     * An estimation of positional accuracy, typically in metres.
+     *
+     * @see #setPositionalAccuracy(Quantity)
+     * @see #positionalAccuracyProperty()
+     */
+    private final ReadOnlyObjectWrapper<Length> positionalAccuracy;
+
     /**
      * The exception or error that occurred during last rendering operation.
      * This is reset to {@code null} when a rendering operation completes 
successfully.
@@ -386,6 +397,7 @@ public abstract class MapCanvas extends PlanarCanvas {
         fixedPane = new StackPane(view);
         GUIUtilities.setClipToBounds(fixedPane);
         isRendering = new ReadOnlyBooleanWrapper(this, "isRendering");
+        positionalAccuracy = new ReadOnlyObjectWrapper<>(this, 
"positionalAccuracy");
         error = new ReadOnlyObjectWrapper<>(this, "error");
     }
 
@@ -652,9 +664,23 @@ public abstract class MapCanvas extends PlanarCanvas {
         event.consume();
     }
 
+    /**
+     * Removes map content and clears the properties of this canvas.
+     * Invoking this method may help to release memory when the map is no 
longer shown.
+     * The effect of the cleanup action may not be immediate, but may happen 
an arbitrary
+     * time after this method call.
+     *
+     * <p>For overriding this method in subclasses, see {@link #clear()}.</p>
+     *
+     * @since 1.7
+     */
+    public final void clearLater() {
+        runAfterRendering(this::clear);
+    }
+
     /**
      * Resets the map view to its default zoom level and default position with 
no rotation.
-     * Contrarily to {@link #clear()}, this method does not remove the map 
content.
+     * Contrarily to {@link #clearLater()}, this method does not remove the 
map content.
      */
     public void reset() {
         invalidObjectiveToDisplay = true;
@@ -982,6 +1008,25 @@ public abstract class MapCanvas extends PlanarCanvas {
         }
     }
 
+    /**
+     * Sets an estimation of the positional accuracy of the pixels or features 
that are rendered on the map.
+     * This method sets the value returned by the read-only
+     * {@linkplain #positionalAccuracyProperty() positional accuracy property}.
+     * The value should be non-zero when the positions are computed by a
+     * {@linkplain org.opengis.referencing.operation.Transformation coordinate 
transformations}.
+     *
+     * <p>This method may be invoked in the {@code commit()} method
+     * of the renderer returned by {@link #createRenderer()}.</p>
+     *
+     * @param  accuracy  an estimation of positional accuracy, or {@code null} 
if none.
+     *
+     * @see #positionalAccuracyProperty()
+     * @since 1.7
+     */
+    protected void setPositionalAccuracy(final Quantity<Length> accuracy) {
+        positionalAccuracy.set(Quantities.castOrCopy(accuracy));
+    }
+
     /**
      * Fires a {@link TransformChangeEvent} for a change in the {@link 
#transform}.
      * This method needs a modifiable {@code before} instance; it will be 
modified.
@@ -1459,13 +1504,16 @@ public abstract class MapCanvas extends PlanarCanvas {
 
     /**
      * Invoked after {@link #REPAINT_DELAY} has been elapsed for performing 
the real repaint request.
+     * This method must be invoked in the JavaFX event thread.
      *
      * @see #requestRepaint()
      */
     private void paintAfterDelay() {
         if (renderingInProgress instanceof Delayed) {
             renderingInProgress = null;
-            repaint();
+            if (!BackgroundThreads.EXECUTOR.isShutdown()) {
+                repaint();
+            }
         }
     }
 
@@ -1532,6 +1580,28 @@ public abstract class MapCanvas extends PlanarCanvas {
         return isRendering.getReadOnlyProperty();
     }
 
+    /**
+     * Returns a property which gives an estimation of positional accuracy, 
typically in metres.
+     * The positions of pixels or features rendered on the map may have 
limited accuracy when the
+     * positions are computed by a
+     * {@linkplain org.opengis.referencing.operation.Transformation coordinate 
transformations}.
+     * The position may also be inaccurate because of approximation applied 
for faster rendering.
+     * The property value may be {@code null} if the accuracy is not specified.
+     *
+     * <p><b>Usage example</b></p>
+     * The {@link StatusBar#lowestAccuracy} property can bind to this property 
for reporting
+     * the positional accuracy on the status bar. This is done automatically 
by the
+     * {@link StatusBar#track(MapCanvas)} method.
+     *
+     * @return a property giving an estimation of positional accuracy.
+     *
+     * @see StatusBar#lowestAccuracy
+     * @since 1.7
+     */
+    public final ReadOnlyObjectProperty<Length> positionalAccuracyProperty() {
+        return positionalAccuracy.getReadOnlyProperty();
+    }
+
     /**
      * Returns a property giving the exception or error that occurred during 
last rendering operation.
      * The property value is reset to {@code null} when a rendering operation 
completed successfully.
@@ -1626,19 +1696,14 @@ public abstract class MapCanvas extends PlanarCanvas {
     }
 
     /**
-     * Removes map content and clears all properties of this canvas.
+     * Removes map content and clears the properties of this canvas.
+     * This method should not be invoked directly by users.
+     * The public <abbr>API</abbr> is {@link #clearLater()}.
      *
-     * <h4>Usage</h4>
-     * Overriding methods in subclasses should invoke {@code super.clear()}.
-     * Other methods should generally not invoke this method directly,
-     * and use the following code instead:
+     * <p>Subclasses should override this method for cleaning their fields.
+     * Implementations in subclasses shall invoke {@code super.clear()}.</p>
      *
-     * {@snippet lang="java" :
-     *     runAfterRendering(this::clear);
-     *     }
-     *
-     * @see #reset()
-     * @see #runAfterRendering(Runnable)
+     * @see #clearLater()
      */
     protected void clear() {
         assert Platform.isFxApplicationThread();
@@ -1649,6 +1714,7 @@ public abstract class MapCanvas extends PlanarCanvas {
         clearError();
         isDragging = false;
         isNavigationDisabled = false;
+        positionalAccuracy.set(null);
         isRendering.set(false);
         requestRepaint();
     }
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapCanvasAWT.java 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapCanvasAWT.java
index c5caa6417e..a3f7f002f4 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapCanvasAWT.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapCanvasAWT.java
@@ -669,19 +669,13 @@ public abstract class MapCanvasAWT extends MapCanvas {
     }
 
     /**
-     * Clears the image and all intermediate buffer.
+     * Removes the image which was shown and releases intermediate buffers.
      * Invoking this method may help to release memory when the map is no 
longer shown.
      *
-     * <h4>Usage</h4>
-     * Overriding methods in subclasses should invoke {@code super.clear()}.
-     * Other methods should generally not invoke this method directly,
-     * and use the following code instead:
+     * <p>Subclasses should override this method for cleaning their fields.
+     * Implementations in subclasses shall invoke {@code super.clear()}.</p>
      *
-     * {@snippet lang="java" :
-     *     runAfterRendering(this::clear);
-     *     }
-     *
-     * @see #runAfterRendering(Runnable)
+     * @see #clearLater()
      */
     @Override
     protected void clear() {
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapMenu.java 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapMenu.java
index 23b28cb580..2c7010f7e7 100644
--- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapMenu.java
+++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapMenu.java
@@ -45,9 +45,12 @@ import org.apache.sis.referencing.IdentifiedObjects;
  *
  * <ul>
  *   <li>{@link #addReferenceSystems(RecentReferenceSystems)}:<ul>
- *     <li><i>Reference system</i> with some items from EPSG database.</li>
+ *     <li><i>Reference system</i> with some items from the <abbr>EPSG</abbr> 
registry if available.</li>
  *     <li><i>Centered projection</i> with the list of {@link 
PositionableProjection} items.</li>
  *   </ul></li>
+ *   <li>{@link #addCopyOptions(StatusBar)}:<ul>
+ *     <li>Coordinates at the mouse position where right click occurred.</li>
+ *   </ul></li>
  * </ul>
  *
  * More choices may be added in a future versions.
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapWindows.java 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapWindows.java
new file mode 100644
index 0000000000..e6c7bbc5b9
--- /dev/null
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapWindows.java
@@ -0,0 +1,185 @@
+/*
+ * 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.map;
+
+import java.util.Map;
+import java.util.LinkedHashMap;
+import javafx.stage.Screen;
+import javafx.stage.Window;
+import javafx.stage.Stage;
+import javafx.scene.Scene;
+import javafx.geometry.Rectangle2D;
+import org.apache.sis.storage.Resource;
+import org.apache.sis.util.collection.Containers;
+import org.apache.sis.util.collection.FrequencySortedSet;
+import org.apache.sis.util.resources.Vocabulary;
+
+
+/**
+ * A group of windows showing map canvases.
+ * Each window can show a mosaic of {@link MapCanvas} instances of the same 
size and a single {@link StatusBar}.
+ * User's navigation can optionally by synchronized so that panning or zooming 
in one map causes the same pan or
+ * zoom to be applied in the other maps.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.7
+ * @since   1.7
+ */
+public class MapWindows implements AutoCloseable {
+    /**
+     * The owner, or {@code null} if none.
+     */
+    private final Window owner;
+
+    /**
+     * All windows created by this {@code MapWindows} instance.
+     * Values may be {@code null} before the window is created.
+     * Entries are removed when windows are closed.
+     */
+    private final Map<MultiCanvas, Stage> windows;
+
+    /**
+     * Creates an initially empty group of windows.
+     * If {@code owner} is non-null, then the windows created by this class 
will always be on top of the owner
+     * and closing or minimizing {@code owner} will also close or minimize the 
windows created by this class.
+     *
+     * @param  owner  the parent stage, or {@code null} if none.
+     */
+    public MapWindows(final Window owner) {
+        this.owner = owner;
+        windows = new LinkedHashMap<>();
+    }
+
+    /**
+     * Creates a new window for the specified {@code MultiCanvas}.
+     * This method shall be invoked at most once per {@code canvas}.
+     */
+    private Stage newWindow(final MultiCanvas canvas) {
+        final Stage window = new Stage();
+        if (owner != null) window.initOwner(owner);
+        window.setScene(new Scene(canvas.getView()));
+        /*
+         * We use an initial size covering a large fraction of the screen 
because
+         * this window is typically used for showing image or large tabular 
data.
+         */
+        final Rectangle2D bounds = Screen.getPrimary().getVisualBounds();
+        window.setWidth (0.8 * bounds.getWidth());
+        window.setHeight(0.8 * bounds.getHeight());
+        window.setOnHidden((event) -> {
+            windows.remove(canvas);
+            canvas.dispose();
+        });
+        return window;
+    }
+
+    /**
+     * Returns whether the given resource is supported.
+     *
+     * @param  resource   the resource to show, or {@code null}.
+     * @return whether the given resource can be shown.
+     */
+    public boolean isSupported(final Resource resource) {
+        return MultiCanvas.isSupported(resource);
+    }
+
+    /**
+     * Tries to allocate a canvas for the given resource and to show it.
+     * The first time that this method is invoked, it will show a new window.
+     * On next invocations, the resources are added in the existing window.
+     * The same resource may be shown many times. Null resources are ignored.
+     *
+     * @param  resource  the resource to add, or {@code null}.
+     * @return whether the given resource has been accepted.
+     */
+    public boolean addResource(final Resource resource) {
+        if (windows.isEmpty()) {
+            final var canvas = new MultiCanvas();
+            canvas.addListener((c) -> updateWindowTitles());
+            windows.put(canvas, null);
+        }
+        for (final Map.Entry<MultiCanvas, Stage> entry : windows.entrySet()) {
+            final MultiCanvas canvas = entry.getKey();
+            if (canvas.addResource(resource)) {
+                Stage window = entry.getValue();
+                if (window == null) {
+                    window = newWindow(canvas);
+                    entry.setValue(window);
+                }
+                window.show();
+                window.toFront();
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Recomputes the titles of all windows by using the title of one of the 
canvas.
+     * This method prefers titles that are not shared by canvases in different 
windows,
+     * in order to use a distinct title for each window.
+     */
+    private void updateWindowTitles() {
+        final var allTitles = new FrequencySortedSet<String>();
+        final var perWindow = new LinkedHashMap<Stage, 
FrequencySortedSet<String>>();
+        for (final Map.Entry<MultiCanvas, Stage> entry : windows.entrySet()) {
+            FrequencySortedSet<String> titles = 
entry.getKey().getCanvasTitles();
+            perWindow.put(entry.getValue(), titles);
+            allTitles.addAll(titles);
+        }
+        /*
+         * Iterate over the less frequently used titles first. In the common 
case where a
+         * title is used only once, this strategy gives a distinct title for 
each window.
+         */
+        for (final String title : allTitles) {
+            int frequency = 0;
+            Stage window = null;    // Window where to set the title.
+            for (final Map.Entry<Stage, FrequencySortedSet<String>> entry : 
perWindow.entrySet()) {
+                final FrequencySortedSet<String> titles = entry.getValue();
+                final int n = titles.frequency(title);
+                if (n > frequency) {
+                    frequency = n;
+                    window = entry.getKey();
+                }
+            }
+            if (window != null) {
+                window.setTitle(title.concat(" — Apache SIS"));
+                perWindow.remove(window);
+            }
+        }
+        /*
+         * If any window got no name, assigne a fallback name.
+         */
+        for (final Map.Entry<Stage, FrequencySortedSet<String>> entry : 
perWindow.entrySet()) {
+            String title = Containers.peekFirst(entry.getValue());
+            if (title == null) title = 
Vocabulary.format(Vocabulary.Keys.Unnamed);
+            entry.getKey().setTitle(title);
+        }
+    }
+
+    /**
+     * Hides all windows and releases resources. Invoking this method is not 
strictly necessary
+     * (waiting for the garbage-collector is sufficient), but may help to 
release memory faster.
+     */
+    @Override
+    public void close() {
+        for (final Map.Entry<MultiCanvas, Stage> entry : windows.entrySet()) {
+            entry.getKey().dispose();
+            entry.getValue().close();
+        }
+        windows.clear();
+    }
+}
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MultiCanvas.java 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MultiCanvas.java
new file mode 100644
index 0000000000..ca9c820576
--- /dev/null
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MultiCanvas.java
@@ -0,0 +1,644 @@
+/*
+ * 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.map;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.NoSuchElementException;
+import java.util.Objects;
+import javafx.application.Platform;
+import javafx.beans.InvalidationListener;
+import javafx.beans.Observable;
+import javafx.scene.Node;
+import javafx.scene.control.Label;
+import javafx.scene.control.Button;
+import javafx.scene.input.MouseEvent;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.Region;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.GridPane;
+import javafx.scene.layout.ConstraintsBase;
+import javafx.scene.layout.RowConstraints;
+import javafx.scene.layout.ColumnConstraints;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.VBox;
+import javafx.geometry.Pos;
+import javafx.scene.layout.Border;
+import javafx.scene.paint.Color;
+import org.apache.sis.gui.Widget;
+import org.apache.sis.gui.coverage.CoverageCanvas;
+import org.apache.sis.gui.internal.BackgroundThreads;
+import org.apache.sis.gui.internal.DataStoreOpener;
+import static org.apache.sis.gui.internal.LogHandler.LOGGER;
+import org.apache.sis.gui.referencing.RecentReferenceSystems;
+import org.apache.sis.storage.Resource;
+import org.apache.sis.storage.GridCoverageResource;
+import org.apache.sis.storage.DataStoreException;
+import org.apache.sis.storage.event.CloseEvent;
+import org.apache.sis.storage.event.StoreListener;
+import org.apache.sis.util.ArraysExt;
+import org.apache.sis.util.collection.FrequencySortedSet;
+import org.apache.sis.util.logging.Logging;
+
+
+/**
+ * A grid of {@link MapCanvas} instances shown side-by-side with the same 
visual size.
+ * User's navigation can optionally by synchronized so that panning or zooming
+ * in one map causes the same pan or zoom to be applied in the other maps.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ */
+final class MultiCanvas extends Widget implements Observable {
+    /**
+     * The border around each canvas. This border is applied only on {@link 
BorderPane}.
+     * Therefore, it does not appear when {@code MultiCanvas} is showing only 
one canvas,
+     * because the canvas view is shown directly (without {@link BorderPane}) 
in such case.
+     */
+    private static final Border CANVAS_BORDER = Border.stroke(Color.LIGHTBLUE);
+
+    /**
+     * Handles the {@link javafx.scene.control.ChoiceBox} and menu items for 
selecting a <abbr>CRS</abbr>.
+     */
+    private final RecentReferenceSystems referenceSystems;
+
+    /**
+     * The view where canvases are shown. All children of this pane shall be 
either {@link MapCanvas#fixedPane}
+     * values, or instances of {@link BorderPane} wrapping the above-cited 
values as their center component.
+     *
+     * @see #getCanvasView(Node)
+     * @see #addCanvasView(MapCanvas)
+     */
+    private final GridPane canvasGrid;
+
+    /**
+     * The constraint applied on all rows. Contains the height as a percentage.
+     */
+    private final RowConstraints rowSize;
+
+    /**
+     * The constraint applied on all columns. Contains the width as a 
percentage.
+     */
+    private final ColumnConstraints colSize;
+
+    /**
+     * The combination of canvases and status bar.
+     * The status bar shall be the last element.
+     */
+    private final VBox view;
+
+    /**
+     * All created canvases, visible or not, associated to their title and 
status bar.
+     *
+     * @see #addResource(Resource)
+     * @see #removeResource(Resource)
+     * @see #createOrReuseCanvas(Resource, Collection)
+     */
+    private final Map<MapCanvas, Controls> canvasPool;
+
+    /**
+     * Controls associated to each map canvas.
+     */
+    private static final class Controls {
+        /**
+         * The title of the associated map canvas. This label is not 
necessarily shown.
+         * If the enclosing {@link MultiCanvas} contains only one {@link 
MapCanvas},
+         * the text of this label may be shown in the window title instead.
+         */
+        final Label title;
+
+        /**
+         * The status bar of the associated map canvas. Only one status bar 
will be shown at any given time,
+         * but it is easier to nevertheless create a separated instance for 
each canvas for avoiding the need
+         * to unregister and register again numerous listeners every time that 
the mouse moves over a new canvas.
+         * Also because the warning message, coordinate format, and the set of 
sample dimensions to show depend
+         * on the {@link MapCanvas} content.
+         */
+        final StatusBar status;
+
+        /**
+         * Creates new controls for a new map canvas, then registers the 
listeners.
+         * Note that these listeners create cyclic references: most references 
are from {@code canvas} to {@code owner}
+         * (therefore could be garbage-collected), but there are also some 
references from {@link #referenceSystems} to
+         * {@linkplain #status} bar, which itself has a reference to the 
canvas, therefore preventing garbage collection.
+         *
+         * @param  owner   the enclosing {@code MultiCanvas}.
+         * @param  canvas  the new canvas.
+         */
+        Controls(final MultiCanvas owner, final MapCanvas canvas) {
+            title  = new Label();
+            status = new StatusBar(owner.referenceSystems);
+            status.track(canvas);
+            final var menu = new MapMenu(canvas);
+            menu.addReferenceSystems(owner.referenceSystems);
+            menu.addCopyOptions(status);
+            getView(canvas).addEventHandler(MouseEvent.MOUSE_ENTERED, (event) 
-> owner.showStatusBar(canvas));
+        }
+    }
+
+    /**
+     * Whether the status bar is visible. If {@code true}, the status bar
+     * shall be the last element of the {@linkplain #view} children.
+     */
+    private boolean isStatusBarVisible;
+
+    /**
+     * The action to execute when any resource contained in this {@code 
MultiCanvas} is closed.
+     * This listener delegates to {@link #removeResource(Resource)}.
+     *
+     * @see #removeResource(Resource)
+     */
+    private final OnClose closer;
+
+    /**
+     * The listeners to notify when the state of this {@code MultiCanvas} 
changed.
+     * This is a copy-on-change array: a new array is created every time that 
a listener is added or removed.
+     *
+     * @see #addListener(InvalidationListener)
+     * @see #removeListener(InvalidationListener)
+     * @see #invalidate()
+     */
+    private InvalidationListener[] listeners;
+
+    /**
+     * Creates an initially empty grid of map canvases.
+     */
+    public MultiCanvas() {
+        canvasPool = new LinkedHashMap<>();
+        canvasGrid = new GridPane();
+        VBox.setVgrow(canvasGrid, Priority.ALWAYS);
+        rowSize = new RowConstraints();
+        colSize = new ColumnConstraints();
+        rowSize.setVgrow(Priority.ALWAYS);
+        colSize.setHgrow(Priority.ALWAYS);
+        referenceSystems = new RecentReferenceSystems();
+        referenceSystems.addUserPreferences();
+        referenceSystems.addAlternatives("EPSG:4326", "EPSG:3395", "MGRS");    
 // WGS 84 / World Mercator
+        view = new VBox(canvasGrid);
+        closer = new OnClose();
+    }
+
+    /**
+     * Returns the encapsulated JavaFX component to add in a scene graph for 
making the canvases visible.
+     * The {@code Region} subclass is implementation dependent and may change 
in any future SIS version.
+     *
+     * @return the JavaFX component to insert in a scene graph.
+     */
+    @Override
+    public Region getView() {
+        return view;
+    }
+
+    /**
+     * Returns the JavaFX node to show for the given canvas. This method is 
defined
+     * for having a central place where this choice is made, for ensuring 
consistency.
+     */
+    private static Region getView(final MapCanvas canvas) {
+        return canvas.fixedPane;
+    }
+
+    /**
+     * Returns the view of the map canvas which is contained in the given node 
of the map canvas grid.
+     * The given {@code child} argument shall be one of the children of {@link 
#canvasGrid}.
+     *
+     * @param  child  an element of the {@code canvasGrid.getChildren()} list.
+     * @return the {@link MapCanvas#fixedPane} value in the given node.
+     * @throws ClassCastException if {@code child} does not comply with the 
conditions documented in {@link #canvasGrid}.
+     */
+    private static Region getCanvasView(final Node child) {
+        return (Region) ((child instanceof BorderPane pane) ? pane.getCenter() 
: child);
+    }
+
+    /**
+     * Returns the controls of a canvas when only its view is known.
+     * This method is inefficient, but is invoked in contextes where the map 
has only one element.
+     *
+     * @param  canvasView  the result of a call to {@link 
#getCanvasView(Node)}.
+     * @return the controls for the canvas having the given view.
+     * @throws NoSuchElementException if no canvas with the given view has 
been found.
+     */
+    private Controls getControls(final Region canvasView) {
+        for (final Map.Entry<MapCanvas, Controls> entry : 
canvasPool.entrySet()) {
+            if (getView(entry.getKey()) == canvasView) {
+                return entry.getValue();
+            }
+        }
+        throw new NoSuchElementException();
+    }
+
+    /**
+     * Adds a view for the given map canvas. If the given {@code canvas} is 
the only map canvas which is shown,
+     * then its view will be added directly. Otherwise, the {@code canvas} 
view will be wrapped as the center of
+     * a {@link BorderPane} with a title on top of it. In the latter case, 
this method may update previous child
+     * for adding also a title bar to them.
+     *
+     * <p>Callers should start a background thread for setting the text of the 
returned label to a map canvas title.
+     * It may be, for example, the label of the resource which is shown. That 
label will not necessarily be visible
+     * now (it depends how many canvases are shown). Its text may be shown in 
the window title bar instead.</p>
+     *
+     * @param  canvas  the canvas for which to add a view.
+     * @return the label on which the caller should set the map canvas title.
+     */
+    @SuppressWarnings("fallthrough")
+    private Label addCanvasView(final MapCanvas canvas) {
+        final Controls controls = canvasPool.computeIfAbsent(canvas, (key) -> 
new Controls(this, key));
+        Region canvasView = getView(canvas);
+        final List<Node> children = canvasGrid.getChildren();
+        switch (children.size()) {
+            case 0:  break;
+            case 1:  var previous = (Region) children.removeLast();
+                     previous = addTitleBar(previous, 
getControls(previous).title);
+                     children.add(previous);
+                     // Fall through
+            default: canvasView = addTitleBar(canvasView, controls.title);
+        }
+        children.add(canvasView);
+        return controls.title;
+    }
+
+    /**
+     * Removes the view of the given map canvas.
+     * Because of bidirectional references between {@link MapCanvas} and 
{@link MultiCanvas} through listeners,
+     * instead of trying to remove entries from the {@link #canvasPool} map, 
current implementation rather just
+     * hides unused canvases and may reuse them later.
+     *
+     * @param  canvas  the canvas for which to remove the view.
+     * @return whether the view has been found and removed.
+     */
+    private boolean removeCanvasView(final MapCanvas canvas) {
+        boolean changed = false;
+        final Region canvasView = getView(canvas);
+        final List<Node> children = canvasGrid.getChildren();
+        for (int i = children.size(); --i >= 0;) {
+            if (getCanvasView(children.get(i)) == canvasView) {
+                children.remove(i);
+                changed = true;
+            }
+        }
+        if (children.size() == 1) {
+            Node previous = children.removeLast();
+            previous = getCanvasView(previous);
+            children.add(previous);                 // Hide the title bar and 
the border.
+        }
+        clear(canvas);
+        return changed;
+    }
+
+    /**
+     * Removes the specified map canvas view.
+     * This method is invoked when the user clicks on the close button.
+     * The {@link MapCanvas} instance is kept in the pool for potential reuse.
+     *
+     * @param  canvasView  view of the canvas to close.
+     */
+    private void removeCanvasView(final Region canvasView) {
+        boolean changed = false;
+        for (final MapCanvas canvas : canvasPool.keySet()) {
+            if (getView(canvas) == canvasView) {
+                changed |= removeCanvasView(canvas);
+                clear(canvas);
+                // Should have only one instance, but continue the loop by 
paranoia.
+            }
+        }
+        if (changed) {
+            layoutGrid();
+            invalidate();
+        }
+    }
+
+    /**
+     * Wraps the given {@code MapCanvas} view into a pane with a title.
+     *
+     * @param  canvasView  the {@link MapCanvas} view for which to add a title 
bar.
+     * @return a wrapper of {@code canvasView} with the addition of a title 
bar.
+     */
+    private BorderPane addTitleBar(final Region canvasView, final Label title) 
{
+        final var close = new Button("❌");
+        close.setOnAction((event) -> removeCanvasView(canvasView));
+        HBox.setHgrow(title, Priority.ALWAYS);
+        HBox.setHgrow(close, Priority.NEVER);
+        final var bar = new HBox(title, close);
+        bar.setAlignment(Pos.CENTER);
+        title.setAlignment(Pos.CENTER);
+        title.setMaxWidth(Double.MAX_VALUE);
+        final var pane = new BorderPane(canvasView);
+        pane.setBorder(CANVAS_BORDER);
+        pane.setTop(bar);
+        return pane;
+    }
+
+    /**
+     * Returns whether the given resource is supported.
+     * This is currently a static method because {@link 
#createOrReuseCanvas(Resource, Collection)} is static.
+     * But this method would become public and non-static of {@code 
createOrReuseCanvas(…)} become public and
+     * non-static.
+     *
+     * @param  resource   the resource to show, or {@code null}.
+     * @return whether the given resource can be shown.
+     */
+    static boolean isSupported(final Resource resource) {
+        return (resource instanceof GridCoverageResource);
+    }
+
+    /**
+     * Returns a canvas showing the given resource, or {@code null} if none.
+     * If one of the canvases in the {@code available} collection is suitable,
+     * this method should configure it for showing the given resource.
+     * Otherwise, this method should create a new canvas.
+     *
+     * <p>There is currently no mechanism for removing a canvas because it is 
tedious to remove all listeners.
+     * However, {@code MultiCanvas} may hide a canvas by setting its resource 
to null and reuse that canvas later
+     * if a new resource is specified.</p>
+     *
+     * @param  resource   the resource to show.
+     * @param  available  previously created canvases that may be reused.
+     * @return a canvas showing the given resource, or {@code null} if none.
+     */
+    private static MapCanvas createOrReuseCanvas(final Resource resource, 
final Collection<MapCanvas> available) {
+        if (resource instanceof GridCoverageResource c) {
+            for (final MapCanvas canvas : available) {
+                if (canvas instanceof CoverageCanvas cc) {
+                    cc.setResource(c);
+                    return cc;
+                }
+            }
+            final var canvas = new CoverageCanvas();
+            canvas.setResource(c);
+            return canvas;
+        }
+        return null;
+    }
+
+    /**
+     * Returns the resource shown by the given canvas, or {@code null} if none.
+     * Note: there is no {@code MapCanvas.getResource()} method because the 
base
+     * {@link MapCanvas} class is not about a single resource.
+     * It may be about a map context.
+     */
+    private static Resource getResource(final MapCanvas canvas) {
+        if (canvas instanceof CoverageCanvas c) {
+            return c.getResource();
+        }
+        return null;
+    }
+
+    /**
+     * Tries to allocate a canvas for the given resource and to show it in 
this multi-canvas view.
+     * The grid is reorganized for accommodating the new canvas,
+     * potentially with the addition of a new row or a new column.
+     * The same resource may be shown many times. Null resources are ignored.
+     *
+     * @param  resource  the resource to add, or {@code null}.
+     * @return whether the given resource has been accepted.
+     */
+    public boolean addResource(final Resource resource) {
+        if (resource == null) {
+            return false;
+        }
+        /*
+         * Get the collection of `MapCanvas` instances which are not currently 
used.
+         * These instances may be recycled for the given resource.
+         */
+        final var available = LinkedHashMap.<Node, 
MapCanvas>newLinkedHashMap(canvasPool.size());
+        canvasPool.keySet().forEach((canvas) -> available.put(getView(canvas), 
canvas));
+        canvasGrid.getChildren().forEach((child) -> 
available.remove(getCanvasView(child)));
+        final MapCanvas canvas = createOrReuseCanvas(resource, 
available.values());
+        if (canvas == null) {
+            return false;
+        }
+        final Locale locale = canvas.getLocale();
+        final var title = addCanvasView(canvas);
+        BackgroundThreads.execute(() -> {
+            String label;
+            try {
+                label = DataStoreOpener.findLabel(resource, locale, true);
+            } catch (DataStoreException | RuntimeException e) {
+                Logging.recoverableException(LOGGER, MultiCanvas.class, 
"addResource", e);
+                label = DataStoreOpener.fallbackLabel(resource, locale);
+            }
+            final String text = label;      // Because lambda functions want 
final variables.
+            Platform.runLater(() -> {
+                title.setText(text);
+                invalidate();
+            });
+        });
+        resource.addListener(CloseEvent.class, closer);
+        layoutGrid();
+        return true;
+    }
+
+    /**
+     * Tries to remove all canvases associated to the given resource.
+     * The grid is reorganized for accommodating the remaining canvases,
+     * potentially with the removal of rows or columns.
+     *
+     * <p>This method is invoked automatically if the resource added with
+     * {@link #addResource(Resource)} fired a {@link CloseEvent}.</p>
+     *
+     * @param  resource  the resource to remove, or {@code null}.
+     * @return whether the given resource has been found and removed.
+     */
+    public boolean removeResource(final Resource resource) {
+        boolean changed = false;
+        if (resource != null) {
+            resource.removeListener(CloseEvent.class, closer);
+            for (final MapCanvas canvas : canvasPool.keySet()) {
+                if (getResource(canvas) == resource) {
+                    changed |= removeCanvasView(canvas);
+                }
+            }
+            if (changed) {
+                layoutGrid();
+                invalidate();
+            }
+        }
+        return changed;
+    }
+
+    /**
+     * Sets the grid row index, column index and span on all map canvases.
+     * The grid size and the indexes of each child depend on the number of 
children.
+     */
+    private void layoutGrid() {
+        final List<Node> children = canvasGrid.getChildren();
+        final int size = children.size();
+        if (size != 0) {
+            final int numRow = (int) Math.rint(Math.sqrt(size));
+            final int numCol = Math.ceilDiv(size, numRow);
+            rowSize.setPercentHeight(resize(rowSize, 
canvasGrid.getRowConstraints(),    numRow));
+            colSize.setPercentWidth (resize(colSize, 
canvasGrid.getColumnConstraints(), numCol));
+            int span = GridPane.REMAINING;  // Allocated to the last canvas.
+            for (int i = size; --i >= 0;) {
+                final int row = i / numCol;
+                final int col = i % numCol;
+                GridPane.setConstraints(children.get(i), col, row, span, 1);
+                span = 1;
+            }
+        }
+    }
+
+    /**
+     * Ensures that the given list has the expected size.
+     * Opportunistically computes the percentage to set for the column width 
or height.
+     *
+     * @param  <C>       {@link RowConstraints} or {@link ColumnConstraints}.
+     * @param  toAdd     the constraint to add if the list is too short.
+     * @param  declared  the current list of constraints.
+     * @param  expected  the expected size of the list.
+     * @return the percentage to set for the column width or row height.
+     */
+    private static <C extends ConstraintsBase> double resize(final C toAdd, 
final List<C> declared, final int expected) {
+        final int size = declared.size();
+        if (size > expected) {
+            declared.subList(expected, size).clear();
+        } else if (size != expected) do {
+            declared.add(toAdd);
+        } while (declared.size() < expected);
+        return 100d / expected;
+    }
+
+    /**
+     * Shows the status bar of the given canvas.
+     * This method is invoked when the mouse enter in a new canvas.
+     *
+     * @param  canvas  the canvas for which to show the status bar.
+     */
+    private void showStatusBar(final MapCanvas canvas) {
+        final Controls controls = canvasPool.get(canvas);
+        if (controls != null) {
+            final Region status = controls.status.getView();
+            final List<Node> children = view.getChildren();
+            if (isStatusBarVisible) {
+                children.set(children.size() - 1, status);
+            } else {
+                children.add(status);
+                isStatusBarVisible = true;
+            }
+        }
+    }
+
+    /**
+     * Returns the titles of all canvases shown in this {@code MultiCanvas}.
+     * If many canvases have the same title, the most frequently used title 
will be first.
+     */
+    final FrequencySortedSet<String> getCanvasTitles() {
+        final var titles = new FrequencySortedSet<String>(true);
+        for (final Controls controls : canvasPool.values()) {
+            final String text = controls.title.getText();
+            if (text != null) titles.add(text);
+        }
+        return titles;
+    }
+
+    /**
+     * Adds a listener which will be notified when a map canvas is added or 
removed.
+     * The listener is also invoked for a change in the title of a map canvas,
+     * for example after completion of the background thread fetching the 
title.
+     *
+     * @param  listener  the listener to add.
+     */
+    @Override
+    public void addListener(final InvalidationListener listener) {
+        Objects.requireNonNull(listener);
+        if (listeners == null) {
+            listeners = new InvalidationListener[] {listener};
+        } else {
+            listeners = ArraysExt.append(listeners, listener);
+        }
+    }
+
+    /**
+     * Removes a listener which is not longer interested to map canvas 
addition or removal.
+     *
+     * @param  listener  the listener to remove.
+     */
+    @Override
+    public void removeListener(final InvalidationListener listener) {
+        Objects.requireNonNull(listener);
+        final InvalidationListener[] snapshot = listeners;
+        if (snapshot != null) {
+            for (int i=0; i<snapshot.length; i++) {
+                if (snapshot[i] == listener) {
+                    listeners = ArraysExt.remove(snapshot, i, 1);
+                    break;
+                }
+            }
+        }
+    }
+
+    /**
+     * Notifies all listeners that a map canvas has been added or removed.
+     * May also be invoked if a title changed.
+     */
+    protected void invalidate() {
+        if (listeners != null) {
+            for (final InvalidationListener listener : listeners) {
+                listener.invalidated(this);
+            }
+        }
+    }
+
+    /**
+     * Clears the content of the given map canvas.
+     * It is better to remove the canvas from {@link #canvasGrid} before to 
invoke this method.
+     *
+     * @param  canvas  the map canvas to clear.
+     */
+    private void clear(final MapCanvas canvas) {
+        final Resource resource = getResource(canvas);
+        if (resource != null) {
+            resource.removeListener(CloseEvent.class, closer);
+        }
+        canvas.clearLater();
+        final Controls controls = canvasPool.get(canvas);       // Should 
never be null, but let be safe.
+        if (controls != null) controls.title.setText(null);
+    }
+
+    /**
+     * Releases resources in an attempt to help the garbage collector.
+     * This is invoked when the window containing this {@code MultiCanvas} is 
closed.
+     */
+    final void dispose() {
+        canvasGrid.getChildren().clear();
+        canvasPool.keySet().forEach(this::clear);
+        canvasPool.clear();
+    }
+
+    /**
+     * The action to execute when a resource is closed.
+     * A single instance of this listener can be shared by all resources.
+     */
+    private final class OnClose implements StoreListener<CloseEvent> {
+        /**
+         * Invoked when a resource is closing. This method can be invoked from 
any thread,
+         * but delegation to {@link #removeResource(Resource)} is done in the 
JavaFX thread.
+         * This method blocks until the JavaFX thread finished to execute 
{@code removeResource(…)}
+         * for avoiding that the background thread closes the resource before 
we removed its usages.
+         */
+        @Override public void eventOccured(final CloseEvent event) {
+            final Resource resource = event.getSource();
+            if (Platform.isFxApplicationThread()) {
+                removeResource(resource);
+            } else BackgroundThreads.runAndWaitDialog(() -> {
+                removeResource(resource);
+                return null;
+            });
+        }
+    }
+}
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/StatusBar.java 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/StatusBar.java
index 5032f31028..1336fc9505 100644
--- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/StatusBar.java
+++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/StatusBar.java
@@ -234,7 +234,7 @@ public class StatusBar extends Widget implements 
EventHandler<MouseEvent> {
      * The transform from <i>objective CRS</i> to the CRS of coordinates shown 
in this status bar.
      * The {@linkplain CoordinateOperation#getSourceCRS() source CRS} is 
{@link #objectiveCRS} and
      * the {@linkplain CoordinateOperation#getTargetCRS() target CRS} is 
{@link CoordinateFormat#getDefaultCRS()}.
-     * This transform may be null if there is no CRS change to apply
+     * This transform may be null if there is no <abbr>CRS</abbr> change to 
apply
      * (in which case {@link #localToPositionCRS} is the same instance as 
{@link #localToObjectiveCRS})
      * or if the target is not a CRS (for example it may be a Military Grid 
Reference System (MGRS) code).
      *
@@ -393,6 +393,7 @@ public class StatusBar extends Widget implements 
EventHandler<MouseEvent> {
      * Note that the "± <var>accuracy</var>" text may be shown or hidden 
depending on the zoom level.
      * If pixels on screen are larger than the accuracy, then the accuracy 
text is hidden.</p>
      *
+     * @see MapCanvas#positionalAccuracyProperty()
      * @see CoordinateFormat#setGroundAccuracy(Quantity)
      *
      * @since 1.3
@@ -497,11 +498,11 @@ public class StatusBar extends Widget implements 
EventHandler<MouseEvent> {
      * Note that in such case, the {@link #localToObjectiveCRS} property value 
will be overwritten
      * at any time (for example every time that a gesture event such as pan, 
zoom or rotation happens).
      *
-     * <p>If the {@code systemChooser} argument is non-null, user will be able 
to select different CRS
-     * using the contextual menu on the status bar.</p>
+     * <p>If the {@code systemChooser} argument is non-null, user will be able 
to select different
+     * reference systems using the contextual menu on the status bar.</p>
      *
      * <h4>Limitations</h4>
-     * This constructor registers numerous listeners on {@code canvas} and 
{@code systemChooser}.
+     * This constructor registers numerous listeners on {@code systemChooser}.
      * There is currently no unregistration mechanism. The {@code StatusBar} 
instance is expected
      * to exist as long as the {@code MapCanvas} and {@code 
RecentReferenceSystems} instances
      * given to this constructor.
@@ -534,10 +535,10 @@ public class StatusBar extends Widget implements 
EventHandler<MouseEvent> {
         view.setPadding(PADDING);
         view.setAlignment(Pos.CENTER_RIGHT);
         /*
-         * Contextual menu can be invoked anywhere on the HBox; we do not 
register this menu
+         * Contextual menu can be invoked anywhere on the HBox. We do not 
register this menu
          * on `position` or `sampleValues` labels because those regions are 
relatively small.
          */
-        final ContextMenu menu = new ContextMenu();
+        final var menu = new ContextMenu();
         view.setOnMousePressed((event) -> {
             if (event.isSecondaryButtonDown() && !menu.getItems().isEmpty()) {
                 menu.show((HBox) event.getSource(), event.getScreenX(), 
event.getScreenY());
@@ -591,11 +592,12 @@ public class StatusBar extends Widget implements 
EventHandler<MouseEvent> {
          * a contextual menu item to be added or removed.
          */
         final ObservableList<MenuItem> items = menu.getItems();
+        final int i = items.size();     // Index where to insert 
`valueChoices`.
         sampleValuesProvider = new SimpleObjectProperty<>(this, 
"valueProvider");
         sampleValuesProvider.addListener((p,o,n) -> {
             ValuesUnderCursor.update(this, o, n);
             if (o != null) items.remove(o.valueChoices);
-            if (n != null) items.add(1, n.valueChoices);
+            if (n != null) items.add(i, n.valueChoices);
             setSampleValuesVisible(n != null);
         });
     }
@@ -607,17 +609,18 @@ public class StatusBar extends Widget implements 
EventHandler<MouseEvent> {
      * value may be overwritten at any time, for example after each gesture 
event such as pan, zoom or rotation.
      *
      * <h4>Limitations</h4>
-     * Current implementation accepts only zero or one {@code MapCanvas}. A 
future implementation
-     * may accept a larger number of canvas for tracking many views with a 
single status bar
-     * (for example images over the same area but at different times).
+     * This constructor registers numerous listeners on {@code canvas}.
+     * There is currently no unregistration mechanism.
+     * This method can be invoked only once.
      *
      * @param  canvas  the canvas that this status bar is tracking.
+     * @throws IllegalStateException if this method has already been invoked 
with another canvas.
      *
      * @since 1.3
      */
     public void track(final MapCanvas canvas) {
         if (this.canvas != null) {
-            throw new IllegalArgumentException(Errors.format(
+            throw new IllegalStateException(Errors.format(
                         Errors.Keys.TooManyCollectionElements_3, "canvas", 1, 
2));
         }
         /*
@@ -625,7 +628,8 @@ public class StatusBar extends Widget implements 
EventHandler<MouseEvent> {
          * We do not allow the canvas to be changed after construction because 
of the added complexity
          * (e.g. we would have to remember all registered listeners so we can 
unregister them).
          */
-        this.canvas = Objects.requireNonNull(canvas);
+        this.canvas = canvas;
+        lowestAccuracy.bind(canvas.positionalAccuracyProperty());
         sampleValuesProvider.set(ValuesUnderCursor.create(canvas));
         canvas.errorProperty().addListener((p,o,n) -> setRenderingError(n));
         canvas.renderingProperty().addListener((p,o,n) -> {
@@ -761,7 +765,7 @@ public class StatusBar extends Widget implements 
EventHandler<MouseEvent> {
 
     /**
      * Implementation of {@link #applyCanvasGeometry(GridGeometry)} without 
changing {@link #position} visibility state.
-     * Invoking this method usually invalidate the coordinates shown in this 
status bar. The new coordinates cannot be
+     * Invoking this method usually invalidates the coordinates shown in this 
status bar. The new coordinates cannot be
      * easily recomputed because the {@link #lastX} and {@link #lastY} values 
may not be valid anymore, as a result of
      * possible changes in JavaFX local coordinate system. Consequently, the 
coordinates should be temporarily hidden
      * until a new {@link MouseEvent} gives us the new local coordinates, 
unless this method is invoked in a context
@@ -1447,7 +1451,7 @@ public class StatusBar extends Widget implements 
EventHandler<MouseEvent> {
     }
 
     /**
-     * Converts and formats the given local coordinates, but without modifying 
text shown in this status bar.
+     * Converts and formats the given local coordinates, but without modifying 
the text shown in this status bar.
      * This is used for copying the coordinates somewhere else, for example on 
the clipboard.
      *
      * @param  x  the <var>x</var> coordinate local to the view.
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/ValuesUnderCursor.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/ValuesUnderCursor.java
index 3909876435..ace282bc0f 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/ValuesUnderCursor.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/ValuesUnderCursor.java
@@ -17,7 +17,6 @@
 package org.apache.sis.gui.map;
 
 import java.util.concurrent.atomic.AtomicReference;
-import javafx.beans.value.WeakChangeListener;
 import javafx.scene.control.Menu;
 import javafx.application.Platform;
 import org.opengis.geometry.DirectPosition;
@@ -161,7 +160,7 @@ public abstract class ValuesUnderCursor {
      */
     protected abstract static class Formatter implements Runnable {
         /**
-         * Coordinates and CRS of the position where to evaluate values.
+         * Coordinates and <abbr>CRS</abbr> of the position where to evaluate 
values.
          * This position shall not be modified; new coordinates shall be 
specified in a new instance.
          * A {@code null} value means that there is no more sample values to 
format.
          *
@@ -319,7 +318,7 @@ public abstract class ValuesUnderCursor {
     }
 
     /**
-     * Creates a new instance for the given canvas and registers as a listener 
by weak reference.
+     * Creates a new instance for the given canvas and registers as a listener.
      * Caller must retain the returned reference somewhere, e.g. in {@link 
StatusBar#sampleValuesProvider}.
      *
      * @param  canvas  the canvas for which to create a {@link 
ValuesUnderCursor}, or {@code null}.
@@ -327,8 +326,8 @@ public abstract class ValuesUnderCursor {
      */
     static ValuesUnderCursor create(final MapCanvas canvas) {
         if (canvas instanceof CoverageCanvas cc) {
-            final ValuesFromCoverage listener = new ValuesFromCoverage();
-            cc.coverageProperty.addListener(new 
WeakChangeListener<>(listener));
+            final var listener = new ValuesFromCoverage();
+            cc.coverageProperty.addListener(listener);
             cc.sliceExtentProperty.addListener((p,o,n) -> 
listener.setSlice(n));
             final GridCoverage coverage = cc.coverageProperty.get();
             if (coverage != null) {
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/package-info.java 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/package-info.java
index 5a157f275a..66256d37f8 100644
--- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/package-info.java
+++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/package-info.java
@@ -29,7 +29,7 @@
  * @author  Smaniotto Enzo (GSoC)
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.3
+ * @version 1.7
  * @since   1.1
  */
 package org.apache.sis.gui;
diff --git 
a/optional/src/org.apache.sis.gui/test/org/apache/sis/gui/coverage/CoverageCanvasApp.java
 
b/optional/src/org.apache.sis.gui/test/org/apache/sis/gui/coverage/CoverageCanvasApp.java
index 5a176e52be..557f3aed7d 100644
--- 
a/optional/src/org.apache.sis.gui/test/org/apache/sis/gui/coverage/CoverageCanvasApp.java
+++ 
b/optional/src/org.apache.sis.gui/test/org/apache/sis/gui/coverage/CoverageCanvasApp.java
@@ -21,8 +21,8 @@ import java.awt.Point;
 import java.awt.image.DataBuffer;
 import javafx.application.Application;
 import javafx.scene.Scene;
-import javafx.scene.layout.BorderPane;
 import javafx.stage.Stage;
+import javafx.scene.layout.BorderPane;
 import org.apache.sis.coverage.grid.PixelInCell;
 import org.apache.sis.coverage.grid.GridCoverage2D;
 import org.apache.sis.coverage.grid.GridGeometry;
@@ -38,7 +38,7 @@ import org.apache.sis.image.TiledImageMock;
 
 /**
  * Shows {@link CoverageCanvas} with random data. The image will have small 
tiles of size
- * {@value #TILE_WIDTH}×{@value #TILE_HEIGHT}. The image will artificially 
fails to provide
+ * {@value #TILE_WIDTH}×{@value #TILE_HEIGHT}. The image will artificially 
fail to provide
  * some tiles in order to test error controls.
  *
  * @author  Martin Desruisseaux (Geomatys)
@@ -73,11 +73,11 @@ public class CoverageCanvasApp extends Application {
      */
     @Override
     public void start(final Stage window) {
-        final CoverageCanvas canvas = new CoverageCanvas();
-        final StatusBar statusBar = new StatusBar(null);
+        final var canvas = new CoverageCanvas();
+        final var statusBar = new StatusBar(null);
         statusBar.track(canvas);
-        canvas.setCoverage(createImage());
-        final BorderPane pane = new BorderPane(canvas.getView());
+        canvas.setCoverage(createImage(true));
+        final var pane = new BorderPane(canvas.getView());
         pane.setBottom(statusBar.getView());
         window.setTitle("CoverageCanvas Test");
         window.setScene(new Scene(pane));
@@ -98,14 +98,17 @@ public class CoverageCanvasApp extends Application {
     }
 
     /**
-     * Creates a dummy image for testing purpose. Some tiles will
+     * Creates a dummy image for testing purpose. Some tiles may
      * have artificial errors in order to see the error controls.
+     *
+     * @param  withErrors  whether to cause artificial errors in some tiles.
+     * @return an image to show in the map canvas.
      */
-    private static GridCoverage2D createImage() {
+    public static GridCoverage2D createImage(final boolean withErrors) {
         final Random random = new Random();
         final int width  = TILE_WIDTH  * 4;
         final int height = TILE_HEIGHT * 2;
-        final TiledImageMock image = new TiledImageMock(
+        final var image = new TiledImageMock(
                 DataBuffer.TYPE_BYTE, 1,
                 random.nextInt(50) - 25,            // minX
                 random.nextInt(50) - 25,            // minY
@@ -116,7 +119,7 @@ public class CoverageCanvasApp extends Application {
                 false);
         image.validate();
         final double sc = 500d / Math.max(width, height);
-        final WritablePixelIterator it = WritablePixelIterator.create(image);
+        final var it = WritablePixelIterator.create(image);
         while (it.next()) {
             final Point p = it.getPosition();
             final double d = Math.hypot(p.x - width/2, p.y - height/2);
@@ -126,7 +129,9 @@ public class CoverageCanvasApp extends Application {
             }
             it.setSample(0, value);
         }
-        image.failRandomly(random, false);
+        if (withErrors) {
+            image.failRandomly(random, false);
+        }
         return new GridCoverage2D(new GridGeometry(null, 
PixelInCell.CELL_CORNER,
                 MathTransforms.identity(2), 
CommonCRS.Engineering.DISPLAY.crs()), null, image);
     }
diff --git 
a/optional/src/org.apache.sis.gui/test/org/apache/sis/gui/map/MapWindowsApp.java
 
b/optional/src/org.apache.sis.gui/test/org/apache/sis/gui/map/MapWindowsApp.java
new file mode 100644
index 0000000000..0b1c570b30
--- /dev/null
+++ 
b/optional/src/org.apache.sis.gui/test/org/apache/sis/gui/map/MapWindowsApp.java
@@ -0,0 +1,111 @@
+/*
+ * 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.map;
+
+import javafx.application.Application;
+import javafx.geometry.Pos;
+import javafx.scene.Scene;
+import javafx.stage.Stage;
+import javafx.scene.layout.VBox;
+import javafx.scene.control.Label;
+import javafx.scene.control.Button;
+import javafx.event.ActionEvent;
+import javafx.event.EventHandler;
+import org.apache.sis.gui.coverage.CoverageCanvasApp;
+import org.apache.sis.gui.internal.BackgroundThreads;
+import org.apache.sis.storage.MemoryGridCoverageResource;
+import org.apache.sis.util.iso.Names;
+
+
+/**
+ * Shows {@link MapWindows} with random data.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ */
+public class MapWindowsApp extends Application implements 
EventHandler<ActionEvent> {
+    /**
+     * The instance to test.
+     */
+    private MapWindows windows;
+
+    /**
+     * Number of windows created, used for creating an identifier.
+     */
+    private int count;
+
+    /**
+     * Creates a widget viewer.
+     */
+    public MapWindowsApp() {
+    }
+
+    /**
+     * Starts the test application.
+     *
+     * @param args  ignored.
+     */
+    public static void main(final String[] args) {
+        launch(args);
+    }
+
+    /**
+     * Creates and starts the test application.
+     *
+     * @param  window  where to show the application.
+     */
+    @Override
+    public void start(final Stage window) {
+        final var canvas = new Button("Add a canvas");
+        final var label = new Label("Close this window for ending the 
application.");
+        final var box = new VBox(24, canvas, label);
+        box.setAlignment(Pos.CENTER);
+        window.setTitle("MapWindows Test");
+        window.setScene(new Scene(box));
+        window.setWidth (400);
+        window.setHeight(200);
+        window.setX(0);
+        window.setY(0);
+        window.show();
+        windows = new MapWindows(window);
+        canvas.setOnAction(this);
+    }
+
+    /**
+     * Invoked when the button is pressed.
+     *
+     * @param  event  the event sent by the button.
+     */
+    @Override
+    public void handle(final ActionEvent event) {
+        windows.addResource(new MemoryGridCoverageResource(
+                null,       // Parent resource.
+                Names.createLocalName(null, null, "Image #" + ++count),
+                CoverageCanvasApp.createImage(false),
+                null));     // Grid coverage processor.
+    }
+
+    /**
+     * Stops background threads for allowing <abbr>JVM</abbr> to exit.
+     *
+     * @throws Exception if an error occurred while stopping the threads.
+     */
+    @Override
+    public void stop() throws Exception {
+        BackgroundThreads.stop();
+        super.stop();
+    }
+}

Reply via email to