You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sis.apache.org by de...@apache.org on 2020/06/08 17:22:10 UTC
[sis] branch geoapi-4.0 updated: Show the coverage value under
mouse cursor position.
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 <ma...@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);
+ }
}