This is an automated email from the ASF dual-hosted git repository. desruisseaux pushed a commit to branch geoapi-4.0 in repository https://gitbox.apache.org/repos/asf/sis.git
commit b4cf213bf313bd3564fb2e6faab233cb6e315cf5 Author: Martin Desruisseaux <martin.desruisse...@geomatys.com> AuthorDate: Sat Sep 17 12:15:09 2022 +0200 Move `ValueUnderCursor.FromCoverage` as a top-level class for easier maintenance. Refactor for fetching and formatting values in a background thread instead of JavaFX thread. --- .../apache/sis/gui/coverage/GridSliceSelector.java | 1 + .../java/org/apache/sis/gui/map/StatusBar.java | 68 +- .../org/apache/sis/gui/map/ValuesFormatter.java | 522 +++++++++++++++ .../org/apache/sis/gui/map/ValuesFromCoverage.java | 264 ++++++++ .../org/apache/sis/gui/map/ValuesUnderCursor.java | 699 +++++++-------------- 5 files changed, 1041 insertions(+), 513 deletions(-) diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridSliceSelector.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridSliceSelector.java index b7040e2c32..ba41db59f4 100644 --- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridSliceSelector.java +++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridSliceSelector.java @@ -194,6 +194,7 @@ public class GridSliceSelector extends Widget { private void setGridGeometry(final GridGeometry gg) { final ObservableList<Node> children = view.getChildren(); if (gg == null || gg.getDimension() <= BIDIMENSIONAL || !gg.isDefined(GridGeometry.EXTENT)) { + selectedExtent.set(null); children.clear(); return; } 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 7e62bffa90..33049342e8 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 @@ -564,7 +564,7 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent> { ValuesUnderCursor.update(this, o, n); if (o != null) items.remove(o.valueChoices); if (n != null) items.add(1, n.valueChoices); - setSampleValuesVisible(n != null && !n.isEmpty()); + setSampleValuesVisible(n != null); }); } @@ -1061,7 +1061,7 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent> { } target.setCoordinateReferenceSystem(crs); format.setDefaultCRS(crs); - targetCoordinates = target; // Assign only after abpve succeed. + targetCoordinates = target; // Assign only after above succeed. formatAsIdentifiers = null; format.setGroundAccuracy(Quantities.max(accuracy, lowestAccuracy.get())); setTooltip(crs); @@ -1365,17 +1365,11 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent> { */ public void setLocalCoordinates(final double x, final double y) { if (x != lastX || y != lastY) { - String text = formatLocalCoordinates(lastX = x, lastY = y); - position.setText(text); if (isSampleValuesVisible) { - String values; - try { - values = sampleValuesProvider.get().evaluate(targetCoordinates); - } catch (RuntimeException e) { - values = cause(e); - } - sampleValues.setText(values); + sampleValuesProvider.get().evaluateLater(targetCoordinates); // Work in a background thread. } + final String text = formatLocalCoordinates(lastX = x, lastY = y); + position.setText(text); /* * Make sure that there is enough space for keeping the coordinates always visible. * This is needed if there is an error message on the left which may be long. @@ -1515,10 +1509,17 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent> { lastX = lastY = Double.NaN; position.setText(outsideText); if (isSampleValuesVisible) { - sampleValues.setText(sampleValuesProvider.get().evaluate(null)); + sampleValuesProvider.get().evaluateLater(null); } } + /** + * Sets the result of formatting sample values under cursor position. + */ + final void setSampleValues(final String text) { + sampleValues.setText(text); + } + /** * Returns {@code true} if the position contains a valid coordinates. */ @@ -1539,21 +1540,23 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent> { */ private void setSampleValuesVisible(final boolean visible) { final ObservableList<Node> c = view.getChildren(); + Label view = sampleValues; 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 (view == null) { + view = new Label(); + view.setAlignment(Pos.CENTER_RIGHT); + view.setTextAlignment(TextAlignment.RIGHT); + view.setMinWidth(Label.USE_PREF_SIZE); + view.setMaxWidth(Label.USE_PREF_SIZE); + sampleValues = view; } - if (c.lastIndexOf(sampleValues) < 0) { + if (c.lastIndexOf(view) < 0) { final Separator separator = new Separator(Orientation.VERTICAL); - c.addAll(separator, sampleValues); + c.addAll(separator, view); } - } else if (sampleValues != null) { - sampleValues.setText(null); - int i = c.lastIndexOf(sampleValues); + } else if (view != null) { + view.setText(null); + int i = c.lastIndexOf(view); if (i >= 0) { c.remove(i); if (--i >= 0) { @@ -1570,7 +1573,7 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent> { * If {@code prototype} is empty, then no sample values are expected and the {@link #sampleValues} label will be * hidden. * - * @param prototype an example of longest normal text that we expect. + * @param prototype an example of longest normal text that we expect, or {@code null} or empty for hiding. * @param others some other texts that may appear, such as labels for missing data. * @return {@code true} on success, or {@code false} if this method should be invoked again. * @@ -1579,13 +1582,14 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent> { final boolean computeSizeOfSampleValues(final String prototype, final Iterable<String> others) { setSampleValuesVisible(prototype != null && !prototype.isEmpty()); if (isSampleValuesVisible) { - sampleValues.setText(prototype); - sampleValues.setPrefWidth(Label.USE_COMPUTED_SIZE); // Enable `prefWidth(…)` computation. - double width = sampleValues.prefWidth(sampleValues.getHeight()); + final Label view = sampleValues; + view.setText(prototype); + view.setPrefWidth(Label.USE_COMPUTED_SIZE); // Enable `prefWidth(…)` computation. + double width = view.prefWidth(view.getHeight()); final double max = Math.max(width * 1.25, 200); // Arbitrary limit. for (final String other : others) { - sampleValues.setText(other); - final double cw = sampleValues.prefWidth(sampleValues.getHeight()); + view.setText(other); + final double cw = view.prefWidth(view.getHeight()); if (cw > width) { width = cw; if (width > max) { @@ -1594,11 +1598,11 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent> { } } } - sampleValues.setText(null); + view.setText(null); if (!(width > 0)) { // May be 0 if canvas is not yet added to scene graph. return false; } - sampleValues.setPrefWidth(width + VALUES_PADDING); + view.setPrefWidth(width + VALUES_PADDING); } return true; } @@ -1722,7 +1726,7 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent> { * @param e the exception. * @return the exception message or class name. */ - private String cause(Throwable e) { + final String cause(Throwable e) { if (e instanceof Exception) { e = Exceptions.unwrap((Exception) e); } diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/ValuesFormatter.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/ValuesFormatter.java new file mode 100644 index 0000000000..8df7202973 --- /dev/null +++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/ValuesFormatter.java @@ -0,0 +1,522 @@ +/* + * 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.HashSet; +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 javax.measure.Unit; +import org.opengis.geometry.DirectPosition; +import org.opengis.metadata.content.TransferFunctionType; +import org.opengis.referencing.crs.CoordinateReferenceSystem; +import org.apache.sis.geometry.AbstractDirectPosition; +import org.apache.sis.referencing.operation.transform.TransferFunction; +import org.apache.sis.coverage.grid.GridExtent; +import org.apache.sis.coverage.grid.GridCoverage; +import org.apache.sis.coverage.SampleDimension; +import org.apache.sis.coverage.Category; +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.logging.Logging; + +import static java.util.logging.Logger.getLogger; + +// Branch-dependent imports +import org.opengis.coverage.CannotEvaluateException; + + +/** + * Fetches values from the coverage and formats them. This task is executed in a background thread + * because calls to {@link GridCoverage#render(GridExtent)} can take an arbitrary amount of time. + * The same {@code Formatter} instance can be reused as long as the configuration does not change. + * + * <p>As a rule of thumbs, all fields in {@link ValuesFromCoverage} class shall be read and written + * from the JavaFX thread, while all fields in this {@code Formatter} class can be read and written + * from a background thread.</p> + * + * @author Martin Desruisseaux (Geomatys) + * @version 1.3 + * @since 1.1 + * @module + */ +final class ValuesFormatter extends ValuesUnderCursor.Formatter { + /** + * 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 object computing or interpolation sample values in the coverage. + */ + private final GridCoverage.Evaluator evaluator; + + /** + * Formatter for the values computed or interpolated by {@link #evaluator}. + * The number of fraction digits is computed from transfer function resolution. + * The same {@link NumberFormat} instance may appear at more than one index. + * Array shall not be modified after construction. + */ + private final NumberFormat[] sampleFormats; + + /** + * Buffer where to format the textual content. + * We use this buffer as a synchronization lock because this class is already synchronized, + * so synchronizing on {@cod buffer} allows us to use only one lock. + */ + private final StringBuffer buffer; + + /** + * Ignored but required by {@link NumberFormat}. + */ + private final FieldPosition field; + + /** + * Unit symbol to write after each value. + * Array shall not be modified after construction. + */ + private final 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. + * Map content shall not be modified after construction. + * + * @see #toNodataKey(int, float) + * @see MathFunctions#toNanOrdinal(float) + */ + private final Map<Long,String> nodata; + + /** + * The text to show when cursor is outside coverage area. It should contain dimension names, for example "(SST)". + * May be {@code null} if {@link #setSelectedBands(BitSet, String[], HashSet)} needs to be invoked. + */ + private String outsideText; + + /** + * The selection status of each band at the time of {@link #setSelectedBands(BitSet, String[], HashSet)} invocation. + * We need a copy of {@link ValuesFromCoverage} field because the two sets are read and updated in different threads. + * This set should not be modified; instead, copy should be made. + * + * @see ValuesFromCoverage#selectedBands + */ + private BitSet selectedBands; + + /** + * Non-null when a new slice needs to be passed to {@link #evaluator}. + * A new value is set when {@link CoverageCanvas#sliceExtentProperty} changed. + * This value is reset to {@code null} after the slice has been taken in account. + * + * @see #setSlice(GridExtent) + */ + private GridExtent newSlice; + + /** + * Creates a new formatter for the specified coverage. + * This constructor should be invoked in a background thread. + * + * @param owner the instance which will evaluate values under cursor position. + * @param inherit formatter from which to inherit band configuration, or {@code null} if none. + * @param coverage new coverage. Shall not be null. + * @param slice initial value of {@link #newSlice}. + * @param bands sample dimensions of the new coverage. + * @param locale locale of number formats to create. + */ + ValuesFormatter(final ValuesUnderCursor owner, final ValuesFormatter inherit, final GridCoverage coverage, + final GridExtent slice, final List<SampleDimension> bands, final Locale locale) + { + super(owner); + buffer = new StringBuffer(); + field = new FieldPosition(0); + newSlice = slice; + evaluator = coverage.forConvertedValues(true).evaluator(); + evaluator.setNullIfOutside(true); + evaluator.setWraparoundEnabled(true); + selectedBands = new BitSet(); + if (inherit != null) { + // Same configuration than previous coverage. + synchronized (inherit.buffer) { + units = inherit.units; + nodata = inherit.nodata; + outsideText = inherit.outsideText; + sampleFormats = inherit.sampleFormats.clone(); + for (int i=0; i < sampleFormats.length; i++) { + sampleFormats[i] = (NumberFormat) sampleFormats[i].clone(); + } + } + return; + } + final int numBands = bands.size(); + sampleFormats = new NumberFormat[numBands]; + units = new String[numBands]; + nodata = new HashMap<>(); + /* + * 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 UnitFormat unitFormat = new UnitFormat(locale); + for (int b=0; b<numBands; b++) { + /* + * Build the list of texts to show for missing values. A coverage can have + * different NaN values representing different kind of missing values. + */ + final SampleDimension sd = bands.get(b); + for (final Category c : sd.forConvertedValues(true).getCategories()) { + final float value = ((Number) c.getSampleRange().getMinValue()).floatValue(); + if (Float.isNaN(value)) try { + nodata.putIfAbsent(toNodataKey(b, 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[b] = 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[b] = 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; + } + } + }); + } + } + + /** + * 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 of 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. + * + * <p>We use {@link ValuesFromCoverage#needsBandRefresh} as a flag meaning that this method needs to be invoked. + * This method invocation sometime needs to be delayed because calculation of text width may be wrong + * (produce 0 values) if invoked before {@link StatusBar#sampleValues} label is added in the scene graph.</p> + * + * <p>This method uses the same synchronization lock than {@link #evaluate(DirectPosition)}. + * Consequently this method may block if data loading are in progress in another thread.</p> + * + * @param selection copy of {@link ValuesFromCoverage#selectedBands} made by the caller in JavaFX thread. + * @param labels labels of {@link ValuesFromCoverage#valueChoices} menu items computed by caller in JavaFX thread. + * @param others an initially empty set where to put textual representation of "no data" values. + * @return the text to use as a prototype for sample values. + */ + final String setSelectedBands(final BitSet selection, final String[] labels, final HashSet<String> others) { + synchronized (buffer) { + final List<SampleDimension> bands = evaluator.getCoverage().getSampleDimensions(); + final StringBuilder names = new StringBuilder().append('('); + buffer.setLength(0); + for (int i = -1; (i = selection.nextSetBit(i+1)) >= 0;) { + if (buffer.length() != 0) { + buffer.append(SEPARATOR); + names.append(", "); + } + names.append(labels[i]); + 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<?>[] {0xFFFF}); // 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]); + } + final String text = buffer.toString(); + /* + * At this point, `text` is the longest string of numerical values that we expect. + * We also need to take in account the width required for displaying "no data" labels. + * If a "no data" label is shown, it will be shown alone (we do not need to compute a + * sum of "no data" label widths). + */ + for (final Map.Entry<Long,String> other : nodata.entrySet()) { + if (selection.get(other.getKey().intValue())) { + others.add(other.getValue()); + } + } + outsideText = text.isEmpty() ? "" : names.append(')').toString(); + selectedBands = selection; // Set only on success. + return text; + } + } + + /** + * Sets the slice in grid coverages where sample values should be evaluated for next positions. + * The given slice will apply to all positions formatted after this method call, + * until this method is invoked again for a new slice. + * + * <p>This method shall be synchronized on the same lock than {@link #copy(DirectPosition)}, + * which is the lock used by {@link #evaluateLater(DirectPosition)}.</p> + * + * @param slice grid coverage slice where to evaluate the sample values. + */ + final synchronized void setSlice(final GridExtent slice) { + newSlice = slice; + } + + /** + * Position of next point to evaluate, together with the grid slice where sample values should be evaluated. + * Those two information are kept together because they are closely related: the slice depends on position + * in dimensions not necessarily expressed in the given {@link DirectPosition}, and we want to take those + * two information in the same synchronized block. + */ + private static final class Position extends AbstractDirectPosition { + /** Coordinates of this position. */ + private final double[] coordinates; + + /** Coordinate reference system of this position. */ + private final CoordinateReferenceSystem crs; + + /** + * Non-null when a new slice needs to be passed to {@link #evaluator}. + * Should be null if the slice did not changed since last invocation. + * This is a copy of {@link ValuesFormatter#newSlice}. + */ + final GridExtent newSlice; + + /** + * Creates a copy of the given position. If {@link #evaluator} needs to be set + * to a new default slice position in order to evaluate the given coordinates, + * that position should be given as a non-null {@code slice} argument. + */ + Position(final DirectPosition position, final GridExtent slice) { + coordinates = position.getCoordinate(); + crs = position.getCoordinateReferenceSystem(); + newSlice = slice; + } + + /** Returns the number of dimensions of this position. */ + @Override public int getDimension() { + return coordinates.length; + } + + /** Returns the coordinate value in given dimension. */ + @Override public double getOrdinate(final int dimension) { + return coordinates[dimension]; + } + + /** Returns the CRS of this position, or {@code null} if unspecified. */ + @Override public CoordinateReferenceSystem getCoordinateReferenceSystem() { + return crs; + } + } + + /** + * Invoked in JavaFX thread for creating a copy of the given position together with related information. + * The related information is the grid coverage slice where the given position can be evaluated. + * The instance returned by this method will be given to {@link #evaluate(DirectPosition)}. + * + * @param point coordinates of the point for which to evaluate the grid coverage value. + * @return a copy of the given point, augmented with the slice where the point can be evaluated. + */ + @Override + DirectPosition copy(final DirectPosition point) { + assert Thread.holdsLock(this); + final Position position = new Position(point, newSlice); + newSlice = null; + return position; + } + + /** + * Computes a string representation of data under the given position. + * The position may be in any CRS; this method will convert coordinates as needed. + * This method should be invoked in a background thread. + * + * @param point the cursor location in arbitrary CRS, or {@code null} if outside canvas region. + * @return string representation of data under given position. + * + * @see GridCoverage.Evaluator#apply(DirectPosition) + */ + @Override + public String evaluate(final DirectPosition point) { + synchronized (buffer) { + buffer.setLength(0); + if (point != null) try { + final GridExtent slice = ((Position) point).newSlice; // This cast should never fail. + if (slice != null) { + evaluator.setDefaultSlice(slice.getSliceCoordinates()); + } + final double[] results = evaluator.apply(point); + if (results != null) { + final BitSet selection = selectedBands; + for (int i = -1; (i = selection.nextSetBit(i+1)) >= 0;) { + if (buffer.length() != 0) { + buffer.append(SEPARATOR); + } + final double value = results[i]; + if (Double.isNaN(value)) try { + /* + * If a value is NaN, returns its label as the whole content. Numerical values + * in other bands are lost. We do that because "no data" strings are often too + * long for being shown together with numerical values, and are often the same + * for all bands. Users can see numerical values by hiding the band containing + * "no data" values with contextual menu on the status bar. + */ + final String label = nodata.get(toNodataKey(i, (float) value)); + if (label != null) { + return label; + } + } catch (IllegalArgumentException e) { + recoverableException("evaluate", e); + } + sampleFormats[i].format(value, buffer, field).append(units[i]); + } + return buffer.toString(); + } + } catch (CannotEvaluateException e) { + recoverableException("evaluate", e); + } + /* + * Point is considered outside coverage area. + * We will write the sample dimension names. + */ + return outsideText; + } + } + + /** + * Returns the key to use in {@link #nodata} map for the given "no data" value. + * The band number can be obtained by {@link Long#intValue()}. + * + * @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; + } + + /** + * Message of the last exception, used for avoiding flooding the logger with repetitive errors. + * + * @see #recoverableException(String, Exception) + */ + private String lastErrorMessage; + + /** + * Invoked when an exception occurred while computing values. + */ + private void recoverableException(final String method, final Exception e) { + final String message = e.getMessage(); + if (!message.equals(lastErrorMessage)) { + lastErrorMessage = message; + Logging.recoverableException(getLogger(Modules.APPLICATION), ValuesUnderCursor.class, method, e); + } + } +} diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/ValuesFromCoverage.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/ValuesFromCoverage.java new file mode 100644 index 0000000000..03712fbf8b --- /dev/null +++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/ValuesFromCoverage.java @@ -0,0 +1,264 @@ +/* + * 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.HashSet; +import java.util.BitSet; +import java.util.Locale; +import java.text.NumberFormat; +import javafx.concurrent.Task; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; +import javafx.beans.property.ReadOnlyProperty; +import javafx.collections.ObservableList; +import javafx.scene.control.CheckMenuItem; +import javafx.scene.control.MenuItem; +import org.apache.sis.gui.coverage.CoverageCanvas; +import org.apache.sis.coverage.grid.GridExtent; +import org.apache.sis.coverage.grid.GridCoverage; +import org.apache.sis.coverage.SampleDimension; +import org.apache.sis.internal.gui.GUIUtilities; +import org.apache.sis.internal.gui.BackgroundThreads; +import org.apache.sis.util.resources.Vocabulary; + + +/** + * Provider of textual content to show in {@link StatusBar} for {@link GridCoverage} values under cursor position. + * This object can be registered as a listener of e.g. {@link CoverageCanvas#coverageProperty} for updating the + * values to show when the coverage is changed. + * + * <h2>Multi-threading</h2> + * This class fetches values and formats them in a background thread because calls + * to {@link GridCoverage#render(GridExtent)} can take an arbitrary amount of time. + * The {@link ValuesFormatter#buffer} is used as a synchronization lock. + * + * @author Martin Desruisseaux (Geomatys) + * @version 1.3 + * @since 1.1 + * @module + */ +final class ValuesFromCoverage extends ValuesUnderCursor implements ChangeListener<GridCoverage> { + /** + * The task to execute in a background thread for fetching values from the coverage and formatting them. + * This is {@code null} if there is no coverage. A new instance shall be created when the coverage changed. + */ + private ValuesFormatter formatter; + + /** + * The selection status of each band. + */ + private final BitSet selectedBands; + + /** + * {@code true} if {@link ValuesFormatter#setSelectedBands(BitSet, String[], HashSet)} + * needs to be invoked again. + */ + private boolean needsBandRefresh; + + /** + * {@code true} if {@link ValuesFormatter#setSelectedBands(BitSet, String[], HashSet)} + * is under execution in a background thread. + */ + private boolean refreshing; + + /** + * Creates a new provider of textual values for a {@link GridCoverage}. + */ + public ValuesFromCoverage() { + selectedBands = new BitSet(); + valueChoices.setText(Vocabulary.format(Vocabulary.Keys.SampleDimensions)); + } + + /** + * Resets this {@code ValuesFromCoverage} to its initial state. + * This is invoked when there is no coverage to show, or in case of failure. + */ + private void clear() { + formatter = null; + refreshing = false; + needsBandRefresh = false; + selectedBands.clear(); + valueChoices.getItems().clear(); + } + + /** + * Sets the slice in grid coverages where sample values should be evaluated for next positions. + * This method is invoked when {@link CoverageCanvas#sliceExtentProperty} changed its value. + */ + final void setSlice(final GridExtent extent) { + if (formatter != null) { + formatter.setSlice(extent); + } + } + + /** + * Returns the slice extent specified in the canvas which contains the given property. + * This is a workaround for the fact that the selected slice is not an information + * provided directly by the {@link GridCoùverage} values. + */ + private static GridExtent getSelectedSlice(final ObservableValue<?> property) { + if (property instanceof ReadOnlyProperty<?>) { + final Object bean = ((ReadOnlyProperty<?>) property).getBean(); + if (bean instanceof CoverageCanvas) { + return ((CoverageCanvas) bean).getSliceExtent(); + } + } + return null; + } + + /** + * 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} if 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, final GridCoverage coverage) + { + if (coverage == null) { + clear(); + return; + } + final GridExtent slice = getSelectedSlice(property); // Need to be invoked in JavaFX thread. + final Locale locale = GUIUtilities.getLocale(property); + BackgroundThreads.execute(new Task<ValuesFormatter>() { + /** + * The formatter from which to inherit configuration if the sample dimensions did not changed. + * The initial {@link #formatter} value needs to be assigned from JavaFX thread. + */ + private ValuesFormatter inherit = formatter; + + /** + * Sample dimensions of the coverage, fetched in background thread in case it is costly to compute. + */ + private List<SampleDimension> bands; + + /** + * Invoked in a background thread for reconfiguring {@link ValuesFromCoverage}. + * This method creates a new formatter with new {@link NumberFormat} instances. + * If successful, the JavaFX components are updated by {@link #succeeded()}. + */ + @Override protected ValuesFormatter call() { + bands = coverage.getSampleDimensions(); + if (!(previous != null && bands.equals(previous.getSampleDimensions()))) { + inherit = null; + } + return new ValuesFormatter(ValuesFromCoverage.this, inherit, coverage, slice, bands, locale); + } + + /** + * Invoked in JavaFX thread after successful configuration by background thread. + * The formatter created in background thread is assigned to {@link #formatter}, + * then the new menu items are created (unless they did not changed). + */ + @Override protected void succeeded() { + formatter = getValue(); + if (inherit == 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. + */ + final int numBands = bands.size(); + final CheckMenuItem[] menuItems = new CheckMenuItem[numBands]; + final BitSet selection = selectedBands; + selection.clear(); + selection.set(0, (numBands <= 3) ? numBands : 1, true); + for (int b=0; b<numBands; b++) { + menuItems[b] = createMenuItem(b, bands.get(b), locale); + } + valueChoices.getItems().setAll(menuItems); + needsBandRefresh = true; + } + } + + /** + * Invoked in JavaFX thread if an error occurred while initializing the formatter. + */ + @Override protected void failed() { + clear(); + setError(getException()); + } + }); + } + + /** + * Creates a new menu item for the given sample dimension. + * This method shall be invoked from JavaFX thread. + * + * @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); + needsBandRefresh = true; + }); + return item; + } + + /** + * Returns the task for fetching and formatting values in a background thread, or {@code null} if none. + * The formatter is created in a background thread as soon as the {@link MapCanvas} data are known. + * This method is invoked in JavaFX thread and may return {@code null} if the formatter is still + * under construction. + */ + @Override + protected Formatter formatter() { + if (refreshing) { + return null; + } + final ValuesFormatter formatter = this.formatter; + if (formatter != null && needsBandRefresh && usePrototype()) { + final ObservableList<MenuItem> menus = valueChoices.getItems(); + final String[] labels = new String[menus.size()]; + for (int i=0; i<labels.length; i++) { + labels[i] = menus.get(i).getText(); + } + final HashSet<String> others = new HashSet<>(); + final BitSet selection = (BitSet) selectedBands.clone(); + BackgroundThreads.execute(new Task<String>() { + /** Invoked in background thread for configuring the formatter. */ + @Override protected String call() { + return formatter.setSelectedBands(selection, labels, others); + } + + /** Invoked in JavaFX thread if the configuration succeeded. */ + @Override protected void succeeded() { + needsBandRefresh = !prototype(getValue(), others); + refreshing = false; + } + + /** Invoked in JavaFX thread if the configuration failed. */ + @Override protected void failed() { + clear(); + setError(getException()); + } + }); + refreshing = true; + } + return formatter; + } +} 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 index 8ec168b956..f256595343 100644 --- 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 @@ -16,55 +16,27 @@ */ package org.apache.sis.gui.map; -import java.util.List; -import java.util.Map; -import java.util.HashMap; -import java.util.HashSet; -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 java.util.concurrent.atomic.AtomicReference; 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 javafx.application.Platform; 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.GridExtent; import org.apache.sis.coverage.grid.GridCoverage; -import org.apache.sis.coverage.SampleDimension; -import org.apache.sis.coverage.Category; -import org.apache.sis.internal.system.Modules; -import org.apache.sis.internal.gui.GUIUtilities; -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.logging.Logging; -import org.apache.sis.util.resources.Vocabulary; - -import static java.util.logging.Logger.getLogger; +import org.apache.sis.geometry.GeneralDirectPosition; +import org.apache.sis.internal.gui.BackgroundThreads; /** * Provider of textual content to show in a {@link StatusBar} for values under cursor position. - * When the mouse cursor moves, {@link #evaluate(DirectPosition)} is invoked with the same + * When the mouse cursor moves, {@link #evaluateLater(DirectPosition)} is invoked with the same * "real world" coordinates than the ones shown in the status bar. * * <h2>Multi-threading</h2> - * Instances of {@code ValueUnderCursor} do not need to be thread-safe. - * {@code ValuesUnderCursor} methods will be invoked from JavaFX thread. + * Instances of {@code ValueUnderCursor} do not need to be thread-safe, because + * all {@code ValuesUnderCursor} methods will be invoked from JavaFX thread. + * However the actual fetching and formatting of values will be done in a background + * thread using the {@link Formatter} inner class, which needs to be thread-safe. * * @author Martin Desruisseaux (Geomatys) * @version 1.3 @@ -74,7 +46,8 @@ import static java.util.logging.Logger.getLogger; 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. + * Each {@link ValuesUnderCursor} instance is used by at most one {@link StatusBar} instance. + * This field shall be read and written from JavaFX thread only. * * @see #update(StatusBar, ValuesUnderCursor, ValuesUnderCursor) */ @@ -84,16 +57,52 @@ public abstract class ValuesUnderCursor { * 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. + * {@link #evaluateLater(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) + * The task to execute in JavaFX thread for showing the result of formatting values at cursor position. + * This is given in a call to {@link Platform#runLater(Runnable)} after the values have been formatted + * as text in a background thread. */ - private String lastErrorMessage; + private final Consumer consumer; + + /** + * Task to execute in JavaFX thread for showing the result of formatting values at cursor position. + * The {@link AtomicReference} value is the text to show in {@linkplain #owner owner} status bar. + * The value is atomically set to {@code null} as it is given to the control. + */ + @SuppressWarnings("serial") // Not intended to be serialized. + private final class Consumer extends AtomicReference<String> implements Runnable { + /** + * Creates a new task to execute in JavaFX thread for showing sample values. + */ + Consumer() { + } + + /** + * Sets the result to the given value, then submits a task in JavaFX thread if no task is already waiting. + * If a task is already waiting to be executed, then that task will use the specified value instead than + * the value which was specified when the previous task was submitted. + */ + final void setLater(final String result) { + if (getAndSet(result) == null) { + Platform.runLater(this); + } + } + + /** + * Invoked in JavaFX thread for showing the sample values. The value is reset to {@code null} + * for letting {@link #setLater(String)} know that the value has been consumed. + */ + @Override + public void run() { + final String text = getAndSet(null); // Must be invoked even if `owner` is null. + final StatusBar c = owner; + if (c != null) c.setSampleValues(text); + } + } /** * Creates a new evaluator instance. The {@link #valueChoices} list of items is initially empty; @@ -101,519 +110,247 @@ public abstract class ValuesUnderCursor { */ protected ValuesUnderCursor() { valueChoices = new Menu(); + consumer = new Consumer(); } /** - * Returns {@code true} if this {@code ValuesUnderCursor} has currently no data to show. - * A {@code ValuesUnderCursor} may be empty for example if user unselected all bands from - * the contextual menu. + * Returns the task for fetching and formatting values in a background thread. + * {@code ValuesUnderCursor} subclasses should keep a single {@link Formatter} instance, + * eventually replaced when the data shown in {@link MapCanvas} changed. + * That instance will be reused every time that the cursor position changed. * - * @return {@code true} if there is no data to show yet. + * @return task for fetching and formatting values in a background thread, or {@code null} if none. + * + * @since 1.3 */ - public abstract boolean isEmpty(); + protected abstract Formatter formatter(); /** - * Returns a string representation of data under given "real world" position. - * The {@linkplain DirectPosition#getCoordinateReferenceSystem() position CRS} + * Formats a string representation of data under given "real world" position. + * This method shall be invoked in JavaFX thread, but values will be fetched + * and formatted in a background thread managed automatically by this + * {@code ValuesUnderCursor} class. + * + * <p>The {@linkplain DirectPosition#getCoordinateReferenceSystem() position CRS} * should be non-null for avoiding ambiguity about what is the default CRS. - * The position CRS may be anything; this method shall transform coordinates itself if needed. + * The position CRS can be anything; it will be transformed if needed.</p> * * @param point the cursor location in arbitrary CRS (usually the CRS shown in the status bar). * May be {@code null} for declaring that the point is outside canvas region. - * @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 {@code main} text should be an example of the longest expected text, - * ignoring "special" labels like "no data" values (those special cases are listed - * in the {@code others} argument). - * - * <p>If {@code main} is an empty string, then no values are expected and {@link MapCanvas} - * may hide the space normally used for showing values.</p> * - * @param main a prototype of longest normal text that we expect. - * @param others some other texts that may appear, such as labels for missing data. - * @return {@code true} on success, or {@code false} if this method should be invoked again. - */ - final boolean prototype(final String main, final Iterable<String> others) { - return (owner == null) || owner.computeSizeOfSampleValues(main, others); - } - - /** - * 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. + * @since 1.3 */ - 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; + public void evaluateLater(final DirectPosition point) { + final Formatter formatter = formatter(); + if (formatter != null) { + formatter.evaluateLater(point); } } /** - * 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}. + * Task for fetching and formatting values in a background thread. + * The background thread and the interaction with JavaFX thread are managed by the enclosing class. + * The same {@code Formatter} instance can be reused as long as the source of data does not change. * - * @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 CoverageCanvas cc = (CoverageCanvas) canvas; - final FromCoverage listener = new FromCoverage(); - cc.coverageProperty.addListener(new WeakChangeListener<>(listener)); - cc.sliceExtentProperty.addListener((p,o,n) -> listener.setSlice(n)); - final GridCoverage coverage = cc.coverageProperty.get(); - if (coverage != null) { - listener.changed(null, null, coverage); - listener.setSlice(cc.getSliceExtent()); - } - 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 can be registered as a listener of e.g. {@link CoverageCanvas#coverageProperty} for updating the - * values to show when the coverage is changed. + * <p>As a rule of thumbs, all properties in {@link ValuesUnderCursor} class shall be read and written + * from the JavaFX thread, while all properties in this {@code Formatter} class may be read and written + * from any thread.</p> * * @author Martin Desruisseaux (Geomatys) * @version 1.3 - * @since 1.1 + * @since 1.3 * @module */ - private 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'; - + protected abstract static class Formatter implements Runnable { /** - * 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 object computing or interpolation sample values in the coverage. - */ - private GridCoverage.Evaluator evaluator; - - /** - * The selection status of each band. - */ - private final BitSet selectedBands; - - /** - * Formatter for the values computed or interpolated by {@link #evaluator}. - * The number of fraction digits is computed from transfer function resolution. - * The same {@link NumberFormat} instance may appear at more than one index. + * Coordinates and CRS of the position where to evaluate values. + * This position shall not be modified; new coordinates shall be specified in a new instance. + * A {@code null} value means that there is no more sample values to format. + * + * <p>Instances are created by {@link #copy(DirectPosition)}. The same instance shall be given + * to {@link #evaluate(DirectPosition)} because subclasses may rely on a specific type.</p> */ - private NumberFormat[] sampleFormats; + private DirectPosition position; /** - * Buffer where to format the textual content. + * Whether there is a new point for which to format sample values. + * The new point may be {@code null}. */ - private final StringBuffer buffer; + private boolean hasNewPoint; /** - * Ignored but required by {@link NumberFormat}. + * Whether a background thread is already running. This information is used for looping + * in the running thread instead of launching many threads when coordinates are updated. */ - private final FieldPosition field; + private boolean isRunning; /** - * Unit symbol to write after each value. + * A copy of {@link ValuesUnderCursor#consumer} field. */ - private String[] units; + private final Consumer consumer; /** - * 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. + * Creates a new formatter instance. * - * @see #toNodataKey(int, float) - * @see MathFunctions#toNanOrdinal(float) - */ - private final Map<Long,String> nodata; - - /** - * The text to show when cursor is outside coverage area. It should contain dimension names, - * for example "(SST)". May be {@code null} if {@link #onBandSelectionChanged()} needs to be invoked. - */ - private String outsideText; - - /** - * {@code true} if {@link #onBandSelectionChanged()} needs to be invoked again. - */ - private boolean needsBandRefresh; - - /** - * Creates a new provider of textual values for a {@link GridCoverage}. + * @param owner instance of the enclosing class which will evaluate values under cursor position. */ - public FromCoverage() { - buffer = new StringBuffer(); - field = new FieldPosition(0); - nodata = new HashMap<>(); - selectedBands = new BitSet(); - valueChoices.setText(Vocabulary.format(Vocabulary.Keys.SampleDimensions)); + protected Formatter(final ValuesUnderCursor owner) { + consumer = owner.consumer; } /** - * Returns {@code true} if all bands are unselected. + * Invoked in JavaFX thread for creating a copy of the given position. + * A copy is needed because the position will be read in a background thread, + * and the {@code point} instance may change concurrently. + * + * <p>Subclasses can override this method for opportunistically fetching + * in JavaFX thread other information related to the current cursor position. + * Those information can be stored in a custom {@link DirectPosition} implementation class. + * The {@link DirectPosition} instance given to the {@link #evaluate(DirectPosition)} method + * will be the instance returned by this method.</p> + * + * @param point position to copy (never {@code null}). + * @return a copy of the given position, or {@code null} if the position should be considered outside. */ - @Override - public boolean isEmpty() { - return selectedBands.isEmpty(); + DirectPosition copy(final DirectPosition point) { + return new GeneralDirectPosition(point); } /** - * Returns the canvas which contains the given property. + * Sets the position of next point to evaluate, then launches background thread if not already running. + * Even if technically this method can be invoked from any thread, it should be the JavaFX thread. + * The given position will be copied in order to protect it from concurrent changes. + * + * @param point coordinates of the point for which to evaluate the grid coverage value. + * May be {@code null} for declaring that the point is outside canvas region. + * + * @see ValuesUnderCursor#evaluateLater(DirectPosition) */ - private static Optional<CoverageCanvas> canvas(final ObservableValue<?> property) { - if (property instanceof ReadOnlyProperty<?>) { - final Object bean = ((ReadOnlyProperty<?>) property).getBean(); - if (bean instanceof CoverageCanvas) { - return Optional.of((CoverageCanvas) bean); - } + final synchronized void evaluateLater(final DirectPosition point) { + position = (point != null) ? copy(point) : null; + hasNewPoint = true; + if (!isRunning) { + BackgroundThreads.execute(this); + isRunning = true; // Set only after success. } - return Optional.empty(); } /** - * 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}. + * Invoked in a background thread for formatting values at the most recent position. + * If the cursor moves while this method is formatting values, then this method will + * continue its execution for formatting also the values at new positions until the + * cursor stop moving. * - * @param property the property which has been updated, or {@code null} if 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. + * <p>This method does not need to be invoked explicitly; it is invoked automatically + * by {@link ValuesUnderCursor}. But it may be overridden for adding pretreatment or + * post-treatment.</p> */ @Override - public void changed(final ObservableValue<? extends GridCoverage> property, - final GridCoverage previous, final GridCoverage coverage) - { - final List<SampleDimension> bands; // Should never be null, but check anyway. - if (coverage == null || (bands = coverage.getSampleDimensions()) == null) { - evaluator = null; - units = null; - sampleFormats = null; - outsideText = null; - nodata.clear(); - selectedBands.clear(); - valueChoices.getItems().clear(); - return; - } - evaluator = coverage.forConvertedValues(true).evaluator(); - evaluator.setNullIfOutside(true); - evaluator.setWraparoundEnabled(true); - canvas(property).ifPresent((c) -> setSlice(c.getSliceExtent())); - if (previous != null && bands.equals(previous.getSampleDimensions())) { - // Same configuration than previous coverage. - return; - } - final int numBands = bands.size(); - units = new String[numBands]; - sampleFormats = new NumberFormat[numBands]; - outsideText = null; // Will be recomputed on next `evaluate(…)` call. - /* - * 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, (numBands <= 3) ? numBands : 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 = GUIUtilities.getLocale(property); - final UnitFormat unitFormat = new UnitFormat(locale); - final CheckMenuItem[] menuItems = new CheckMenuItem[numBands]; - for (int b=0; b<numBands; b++) { - final SampleDimension sd = bands.get(b); - menuItems[b] = createMenuItem(b, 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(b, value), c.getName().toString(locale)); - } catch (IllegalArgumentException e) { - recoverableException("changed", e); + public void run() { + for (;;) { // `while(hasNewPoint)` but synchronized. + final DirectPosition point; + synchronized (this) { + if (!hasNewPoint) { + isRunning = false; // Must be inside the synchronized block. + break; } + point = position; + position = null; + hasNewPoint = false; } - /* - * 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[b] = 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[b] = 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; - } - } - }); + consumer.setLater(evaluate(point)); } - valueChoices.getItems().setAll(menuItems); - onBandSelectionChanged(); } /** - * Returns the key to use in {@link #nodata} map for the given "no data" value. - * The band number can be obtained by {@link Long#intValue()}. + * Returns a string representation of data under the given "real world" position. + * The {@linkplain DirectPosition#getCoordinateReferenceSystem() position CRS} + * should be non-null for avoiding ambiguity about what is the default CRS. + * The position CRS may be anything; this method shall transform coordinates itself if needed. * - * @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. + * <p>This method is invoked by {@link #run()} in a background thread. + * Implementations are responsible for fetching data in a thread-safe manner.</p> * - * @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. + * @param point the cursor location in arbitrary CRS (usually the CRS shown in the status bar). + * May be {@code null} for declaring that the point is outside canvas region. + * @return string representation of data under given position, or {@code null} if none. */ - 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); - onBandSelectionChanged(); - }); - return item; - } + public abstract String evaluate(final DirectPosition point); + } - /** - * Tells to the evaluator in which slice to evaluate coordinates. - * This method is invoked when {@link CoverageCanvas#sliceExtentProperty} changed its value. - */ - final void setSlice(final GridExtent extent) { - if (evaluator != null) { - evaluator.setDefaultSlice(extent != null ? extent.getSliceCoordinates() : null); - } - } + /** + * Returns whether a status bar is associated to this instance. + * If {@code false}, then it is useless to compute values for {@link #prototype(String, Iterable)}. + */ + final boolean usePrototype() { + return owner != null; + } - /** - * 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, or {@code null} if outside canvas region. - * @return string representation of data under given position, or {@code null} if none. - * - * @see GridCoverage.Evaluator#apply(DirectPosition) - */ - @Override - public String evaluate(final DirectPosition point) { - if (needsBandRefresh && evaluator != null) { - onBandSelectionChanged(); - } - if (point != null) { - /* - * Take lock once instead of 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 (evaluator != null) try { - final double[] results = evaluator.apply(point); - 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)) try { - /* - * If a value is NaN, returns its label as the whole content. Numerical values - * in other bands are lost. We do that because "no data" strings are often too - * long for being shown together with numerical values, and are often the same - * for all bands. Users can see numerical values by hiding the band containing - * "no data" values with contextual menu on the status bar. - */ - final String label = nodata.get(toNodataKey(i, (float) value)); - if (label != null) return label; - } catch (IllegalArgumentException e) { - recoverableException("evaluate", e); - } - sampleFormats[i].format(value, buffer, field).append(units[i]); - } - return buffer.toString(); - } - } catch (CannotEvaluateException e) { - recoverableException("evaluate", e); - } - } - } - /* - * Coordinate is considered outside coverage area. - * Format the sample dimension names. - */ - return outsideText; - } + /** + * Invoked when a new source of values is known for computing the expected size. + * The given {@code main} text should be an example of the longest expected text, + * ignoring "special" labels like "no data" values (those special cases are listed + * in the {@code others} argument). + * + * <p>If {@code main} is an empty string, then no values are expected and {@link MapCanvas} + * may hide the space normally used for showing values.</p> + * + * @param main a prototype of longest normal text that we expect. + * @param others some other texts that may appear, such as labels for missing data. + * @return {@code true} on success, or {@code false} if this method should be invoked again. + */ + final boolean prototype(final String main, final Iterable<String> others) { + return (owner == null) || owner.computeSizeOfSampleValues(main, others); + } - /** - * 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 of 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(); + /** + * 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 != owner) { + if (newValue.owner != null) { + newValue.owner.sampleValuesProvider.set(null); } + newValue.owner = owner; } + } - /** - * 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. - * - * <p>We use {@link #needsBandRefresh} as a flag meaning meaning that this method needs - * to be invoked. This method invocation sometime needs to be delayed because calculation of - * text width may be wrong (produce 0 values) if invoked before {@link StatusBar#sampleValues} - * label is added in the scene graph.</p> - */ - private void onBandSelectionChanged() { - final ObservableList<MenuItem> menus = valueChoices.getItems(); - final List<SampleDimension> bands = evaluator.getCoverage().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<?>[] {0xFFFF}); // 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(); - } - /* - * At this point, `text` is the longest string of numerical values that we expect. - * We also need to take in account the width required for displaying "no data" labels. - * If a "no data" label is shown, it will be shown alone (we do not need to compute a - * sum of "no data" label widths). - */ - outsideText = text.isEmpty() ? "" : names.append(')').toString(); - final HashSet<String> others = new HashSet<>(); - for (final Map.Entry<Long,String> other : nodata.entrySet()) { - if (selectedBands.get(other.getKey().intValue())) { - others.add(other.getValue()); - } + /** + * 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 CoverageCanvas cc = (CoverageCanvas) canvas; + final ValuesFromCoverage listener = new ValuesFromCoverage(); + cc.coverageProperty.addListener(new WeakChangeListener<>(listener)); + cc.sliceExtentProperty.addListener((p,o,n) -> listener.setSlice(n)); + final GridCoverage coverage = cc.coverageProperty.get(); + if (coverage != null) { + listener.changed(null, null, coverage); } - needsBandRefresh = !prototype(text, others); + return listener; + } else { + // More cases may be added in the future. } + return null; } /** * 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(getLogger(Modules.APPLICATION), ValuesUnderCursor.class, method, e); + final void setError(final Throwable e) { + final StatusBar owner = this.owner; + if (owner != null) { + owner.setSampleValues(owner.cause(e)); } } }