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: Show the coverage value under mouse cursor position.
Date Mon, 08 Jun 2020 17:22:10 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 03392c5  Show the coverage value under mouse cursor position.
03392c5 is described below

commit 03392c53e4603b4c2a95c3cdc5320f0a83a92c08
Author: Martin Desruisseaux <martin.desruisseaux@geomatys.com>
AuthorDate: Mon Jun 8 19:21:48 2020 +0200

    Show the coverage value under mouse cursor position.
---
 .../org/apache/sis/gui/coverage/CellFormat.java    |   2 +-
 .../java/org/apache/sis/gui/map/StatusBar.java     | 130 ++++-
 .../org/apache/sis/gui/map/ValuesUnderCursor.java  | 547 +++++++++++++++++++++
 .../gui/referencing/RecentReferenceSystems.java    |   4 +-
 .../java/org/apache/sis/internal/gui/Styles.java   |   5 +
 .../sis/coverage/grid/ConvertedGridCoverage.java   |   2 +-
 .../sis/coverage/grid/ResampledGridCoverage.java   |  14 +
 7 files changed, 684 insertions(+), 20 deletions(-)

diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CellFormat.java
b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CellFormat.java
index 0c7b08a..cb7cf98 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CellFormat.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CellFormat.java
@@ -296,7 +296,7 @@ final class CellFormat extends SimpleStringProperty {
      */
     private void formatCell(final double value) {
         if (Double.isNaN(value)) {
-            lastValueAsText = "⬚";
+            lastValueAsText = Styles.SYMBOL_NaN;
         } else {
             lastValueAsText = cellFormat.format(value, buffer, formatField).toString();
         }
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
index afc538a..3675282 100644
--- 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
@@ -23,6 +23,7 @@ import javax.measure.Unit;
 import javafx.geometry.Pos;
 import javafx.geometry.Insets;
 import javafx.geometry.Point2D;
+import javafx.scene.Node;
 import javafx.scene.paint.Color;
 import javafx.scene.layout.HBox;
 import javafx.scene.layout.Region;
@@ -31,6 +32,7 @@ import javafx.scene.control.Label;
 import javafx.scene.control.Button;
 import javafx.scene.control.Tooltip;
 import javafx.scene.control.Menu;
+import javafx.scene.control.MenuItem;
 import javafx.scene.control.ContextMenu;
 import javafx.scene.input.MouseEvent;
 import javafx.scene.text.TextAlignment;
@@ -44,6 +46,7 @@ import javafx.beans.property.ReadOnlyObjectPropertyBase;
 import javafx.beans.property.SimpleObjectProperty;
 import javafx.beans.value.ChangeListener;
 import javafx.collections.ListChangeListener;
+import javafx.collections.ObservableList;
 import javax.measure.quantity.Length;
 import org.opengis.geometry.Envelope;
 import org.opengis.geometry.MismatchedDimensionException;
@@ -302,7 +305,7 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent>
{
     private final CoordinateFormat format;
 
     /**
-     * The labels where to format the cursor position, either as coordinate values or other
representations.
+     * The label where to format the cursor position, either as coordinate values or other
representations.
      * The text is usually the result of formatting coordinate values as numerical values,
      * but may also be other representations such as Military Grid Reference System (MGRS)
codes.
      *
@@ -311,6 +314,13 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent>
{
     protected final Label position;
 
     /**
+     * Maximal length of {@linkplain #position} text found so far. This is used for detecting
when
+     * to compute a minimal {@linkplain #position} width for making sure that the coordinates
stay
+     * visible even when an error message is shown on the left.
+     */
+    private int maximalPositionLength;
+
+    /**
      * The {@link #position} text to show when the mouse is outside the canvas area.
      * This text is set to the axis abbreviations, for example "(φ, λ)".
      *
@@ -319,6 +329,20 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent>
{
     private String outsideText;
 
     /**
+     * The label where to format the sample value(s) below cursor position, or {@code null}
if none.
+     *
+     * @see #setSampleValuesVisible(boolean)
+     */
+    private Label sampleValues;
+
+    /**
+     * The object providing sample values under cursor position.
+     * The property value may be {@code null} if there is no sample values to format.
+     * If non-null, the text provided by this object will appear at the right of the coordinates.
+     */
+    public final ObjectProperty<ValuesUnderCursor> sampleValuesProvider;
+
+    /**
      * The listener registered on {@link MapCanvas#renderingProperty()}, or {@code null}
if the
      * listener has not yet been registered. This listener is remembered for allowing removal.
      *
@@ -370,6 +394,7 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent>
{
         position.setTextAlignment(TextAlignment.RIGHT);
         canvas = new SimpleObjectProperty<>(this, "canvas");
         canvas.addListener((p,o,n) -> onCanvasSpecified(o,n));
+        final ContextMenu menu = new ContextMenu();
         this.systemChooser = systemChooser;
         if (systemChooser == null) {
             selectedSystem = null;
@@ -394,22 +419,36 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent>
{
             /*
              * Create a contextual menu offering to user a choice of CRS in which to display
the coordinates.
              * The CRS choices are controlled by `RecentReferenceSystems`. Selection of a
new CRS causes the
-             * `setPositionCRS(…)` method to be invoked. Contextual menu can be invoked
anywhere on the HBox;
-             * we do not register this menu to `position` only because it is a relatively
small area.
+             * `setPositionCRS(…)` method to be invoked.
              */
             final Menu choices = systemChooser.createMenuItems((property, oldValue, newValue)
-> {
                 setPositionCRS(newValue instanceof CoordinateReferenceSystem ? (CoordinateReferenceSystem)
newValue : null);
             });
             selectedSystem = RecentReferenceSystems.getSelectedProperty(choices);
-            final ContextMenu menu = new ContextMenu(choices);
-            view.setOnMousePressed((event) -> {
-                if (event.isSecondaryButtonDown()) {
-                    menu.show((HBox) event.getSource(), event.getScreenX(), event.getScreenY());
-                } else {
-                    menu.hide();
-                }
-            });
+            menu.getItems().add(choices);
         }
+        /*
+         * Configure the property that allow user to specify which values to display on the
right of cursor position.
+         */
+        final ObservableList<MenuItem> items = menu.getItems();
+        sampleValuesProvider = new SimpleObjectProperty<>(this, "valueProvider");
+        sampleValuesProvider.addListener((p,o,n) -> {
+            ValuesUnderCursor.update(this, o, n);
+            setSampleValuesVisible(n != null);
+            if (o != null) items.remove(o.valueChoices);
+            if (n != null) items.add(n.valueChoices);
+        });
+        /*
+         * Contextual menu can be invoked anywhere on the HBox; we do not register this menu
+         * to `position` or `sampleValues` only because they are relatively small regions.
+         */
+        view.setOnMousePressed((event) -> {
+            if (event.isSecondaryButtonDown() && !menu.getItems().isEmpty()) {
+                menu.show((HBox) event.getSource(), event.getScreenX(), event.getScreenY());
+            } else {
+                menu.hide();
+            }
+        });
     }
 
     /**
@@ -433,7 +472,8 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent>
{
             previous.floatingPane.removeEventHandler(MouseEvent.MOUSE_MOVED,   this);
             previous.renderingProperty().removeListener(renderingListener);
             previous.errorProperty().removeListener(errorListener);
-            renderingListener = null;
+            renderingListener         = null;
+            errorListener             = null;
             isMouseListenerRegistered = false;
         }
         if (value != null) {
@@ -454,6 +494,7 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent>
{
             setRenderingError(e);
         }
         applyCanvasGeometry(geometry);
+        sampleValuesProvider.set(ValuesUnderCursor.create(value));
     }
 
     /**
@@ -648,6 +689,7 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent>
{
         if (objectiveToPositionCRS != null) {
             localToPositionCRS = MathTransforms.concatenate(localToObjectiveCRS.get(), objectiveToPositionCRS);
         }
+        targetCoordinates.setCoordinateReferenceSystem(format.getDefaultCRS());
     }
 
     /**
@@ -760,6 +802,7 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent>
{
                 objectiveCRS = crs;
             }
             position.setMinWidth(0);
+            maximalPositionLength = 0;
             resetPositionCRS(Styles.NORMAL_TEXT);
         }
     }
@@ -781,6 +824,7 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent>
{
         updateLocalToPositionCRS();
         position.setTextFill(Styles.NORMAL_TEXT);
         position.setMinWidth(0);
+        maximalPositionLength = 0;
         if (isPositionVisible()) {
             final double x = lastX;
             final double y = lastY;
@@ -817,7 +861,7 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent>
{
         }
         position.setTooltip(tp);
         /*
-         * Prepare the text to show when the moust is outside the canvas area.
+         * Prepare the text to show when the mouse is outside the canvas area.
          * We will write axis abbreviations, for example "(φ, λ)".
          */
         text = null;
@@ -845,10 +889,15 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent>
{
                 }
             }
         }
+        /*
+         * If the mouse is already outside canvas area, update the `position` text now.
+         * Otherwise `position` is probably showing coordinates, which we leave unchanged
for now.
+         */
         if (position.getText() == outsideText) {          // Identity comparison is okay
for this value.
             position.setText(text);
         }
         outsideText = text;
+        targetCoordinates.setCoordinateReferenceSystem(crs);
         ((PositionSystem) positionReferenceSystem).fireValueChangedEvent();
     }
 
@@ -946,6 +995,7 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent>
{
         if (x != lastX || y != lastY) {
             sourceCoordinates[0] = lastX = x;
             sourceCoordinates[1] = lastY = y;
+            boolean success = false;
             String text;
             try {
                 Matrix derivative;
@@ -988,6 +1038,7 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent>
{
                 }
                 format.setPrecisions(precisions);
                 text = format.format(targetCoordinates);
+                success = true;
             } catch (TransformException | RuntimeException e) {
                 /*
                  * If even the fallback without derivative failed, show the error message.
@@ -1003,9 +1054,16 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent>
{
              * Make sure that there is enough space for keeping the coordinates always visible.
              * This is the needed if there is an error message on the left which may be long.
              */
-            final double width = Math.min(view.getWidth() / 2, Math.ceil(position.prefWidth(position.getHeight())));
-            if (width > position.getMinWidth()) {
-                position.setMinWidth(width);
+            if (text.length() > maximalPositionLength) {
+                maximalPositionLength = text.length();
+                position.setMinWidth(Math.min(view.getWidth() / 2, Math.ceil(position.prefWidth(position.getHeight()))));
+            }
+            /*
+             * Format the values under cursor if the coordinates are valid.
+             */
+            final ValuesUnderCursor vp = sampleValuesProvider.get();
+            if (vp != null) {
+                sampleValues.setText(success ? vp.evaluate(targetCoordinates) : null);
             }
         }
     }
@@ -1054,6 +1112,46 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent>
{
     }
 
     /**
+     * Sets whether to show or hide the label for values under the cursor.
+     * This method is invoked when {@link #sampleValuesProvider} changed.
+     *
+     * @see #sampleValuesProvider
+     * @see #sampleValues
+     */
+    private void setSampleValuesVisible(final boolean visible) {
+        final ObservableList<Node> c = view.getChildren();
+        if (visible) {
+            if (sampleValues == null) {
+                sampleValues = new Label();
+                sampleValues.setAlignment(Pos.CENTER_RIGHT);
+                sampleValues.setTextAlignment(TextAlignment.RIGHT);
+                sampleValues.setMinWidth(Label.USE_PREF_SIZE);
+                sampleValues.setMaxWidth(Label.USE_PREF_SIZE);
+            }
+            if (c.lastIndexOf(sampleValues) < 0) {
+                c.add(sampleValues);
+            }
+        } else if (sampleValues != null) {
+            c.remove(sampleValues);
+        }
+        sampleValues.setText(null);
+    }
+
+    /**
+     * Given the longest expected text for values under the cursor,
+     * computes the {@link #sampleValues} minimal width.
+     *
+     * @see ValuesUnderCursor#prototype(String)
+     */
+    final void computeSizeOfSampleValues(final String prototype) {
+        if (sampleValues != null) {
+            sampleValues.setText(prototype);
+            sampleValues.setPrefWidth(sampleValues.prefWidth(sampleValues.getHeight()));
+            sampleValues.setText(null);
+        }
+    }
+
+    /**
      * Returns the error message currently shown.
      *
      * @return the current error message, or an empty value if none.
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/ValuesUnderCursor.java
b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/ValuesUnderCursor.java
new file mode 100644
index 0000000..2ddb219
--- /dev/null
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/ValuesUnderCursor.java
@@ -0,0 +1,547 @@
+/*
+ * 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.List;
+import java.util.Map;
+import java.util.HashMap;
+import java.util.BitSet;
+import java.util.Locale;
+import java.util.Optional;
+import java.text.FieldPosition;
+import java.text.NumberFormat;
+import java.text.DecimalFormat;
+import javafx.beans.value.ChangeListener;
+import javafx.beans.value.ObservableValue;
+import javafx.beans.property.ReadOnlyProperty;
+import javafx.beans.value.WeakChangeListener;
+import javafx.collections.ObservableList;
+import javafx.scene.control.Menu;
+import javafx.scene.control.CheckMenuItem;
+import javafx.scene.control.MenuItem;
+import javax.measure.Unit;
+import org.opengis.geometry.DirectPosition;
+import org.opengis.coverage.CannotEvaluateException;
+import org.opengis.metadata.content.TransferFunctionType;
+import org.apache.sis.referencing.operation.transform.TransferFunction;
+import org.apache.sis.gui.coverage.CoverageCanvas;
+import org.apache.sis.coverage.grid.GridCoverage;
+import org.apache.sis.coverage.SampleDimension;
+import org.apache.sis.coverage.Category;
+import org.apache.sis.internal.gui.Styles;
+import org.apache.sis.internal.system.Modules;
+import org.apache.sis.math.DecimalFunctions;
+import org.apache.sis.math.MathFunctions;
+import org.apache.sis.measure.NumberRange;
+import org.apache.sis.measure.UnitFormat;
+import org.apache.sis.util.Characters;
+import org.apache.sis.util.Localized;
+import org.apache.sis.util.logging.Logging;
+import org.apache.sis.util.resources.Vocabulary;
+
+
+/**
+ * Provider of textual content to show in a {@link StatusBar} for values under cursor position.
+ * Different subtypes are defined for different data sources such as {@link GridCoverage}.
+ * Instances of {@code ValueUnderCursor} do not need to be thread-safe.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+public abstract class ValuesUnderCursor {
+    /**
+     * The status bar for which this object is providing values.
+     * Each {@link ValuesUnderCursor} instance is used by at most {@link StatusBar} instance.
+     *
+     * @see #update(StatusBar, ValuesUnderCursor, ValuesUnderCursor)
+     */
+    private StatusBar owner;
+
+    /**
+     * Menu offering choices among the values that this {@code ValuesUnderCursor} can show.
+     * This menu will be available as a contextual menu in the {@link StatusBar}.
+     * It is subclass responsibility to listen to menu selections and adapt their
+     * {@link #evaluate(DirectPosition)} output accordingly.
+     */
+    protected final Menu valueChoices;
+
+    /**
+     * Message of the last exception, used for avoiding flooding the logger with repetitive
errors.
+     *
+     * @see #recoverableException(String, Exception)
+     */
+    private String lastErrorMessage;
+
+    /**
+     * Creates a new instance.
+     */
+    protected ValuesUnderCursor() {
+        valueChoices = new Menu();
+    }
+
+    /**
+     * Returns a string representation of data under given position.
+     * The position may be in any CRS; this method should convert coordinates as needed.
+     *
+     * @param  point  the cursor location in arbitrary CRS (usually the CRS shown in the
status bar).
+     * @return string representation of data under given position, or {@code null} if none.
+     */
+    public abstract String evaluate(final DirectPosition point);
+
+    /**
+     * Invoked when a new source of values is known for computing the expected size.
+     * The given text should be the longest text that we expect to format.
+     */
+    final void prototype(final String text) {
+        if (owner != null) {
+            owner.computeSizeOfSampleValues(text);
+        }
+    }
+
+    /**
+     * Returns the locale of the JavaBean containing the given property, or {@code null}
if unknown.
+     * The bean is typically an instance of {@link MapCanvas}.
+     *
+     * @see MapCanvas#getLocale()
+     */
+    private static Locale getLocale(final ObservableValue<?> property) {
+        if (property instanceof ReadOnlyProperty<?>) {
+            final Object bean = ((ReadOnlyProperty<?>) property).getBean();
+            if (bean instanceof Localized) {
+                return ((Localized) bean).getLocale();
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Invoked when {@link StatusBar#sampleValuesProvider} changed. Each {@link ValuesUnderCursor}
instance
+     * can be used by at most one {@link StatusBar} instance. Current implementation silently
does nothing
+     * if this is not the case.
+     */
+    static void update(final StatusBar owner, final ValuesUnderCursor oldValue, final ValuesUnderCursor
newValue) {
+        if (oldValue != null && oldValue.owner == owner) {
+            oldValue.owner = null;
+        }
+        if (newValue != null && newValue.owner == null) {
+            newValue.owner = owner;
+        }
+    }
+
+    /**
+     * Creates a new instance for the given canvas and registers as a listener by weak reference.
+     * 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}.
+     * @return the sample values provider, or {@code null} if none.
+     */
+    static ValuesUnderCursor create(final MapCanvas canvas) {
+        if (canvas instanceof CoverageCanvas) {
+            final FromCoverage listener = new FromCoverage();
+            ((CoverageCanvas) canvas).coverageProperty.addListener(new WeakChangeListener<>(listener));
+            return listener;
+        } else {
+            // More cases may be added in the future.
+        }
+        return null;
+    }
+
+    /**
+     * Provider of textual content to show in {@link StatusBar} for {@link GridCoverage}
values under cursor position.
+     * This object should be registered as a listener of some coverage property,
+     * for example {@link CoverageCanvas#coverageProperty}.
+     *
+     * @author  Martin Desruisseaux (Geomatys)
+     * @version 1.1
+     * @since   1.1
+     * @module
+     */
+    public static class FromCoverage extends ValuesUnderCursor implements ChangeListener<GridCoverage>
{
+        /**
+         * The separator to insert between sample values. We use EM space.
+         */
+        private static final char SEPARATOR = '\u2003';
+
+        /**
+         * Pseudo amount of fraction digits for default format.
+         * Used when we don't know how many fraction digits to use.
+         */
+        private static final int DEFAULT_FORMAT = -1;
+
+        /**
+         * Pseudo amount of fraction digits for scientific notation.
+         */
+        private static final int SCIENTIFIC_NOTATION = -2;
+
+        /**
+         * The source of values converted to the {@linkplain #units} of measurement.
+         * Not necessarily the same instance than the property this {@code FromCoverage}
is listening,
+         * since we take the instance returned by {@link GridCoverage#forConvertedValues(boolean)}.
+         */
+        private GridCoverage coverage;
+
+        /**
+         * The selection status of each band.
+         */
+        private final BitSet selectedBands;
+
+        /**
+         * A temporary buffer for getting numerical values. Created when first needed.
+         */
+        private double[] results;
+
+        /**
+         * Formatter for {@link #results} values.
+         * The number of fraction digits is computed from transfer function resolution.
+         * The same {@link NumberFormat} instance may appear at more than one index.
+         */
+        private NumberFormat[] sampleFormats;
+
+        /**
+         * Buffer where to format the textual content.
+         */
+        private final StringBuffer buffer;
+
+        /**
+         * Ignored but required by {@link NumberFormat}.
+         */
+        private final FieldPosition field;
+
+        /**
+         * Unit symbol to write after each value.
+         */
+        private String[] units;
+
+        /**
+         * The text to show when value under cursor is a NaN value.
+         * Values are packed with band number in low bits and float ordinal value in high
bits.
+         *
+         * @see #toNodataKey(int, float)
+         * @see MathFunctions#toNanOrdinal(float)
+         */
+        private final Map<Long,String> nodata;
+
+        /**
+         * The text to show when cursor is outside coverage area.
+         * This text should contain the sample dimension names.
+         */
+        private String outsideText;
+
+        /**
+         * Creates a new provider of textual values for a {@link GridCoverage}.
+         */
+        public FromCoverage() {
+            buffer        = new StringBuffer();
+            field         = new FieldPosition(0);
+            nodata        = new HashMap<>();
+            selectedBands = new BitSet();
+        }
+
+        /**
+         * Notifies this {@code ValuesUnderCursor} object that it needs to display values
for a new coverage.
+         * The {@code previous} argument should be the argument given in the last call to
this method and is
+         * used as an optimization hint. In case of doubt, it can be {@code null}.
+         *
+         * @param  property  the property which has been updated, or {@code null} is unknown.
+         * @param  previous  previous property value, of {@code null} if none or unknown.
+         * @param  coverage  new coverage for which to show sample values, or {@code null}
if none.
+         */
+        @Override
+        public void changed(final ObservableValue<? extends GridCoverage> property,
+                            final GridCoverage previous, GridCoverage coverage)
+        {
+            final List<SampleDimension> bands;      // Should never be null, but check
anyway.
+            if (coverage == null || (bands = coverage.getSampleDimensions()) == null) {
+                this.coverage = null;
+                units         = null;
+                sampleFormats = null;
+                outsideText   = null;
+                nodata.clear();
+                selectedBands.clear();
+                valueChoices.getItems().clear();
+                return;
+            }
+            this.coverage = coverage.forConvertedValues(true);
+            if (previous != null && bands.equals(previous.forConvertedValues(true).getSampleDimensions()))
{
+                // Same configuration than previous coverage.
+                return;
+            }
+            final int n   = bands.size();
+            units         = new String[n];
+            sampleFormats = new NumberFormat[n];
+            outsideText   = null;
+            /*
+             * Only the first band is initially selected, unless the image has only 2 or
3 bands
+             * in which case all bands are selected. An image with two bands is often giving
the
+             * (u,v) components of velocity vectors, which we want to keep together by default.
+             */
+            selectedBands.clear();
+            selectedBands.set(0, (n <= 3) ? n : 1, true);
+            nodata.clear();
+            /*
+             * Loop below initializes number formats and unit symbols for all bands, regardless
+             * if selected or not. We do that on the assumption that the same format and
symbol
+             * are typically shared by all bands.
+             */
+            final Map<Integer,NumberFormat> sharedFormats = new HashMap<>();
+            final Map<Unit<?>,String>       sharedSymbols = new HashMap<>();
+            final Locale                    locale        = getLocale(property);
+            final UnitFormat                unitFormat    = new UnitFormat(locale);
+            final CheckMenuItem[]           menuItems     = new CheckMenuItem[n];
+            valueChoices.setText(Vocabulary.getResources(locale).getString(Vocabulary.Keys.SampleDimensions));
+            for (int i=0; i<n; i++) {
+                final SampleDimension sd = bands.get(i);
+                menuItems[i] = createMenuItem(i, sd, locale);
+                /*
+                 * Build the list of texts to show for missing values. A coverage can have
+                 * different NaN values representing different kind of missing values.
+                 */
+                for (final Category c : sd.forConvertedValues(true).getCategories()) {
+                    final float value = ((Number) c.getSampleRange().getMinValue()).floatValue();
+                    if (Float.isNaN(value)) try {
+                        nodata.putIfAbsent(toNodataKey(i, value), c.getName().toString(locale));
+                    } catch (IllegalArgumentException e) {
+                        recoverableException("changed", e);
+                    }
+                }
+                /*
+                 * Format in advance the units of measurement. If none, an empty string is
used.
+                 * Note: it is quite common that all bands use the same unit of measurement.
+                 */
+                units[i] = sd.getUnits().map((unit) -> sharedSymbols.computeIfAbsent(unit,
+                                              (key) -> format(unitFormat, key))).orElse("");
+                /*
+                 * Infer a number of fraction digits to use for the resolution of sample
values in each band.
+                 */
+                final SampleDimension isd = sd.forConvertedValues(false);
+                final Integer nf = isd.getTransferFunctionFormula().map(
+                        (formula) -> suggestFractionDigits(formula, isd)).orElse(DEFAULT_FORMAT);
+                /*
+                 * Create number formats with a number of fraction digits inferred from sample
value resolution.
+                 * The same format instances are shared when possible. Keys are the number
of fraction digits.
+                 * Special values:
+                 *
+                 *   - Key  0 is for integer values.
+                 *   - Key -1 is for default format with unspecified number of fraction digits.
+                 *   - Key -2 is for scientific notation.
+                 */
+                sampleFormats[i] = sharedFormats.computeIfAbsent(nf, (precision) -> {
+                    switch (precision) {
+                        case 0:              return NumberFormat.getIntegerInstance(locale);
+                        case DEFAULT_FORMAT: return NumberFormat.getNumberInstance(locale);
+                        case SCIENTIFIC_NOTATION: {
+                            final NumberFormat format = NumberFormat.getNumberInstance(locale);
+                            if (precision == SCIENTIFIC_NOTATION && format instanceof
DecimalFormat) {
+                                ((DecimalFormat) format).applyPattern("0.000E00");
+                            }
+                            return format;
+                        }
+                        default: {
+                            final NumberFormat format = NumberFormat.getNumberInstance(locale);
+                            format.setMinimumFractionDigits(precision);
+                            format.setMaximumFractionDigits(precision);
+                            return format;
+                        }
+                    }
+                });
+            }
+            valueChoices.getItems().setAll(menuItems);
+            onBandSelectionChanged();
+        }
+
+        /**
+         * Returns the key to use in {@link #nodata} map for the given "no data" value.
+         *
+         * @param  band   band index.
+         * @param  value  the NaN value used for "no data".
+         * @return key to use in {@link #nodata} map.
+         * @throws IllegalArgumentException if the given value is not a NaN value
+         *         or does not use a supported bits pattern.
+         */
+        private static Long toNodataKey(final int band, final float value) {
+            return (((long) MathFunctions.toNanOrdinal(value)) << Integer.SIZE) | band;
+        }
+
+        /**
+         * Suggests a number of fraction digits for numbers formatted after conversion by
the given formula.
+         * This is either a positive number (including 0 for integers), or the {@value #SCIENTIFIC_NOTATION}
+         * or {@value #DEFAULT_FORMAT} sentinel values.
+         */
+        private static Integer suggestFractionDigits(final TransferFunction formula, final
SampleDimension isd) {
+            int nf;
+            if (formula.getType() != TransferFunctionType.LINEAR) {
+                nf = SCIENTIFIC_NOTATION;
+            } else {
+                double resolution = formula.getScale();
+                if (resolution > 0 && resolution <= Double.MAX_VALUE) {   
 // Non-zero, non-NaN and finite.
+                    final Optional<NumberRange<?>> range = isd.getSampleRange();
+                    if (range.isPresent()) {
+                        // See StatusBar.inflatePrecisions for rationale.
+                        resolution *= (0.5 / range.get().getSpan()) + 1;
+                    }
+                    nf = DecimalFunctions.fractionDigitsForDelta(resolution, false);
+                    if (nf < -9 || nf > 6) nf = SCIENTIFIC_NOTATION;        // Arbitrary
thresholds.
+                } else {
+                    nf = DEFAULT_FORMAT;
+                }
+            }
+            return nf;
+        }
+
+        /**
+         * Creates a new menu item for the given sample dimension.
+         *
+         * @param  index   index of the sample dimension.
+         * @param  sd      the sample dimension for which to create a menu item.
+         * @param  locale  the locale to use for fetching the sample dimension name.
+         */
+        private CheckMenuItem createMenuItem(final int index, final SampleDimension sd, final
Locale locale) {
+            final CheckMenuItem item = new CheckMenuItem(sd.getName().toInternationalString().toString(locale));
+            item.setSelected(selectedBands.get(index));
+            item.selectedProperty().addListener((p,o,n) -> {
+                selectedBands.set(index, n);
+                outsideText = null;
+            });
+            return item;
+        }
+
+        /**
+         * Returns a string representation of data under given position.
+         * The position may be in any CRS; this method will convert coordinates as needed.
+         *
+         * @param  point  the cursor location in arbitrary CRS.
+         * @return string representation of data under given position, or {@code null} if
none.
+         *
+         * @see GridCoverage#evaluate(DirectPosition, double[])
+         */
+        @Override
+        public String evaluate(final DirectPosition point) {
+            /*
+             * Take lock once instead than at each StringBuffer method call. It makes this
method thread-safe,
+             * but this is a side effect of the fact that `NumberFormat` accepts only `StringBuffer`
argument.
+             * We do not document this thread-safety in method contract since it is not guaranteed
to apply in
+             * future SIS versions if a future `NumberFormat` version accepts non-synchronized
`StringBuilder`.
+             */
+            synchronized (buffer) {
+                buffer.setLength(0);
+                if (coverage != null) try {
+                    results = coverage.evaluate(point, results);
+                    if (results != null) {
+                        for (int i = -1; (i = selectedBands.nextSetBit(i+1)) >= 0;) {
+                            if (buffer.length() != 0) {
+                                buffer.append(SEPARATOR);
+                            }
+                            final double value = results[i];
+                            if (Double.isNaN(value)) {
+                                Long key;
+                                try {
+                                    key = toNodataKey(i, (float) value);
+                                } catch (IllegalArgumentException e) {
+                                    recoverableException("evaluate", e);
+                                    key = null;
+                                }
+                                buffer.append(nodata.getOrDefault(key, Styles.SYMBOL_NaN));
+                            } else {
+                                sampleFormats[i].format(value, buffer, field).append(units[i]);
+                            }
+                        }
+                        return buffer.toString();
+                    }
+                } catch (CannotEvaluateException e) {
+                    // Ignore.
+                }
+                /*
+                 * Coordinate is considered outside coverage area.
+                 * Format the sample dimension names.
+                 */
+                if (outsideText == null) {
+                    onBandSelectionChanged();
+                }
+                return outsideText;
+            }
+        }
+
+        /**
+         * Formats the unit symbol to append after a sample value. The unit symbols are created
in advance
+         * and reused for all sample value formatting as long as the sample dimensions do
not change.
+         */
+        private String format(final UnitFormat format, final Unit<?> unit) {
+            synchronized (buffer) {         // Take lock once instead than at each StringBuffer
method call.
+                buffer.setLength(0);
+                format.format(unit, buffer, field);
+                if (buffer.length() != 0 && Character.isLetterOrDigit(buffer.codePointAt(0)))
{
+                    buffer.insert(0, Characters.NO_BREAK_SPACE);
+                }
+                return buffer.toString();
+            }
+        }
+
+        /**
+         * Formats the widest text that we expect. This text is used for computing the label
width.
+         * Also computes the text to show when cursor is outside coverage area. This method
is invoked
+         * when the bands selection changed, either because of selection in contextual menu
or because
+         * {@link ValuesUnderCursor} is providing data for a new coverage.
+         */
+        private void onBandSelectionChanged() {
+            final ObservableList<MenuItem> menus = valueChoices.getItems();
+            final List<SampleDimension>    bands = coverage.getSampleDimensions();
+            final StringBuilder            names = new StringBuilder().append('(');
+            final String text;
+            synchronized (buffer) {
+                buffer.setLength(0);
+                for (int i = -1; (i = selectedBands.nextSetBit(i+1)) >= 0;) {
+                    if (buffer.length() != 0) {
+                        buffer.append(SEPARATOR);
+                        names.append(", ");
+                    }
+                    names.append(menus.get(i).getText());
+                    final int start = buffer.length();
+                    final Comparable<?>[] sampleValues = bands.get(i).forConvertedValues(true)
+                            .getSampleRange().map((r) -> new Comparable<?>[] {r.getMinValue(),
r.getMaxValue()})
+                            .orElseGet(() -> new Comparable<?>[] {0xFF});      
            // Arbitrary value.
+                    for (final Comparable<?> value : sampleValues) {
+                        final int end = buffer.length();
+                        sampleFormats[i].format(value, buffer, field);
+                        final int length = buffer.length();
+                        if (length - end >= end - start) {
+                            buffer.delete(start, end);      // Delete first number if it
was shorter.
+                        } else {
+                            buffer.setLength(end);          // Delete second number if it
is shorter.
+                        }
+                    }
+                    buffer.append(units[i]);
+                }
+                text = buffer.toString();
+            }
+            outsideText = text.isEmpty() ? null : names.append(')').toString();
+            prototype(text);
+        }
+    }
+
+    /**
+     * Invoked when an exception occurred while computing values.
+     */
+    final void recoverableException(final String method, final Exception e) {
+        final String message = e.getMessage();
+        if (!message.equals(lastErrorMessage)) {
+            lastErrorMessage = message;
+            Logging.recoverableException(Logging.getLogger(Modules.APPLICATION), ValuesUnderCursor.class,
method, e);
+        }
+    }
+}
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/referencing/RecentReferenceSystems.java
b/application/sis-javafx/src/main/java/org/apache/sis/gui/referencing/RecentReferenceSystems.java
index edf7167..3264899 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/referencing/RecentReferenceSystems.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/referencing/RecentReferenceSystems.java
@@ -796,7 +796,7 @@ next:       for (int i=0; i<count; i++) {
     }
 
     /**
-     * Creates a box offering choices among the reference systems specified to this {@code
ShortChoiceList}.
+     * Creates a box offering choices among the reference systems specified to this {@code
RecentReferenceSystems}.
      * The returned control may be initially empty, in which case its content will be automatically
set at
      * a later time (after a background thread finished to process the {@link CoordinateReferenceSystem}s).
      *
@@ -814,7 +814,7 @@ next:       for (int i=0; i<count; i++) {
     }
 
     /**
-     * Creates menu items offering choices among the reference systems specified to this
{@code ShortChoiceList}.
+     * Creates menu items offering choices among the reference systems specified to this
{@code RecentReferenceSystems}.
      * The items will be inserted in the {@linkplain Menu#getItems() menu list}. The content
of that list will
      * change at any time after this method returned: items will be added or removed as a
result of user actions.
      *
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 d681eeb..ea47d39 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
@@ -124,6 +124,11 @@ public final class Styles extends Static {
     public static final String WARNING_ICON = "\u26A0\uFE0F";           // ⚠
 
     /**
+     * Text to format in place of NaN values.
+     */
+    public static final String SYMBOL_NaN = "⬚";
+
+    /**
      * Do not allow instantiation of this class.
      */
     private Styles() {
diff --git a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ConvertedGridCoverage.java
b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ConvertedGridCoverage.java
index 22d50a5..f039471 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ConvertedGridCoverage.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ConvertedGridCoverage.java
@@ -177,7 +177,7 @@ final class ConvertedGridCoverage extends GridCoverage {
 
     /**
      * Returns a sequence of double values for a given point in the coverage.
-     * This method delegates to the source coverage, then convert values.
+     * This method delegates to the source coverage, then converts the values.
      *
      * @param  point   the coordinate point where to evaluate.
      * @param  buffer  an array in which to store values, or {@code null} to create a new
array.
diff --git a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ResampledGridCoverage.java
b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ResampledGridCoverage.java
index 898b2d8..3ba691f 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ResampledGridCoverage.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ResampledGridCoverage.java
@@ -22,6 +22,7 @@ import java.util.Optional;
 import java.awt.Rectangle;
 import java.awt.image.RenderedImage;
 import org.opengis.util.FactoryException;
+import org.opengis.geometry.DirectPosition;
 import org.opengis.coverage.CannotEvaluateException;
 import org.opengis.referencing.datum.PixelInCell;
 import org.opengis.referencing.crs.CoordinateReferenceSystem;
@@ -527,4 +528,17 @@ final class ResampledGridCoverage extends GridCoverage {
         final RenderedImage values = source.render(sourceExtent);
         return imageProcessor.resample(bounds, toSource, values);
     }
+
+    /**
+     * Delegates to the source coverage, which should transform the point itself if needed.
+     *
+     * @param  point   the coordinate point where to evaluate.
+     * @param  buffer  an array in which to store values, or {@code null} to create a new
array.
+     * @return the {@code buffer} array, or a newly created array if {@code buffer} was null.
+     * @throws CannotEvaluateException if the values can not be computed at the specified
coordinate.
+     */
+    @Override
+    public double[] evaluate(final DirectPosition point, final double[] buffer) throws CannotEvaluateException
{
+        return source.evaluate(point, buffer);
+    }
 }


Mime
View raw message