sis-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From desruisse...@apache.org
Subject [sis] branch geoapi-4.0 updated: Improve StatusBar, make it public and make it useable with arbitrary MapCanvas.
Date Mon, 13 Apr 2020 10:59:37 GMT
This is an automated email from the ASF dual-hosted git repository.

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


The following commit(s) were added to refs/heads/geoapi-4.0 by this push:
     new 2f96f80  Improve StatusBar, make it public and make it useable with arbitrary MapCanvas.
2f96f80 is described below

commit 2f96f80af1f9e73aa0fd810e6167f2d3700337a4
Author: Martin Desruisseaux <martin.desruisseaux@geomatys.com>
AuthorDate: Mon Apr 13 12:58:08 2020 +0200

    Improve StatusBar, make it public and make it useable with arbitrary MapCanvas.
---
 .../org/apache/sis/gui/coverage/CoverageView.java  | 104 +---
 .../java/org/apache/sis/gui/coverage/GridView.java |  22 +-
 .../org/apache/sis/gui/coverage/GridViewSkin.java  |  26 +-
 .../org/apache/sis/gui/coverage/ImageLoader.java   |   2 +-
 .../org/apache/sis/gui/coverage/ImageRequest.java  |  17 +-
 .../org/apache/sis/gui/coverage/StatusBar.java     | 336 ------------
 .../java/org/apache/sis/gui/map/MapCanvas.java     |  85 +++-
 .../java/org/apache/sis/gui/map/MapCanvasAWT.java  |  12 +-
 .../java/org/apache/sis/gui/map/StatusBar.java     | 563 +++++++++++++++++++++
 .../java/org/apache/sis/internal/gui/Styles.java   |   5 +
 10 files changed, 704 insertions(+), 468 deletions(-)

diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageView.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageView.java
index 2d558aa..ce404d5 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageView.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageView.java
@@ -21,7 +21,6 @@ import java.util.EnumMap;
 import java.util.Objects;
 import java.awt.Graphics2D;
 import java.awt.geom.AffineTransform;
-import java.awt.geom.NoninvertibleTransformException;
 import java.awt.image.RenderedImage;
 import javafx.scene.paint.Color;
 import javafx.scene.layout.Region;
@@ -32,7 +31,6 @@ import javafx.beans.value.ObservableValue;
 import javafx.beans.property.ObjectProperty;
 import javafx.beans.property.SimpleObjectProperty;
 import javafx.concurrent.Task;
-import javafx.scene.input.MouseEvent;
 import org.opengis.geometry.Envelope;
 import org.opengis.referencing.datum.PixelInCell;
 import org.opengis.referencing.operation.TransformException;
@@ -40,14 +38,14 @@ import org.apache.sis.coverage.grid.GridCoverage;
 import org.apache.sis.coverage.grid.GridExtent;
 import org.apache.sis.coverage.grid.GridGeometry;
 import org.apache.sis.internal.gui.ImageRenderings;
-import org.apache.sis.util.collection.BackingStoreException;
 import org.apache.sis.referencing.operation.matrix.AffineTransforms2D;
 import org.apache.sis.referencing.operation.transform.MathTransforms;
 import org.apache.sis.gui.map.MapCanvasAWT;
+import org.apache.sis.gui.map.StatusBar;
 
 
 /**
- * Shows a {@link RenderedImage} produced by a {@link GridCoverage}.
+ * A canvas for {@link RenderedImage} produced by a {@link GridCoverage}.
  *
  * @author  Martin Desruisseaux (Geomatys)
  * @version 1.1
@@ -56,8 +54,8 @@ import org.apache.sis.gui.map.MapCanvasAWT;
  */
 final class CoverageView extends MapCanvasAWT {
     /**
-     * The data shown in this view. Note that setting this property to a non-null value may not
-     * modify the view content immediately. Instead, a background process will request the tiles.
+     * The data shown in this canvas. Note that setting this property to a non-null value may not
+     * modify the canvas content immediately. Instead, a background process will request the tiles.
      *
      * <p>Current implementation is restricted to {@link GridCoverage} instances, but a future
      * implementation may generalize to {@link org.opengis.coverage.Coverage} instances.</p>
@@ -69,7 +67,7 @@ final class CoverageView extends MapCanvasAWT {
 
     /**
      * A subspace of the grid coverage extent where all dimensions except two have a size of 1 cell.
-     * May be {@code null} if this grid coverage has only two dimensions with a size greater than 1 cell.
+     * May be {@code null} if the grid coverage has only two dimensions with a size greater than 1 cell.
      *
      * @see #getSliceExtent()
      * @see #setSliceExtent(GridExtent)
@@ -92,10 +90,10 @@ final class CoverageView extends MapCanvasAWT {
     private RangeType currentDataAlternative;
 
     /**
-     * The data to shown, or {@code null} if not yet specified. This image may be tiled,
+     * The data to show, or {@code null} if not yet specified. This image may be tiled,
      * and fetching tiles may require computations to be performed in background thread.
-     * The size of this image is not necessarily {@link #buffer} or {@link #image} size.
-     * In particular this image way cover a larger area.
+     * The size of this {@code RenderedImage} is not necessarily the {@link #image} size.
+     * In particular {@code data} way cover a larger area.
      */
     private RenderedImage data;
 
@@ -105,26 +103,12 @@ final class CoverageView extends MapCanvasAWT {
      */
     private AffineTransform gridToCRS;
 
-    /*
-     * The transform from {@link #data} pixel coordinates to pixel coordinates in the widget.
-     * This is the concatenation of {@link #gridToCRS} followed by {@link #objectiveToDisplay}.
-     * This transform is replaced when the zoom changes or when the viewed area is translated.
-     * We create a new transform each time (we do not modify the existing instance) because it
-     * may be used by a background thread.
-     */
-    private AffineTransform gridToDisplay;
-
     /**
      * The image together with the status bar.
      */
     private final BorderPane imageAndStatus;
 
     /**
-     * The bar where to format the coordinates below mouse cursor.
-     */
-    private final StatusBar statusBar;
-
-    /**
      * Creates a new two-dimensional canvas for {@link RenderedImage}.
      */
     public CoverageView() {
@@ -133,14 +117,12 @@ final class CoverageView extends MapCanvasAWT {
         sliceExtentProperty    = new SimpleObjectProperty<>(this, "sliceExtent");
         dataAlternatives       = new EnumMap<>(RangeType.class);
         currentDataAlternative = RangeType.DECLARED;
-        statusBar              = new StatusBar(this::toImageCoordinates);
         imageAndStatus         = new BorderPane(fixedPane);
-        imageAndStatus.setBottom(statusBar);
         coverageProperty   .addListener(this::onImageSpecified);
         sliceExtentProperty.addListener(this::onImageSpecified);
-        floatingPane.setOnMouseMoved(this::onMouveMoved);
-        floatingPane.setOnMouseEntered(statusBar);
-        floatingPane.setOnMouseExited (statusBar);
+        final StatusBar statusBar = new StatusBar();
+        statusBar.setCanvas(this);
+        imageAndStatus.setBottom(statusBar.getView());
     }
 
     /**
@@ -219,21 +201,6 @@ final class CoverageView extends MapCanvasAWT {
     }
 
     /**
-     * Starts a background task for loading data, computing slice or rendering the data in a {@link CoverageView}.
-     *
-     * <p>Tasks need to be careful to not use any {@link CoverageView} field in their {@link Task#call()}
-     * method (needed fields shall be copied in the JavaFX thread before the background thread is started).
-     * But {@link Task#succeeded()} and similar methods can read and write those fields.</p>
-     */
-    @Override
-    protected final void execute(final Task<?> task) {
-        statusBar.setErrorMessage(null);
-        task.runningProperty().addListener(statusBar::setRunningState);
-        task.setOnFailed((e) -> errorOccurred(e.getSource().getException()));
-        super.execute(task);
-    }
-
-    /**
      * Invoked when a new coverage has been specified or when the slice extent changed.
      *
      * @param  property  the {@link #coverageProperty} or {@link #sliceExtentProperty} (ignored).
@@ -326,12 +293,11 @@ final class CoverageView extends MapCanvasAWT {
      *
      * @param  image        the image to load.
      * @param  geometry     the grid geometry of the coverage that produced the image.
-     * @param  sliceExtent  the extent that were requested.
+     * @param  sliceExtent  the extent that was requested.
      */
     private void setImage(final RenderedImage image, final GridGeometry geometry, final GridExtent sliceExtent) {
         setImage(RangeType.DECLARED, image);
         setRangeType(currentDataAlternative);
-        statusBar.setCoordinateConversion(geometry, sliceExtent);
         try {
             gridToCRS = AffineTransforms2D.castOrCopy(geometry.getGridToCRS(PixelInCell.CELL_CENTER));
         } catch (RuntimeException e) {                      // Conversion not defined or not affine.
@@ -357,7 +323,7 @@ final class CoverageView extends MapCanvasAWT {
      * The rendering will be done later by a call to {@link Renderer#paint(Graphics2D)}.
      */
     @Override
-    protected Renderer createRenderer(){
+    protected Renderer createRenderer() {
         final RenderedImage data = this.data;       // Need to copy this reference here before background tasks.
         if (data == null) {
             return null;
@@ -368,52 +334,14 @@ final class CoverageView extends MapCanvasAWT {
          * vary at any time, and also because we need a new `AffineTransform` instance anyway (we can not reuse
          * an existing instance, because it needs to be stable for use by the background thread).
          */
-        final AffineTransform tr = new AffineTransform(objectiveToDisplay);
+        final AffineTransform gridToDisplay = new AffineTransform(objectiveToDisplay);
         if (gridToCRS != null) {
-            tr.concatenate(gridToCRS);
+            gridToDisplay.concatenate(gridToCRS);
         }
-        gridToDisplay = tr;
         return new Renderer() {
             @Override protected void paint(final Graphics2D gr) {
-                gr.drawRenderedImage(data, tr);
+                gr.drawRenderedImage(data, gridToDisplay);
             }
         };
     }
-
-    /**
-     * Invoked when an error occurred.
-     *
-     * @param  ex  the exception that occurred.
-     *
-     * @todo Should provide a button for getting more details.
-     */
-    @Override
-    protected void errorOccurred(final Throwable ex) {
-        String message = ex.getMessage();
-        if (message == null) {
-            message = ex.toString();
-        }
-        statusBar.setErrorMessage(message);
-    }
-
-    /**
-     * Invoked when the mouse moved. This method update the coordinates below mouse cursor.
-     */
-    private void onMouveMoved(final MouseEvent event) {
-        statusBar.setCoordinates((int) Math.round(event.getX()),
-                                 (int) Math.round(event.getY()));
-    }
-
-    /**
-     * Converts pixel indices in the window to pixel indices in the image.
-     */
-    private boolean toImageCoordinates(final double[] indices) {
-        if (gridToDisplay != null) try {
-            gridToDisplay.inverseTransform(indices, 0, indices, 0, 1);
-            return true;
-        } catch (NoninvertibleTransformException e) {
-            throw new BackingStoreException(e);         // Will be unwrapped by the caller
-        }
-        return false;
-    }
 }
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridView.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridView.java
index 18e9038..fc6fc6e 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridView.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridView.java
@@ -40,6 +40,7 @@ import javafx.scene.paint.Paint;
 import org.apache.sis.util.ArgumentChecks;
 import org.apache.sis.coverage.grid.GridCoverage;
 import org.apache.sis.internal.gui.BackgroundThreads;
+import org.apache.sis.internal.gui.Styles;
 
 
 /**
@@ -387,7 +388,7 @@ public class GridView extends Control {
          * we take a value close to the vertical scrollbar width as a safety.
          */
         final double w = getSizeValue(cellWidth);
-        return width * w + getSizeValue(headerWidth) + Math.max(w, GridViewSkin.SCROLLBAR_WIDTH);
+        return width * w + getSizeValue(headerWidth) + Math.max(w, Styles.SCROLLBAR_WIDTH);
     }
 
     /**
@@ -411,6 +412,14 @@ public class GridView extends Control {
     }
 
     /**
+     * Returns the offset to add for converting cell indices to pixel indices. They are often the same indices,
+     * but may differ if the {@link RenderedImage} uses a coordinate system where coordinates of the upper-left
+     * corner is not (0,0).
+     */
+    final int getImageMinX() {return minX;}
+    final int getImageMinY() {return minY;}
+
+    /**
      * Returns the bounds of a single tile in the image. This method is invoked only
      * if an error occurred during {@link RenderedImage#getTile(int, int)} invocation.
      * The returned bounds are zero-based (may not be the bounds in image coordinates).
@@ -515,17 +524,6 @@ public class GridView extends Control {
     }
 
     /**
-     * Converts cell indices to pixel indices. They are often the same indices, but may differ if the
-     * {@link RenderedImage} uses a coordinate system where the coordinates of the upper-left corner
-     * is not (0,0).
-     */
-    final boolean toImageCoordinates(final double[] indices) {
-        indices[0] += minX;
-        indices[1] += minY;
-        return true;
-    }
-
-    /**
      * Creates a new instance of the skin responsible for rendering this grid view.
      * From the perspective of this {@link Control}, the {@link Skin} is a black box.
      * It listens and responds to changes in state of this grid view. This method is
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridViewSkin.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridViewSkin.java
index 1e2b46d..7b80ede 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridViewSkin.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridViewSkin.java
@@ -33,6 +33,7 @@ import javafx.scene.text.FontWeight;
 import javafx.scene.text.Font;
 import javafx.scene.input.MouseEvent;
 import javafx.event.EventHandler;
+import org.apache.sis.gui.map.StatusBar;
 import org.apache.sis.internal.gui.Styles;
 
 
@@ -56,11 +57,6 @@ import org.apache.sis.internal.gui.Styles;
  */
 final class GridViewSkin extends VirtualContainerBase<GridView, GridRow> implements EventHandler<MouseEvent> {
     /**
-     * Approximate size of vertical scroll bar.
-     */
-    static final int SCROLLBAR_WIDTH = 20;
-
-    /**
      * The cells that we put in the header row on the top of the view. The children list is initially empty;
      * new elements are added or removed when first needed and when the view size changed.
      */
@@ -183,13 +179,14 @@ final class GridViewSkin extends VirtualContainerBase<GridView, GridRow> impleme
          * The status bar where to show coordinates of selected cell.
          * Mouse exit event is handled by `hideSelection(…)`.
          */
-        statusBar = new StatusBar(view::toImageCoordinates);
+        statusBar = new StatusBar();
         flow.setOnMouseEntered(statusBar);
         /*
          * The list of children is initially empty. We need to
          * add the virtual flow, otherwise nothing will appear.
          */
-        getChildren().addAll(topBackground, leftBackground, selectedColumn, selectedRow, headerRow, selection, statusBar, flow);
+        getChildren().addAll(topBackground, leftBackground, selectedColumn, selectedRow,
+                             headerRow, selection, statusBar.getView(), flow);
     }
 
     /**
@@ -216,13 +213,18 @@ final class GridViewSkin extends VirtualContainerBase<GridView, GridRow> impleme
                     selection.relocate(x, y);
                     selectedRow.setY(y);
                     selectedColumn.setX(x);
-                    statusBar.setCoordinates(((int) visibleColumn) + firstVisibleColumn, row.getIndex());
+                    final GridView view = getSkinnable();
+                    statusBar.setLocalCoordinates(view.getImageMinX() + ((int) visibleColumn) + firstVisibleColumn,
+                                                  view.getImageMinY() + row.getIndex());
                 }
             }
         }
         selection     .setVisible(visible);
         selectedRow   .setVisible(visible);
         selectedColumn.setVisible(visible);
+        if (!visible) {
+            statusBar.handle(null);
+        }
     }
 
     /**
@@ -440,7 +442,7 @@ final class GridViewSkin extends VirtualContainerBase<GridView, GridRow> impleme
         final Flow   flow         = (Flow) getVirtualFlow();
         final double cellHeight   = flow.getFixedCellSize();
         final double headerHeight = cellHeight + 2*cellSpacing;
-        final double statusHeight = statusBar.getHeight();
+        final double statusHeight = statusBar.getView().getHeight();
         final double dataY        = y + headerHeight;
         final double dataHeight   = height - headerHeight - statusHeight;
         layoutAll |= (flow.getWidth() != width) || (flow.getHeight() != dataHeight);
@@ -487,8 +489,10 @@ final class GridViewSkin extends VirtualContainerBase<GridView, GridRow> impleme
          * may need to be added or removed).
          */
         if (layoutAll || oldPos != leftPosition) {
-            layoutInArea(headerRow, x, y, width, headerHeight, Node.BASELINE_OFFSET_SAME_AS_HEIGHT, HPos.LEFT, VPos.TOP);
-            layoutInArea(statusBar, x, height - statusHeight, width, statusHeight, Node.BASELINE_OFFSET_SAME_AS_HEIGHT, HPos.RIGHT, VPos.BOTTOM);
+            layoutInArea(headerRow, x, y, width, headerHeight,
+                         Node.BASELINE_OFFSET_SAME_AS_HEIGHT, HPos.LEFT, VPos.TOP);
+            layoutInArea(statusBar.getView(), x, height - statusHeight, width, statusHeight,
+                         Node.BASELINE_OFFSET_SAME_AS_HEIGHT, HPos.RIGHT, VPos.BOTTOM);
             final ObservableList<Node> children = headerRow.getChildren();
             final int count   = children.size();
             final int missing = (int) Math.ceil((width - headerWidth) / cellWidth) - count;
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ImageLoader.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ImageLoader.java
index 7ea130c..b6faa39 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ImageLoader.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ImageLoader.java
@@ -43,7 +43,7 @@ final class ImageLoader extends Task<RenderedImage> {
     /**
      * The {@value} value, for identifying code that assume two-dimensional objects.
      */
-    public static final int BIDIMENSIONAL = 2;
+    private static final int BIDIMENSIONAL = 2;
 
     /**
      * The image source together with optional parameters for reading only a subset.
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ImageRequest.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ImageRequest.java
index e1fae05..9431c33 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ImageRequest.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ImageRequest.java
@@ -25,6 +25,8 @@ import org.apache.sis.coverage.grid.GridCoverage;
 import org.apache.sis.coverage.grid.GridGeometry;
 import org.apache.sis.coverage.grid.GridExtent;
 import org.apache.sis.util.ArgumentChecks;
+import org.apache.sis.gui.map.StatusBar;
+import org.apache.sis.referencing.operation.transform.MathTransforms;
 
 
 /**
@@ -254,6 +256,19 @@ public class ImageRequest {
      */
     final void configure(final StatusBar bar) {
         final GridCoverage cv = coverage;
-        bar.setCoordinateConversion(cv != null ? cv.getGridGeometry() : null, sliceExtent);
+        final GridExtent request = sliceExtent;
+        bar.applyCanvasGeometry(cv != null ? cv.getGridGeometry() : null);
+        /*
+         * By `GridCoverage.render(GridExtent)` contract, the `RenderedImage` pixel coordinates are relative
+         * to the requested `GridExtent`. Consequently we need to translate the image coordinates so that it
+         * become the coordinates of the original `GridGeometry` before to apply `gridToCRS`.
+         */
+        if (request != null) {
+            final double[] origin = new double[request.getDimension()];
+            for (int i=0; i<origin.length; i++) {
+                origin[i] = request.getLow(i);
+            }
+            bar.setLocalToCRS(MathTransforms.concatenate(MathTransforms.translation(origin), bar.getLocalToCRS()));
+        }
     }
 }
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/StatusBar.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/StatusBar.java
deleted file mode 100644
index 4a00b6b..0000000
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/StatusBar.java
+++ /dev/null
@@ -1,336 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License.  You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.sis.gui.coverage;
-
-import java.util.Locale;
-import java.util.function.Predicate;
-import javax.measure.Unit;
-import javafx.geometry.Insets;
-import javafx.scene.paint.Color;
-import javafx.scene.layout.HBox;
-import javafx.scene.layout.Priority;
-import javafx.scene.control.Label;
-import javafx.scene.control.Tooltip;
-import javafx.scene.control.ProgressBar;
-import javafx.scene.input.MouseEvent;
-import javafx.event.EventHandler;
-import javafx.beans.value.ObservableValue;
-import org.opengis.referencing.datum.PixelInCell;
-import org.opengis.referencing.cs.CoordinateSystem;
-import org.opengis.referencing.operation.Matrix;
-import org.opengis.referencing.operation.MathTransform;
-import org.opengis.referencing.operation.TransformException;
-import org.opengis.referencing.crs.CoordinateReferenceSystem;
-import org.apache.sis.referencing.operation.transform.MathTransforms;
-import org.apache.sis.referencing.IdentifiedObjects;
-import org.apache.sis.geometry.GeneralDirectPosition;
-import org.apache.sis.geometry.CoordinateFormat;
-import org.apache.sis.coverage.grid.GridExtent;
-import org.apache.sis.coverage.grid.GridGeometry;
-import org.apache.sis.measure.Units;
-import org.apache.sis.util.Classes;
-import org.apache.sis.util.Exceptions;
-
-
-/**
- * A status bar showing coordinates of a grid cell.
- * The number of fraction digits is adjusted according pixel resolution for each coordinate to format.
- *
- * <p>Callers can register this object to {@link javafx.scene.Node#setOnMouseEntered(EventHandler)} and
- * {@link javafx.scene.Node#setOnMouseExited(EventHandler)} for showing or hiding the coordinate values
- * when the mouse enters or exits the region of interest.</p>
- *
- * @author  Martin Desruisseaux (Geomatys)
- * @version 1.1
- * @since   1.1
- * @module
- */
-final class StatusBar extends HBox implements EventHandler<MouseEvent> {
-    /**
-     * Some spaces to add around the status bar.
-     */
-    private static final Insets PADDING = new Insets(5, GridViewSkin.SCROLLBAR_WIDTH, 6, 0);
-
-    /**
-     * The progress bar, hidden by default. This bar is initialized to undetermined state.
-     */
-    private final ProgressBar progress;
-
-    /**
-     * Message to write in the middle of the status bar.
-     * This component usually has nothing to show; it is used mostly for error messages.
-     * It takes all the space between {@link #progress} and {@link #coordinates}.
-     */
-    private final Label message;
-
-    /**
-     * A function which take cell indices in input and gives pixel coordinates in output.
-     * The input and output coordinates are in the given array, which is updated in-place.
-     * This method returns {@code false} if it can not compute the coordinates.
-     *
-     * @see GridView#toImageCoordinates(double[])
-     */
-    private final Predicate<double[]> toImageCoordinates;
-
-    /**
-     * Zero-based cell coordinates currently formatted in the {@link #coordinates} field.
-     * This is used for detecting if coordinate values changed since last formatting.
-     */
-    private int column, row;
-
-    /**
-     * Conversion from ({@linkplain #column},{@linkplain #row}) cell coordinates
-     * to geographic or projected coordinates.
-     */
-    private MathTransform gridToCRS;
-
-    /**
-     * The source cell indices before conversion to geospatial coordinates.
-     * The number of dimensions should be 2.
-     */
-    private double[] sourceCoordinates;
-
-    /**
-     * Coordinates after conversion to the CRS. The number of dimensions depends on
-     * the target CRS. This object is reused during each coordinate transformation.
-     */
-    private GeneralDirectPosition targetCoordinates;
-
-    /**
-     * The desired precisions for each dimension in the {@link #targetCoordinates} to format.
-     * It may vary for each position if the {@link #gridToCRS} transform is non-linear.
-     */
-    private double[] precisions;
-
-    /**
-     * A multiplication factory slightly greater than 1 applied on {@link #precisions}.
-     * The intent is to avoid that a precision like 0.09999 is interpreted as requiring
-     * two decimal digits instead of 1. For avoiding that, we add a small value to the
-     * precision: <var>precision</var> += <var>precision</var> × ε, which we compact as
-     * <var>precision</var> *= (1 + ε). The ε value is chosen to represent an increase
-     * of no more than 0.5 pixel between the lower and upper indices of the grid.
-     */
-    private double[] inflatePrecisions;
-
-    /**
-     * The object to use for formatting coordinate values.
-     */
-    private final CoordinateFormat format;
-
-    /**
-     * The labels where to format the coordinates.
-     */
-    private final Label coordinates;
-
-    /**
-     * Creates a new status bar.
-     */
-    StatusBar(final Predicate<double[]> toImageCoordinates) {
-        this.toImageCoordinates = toImageCoordinates;
-        format      = new CoordinateFormat();
-        coordinates = new Label();
-        message     = new Label();
-        progress    = new ProgressBar();
-        progress.setVisible(false);
-        message.setTextFill(Color.RED);
-        message.setMaxWidth(Double.POSITIVE_INFINITY);
-        HBox.setHgrow(message, Priority.ALWAYS);
-        getChildren().setAll(progress, message, coordinates);
-        setPadding(PADDING);
-        setSpacing(12);
-        /*
-         * Following method call will initialize `gridtoCRS` to default value.
-         */
-        setCoordinateConversion(null, null);
-    }
-
-    /**
-     * Sets the conversion from (column, row) cell indices to geographic or projected coordinates.
-     * The conversion is computed from the given grid geometry.
-     *
-     * @param  geometry  geometry of the grid coverage shown in {@link GridView}, or {@code null}.
-     * @param  request   sub-region of the coverage which is shown, or {@code null} for the full coverage.
-     */
-    final void setCoordinateConversion(final GridGeometry geometry, GridExtent request) {
-        gridToCRS = MathTransforms.identity(2);
-        precisions = null;
-        CoordinateReferenceSystem crs = null;
-        double resolution = 1;
-        Unit<?> unit = Units.PIXEL;
-        if (geometry != null) {
-            if (geometry.isDefined(GridGeometry.GRID_TO_CRS)) {
-                gridToCRS = geometry.getGridToCRS(PixelInCell.CELL_CENTER);
-                if (geometry.isDefined(GridGeometry.CRS)) {
-                    crs = geometry.getCoordinateReferenceSystem();
-                }
-            }
-            if (request == null && geometry.isDefined(GridGeometry.EXTENT)) {
-                request = geometry.getExtent();
-            }
-            /*
-             * Computes the precision of coordinates to format. We use the finest resolution,
-             * looking only at axes having the same units of measurement than the first axis.
-             * This will be used as a fallback if we can not compute the precision specific
-             * to a coordinate, for example if we can not compute the derivative.
-             */
-            if (geometry.isDefined(GridGeometry.RESOLUTION)) {
-                double[] resolutions = geometry.getResolution(true);
-                if (crs != null && resolutions.length != 0) {
-                    final CoordinateSystem cs = crs.getCoordinateSystem();
-                    unit = cs.getAxis(0).getUnit();
-                    for (int i=0; i<resolutions.length; i++) {
-                        if (unit.equals(cs.getAxis(i).getUnit())) {
-                            final double r = resolutions[i];
-                            if (r < resolution) resolution = r;
-                        }
-                    }
-                }
-            }
-        }
-        /*
-         * By `GridCoverage.render(GridExtent)` contract, the `RenderedImage` pixel coordinates are relative
-         * to the requested `GridExtent`. Consequently we need to translate the image coordinates so that it
-         * become the coordinates of the original `GridGeometry` before to apply `gridToCRS`.
-         */
-        if (request != null) {
-            final int n = request.getDimension();
-            if (inflatePrecisions == null || inflatePrecisions.length != n) {
-                inflatePrecisions = new double[n];
-            }
-            final double[] origin = new double[n];
-            for (int i=0; i<n; i++) {
-                origin[i] = request.getLow(i);
-                inflatePrecisions[i] = (0.5 / request.getSize(i)) + 1;
-            }
-            gridToCRS = MathTransforms.concatenate(MathTransforms.translation(origin), gridToCRS);
-        } else {
-            inflatePrecisions = null;
-        }
-        /*
-         * Prepare objects to be reused for each coordinate transformation.
-         * Configure the CoordinateFormat with the CRS.
-         */
-        if (gridToCRS != null) {
-            sourceCoordinates = new double[Math.max(gridToCRS.getSourceDimensions(), ImageLoader.BIDIMENSIONAL)];
-            targetCoordinates = new GeneralDirectPosition(gridToCRS.getTargetDimensions());
-        } else {
-            targetCoordinates = new GeneralDirectPosition(ImageLoader.BIDIMENSIONAL);
-            sourceCoordinates = targetCoordinates.coordinates;
-        }
-        format.setDefaultCRS(crs);
-        format.setPrecision(resolution, unit);
-        Tooltip tp = null;
-        if (crs != null) {
-            tp = new Tooltip(IdentifiedObjects.getDisplayName(crs, format.getLocale(Locale.Category.DISPLAY)));
-        }
-        coordinates.setTooltip(tp);
-    }
-
-    /**
-     * Sets the pixel coordinates to show. Those pixel coordinates will be automatically
-     * transformed to geographic coordinates if a "grid to CRS" conversion is available.
-     */
-    final void setCoordinates(final int x, final int y) {
-        if (x != column || y != row) {
-            sourceCoordinates[0] = column = x;
-            sourceCoordinates[1] = row    = y;
-            String text = null;
-            try {
-                if (toImageCoordinates.test(sourceCoordinates)) {
-                    Matrix derivative;
-                    try {
-                        derivative = MathTransforms.derivativeAndTransform(gridToCRS,
-                                sourceCoordinates, 0, targetCoordinates.coordinates, 0);
-                    } catch (TransformException ignore) {
-                        /*
-                         * If above operation failed, it may be because the MathTransform does not support
-                         * derivative calculation. Try again without derivative (the precision will be set
-                         * to the default resolution computed in `setCoordinateConversion(…)`).
-                         */
-                        gridToCRS.transform(sourceCoordinates, 0, targetCoordinates.coordinates, 0, 1);
-                        derivative = null;
-                    }
-                    if (derivative == null) {
-                        precisions = null;
-                    } else {
-                        if (precisions == null) {
-                            precisions = new double[targetCoordinates.getDimension()];
-                        }
-                        /*
-                         * Estimate the precision by looking at the maximal displacement in the CRS caused by
-                         * a displacement of one cell (i.e. when moving by row or one column).  We search for
-                         * maximal displacement instead than minimal because we expect the displacement to be
-                         * zero along some axes (e.g. one row down does not change longitude value in a Plate
-                         * Carrée projection).
-                         */
-                        for (int j=derivative.getNumRow(); --j >= 0;) {
-                            double p = 0;
-                            for (int i=derivative.getNumCol(); --i >= 0;) {
-                                double e = Math.abs(derivative.getElement(j, i));
-                                if (inflatePrecisions != null) {
-                                    e *= inflatePrecisions[i];
-                                }
-                                if (e > p) p = e;
-                            }
-                            precisions[j] = p;
-                        }
-                    }
-                    format.setPrecisions(precisions);
-                    text = format.format(targetCoordinates);
-                }
-            } catch (TransformException | RuntimeException e) {
-                /*
-                 * If even the fallback without derivative failed, show the error message.
-                 */
-                Throwable cause = Exceptions.unwrap(e);
-                text = cause.getLocalizedMessage();
-                if (text == null) {
-                    text = Classes.getShortClassName(cause);
-                }
-            }
-            coordinates.setText(text);
-        }
-    }
-
-    /**
-     * Shows or hide the coordinates depending on whether the mouse entered or exited
-     * the region for which this status bar is providing information.
-     *
-     * <p>For the convenience of {@link GridViewSkin}, a null value is equivalent to
-     * a mouse exit event.</p>
-     */
-    @Override
-    public final void handle(final MouseEvent event) {
-        coordinates.setVisible(event != null && event.getEventType() != MouseEvent.MOUSE_EXITED);
-    }
-
-    /**
-     * Invoked when the {@link javafx.concurrent.Worker#runningProperty()} state of a task changed.
-     * This method shows or hides the progress bar according the {@code newValue} argument.
-     */
-    final void setRunningState(ObservableValue<? extends Boolean> property, Boolean oldValue, Boolean newValue) {
-        progress.setVisible(newValue);
-    }
-
-    /**
-     * Invoked when an error occurred, or for clearing the error message.
-     */
-    final void setErrorMessage(final String text) {
-        message.setVisible(text != null);
-        message.setText(text);
-    }
-}
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvas.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvas.java
index 630d91b..10ce967 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvas.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvas.java
@@ -17,6 +17,7 @@
 package org.apache.sis.gui.map;
 
 import java.util.Locale;
+import java.util.Objects;
 import java.awt.geom.AffineTransform;
 import javafx.geometry.Bounds;
 import javafx.geometry.Point2D;
@@ -31,11 +32,16 @@ import javafx.scene.input.GestureEvent;
 import javafx.scene.Cursor;
 import javafx.event.EventType;
 import javafx.beans.Observable;
+import javafx.beans.property.ReadOnlyBooleanProperty;
+import javafx.beans.property.ReadOnlyBooleanWrapper;
+import javafx.beans.property.ReadOnlyObjectProperty;
+import javafx.beans.property.ReadOnlyObjectWrapper;
 import javafx.concurrent.Task;
 import javafx.scene.shape.Rectangle;
 import javafx.scene.transform.Affine;
 import javafx.scene.transform.NonInvertibleTransformException;
 import org.opengis.geometry.Envelope;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
 import org.apache.sis.referencing.operation.matrix.Matrices;
 import org.apache.sis.referencing.operation.matrix.MatrixSIS;
 import org.apache.sis.referencing.operation.transform.MathTransforms;
@@ -46,7 +52,6 @@ import org.apache.sis.util.ArgumentChecks;
 import org.apache.sis.util.logging.Logging;
 import org.apache.sis.internal.util.Numerics;
 import org.apache.sis.internal.gui.BackgroundThreads;
-import org.apache.sis.internal.gui.ExceptionReporter;
 import org.apache.sis.internal.map.PlanarCanvas;
 import org.apache.sis.internal.map.RenderException;
 import org.apache.sis.internal.system.Modules;
@@ -217,6 +222,23 @@ public abstract class MapCanvas extends PlanarCanvas {
     private double xPanStart, yPanStart;
 
     /**
+     * Whether a rendering is in progress. This property is set to {@code true} when {@code MapCanvas}
+     * is about to start a background thread for performing a rendering, and is reset to {@code false}
+     * after the {@code MapCanvas} has been updated with new rendering result.
+     *
+     * @see #renderingProperty()
+     */
+    private final ReadOnlyBooleanWrapper isRendering;
+
+    /**
+     * The exception or error that occurred during last rendering operation.
+     * This is reset to {@code null} when a rendering operation completes successfully.
+     *
+     * @see #errorProperty()
+     */
+    private final ReadOnlyObjectWrapper<Throwable> error;
+
+    /**
      * Creates a new canvas for JavaFX application.
      *
      * @param  locale  the locale to use for labels and some messages, or {@code null} for default.
@@ -257,6 +279,8 @@ public abstract class MapCanvas extends PlanarCanvas {
         clip.widthProperty() .bind(fixedPane.widthProperty());
         clip.heightProperty().bind(fixedPane.heightProperty());
         fixedPane.setClip(clip);
+        isRendering = new ReadOnlyBooleanWrapper(this, "isRendering");
+        error = new ReadOnlyObjectWrapper<>(this, "exception");
     }
 
     /**
@@ -446,6 +470,7 @@ public abstract class MapCanvas extends PlanarCanvas {
 
     /**
      * 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.
      */
     public void reset() {
         invalidObjectiveToDisplay = true;
@@ -638,14 +663,18 @@ public abstract class MapCanvas extends PlanarCanvas {
             if (invalidObjectiveToDisplay) {
                 invalidObjectiveToDisplay = false;
                 LinearTransform tr;
+                final CoordinateReferenceSystem crs;
                 if (objectiveBounds != null) {
+                    crs = objectiveBounds.getCoordinateReferenceSystem();
                     final Envelope2D target = getDisplayBounds();
                     final MatrixSIS m = Matrices.createTransform(objectiveBounds, target);
                     Matrices.forceUniformScale(m, 0, new double[] {target.width / 2, target.height / 2});
                     tr = MathTransforms.linear(m);
                 } else {
                     tr = MathTransforms.identity(BIDIMENSIONAL);
+                    crs = null;
                 }
+                setObjectiveCRS(crs);
                 setObjectiveToDisplay(tr);
                 transform.setToIdentity();
             }
@@ -661,6 +690,7 @@ public abstract class MapCanvas extends PlanarCanvas {
         assert changeInProgress.isIdentity() : changeInProgress;
         changeInProgress.setToTransform(transform);
         transformOnNewImage.setToIdentity();
+        isRendering.set(true);
         if (!transform.isIdentity()) {
             transformDisplayCoordinates(new AffineTransform(
                     transform.getMxx(), transform.getMyx(),
@@ -674,13 +704,16 @@ public abstract class MapCanvas extends PlanarCanvas {
         final Renderer context = createRenderer();
         if (context != null && context.initialize(floatingPane)) {
             executeRendering(createWorker(context));
+        } else {
+            error.set(null);
+            isRendering.set(false);
         }
     }
 
     /**
      * Creates the background task which will invoke {@link Renderer#render()} in a background thread.
-     * The tasks must invoke {@link #renderingCompleted()} in JavaFX thread after completion, either
-     * successful or not.
+     * The tasks must invoke {@link #renderingCompleted(Task)} in JavaFX thread after completion,
+     * either successful or not.
      */
     Task<?> createWorker(final Renderer renderer) {
         return new Task<Void>() {
@@ -693,15 +726,15 @@ public abstract class MapCanvas extends PlanarCanvas {
             /** Invoked in JavaFX thread on success. */
             @Override protected void succeeded() {
                 final boolean done = renderer.commit();
-                renderingCompleted();
+                renderingCompleted(this);
                 if (!done || contentsChanged()) {
                     repaint();
                 }
             }
 
             /** Invoked in JavaFX thread on failure. */
-            @Override protected void failed()    {renderingCompleted();}
-            @Override protected void cancelled() {renderingCompleted();}
+            @Override protected void failed()    {renderingCompleted(this);}
+            @Override protected void cancelled() {renderingCompleted(this);}
         };
     }
 
@@ -711,7 +744,7 @@ public abstract class MapCanvas extends PlanarCanvas {
      * which is now integrated in the map. That transform will be removed from {@link #floatingPane} transforms.
      * It may be identity if no zoom, rotation or pan gesture has been applied since last rendering.
      */
-    final void renderingCompleted() {
+    final void renderingCompleted(final Task<?> task) {
         renderingInProgress = null;
         floatingPane.setCursor(Cursor.CROSSHAIR);
         final Point2D p = changeInProgress.transform(xPanStart, yPanStart);
@@ -719,6 +752,8 @@ public abstract class MapCanvas extends PlanarCanvas {
         yPanStart = p.getY();
         changeInProgress.setToIdentity();
         transform.setToTransform(transformOnNewImage);
+        error.set(task.getException());
+        isRendering.set(false);
     }
 
     /**
@@ -782,13 +817,35 @@ public abstract class MapCanvas extends PlanarCanvas {
     }
 
     /**
-     * Invoked when an error occurred. The default implementation popups a dialog box.
-     * Subclasses may override. For example the error messages could be written in a status bar instead.
+     * Returns a property telling whether a rendering is in progress. This property become {@code true}
+     * when this {@code MapCanvas} is about to start a background thread for performing a rendering, and
+     * is reset to {@code false} after this {@code MapCanvas} has been updated with new rendering result.
+     *
+     * @return a property telling whether a rendering is in progress.
+     */
+    public final ReadOnlyBooleanProperty renderingProperty() {
+        return isRendering.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.
+     *
+     * @return a property giving the exception or error that occurred during last rendering operation.
+     */
+    public final ReadOnlyObjectProperty<Throwable> errorProperty() {
+        return error.getReadOnlyProperty();
+    }
+
+    /**
+     * Sets the error property to the given value. This method is provided for subclasses that perform
+     * processing outside the {@link Renderer}. It does not need to be invoked if the error occurred
+     * during the rendering process.
      *
-     * @param  ex  the exception that occurred.
+     * @param  ex  the exception that occurred (can not be null).
      */
     protected void errorOccurred(final Throwable ex) {
-        ExceptionReporter.show(null, null, ex);
+        error.set(Objects.requireNonNull(ex));
     }
 
     /**
@@ -799,8 +856,7 @@ public abstract class MapCanvas extends PlanarCanvas {
     }
 
     /**
-     * Clears the map.
-     * Invoking this method may help to release memory when the map is no longer shown.
+     * Removes map content and clears all properties of this canvas.
      *
      * @see #reset()
      */
@@ -808,5 +864,8 @@ public abstract class MapCanvas extends PlanarCanvas {
         transform.setToIdentity();
         changeInProgress.setToIdentity();
         invalidObjectiveToDisplay = true;
+        objectiveBounds = null;
+        error.set(null);
+        isRendering.set(false);
     }
 }
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvasAWT.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvasAWT.java
index 6ee621a..36a1059 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvasAWT.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvasAWT.java
@@ -315,14 +315,14 @@ public abstract class MapCanvasAWT extends MapCanvas {
             bufferWrapper       = wrapper;
             bufferConfiguration = configuration;
             final boolean done  = renderer.commit();
-            renderingCompleted();
+            renderingCompleted(this);
             if (!done || contentsChanged()) {
                 repaint();
             }
         }
 
-        @Override protected void failed()    {renderingCompleted();}
-        @Override protected void cancelled() {renderingCompleted();}
+        @Override protected void failed()    {renderingCompleted(this);}
+        @Override protected void cancelled() {renderingCompleted(this);}
     }
 
     /**
@@ -434,14 +434,14 @@ public abstract class MapCanvasAWT extends MapCanvas {
                 drawTo.flush();                     // Release native resources.
             }
             final boolean done = renderer.commit();
-            renderingCompleted();
+            renderingCompleted(this);
             if (!done || contentsLost || contentsChanged()) {
                 repaint();
             }
         }
 
-        @Override protected void failed()    {renderingCompleted();}
-        @Override protected void cancelled() {renderingCompleted();}
+        @Override protected void failed()    {renderingCompleted(this);}
+        @Override protected void cancelled() {renderingCompleted(this);}
     }
 
     /**
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/StatusBar.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/StatusBar.java
new file mode 100644
index 0000000..4e7c0eb
--- /dev/null
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/StatusBar.java
@@ -0,0 +1,563 @@
+/*
+ * 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.Locale;
+import java.util.Optional;
+import javax.measure.Unit;
+import javafx.geometry.Insets;
+import javafx.geometry.Point2D;
+import javafx.scene.paint.Color;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Region;
+import javafx.scene.layout.Priority;
+import javafx.scene.control.Label;
+import javafx.scene.control.Button;
+import javafx.scene.control.Tooltip;
+import javafx.scene.control.ProgressBar;
+import javafx.scene.input.MouseEvent;
+import javafx.event.EventHandler;
+import javafx.event.EventType;
+import javafx.beans.value.ObservableValue;
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.beans.value.ChangeListener;
+import org.opengis.geometry.MismatchedDimensionException;
+import org.opengis.referencing.datum.PixelInCell;
+import org.opengis.referencing.cs.CoordinateSystem;
+import org.opengis.referencing.operation.Matrix;
+import org.opengis.referencing.operation.MathTransform;
+import org.opengis.referencing.operation.TransformException;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.apache.sis.referencing.operation.transform.MathTransforms;
+import org.apache.sis.referencing.IdentifiedObjects;
+import org.apache.sis.geometry.GeneralDirectPosition;
+import org.apache.sis.geometry.CoordinateFormat;
+import org.apache.sis.coverage.grid.GridGeometry;
+import org.apache.sis.coverage.grid.GridExtent;
+import org.apache.sis.internal.map.RenderException;
+import org.apache.sis.internal.util.Strings;
+import org.apache.sis.measure.Units;
+import org.apache.sis.util.Classes;
+import org.apache.sis.util.Exceptions;
+import org.apache.sis.util.ArgumentChecks;
+import org.apache.sis.util.resources.Errors;
+import org.apache.sis.gui.Widget;
+import org.apache.sis.internal.gui.ExceptionReporter;
+import org.apache.sis.internal.gui.Resources;
+import org.apache.sis.internal.gui.Styles;
+
+
+/**
+ * A status bar showing geographic or projected coordinates under mouse cursor.
+ * The number of fraction digits is adjusted according pixel resolution for each coordinate to format.
+ * Other components such as progress bar or error message may also be shown.
+ *
+ * <p>Since the main {@code StatusBar} job is to listen to mouse events for updating coordinates,
+ * this class implements {@link EventHandler} directly. {@code StatusBar} can be registered as a listener
+ * using the following methods:</p>
+ *
+ * <ul>
+ *   <li>{@link javafx.scene.Node#setOnMouseEntered(EventHandler)} for showing the coordinate values
+ *     when the mouse enters the region of interest.</li>
+ *   <li>{@link javafx.scene.Node#setOnMouseExited(EventHandler)} for hiding the coordinate values
+ *     when the mouse exits the region of interest.</li>
+ *   <li>{@link javafx.scene.Node#setOnMouseMoved(EventHandler)} for updating the coordinate values
+ *     when the mouse moves inside the region of interest.</li>
+ * </ul>
+ *
+ * Alternatively users can omit some or all above listener registrations and invoke
+ * {@link #setLocalCoordinates(double, double)} explicitly instead.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+public class StatusBar extends Widget implements EventHandler<MouseEvent> {
+    /**
+     * The {@value} value, for identifying code that assume two-dimensional objects.
+     */
+    private static final int BIDIMENSIONAL = 2;
+
+    /**
+     * Some spaces to add around the status bar.
+     */
+    private static final Insets PADDING = new Insets(5, Styles.SCROLLBAR_WIDTH, 6, 0);
+
+    /**
+     * The container of controls making the status bar.
+     */
+    private final HBox view;
+
+    /**
+     * The progress bar, hidden by default. This bar is initialized to undetermined state.
+     */
+    private final ProgressBar progress;
+
+    /**
+     * Message to write in the middle of the status bar.
+     * This component usually has nothing to show; it is used mostly for error messages.
+     * It takes all the space between {@link #progress} and {@link #coordinates}.
+     */
+    private final Label message;
+
+    /**
+     * Local coordinates currently formatted in the {@link #coordinates} field.
+     * This is used for detecting if coordinate values changed since last formatting.
+     * Those coordinates are often integer values.
+     */
+    private double lastX, lastY;
+
+    /**
+     * Conversion from local coordinates to geographic or projected coordinates.
+     * This conversion shall never be null but may be the identity transform.
+     */
+    private MathTransform localToCRS;
+
+    /**
+     * The source local indices before conversion to geospatial coordinates.
+     * The number of dimensions is often {@value #BIDIMENSIONAL}.
+     * Shall never be {@code null}.
+     */
+    private double[] sourceCoordinates;
+
+    /**
+     * Coordinates after conversion to the CRS. The number of dimensions depends on
+     * the target CRS. This object is reused during each coordinate transformation.
+     * Shall never be {@code null}.
+     */
+    private GeneralDirectPosition targetCoordinates;
+
+    /**
+     * The desired precisions for each dimension in the {@link #targetCoordinates} to format.
+     * It may vary for each position if the {@link #localToCRS} transform is non-linear.
+     * This array is initially {@code null} and created when first needed.
+     */
+    private double[] precisions;
+
+    /**
+     * A multiplication factory slightly greater than 1 applied on {@link #precisions}.
+     * The intent is to avoid that a precision like 0.09999 is interpreted as requiring
+     * two decimal digits instead of 1. For avoiding that, we add a small value to the
+     * precision: <var>precision</var> += <var>precision</var> × ε, which we compact as
+     * <var>precision</var> *= (1 + ε). The ε value is chosen to represent an increase
+     * of no more than 0.5 pixel between the lower and upper indices of the grid.
+     * This array may be {@code null} if it has not been computed.
+     */
+    private double[] inflatePrecisions;
+
+    /**
+     * The object to use for formatting coordinate values.
+     */
+    private final CoordinateFormat format;
+
+    /**
+     * The labels where to format the coordinates.
+     */
+    private final Label coordinates;
+
+    /**
+     * The canvas that this status bar is tracking.
+     * The property value is {@code null} if there is none.
+     *
+     * @see #getCanvas()
+     * @see #setCanvas(MapCanvas)
+     */
+    public final ObjectProperty<MapCanvas> canvasProperty;
+
+    /**
+     * The listener registered on {@link MapCanvas#renderingProperty()}.
+     * This reference is stored for allowed removal.
+     *
+     * @see #setCanvas(MapCanvas)
+     */
+    private ChangeListener<Boolean> renderingListener;
+
+    /**
+     * Creates a new status bar.
+     */
+    public StatusBar() {
+        localToCRS        = MathTransforms.identity(BIDIMENSIONAL);
+        targetCoordinates = new GeneralDirectPosition(BIDIMENSIONAL);
+        sourceCoordinates = targetCoordinates.coordinates;
+        lastX = lastY     = Double.NaN;
+        format            = new CoordinateFormat();
+        coordinates       = new Label();
+        message           = new Label();
+        progress          = new ProgressBar();
+        progress.setVisible(false);
+        message.setTextFill(Color.RED);
+        message.setMaxWidth(Double.POSITIVE_INFINITY);
+        HBox.setHgrow(message, Priority.ALWAYS);
+        view = new HBox(12, progress, message, coordinates);
+        view.setPadding(PADDING);
+        canvasProperty = new SimpleObjectProperty<>(this, "canvas");
+        canvasProperty.addListener(this::onCanvasSpecified);
+    }
+
+    /**
+     * Returns the node to add to the scene graph for showing the status bar.
+     */
+    @Override
+    public final Region getView() {
+        return view;
+    }
+
+    /**
+     * Returns the canvas that this status bar is tracking.
+     *
+     * @return canvas that this status bar is tracking, or {@code null} if none.
+     *
+     * @see #canvasProperty
+     */
+    public final MapCanvas getCanvas() {
+        return canvasProperty.get();
+    }
+
+    /**
+     * Sets the canvas that this status bar is tracking.
+     * This method register all necessary listeners.
+     * A value of {@code null} unregister all listeners.
+     *
+     * @param  canvas  the canvas to track, or {@code null} if none.
+     *
+     * @see #canvasProperty
+     */
+    public final void setCanvas(final MapCanvas canvas) {
+        canvasProperty.set(canvas);
+    }
+
+    /**
+     * Invoked when a new value is set on {@link #canvasProperty}.
+     */
+    private void onCanvasSpecified(final ObservableValue<? extends MapCanvas> property,
+                                   final MapCanvas previous, final MapCanvas value)
+    {
+        if (previous != null) {
+            previous.floatingPane.removeEventHandler(MouseEvent.MOUSE_ENTERED, this);
+            previous.floatingPane.removeEventHandler(MouseEvent.MOUSE_EXITED,  this);
+            previous.floatingPane.removeEventHandler(MouseEvent.MOUSE_MOVED,   this);
+            previous.renderingProperty().removeListener(renderingListener);
+            renderingListener = null;
+        }
+        if (value != null) {
+            value.floatingPane.addEventHandler(MouseEvent.MOUSE_ENTERED, this);
+            value.floatingPane.addEventHandler(MouseEvent.MOUSE_EXITED,  this);
+            value.floatingPane.addEventHandler(MouseEvent.MOUSE_MOVED,   this);
+            value.renderingProperty().addListener(renderingListener = new RenderingListener());
+        }
+    }
+
+    /**
+     * Listener notified when {@link MapCanvas} completed its rendering.
+     * This listener set {@link StatusBar#localToCRS} to the inverse of
+     * {@link MapCanvas#objectiveToDisplay}.
+     */
+    private final class RenderingListener implements ChangeListener<Boolean> {
+        @Override public void changed(final ObservableValue<? extends Boolean> property,
+                                      final Boolean previous, final Boolean value)
+        {
+            progress.setVisible(value);
+            if (!value) try {
+                applyCanvasGeometry(getCanvas().getGridGeometry());
+            } catch (RenderException e) {
+                setErrorMessage(null, e);
+            }
+        }
+    }
+
+    /**
+     * Configures this status bar for showing coordinates in the CRS and resolution given by the specified
+     * grid geometry. The geometry properties are applied as below:
+     *
+     * <ul>
+     *   <li>{@link GridGeometry#getCoordinateReferenceSystem()} defines the CRS of the coordinates to format.</li>
+     *   <li>{@link GridGeometry#getGridToCRS(PixelInCell) GridGeometry.getGridToCRS(PixelInCell.CELL_CENTER)}
+     *       defines the conversion from coordinate values locale to the canvas to coordinate values in the CRS
+     *       (the {@linkplain #getLocalToCRS() local to CRS} conversion).</li>
+     *   <li>{@link GridGeometry#getExtent()} provides the view size in pixels, used for estimating a resolution.</li>
+     *   <li>{@link GridGeometry#getResolution(boolean)} is also used for estimating a resolution.</li>
+     * </ul>
+     *
+     * All above properties are optional.
+     * The "local to CRS" conversion can be updated after this method call with {@link #setLocalToCRS(MathTransform)}.
+     *
+     * @param  geometry  geometry of the coverage shown in {@link MapCanvas}, or {@code null}.
+     */
+    public void applyCanvasGeometry(final GridGeometry geometry) {
+        localToCRS = null;
+        precisions = null;
+        inflatePrecisions = null;
+        CoordinateReferenceSystem crs = null;
+        double resolution = 1;
+        Unit<?> unit = Units.PIXEL;
+        if (geometry != null) {
+            if (geometry.isDefined(GridGeometry.GRID_TO_CRS)) {
+                localToCRS = geometry.getGridToCRS(PixelInCell.CELL_CENTER);
+                if (geometry.isDefined(GridGeometry.CRS)) {
+                    crs = geometry.getCoordinateReferenceSystem();
+                }
+            }
+            /*
+             * Computes the precision of coordinates to format. We use the finest resolution,
+             * looking only at axes having the same units of measurement than the first axis.
+             * This will be used as a fallback if we can not compute the precision specific
+             * to a coordinate, for example if we can not compute the derivative.
+             */
+            if (geometry.isDefined(GridGeometry.RESOLUTION)) {
+                double[] resolutions = geometry.getResolution(true);
+                if (crs != null && resolutions.length != 0) {
+                    final CoordinateSystem cs = crs.getCoordinateSystem();
+                    unit = cs.getAxis(0).getUnit();
+                    for (int i=0; i<resolutions.length; i++) {
+                        if (unit.equals(cs.getAxis(i).getUnit())) {
+                            final double r = resolutions[i];
+                            if (r < resolution) resolution = r;
+                        }
+                    }
+                }
+            }
+            /*
+             * Add a tolerance factor of ½ pixel when computing the number of significant
+             * fraction digits to shown in coordinates.
+             */
+            if (geometry.isDefined(GridGeometry.EXTENT)) {
+                final GridExtent extent = geometry.getExtent();
+                final int n = extent.getDimension();
+                inflatePrecisions = new double[n];
+                for (int i=0; i<n; i++) {
+                    inflatePrecisions[i] = (0.5 / extent.getSize(i)) + 1;
+                }
+            }
+        }
+        /*
+         * Prepare objects to be reused for each coordinate transformation.
+         * Configure the `CoordinateFormat` with the CRS.
+         */
+        if (localToCRS != null) {
+            sourceCoordinates = new double[Math.max(localToCRS.getSourceDimensions(), BIDIMENSIONAL)];
+            targetCoordinates = new GeneralDirectPosition(localToCRS.getTargetDimensions());
+        } else {
+            localToCRS        = MathTransforms.identity(BIDIMENSIONAL);
+            targetCoordinates = new GeneralDirectPosition(BIDIMENSIONAL);
+            sourceCoordinates = targetCoordinates.coordinates;      // Okay to share array if same dimension.
+        }
+        format.setDefaultCRS(crs);
+        format.setPrecision(resolution, unit);
+        final String text = IdentifiedObjects.getDisplayName(crs, format.getLocale(Locale.Category.DISPLAY));
+        Tooltip tp = null;
+        if (text != null) {
+            tp = coordinates.getTooltip();
+            if (tp == null) {
+                tp = new Tooltip(text);
+            } else {
+                tp.setText(text);
+            }
+        }
+        coordinates.setTooltip(tp);
+        lastX = lastY = Double.NaN;
+    }
+
+    /**
+     * Returns the conversion from local coordinates to geographic or projected coordinates.
+     * The local coordinates are the coordinates of the view, as given for example in {@link MouseEvent}.
+     * This is initially an identity transform and can be computed by {@link #applyCanvasGeometry(GridGeometry)}.
+     *
+     * @return conversion from local coordinates to "real world" coordinates.
+     */
+    public final MathTransform getLocalToCRS() {
+        return localToCRS;
+    }
+
+    /**
+     * Sets the conversion from local coordinates to geographic or projected coordinates.
+     * The given value must have the same number of source and target dimensions than the
+     * previous value. If a change in the number of dimension is desired,
+     * use {@link #applyCanvasGeometry(GridGeometry)} instead.
+     *
+     * @param  conversion  the new conversion from local coordinates to "real world" coordinates.
+     * @throws MismatchedDimensionException if the number of dimensions is not the same than previous conversion.
+     */
+    public final void setLocalToCRS(final MathTransform conversion) {
+        ArgumentChecks.ensureNonNull("conversion", conversion);
+        int expected = localToCRS.getSourceDimensions();
+        int actual   = conversion.getSourceDimensions();
+        if (expected == actual) {
+            expected = localToCRS.getTargetDimensions();
+            actual   = conversion.getTargetDimensions();
+            if (expected == actual) {
+                localToCRS = conversion;
+                return;
+            }
+        }
+        throw new MismatchedDimensionException(Errors.format(
+                Errors.Keys.MismatchedDimension_3, "conversion", expected, actual));
+    }
+
+    /**
+     * Returns the coordinates given to the last call to {@link #setLocalCoordinates(double, double)},
+     * or an empty value if those coordinates are not visible.
+     *
+     * @return the local coordinates currently shown in the status bar.
+     */
+    public Optional<Point2D> getLocalCoordinates() {
+        if (coordinates.isVisible() && !Double.isNaN(lastX) && !Double.isNaN(lastY)) {
+            return Optional.of(new Point2D(lastX, lastY));
+        }
+        return Optional.empty();
+    }
+
+    /**
+     * Converts and formats the given pixel coordinates. Those coordinates will be automatically
+     * converted to geographic or projected coordinates if a "local to CRS" conversion is available.
+     *
+     * @param  x  the <var>x</var> coordinate local to the view.
+     * @param  y  the <var>y</var> coordinate local to the view.
+     *
+     * @see #handle(MouseEvent)
+     */
+    public void setLocalCoordinates(final double x, final double y) {
+        if (x != lastX || y != lastY) {
+            sourceCoordinates[0] = lastX = x;
+            sourceCoordinates[1] = lastY = y;
+            String text;
+            try {
+                Matrix derivative;
+                try {
+                    derivative = MathTransforms.derivativeAndTransform(localToCRS,
+                            sourceCoordinates, 0, targetCoordinates.coordinates, 0);
+                } catch (TransformException ignore) {
+                    /*
+                     * If above operation failed, it may be because the MathTransform does not support
+                     * derivative calculation. Try again without derivative (the precision will be set
+                     * to the default resolution computed in `setCanvasGeometry(…)`).
+                     */
+                    localToCRS.transform(sourceCoordinates, 0, targetCoordinates.coordinates, 0, 1);
+                    derivative = null;
+                }
+                if (derivative == null) {
+                    precisions = null;
+                } else {
+                    if (precisions == null) {
+                        precisions = new double[targetCoordinates.getDimension()];
+                    }
+                    /*
+                     * Estimate the precision by looking at the maximal displacement in the CRS caused by
+                     * a displacement of one cell (i.e. when moving by one row or column).  We search for
+                     * maximal displacement instead than minimal because we expect the displacement to be
+                     * zero along some axes (e.g. one row down does not change longitude value in a Plate
+                     * Carrée projection).
+                     */
+                    for (int j=derivative.getNumRow(); --j >= 0;) {
+                        double p = 0;
+                        for (int i=derivative.getNumCol(); --i >= 0;) {
+                            double e = Math.abs(derivative.getElement(j, i));
+                            if (inflatePrecisions != null) {
+                                e *= inflatePrecisions[i];
+                            }
+                            if (e > p) p = e;
+                        }
+                        precisions[j] = p;
+                    }
+                }
+                format.setPrecisions(precisions);
+                text = format.format(targetCoordinates);
+            } catch (TransformException | RuntimeException e) {
+                /*
+                 * If even the fallback without derivative failed, show the error message.
+                 */
+                Throwable cause = Exceptions.unwrap(e);
+                text = cause.getLocalizedMessage();
+                if (text == null) {
+                    text = Classes.getShortClassName(cause);
+                }
+            }
+            coordinates.setText(text);
+            coordinates.setVisible(true);
+        }
+    }
+
+    /**
+     * Updates the coordinates shown in the status bar with the value given by the mouse event.
+     * This method handles the following events:
+     *
+     * <ul>
+     *   <li>{@link MouseEvent#MOUSE_ENTERED}: show the coordinates.</li>
+     *   <li>{@link MouseEvent#MOUSE_EXITED}:  hide the coordinates.</li>
+     *   <li>{@link MouseEvent#MOUSE_MOVED}:   delegate to {@link #setLocalCoordinates(double, double)}.</li>
+     * </ul>
+     *
+     * @param  event  the enter, exit or move event. For the convenience of programmatic calls,
+     *                a null value is synonymous to a mouse exit event.
+     */
+    @Override
+    public void handle(final MouseEvent event) {
+        boolean visible = (event != null);
+        if (visible) {
+            final EventType<? extends MouseEvent> type = event.getEventType();
+            final boolean enter = (type == MouseEvent.MOUSE_ENTERED);
+            final boolean moved = (type == MouseEvent.MOUSE_MOVED);
+            if (enter | moved) {
+                setLocalCoordinates(event.getX(), event.getY());
+                if (moved) return;
+            }
+            if (!enter && type != MouseEvent.MOUSE_EXITED) return;
+        }
+        coordinates.setVisible(visible);
+    }
+
+    /**
+     * Returns the error message currently shown.
+     *
+     * @return the current error message, or an empty value if none.
+     */
+    public Optional<String> getErrorMessage() {
+        return Optional.ofNullable(message.getText());
+    }
+
+    /**
+     * Show or hide an error message on the status bar, optionally with a button showing details in a dialog box.
+     * The {@code text} argument specifies the message to show on the status bar.
+     * If {@code text} is null, the message will be taken from the {@code details} if non-null.
+     * If {@code details} is also null, then the error message will be hidden.
+     *
+     * @param  text     the error message to show, or {@code null} if none.
+     * @param  details  the exception that caused the error, or {@code null} if none.
+     */
+    public void setErrorMessage(String text, final Throwable details) {
+        text = Strings.trimOrNull(text);
+        Button more = null;
+        if (details != null) {
+            final Locale locale = format.getLocale(Locale.Category.DISPLAY);
+            if (text == null) {
+                text = Exceptions.getLocalizedMessage(details, locale);
+                if (text == null) {
+                    text = details.getClass().getSimpleName();
+                }
+            }
+            final String alert = text;
+            more = new Button(Styles.ERROR_DETAILS);
+            more.setOnAction((e) -> ExceptionReporter.show(
+                    Resources.forLocale(locale).getString(Resources.Keys.ErrorDetails), alert, details));
+        }
+        message.setVisible(text != null);
+        message.setGraphic(more);
+        message.setText(text);
+        message.setTextFill(Styles.ERROR_TEXT);
+    }
+}
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Styles.java b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Styles.java
index a7c1379..0cc704a 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Styles.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Styles.java
@@ -50,6 +50,11 @@ public final class Styles {
     public static final double INITIAL_SPLIT = 200;
 
     /**
+     * Approximate size of vertical scroll bar.
+     */
+    public static final int SCROLLBAR_WIDTH = 20;
+
+    /**
      * "Standard" height of table rows. Can be approximate.
      */
     public static final double ROW_HEIGHT = 30;


Mime
View raw message