blob: eff59d0416165aff0dc498842ce5265495508de6 [file] [log] [blame]
/*
* 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.Arrays;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Predicate;
import java.awt.image.RenderedImage;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import javafx.geometry.Pos;
import javafx.geometry.Insets;
import javafx.geometry.Point2D;
import javafx.geometry.Orientation;
import javafx.scene.Node;
import javafx.scene.paint.Color;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Region;
import javafx.scene.layout.Priority;
import javafx.scene.control.Label;
import javafx.scene.control.Button;
import javafx.scene.control.Tooltip;
import javafx.scene.control.Menu;
import javafx.scene.control.MenuItem;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Separator;
import javafx.scene.input.MouseEvent;
import javafx.scene.text.TextAlignment;
import javafx.event.EventHandler;
import javafx.event.EventType;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ObjectPropertyBase;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectPropertyBase;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.concurrent.Task;
import javax.measure.Unit;
import javax.measure.Quantity;
import javax.measure.IncommensurableException;
import javax.measure.quantity.Length;
import org.opengis.geometry.Envelope;
import org.opengis.geometry.DirectPosition;
import org.opengis.referencing.ReferenceSystem;
import org.apache.sis.coverage.grid.PixelInCell;
import org.opengis.referencing.cs.CoordinateSystem;
import org.opengis.referencing.cs.CoordinateSystemAxis;
import org.opengis.referencing.operation.Matrix;
import org.opengis.referencing.operation.MathTransform;
import org.opengis.referencing.operation.TransformException;
import org.opengis.referencing.operation.CoordinateOperation;
import org.opengis.referencing.crs.CoordinateReferenceSystem;
import org.apache.sis.referencing.CRS;
import org.apache.sis.referencing.IdentifiedObjects;
import org.apache.sis.referencing.operation.transform.MathTransforms;
import org.apache.sis.geometry.GeneralDirectPosition;
import org.apache.sis.geometry.DirectPosition2D;
import org.apache.sis.geometry.CoordinateFormat;
import org.apache.sis.coverage.grid.GridCoverage;
import org.apache.sis.coverage.grid.GridGeometry;
import org.apache.sis.coverage.grid.GridExtent;
import org.apache.sis.portrayal.RenderException;
import org.apache.sis.util.Classes;
import org.apache.sis.util.ArraysExt;
import org.apache.sis.util.Utilities;
import org.apache.sis.util.Exceptions;
import org.apache.sis.util.ArgumentChecks;
import org.apache.sis.util.ComparisonMode;
import org.apache.sis.util.privy.Strings;
import org.apache.sis.measure.Quantities;
import org.apache.sis.measure.Units;
import org.apache.sis.util.resources.Errors;
import org.apache.sis.util.logging.Logging;
import org.apache.sis.gui.Widget;
import org.apache.sis.gui.referencing.RecentReferenceSystems;
import org.apache.sis.gui.internal.BackgroundThreads;
import org.apache.sis.gui.internal.ExceptionReporter;
import org.apache.sis.gui.internal.GUIUtilities;
import org.apache.sis.gui.internal.Resources;
import org.apache.sis.gui.internal.Styles;
import org.apache.sis.referencing.gazetteer.ReferencingByIdentifiers;
import static org.apache.sis.gui.internal.LogHandler.LOGGER;
// Specific to the main branch:
import org.opengis.geometry.MismatchedDimensionException;
/**
* A status bar showing geographic or projected coordinates under mouse cursor.
* The number of fraction digits is adjusted according pixel resolution for each coordinate to format.
* Other components such as error message may also be shown.
*
* <p>Since the main {@code StatusBar} job is to listen to mouse events for updating coordinates,
* this class implements {@link EventHandler} directly. {@code StatusBar} can be registered as a listener
* using the following methods:</p>
*
* <ul>
* <li>{@link javafx.scene.Node#setOnMouseEntered(EventHandler)} for showing the coordinate values
* when the mouse enters the region of interest.</li>
* <li>{@link javafx.scene.Node#setOnMouseExited(EventHandler)} for hiding the coordinate values
* when the mouse exits the region of interest.</li>
* <li>{@link javafx.scene.Node#setOnMouseMoved(EventHandler)} for updating the coordinate values
* when the mouse moves inside the region of interest.</li>
* </ul>
*
* Alternatively, users can omit some or all above listener registrations and invoke
* {@link #setLocalCoordinates(double, double)} explicitly instead.
*
* @author Martin Desruisseaux (Geomatys)
* @version 1.4
* @since 1.1
*/
public class StatusBar extends Widget implements EventHandler<MouseEvent> {
/**
* The {@value} value, for identifying code that assume two-dimensional objects.
*
* @see #getXYDimensions()
*/
private static final int BIDIMENSIONAL = 2;
/**
* Some spaces to add around the status bar.
*/
private static final Insets PADDING = new Insets(5, Styles.SCROLLBAR_WIDTH, 6, 9);
/**
* An arbitrary increase in size of the text field where sample values are shown.
* This is in case {@link #computeSizeOfSampleValues(String, Iterable)} underestimates the required size.
*/
private static final int VALUES_PADDING = 9;
/**
* The container of controls making the status bar.
* Contains {@link #message}, {@link #position} and {@link #sampleValues}.
*
* @see #getView()
*/
private final HBox view;
/**
* Message to write in the middle of the status bar.
* This component usually has nothing to show; it is used mostly for error messages.
* It takes all the space before {@link #position}.
*
* @see #getMessage()
*/
private final Label message;
/**
* Local coordinates currently formatted in the {@link #position} field.
* This is used for detecting if coordinate values changed since last formatting.
* If the mouse moved outside the canvas, then those coordinates are set to NaN.
* Otherwise those coordinates are usually integer values.
*
* @see #getLocalCoordinates()
*/
private double lastX, lastY;
/**
* The canvas that this status bar is tracking. The value is {@code null} if this status bar is not associated
* to a canvas. If non-null, this {@code StatusBar} will show coordinates (usually geographic or projected) of
* mouse cursor position when the mouse is over that canvas.
*
* <p>Note that if this field is non-null, then the {@link #localToObjectiveCRS} property value may be overwritten
* at any time, for example every time that a gesture event such as pan, zoom or rotation happens.</p>
*/
private MapCanvas canvas;
/**
* The manager of reference systems chosen by user, or {@code null} if none.
* The {@link RecentReferenceSystems#areaOfInterest} property is used for
* computing {@link #objectiveToPositionCRS}.
*/
private final RecentReferenceSystems systemChooser;
/**
* The selected reference system, or {@code null} if there is no such property. This property is provided
* by {@link RecentReferenceSystems}. It usually has the same value as {@link #positionReferenceSystem},
* but the values may temporarily differ between the time a CRS is selected and when it became applied.
*
* @see #positionReferenceSystem
*/
private final ObjectProperty<ReferenceSystem> selectedSystem;
/**
* The reference system used for rendering the data for which this status bar is providing cursor coordinates.
* This is the "{@linkplain RecentReferenceSystems#setPreferred(boolean, ReferenceSystem) preferred}" or native
* data CRS. It may be different than the CRS of coordinates actually shown in the status bar.
*
* @see MapCanvas#getObjectiveCRS()
*/
private CoordinateReferenceSystem objectiveCRS;
/**
* The transform from <i>objective CRS</i> to the CRS of coordinates shown in this status bar.
* The {@linkplain CoordinateOperation#getSourceCRS() source CRS} is {@link #objectiveCRS} and
* the {@linkplain CoordinateOperation#getTargetCRS() target CRS} is {@link CoordinateFormat#getDefaultCRS()}.
* This transform may be null if there is no CRS change to apply
* (in which case {@link #localToPositionCRS} is the same instance as {@link #localToObjectiveCRS})
* or if the target is not a CRS (for example it may be a Military Grid Reference System (MGRS) code).
*
* @see #localToObjectiveCRS
* @see #localToPositionCRS
*/
private MathTransform objectiveToPositionCRS;
/**
* Conversion from local coordinates to geographic or projected coordinates of rendered data.
* The local coordinates are the coordinates of the JavaFX view, as given for example in {@link MouseEvent}
* The objective coordinates are geographic or projected coordinates of rendered data, ignoring all CRS changes
* that may result from user selecting a different CRS in the contextual menu. Consequently while this transform
* is often the conversion from pixel coordinates to the coordinates shown in this status bar,
* this is not always the case.
*
* <p>This transform shall never be null. It is initially an identity transform and is modified by
* {@link #applyCanvasGeometry(GridGeometry)}. The transform is usually (but not necessarily) affine
* and should have no {@linkplain CoordinateOperation#getCoordinateOperationAccuracy() inaccuracy}
* (ignoring rounding error). This transform is normally the inverse of {@linkplain #canvas}
* {@linkplain MapCanvas#getObjectiveToDisplay() objective to display} transform,
* but temporary mismatches may exist during gesture events such as pans, zooms and rotations.</p>
*
* <p>If this transform is set to a new value, the given transform must have the same number of source
* and target dimensions than the previous value (if a change in the number of dimension is desired,
* use {@link #applyCanvasGeometry(GridGeometry)} instead). The status bar is updated as if the new
* conversion was applied <em>before</em> any CRS changes resulting from user selecting a different
* CRS in the contextual menu. Note however that any specified transform may be overwritten if some
* {@linkplain #canvas} gesture events happen later; setting an explicit transform is more useful
* when this {@code StatusBar} is <em>not</em> associated to a {@link MapCanvas}
* (for example it may be used with a {@link org.apache.sis.gui.coverage.GridView} instead).</p>
*
* <h4>API note</h4>
* We do not provide getter/setter for this property; use {@link ObjectProperty#set(Object)}
* directly instead. We omit the "Property" suffix for making this operation more natural.
*
* @see MapCanvas#getObjectiveCRS()
* @see MapCanvas#getObjectiveToDisplay()
*/
public final ObjectProperty<MathTransform> localToObjectiveCRS;
/**
* The reference systems used by the coordinates shown in this status bar.
* This is initially the <i>objective CRS</i>, but may become different
* if the user selects another reference system through contextual menu.
*
* <h4>API note</h4>
* We do not provide getter method for this property; use {@link ReadOnlyObjectProperty#get()}
* directly instead. We omit the "Property" suffix for making this operation more natural.
*
* @see #position
*/
public final ReadOnlyObjectProperty<ReferenceSystem> positionReferenceSystem;
/**
* Transform from local coordinates to geographic or projected coordinates shown in this status bar.
* This is the concatenation of {@link #localToObjectiveCRS} with {@link #objectiveToPositionCRS} transform.
* The result is a transform to the user-selected CRS for coordinates shown in the status bar.
* That transform target CRS shall correspond to {@link CoordinateFormat#getDefaultCRS()}.
* This transform shall never be null but may be the identity transform.
* It is usually non-affine if the display CRS is not the same as the objective CRS.
* This transform may have a {@linkplain CoordinateOperation#getCoordinateOperationAccuracy() limited accuracy}.
*
* @see #localToObjectiveCRS
* @see #getPositionCRS()
*/
private MathTransform localToPositionCRS;
/**
* If non-null, determines if {@link #apply(GridGeometry)} needs to update {@link #localToPositionCRS} with a
* potentially costly search for coordinate operation even in context where it would normally not be required.
* An explanation of the context when it may happen is given in {@link OperationFinder#dataGeometry}.
* This is rarely needed for most data (i.e. this field is almost always {@code null}).
*
* @see OperationFinder#fullOperationSearchRequired()
*/
private Predicate<MapCanvas> fullOperationSearchRequired;
/**
* Indices where to assign the values of the <var>x</var> and <var>y</var> arguments in {@link #sourceCoordinates}.
* They are usually 0 for <var>x</var> and 1 for <var>y</var>.
*
* @see #BIDIMENSIONAL
*/
private int xDimension, yDimension;
/**
* The source local indices before conversion to geospatial coordinates (never {@code null}).
* The number of dimensions is often {@value #BIDIMENSIONAL}. May be the same array as
* <code>{@linkplain #targetCoordinates}.coordinates</code> if both are two-dimensional
* (if more than 2 dimensions, we need to avoid overwriting values in extra dimensions).
*
* @see #targetCoordinates
* @see #position
*/
private double[] sourceCoordinates;
/**
* Coordinates after conversion to the CRS. The number of dimensions depends on the target CRS.
* This object is reused during each coordinate transformation. Shall never be {@code null}.
*
* @see #sourceCoordinates
* @see #position
* @see #setPositionCRS(CoordinateReferenceSystem)
*/
private GeneralDirectPosition targetCoordinates;
/**
* The desired precisions for each dimension in the {@link #targetCoordinates} to format.
* It may vary for each position if the {@link #localToPositionCRS} transform is non-linear.
* This array is initially {@code null} and created when first needed.
* It is the argument to be given to {@link CoordinateFormat#setPrecisions(double...)}.
*
* @see CoordinateFormat#setPrecisions(double...)
*/
private double[] precisions;
/**
* A multiplication factory slightly greater than 1 applied on {@link #precisions}.
* The intent is to avoid that a precision like 0.09999 is interpreted as requiring
* two decimal digits instead of one. For avoiding that, we add a small value to the
* precision: <var>precision</var> += <var>precision</var> × ε, which we compact as
* <var>precision</var> *= (1 + ε). The ε value is chosen to represent an increase
* of no more than 0.5 pixel between the lower and upper indices of the grid.
* This array may be {@code null} if it has not been computed.
*/
private double[] inflatePrecisions;
/**
* The unit of measurement for {@link #precisions}.
* This is the unit of measurement of the first coordinate system axis.
*/
private Unit<?> precisionUnit;
/**
* Number of elements in {@link #precisions} having the same unit of measurement than {@link #precisionUnit}.
* This value shall be between 1 and {@code precisions.length} inclusive, or 0 if {@link #precisionUnit} is null.
*/
private int compatiblePrecisionCount;
/**
* Specifies a minimal uncertainty to append as "± <var>accuracy</var>" after the coordinate values.
* This uncertainty can be caused for example by a coordinate transformation applied on data before
* rendering in the canvas.
*
* <p>Note that {@code StatusBar} maintains also its own uncertainty, which can be caused by transformation
* from objective CRS to the {@linkplain #positionReferenceSystem reference system used in this status bar}.
* Such transformations happen when users select a CRS on the status bar (e.g. using the contextual menu)
* which is different than the canvas {@linkplain MapCanvas#getObjectiveCRS() objective CRS}.
* In such case we have two sources of stochastic errors: one internal to this status bar and one having
* causes external to this status bar. This {@code lowestAccuracy} property is for specifying the latter.</p>
*
* <p>The accuracy actually shown by {@code StatusBar} will be the greatest value between the accuracy
* specified in this property and the accuracy computed internally by {@code StatusBar}.
* Note that the "± <var>accuracy</var>" text may be shown or hidden depending on the zoom level.
* If pixels on screen are larger than the accuracy, then the accuracy text is hidden.</p>
*
* @see CoordinateFormat#setGroundAccuracy(Quantity)
*
* @since 1.3
*/
public final ObjectProperty<Quantity<Length>> lowestAccuracy;
/**
* The object to use for formatting coordinate values.
* This reference shall not be null because it is the instance to use most of the time.
* In the rarer cases where {@link #formatAsIdentifiers} is non-null, the latter has precedence.
*/
private final CoordinateFormat format;
/**
* The object to use for formatting coordinate values as identifiers (MGRS, GeoHash…).
* The null/non-null state tells whether to format coordinates as identifiers or not;
* a {@code null} values mean that coordinates shall be formatted using {@link #format} instead.
*
* <p>If non-null, then {@link #getPositionCRS()} should be the {@link #objectiveCRS}
* and {@link #objectiveToPositionCRS} should be null.</p>
*/
private ReferencingByIdentifiers.Coder formatAsIdentifiers;
/**
* 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.
*
* @see #positionReferenceSystem
*/
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 "(φ, λ)".
*
* @see #setFormatCRS(CoordinateReferenceSystem, Quantity)
*/
private String outsideText;
/**
* The label where to format the sample value(s) below cursor position, or {@code null} if none.
*
* @see #isSampleValuesVisible
* @see #setSampleValuesVisible(boolean)
*/
private Label sampleValues;
/**
* The object providing sample values under cursor position.
* The property value may be {@code null} if there are no sample values to format.
* If non-null, the text provided by this object will appear at the right of the coordinates.
*
* <h4>API note</h4>
* We do not provide getter/setter for this property; use {@link ObjectProperty#set(Object)}
* directly instead. We omit the "Property" suffix for making this operation more natural.
*/
public final ObjectProperty<ValuesUnderCursor> sampleValuesProvider;
/**
* Whether the {@link #sampleValues} are visible.
* This field is {@code true} only if all following conditions are met:
*
* <ul>
* <li>{@link #sampleValues} is non-null (it is created by {@link #setSampleValuesVisible(boolean)}).</li>
* <li>{@link #sampleValuesProvider} property value is non-null (it is set by user).</li>
* <li>{@link ValuesUnderCursor#isEmpty()} is {@code false}.</li>
* </ul>
*
* @see #setSampleValuesVisible(boolean)
*/
private boolean isSampleValuesVisible;
/**
* The background task under execution, or {@code null} if none. This is used for cancellation.
*
* @see #cancelWorker()
* @see #terminated(Task)
*/
private Task<?> worker;
/**
* Creates a new status bar for showing coordinates of mouse cursor position in a canvas.
* If {@link #track(Canvas)} is invoked, then this {@code StatusBar} will show coordinates
* (usually geographic or projected) of mouse cursor position when the mouse is over that canvas.
* Note that in such case, the {@link #localToObjectiveCRS} property value will be overwritten
* at any time (for example every time that a gesture event such as pan, zoom or rotation happens).
*
* <p>If the {@code systemChooser} argument is non-null, user will be able to select different CRS
* using the contextual menu on the status bar.</p>
*
* <h4>Limitations</h4>
* This constructor registers numerous listeners on {@code canvas} and {@code systemChooser}.
* There is currently no unregistration mechanism. The {@code StatusBar} instance is expected
* to exist as long as the {@code MapCanvas} and {@code RecentReferenceSystems} instances
* given to this constructor.
*
* @param systemChooser the manager of reference systems chosen by user, or {@code null} if none.
*/
public StatusBar(final RecentReferenceSystems systemChooser) {
positionReferenceSystem = new PositionSystem();
localToObjectiveCRS = new LocalToObjective();
localToPositionCRS = localToObjectiveCRS.get();
targetCoordinates = new GeneralDirectPosition(BIDIMENSIONAL);
sourceCoordinates = targetCoordinates.coordinates;
lastX = lastY = Double.NaN;
yDimension = 1;
format = new CoordinateFormat();
lowestAccuracy = new SimpleObjectProperty<>(this, "lowestAccuracy");
message = new Label();
message.setVisible(false); // Waiting for getting a message to display.
message.setMaxWidth(Double.POSITIVE_INFINITY);
HBox.setHgrow(message, Priority.ALWAYS);
position = new Label();
position.setAlignment(Pos.CENTER_RIGHT);
position.setTextAlignment(TextAlignment.RIGHT);
view = new HBox(6, message, position);
view.setPadding(PADDING);
view.setAlignment(Pos.CENTER_RIGHT);
/*
* Contextual menu can be invoked anywhere on the HBox; we do not register this menu
* on `position` or `sampleValues` labels because those regions are relatively small.
*/
final ContextMenu menu = new ContextMenu();
view.setOnMousePressed((event) -> {
if (event.isSecondaryButtonDown() && !menu.getItems().isEmpty()) {
menu.show((HBox) event.getSource(), event.getScreenX(), event.getScreenY());
event.consume();
} else {
menu.hide();
}
});
/*
* 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.
*/
this.systemChooser = systemChooser;
if (systemChooser == null) {
selectedSystem = null;
} else {
final Menu choices = systemChooser.createMenuItems(false, (property, oldValue, newValue) -> {
if (newValue instanceof CoordinateReferenceSystem) {
setPositionCRS((CoordinateReferenceSystem) newValue);
} else if (newValue instanceof ReferencingByIdentifiers) {
setPositionRID((ReferencingByIdentifiers) newValue);
} else {
setPositionCRS(null); // Default to `objectiveCRS`.
}
});
selectedSystem = RecentReferenceSystems.getSelectedProperty(choices);
menu.getItems().add(choices);
/*
* Ensure (as much as possible) that the CRS of coordinates formatted in this status bar is one
* of the CRSs in the list of choices offered to the user. It happens often that the CRS given
* to `applyCanvasGeometry(GridGeometry)` has (λ,φ) axis order but the CRS offered to user have
* (φ,λ) axis order (because we try to comply with definitions following geographers practice).
* In such case we will replace (λ,φ) by (φ,λ). Since we use the list of choices as the source
* of desired CRS, we have to listen to new elements added to that list. This is necessary because
* the list is often empty at construction time and filled later after a background thread task.
*/
systemChooser.getItems().addListener((ListChangeListener.Change<? extends ReferenceSystem> change) -> {
if (formatAsIdentifiers == null) {
while (change.next()) {
if (change.wasAdded() || change.wasReplaced()) {
setReplaceablePositionCRS(getPositionCRS());
break;
}
}
}
});
}
/*
* Configure the property controlling the values shown on the right of cursor coordinates.
* A default value will be provided if `canvas` is non-null. Changing this property causes
* a contextual menu item to be added or removed.
*/
final ObservableList<MenuItem> items = menu.getItems();
sampleValuesProvider = new SimpleObjectProperty<>(this, "valueProvider");
sampleValuesProvider.addListener((p,o,n) -> {
ValuesUnderCursor.update(this, o, n);
if (o != null) items.remove(o.valueChoices);
if (n != null) items.add(1, n.valueChoices);
setSampleValuesVisible(n != null);
});
}
/**
* Registers listeners on the specified canvas for tracking mouse movements.
* After this method call, this {@code StatusBar} will show coordinates (usually geographic or projected)
* of mouse cursor position when the mouse is over that canvas. The {@link #localToObjectiveCRS} property
* value may be overwritten at any time, for example after each gesture event such as pan, zoom or rotation.
*
* <h4>Limitations</h4>
* Current implementation accepts only zero or one {@code MapCanvas}. A future implementation
* may accept a larger number of canvas for tracking many views with a single status bar
* (for example images over the same area but at different times).
*
* @param canvas the canvas that this status bar is tracking.
*
* @since 1.3
*/
public void track(final MapCanvas canvas) {
if (this.canvas != null) {
throw new IllegalArgumentException(Errors.format(
Errors.Keys.TooManyCollectionElements_3, "canvas", 1, 2));
}
/*
* If a canvas is specified, register listeners for mouse position, rendering events, errors, etc.
* We do not allow the canvas to be changed after construction because of the added complexity
* (e.g. we would have to remember all registered listeners so we can unregister them).
*/
this.canvas = Objects.requireNonNull(canvas);
sampleValuesProvider.set(ValuesUnderCursor.create(canvas));
canvas.errorProperty().addListener((p,o,n) -> setRenderingError(n));
canvas.renderingProperty().addListener((p,o,n) -> {if (!n) applyCanvasGeometry();});
applyCanvasGeometry();
if (canvas.getObjectiveCRS() != null) {
registerMouseListeners();
} else {
/*
* Wait for objective CRS to be known before to register listeners.
* The canvas "objective CRS" is null only for unitialized canvas.
* After the canvas has been initialized, it cannot be null anymore.
* We delay listeners registration because if listeners were enabled
* on uninitialized canvas, the status bar would show irrelevant coordinates.
*/
canvas.addPropertyChangeListener(MapCanvas.OBJECTIVE_CRS_PROPERTY, new PropertyChangeListener() {
@Override public void propertyChange(final PropertyChangeEvent event) {
canvas.removePropertyChangeListener(MapCanvas.OBJECTIVE_CRS_PROPERTY, this);
registerMouseListeners();
}
});
}
}
/**
* Registers mouse listeners for the canvas after the objective CRS became known.
* This method is invoked only once for the {@link StatusBar} instance.
*/
private void registerMouseListeners() {
final Pane floatingPane = canvas.floatingPane;
floatingPane.addEventHandler(MouseEvent.MOUSE_ENTERED, this);
floatingPane.addEventHandler(MouseEvent.MOUSE_EXITED, this);
floatingPane.addEventHandler(MouseEvent.MOUSE_MOVED, this);
}
/**
* Returns the node to add to the scene graph for showing the status bar.
*/
@Override
public final Region getView() {
return view;
}
/**
* Invoked when {@link MapCanvas} completed its rendering. This method sets
* {@link StatusBar#localToObjectiveCRS} to the inverse of {@link MapCanvas#objectiveToDisplay}.
* It assumes that even if the JavaFX local coordinates and {@link #localToPositionCRS} transform
* changed, the "real world" coordinates under the mouse cursor is still the same. This assumption
* should be true if this listener is notified as a result of zoom, translation or rotation events.
*/
private void applyCanvasGeometry() {
try {
apply(canvas.getGridGeometry());
/*
* Do not hide `position` since we assume that "real world" coordinates are still valid.
* Do not try to rewrite position neither since `lastX` and `lastY` are not valid anymore.
*/
} catch (RenderException e) {
setRenderingError(e);
}
}
/**
* Configures this status bar for showing coordinates in the CRS and with the resolution given
* by the specified grid geometry. The geometry properties are applied as below:
*
* <ul>
* <li>{@link GridGeometry#getCoordinateReferenceSystem()} defines the CRS of the coordinates to format.</li>
* <li>{@link GridGeometry#getGridToCRS(PixelInCell) GridGeometry.getGridToCRS(PixelInCell.CELL_CENTER)}
* defines the conversion from coordinate values local to the canvas to coordinate values in the CRS
* (the {@linkplain #localToObjectiveCRS local to objective CRS} conversion).</li>
* <li>{@link GridGeometry#getExtent()} provides the view size in pixels, used for estimating a resolution.</li>
* <li>{@link GridGeometry#getResolution(boolean)} is also used for estimating a resolution.</li>
* </ul>
*
* All above properties are optional. The "local to objective CRS" conversion can be updated
* after this method call by setting the {@link #localToObjectiveCRS} property.
*
* @param geometry geometry of the coverage shown in {@link MapCanvas}, or {@code null}.
*
* @see MapCanvas#getGridGeometry()
*/
public void applyCanvasGeometry(final GridGeometry geometry) {
position.setText(null);
xDimension = 0;
yDimension = 1;
apply(geometry);
}
/**
* Configures this status bar for showing coordinates of a slice of a grid coverage.
* This method is useful for tracking the pixel coordinates of an image obtained by
* a call to {@link GridCoverage#render(GridExtent)}.
* By {@code render(GridExtent)} contract, the {@link RenderedImage} pixel coordinates
* are relative to the requested {@link GridExtent}. Consequently, we need to translate
* the grid coordinates so that the request coordinates start at zero.
* This method handles that translation.
*
* @param geometry geometry of the coverage which produced the {@link RenderedImage} to track, or {@code null}.
* @param sliceExtent the extent specified in call to {@link GridCoverage#render(GridExtent)} (can be {@code null}).
* @param xdim the grid dimension where to assign the values of <var>x</var> pixel coordinates.
* @param ydim the grid dimension where to assign the values of <var>y</var> pixel coordinates.
*
* @since 1.3
*/
public void applyCanvasGeometry(GridGeometry geometry, GridExtent sliceExtent, final int xdim, final int ydim) {
position.setText(null);
if (geometry != null) {
final int dimension = geometry.getDimension();
ArgumentChecks.ensureBetween("xdim", 0, dimension-1, xdim);
ArgumentChecks.ensureBetween("ydim", xdim+1, dimension-1, ydim);
xDimension = xdim;
yDimension = ydim; // Shall be assigned before call to `getXYDimensions()` below.
if (sliceExtent != null) {
final long[] offset = new long[dimension];
for (final int i : getXYDimensions()) {
offset[i] = Math.negateExact(sliceExtent.getLow(i));
}
sliceExtent = sliceExtent.translate(offset);
geometry = geometry.shiftGrid(offset); // Does not change the "real world" envelope.
try {
geometry = geometry.relocate(sliceExtent); // Changes the "real world" envelope.
} catch (TransformException e) {
setErrorMessage(null, e);
}
}
}
apply(geometry);
}
/**
* Implementation of {@link #applyCanvasGeometry(GridGeometry)} without changing {@link #position} visibility state.
* Invoking this method usually invalidate the coordinates shown in this status bar. The new coordinates cannot be
* easily recomputed because the {@link #lastX} and {@link #lastY} values may not be valid anymore, as a result of
* possible changes in JavaFX local coordinate system. Consequently, the coordinates should be temporarily hidden
* until a new {@link MouseEvent} gives us the new local coordinates, unless this method is invoked in a context
* where we know that the "real world" coordinates should be the same even if local coordinates changed.
*
* @param geometry geometry of the coverage shown in {@link MapCanvas}, or {@code null}.
*/
private void apply(final GridGeometry geometry) {
/*
* Compute values in local variables without modifying `StatusBar` fields for now.
* The fields will be updated only after we know that this operation is successful.
*/
MathTransform localToCRS = null;
CoordinateReferenceSystem crs = null;
double[] pointOfInterest = ArraysExt.EMPTY_DOUBLE;
double[] inflate = null;
double resolution = 1;
Unit<?> unit = Units.PIXEL;
if (geometry != null) {
if (geometry.isDefined(GridGeometry.CRS)) {
crs = geometry.getCoordinateReferenceSystem();
}
if (geometry.isDefined(GridGeometry.GRID_TO_CRS)) {
localToCRS = geometry.getGridToCRS(PixelInCell.CELL_CENTER);
}
/*
* Compute the precision of coordinates to format. We use the finest resolution,
* looking only at axes having the same units of measurement than the first axis.
* This will be used as a fallback if we cannot compute the precision specific
* to a coordinate, for example if we cannot compute the derivative.
*/
if (geometry.isDefined(GridGeometry.RESOLUTION)) {
double[] resolutions = geometry.getResolution(true);
if (crs != null && resolutions.length != 0) {
final CoordinateSystem cs = crs.getCoordinateSystem();
unit = cs.getAxis(0).getUnit();
for (int i=0; i<resolutions.length; i++) {
if (unit.equals(cs.getAxis(i).getUnit())) {
final double r = resolutions[i];
if (r < resolution) resolution = r;
}
}
}
}
/*
* Add a tolerance factor of ½ pixel when computing the number of significant
* fraction digits to show in coordinates.
*/
if (geometry.isDefined(GridGeometry.EXTENT)) {
final GridExtent extent = geometry.getExtent();
final int n = extent.getDimension();
inflate = new double[n];
for (int i=0; i<n; i++) {
inflate[i] = (0.5 / extent.getSize(i)) + 1;
}
pointOfInterest = extent.getPointOfInterest(PixelInCell.CELL_CENTER);
}
}
/*
* If the objective CRS stay unchanged, then we will try to keep the same position CRS
* (which may be different), which implies keeping the same `objectiveToPositionCRS`.
*/
final boolean clear = (fullOperationSearchRequired != null) && fullOperationSearchRequired.test(canvas);
final boolean sameCRS = !clear && Utilities.equalsIgnoreMetadata(objectiveCRS, crs);
if (localToCRS == null) {
localToCRS = MathTransforms.identity(BIDIMENSIONAL);
}
if (sameCRS && objectiveToPositionCRS != null) {
localToCRS = MathTransforms.concatenate(localToCRS, objectiveToPositionCRS);
}
final int srcDim = Math.max(localToCRS.getSourceDimensions(), BIDIMENSIONAL);
final int tgtDim = localToCRS.getTargetDimensions();
/*
* Remaining code should not fail, so we can start modifying the `StatusBar` fields.
* The buffers for source and target coordinates are recreated because the number of
* dimensions may have changed. The `lastX` and `lastY` coordinates are local to the
* JavaFX view and considered invalid because they depend on the transforms applied
* on JavaFX node, which may have changed together with `localToObjectiveCRS` change.
* So we cannot use those values for updating the coordinates shown in status bar.
* Instead, we will wait for the next mouse event to provide new local coordinates.
*/
((LocalToObjective) localToObjectiveCRS).setNoCheck(localToCRS);
sourceCoordinates = Arrays.copyOf(pointOfInterest, srcDim);
targetCoordinates = new GeneralDirectPosition(tgtDim);
objectiveCRS = crs;
localToPositionCRS = localToCRS;
inflatePrecisions = inflate;
precisions = null;
lastX = lastY = Double.NaN; // Not valid anymove — see above block comment.
/*
* If the objective CRS is unchanged, keep the same position CRS (the CRS selected
* by user for formatting coordinates; it may be different than the objective CRS).
* Otherwise we reset the formatter to the CRS specified in the grid geometry.
*/
if (sameCRS) {
crs = getPositionCRS();
targetCoordinates.setCoordinateReferenceSystem(crs);
} else {
objectiveToPositionCRS = null;
setFormatCRS(crs, null); // Should be invoked before to set ground precision.
crs = OperationFinder.toGeospatial(crs, canvas);
crs = setReplaceablePositionCRS(crs); // May invoke setFormatCRS(…) after background work.
}
format.setGroundPrecision(Quantities.create(resolution, unit));
/*
* If the CRS changed, we may need to update the selected menu item. It happens when this method
* is invoked because new data have been loaded, as opposed to this method being invoked after a
* gesture event (zoom, pan, rotation).
*/
if (!sameCRS && selectedSystem != null) {
selectedSystem.set(crs);
}
}
/**
* Sets the CRS of the position shown in this status bar after replacement by one of the available CRS
* if a match is found. This method compares the given CRS with the list of choices before to delegate
* to {@link #setPositionCRS(CoordinateReferenceSystem)} possibly with different axis order. A typical
* scenario is {@link #apply(GridGeometry)} invoked with (<var>longitude</var>, <var>latitude</var>)
* axis order, and this method swapping axes to standard (<var>latitude</var>, <var>longitude</var>)
* axis order for coordinates display purpose.
*
* <h4>Prerequisite</h4>
* This method should be invoked only when {@link #formatAsIdentifiers} is null. This is not verified.
*
* @param crs the new CRS (ignoring axis order), or {@code null} for {@link #objectiveCRS}.
* @return the reference system actually used for formatting coordinates. It may have different axis order
* and units than the specified CRS. This is the CRS that {@link CoordinateFormat#getDefaultCRS()}
* will return a little bit later (pending completion of a background task).
*/
private CoordinateReferenceSystem setReplaceablePositionCRS(CoordinateReferenceSystem crs) {
if (crs != null && systemChooser != null) {
final ComparisonMode mode = systemChooser.duplicationCriterion.get();
for (final ReferenceSystem system : systemChooser.getItems()) {
if (Utilities.deepEquals(crs, system, mode)) {
crs = (CoordinateReferenceSystem) system; // Same CRS but possibly different axis order.
break;
}
}
}
if (crs != getPositionCRS()) {
setPositionCRS(crs);
}
return crs;
}
/**
* Sets the coordinate reference system of the position shown in this status bar.
* The change may not appear immediately after method return; this method may use a
* background thread for computing the coordinate operation. That task may be long
* the first time that it is executed, but should be fast on subsequent invocations.
*
* @param crs the new CRS, or {@code null} for {@link #objectiveCRS}.
*
* @see #setPositionRID(ReferencingByIdentifiers)
* @see #getPositionCRS()
*/
private void setPositionCRS(final CoordinateReferenceSystem crs) {
cancelWorker();
if (crs != null && objectiveCRS != null && objectiveCRS != crs) {
position.setTextFill(Styles.OUTDATED_TEXT);
/*
* Take snapshots of references to all objects that the background thread will use.
* The background thread shall not read StatusBar fields directly since they may be
* in the middle of changes at any time. All objects are assumed immutable.
*/
final Envelope aoi = (systemChooser != null) ? systemChooser.areaOfInterest.get() : null;
BackgroundThreads.execute(worker = new OperationFinder(canvas, aoi, objectiveCRS, crs) {
/**
* The accuracy to show on the status bar, or {@code null} if none.
* This is computed after {@link CoordinateOperation} has been determined.
*
* @see StatusBar#lowestAccuracy
*/
private Quantity<Length> accuracy;
/**
* Invoked in a background thread for fetching transformation to target CRS.
* The potentially costly part is {@code CRS.findOperation(…)} in super.call().
*/
@Override protected MathTransform call() throws Exception {
final MathTransform value = super.call();
double a = CRS.getLinearAccuracy(getOperation());
if (a > 0) {
accuracy = GUIUtilities.shorter(null, a);
}
return value;
}
/**
* Invoked in JavaFX thread on success. The {@link StatusBar#localToPositionCRS} transform
* is set to the transform that we computed in background and the {@link CoordinateFormat}
* is configured with auxiliary information such as positional accuracy.
*/
@Override protected void succeeded() {
terminated(this);
setPositionCRS(this, accuracy);
}
/**
* Invoked in JavaFX thread on failure. The previous CRS is kept unchanged but
* the coordinates will appear in red for telling user that there is a problem.
*/
@Override protected void failed() {
terminated(this);
setReferenceSystemError(crs, getException());
}
/** For logging purpose if a non-fatal error occurs. */
@Override protected Class<?> getCallerClass() {return StatusBar.class;}
@Override protected String getCallerMethod() {return "setPositionCRS";}
});
} else {
/*
* If the requested CRS is the objective CRS, avoid above costly operation.
* The work that we need to do is to cancel the effect of `localToPositionCRS`.
* As a special case if `objectiveCRS` was unknown before this method call,
* set it to the given value. This is needed for initializing the format CRS
* to the first reference system listed in `RecentReferenceSystems` choices.
* We could not do this work at construction time because the CRS choices may
* be computed in a background thread, in which case it became known only a
* little bit later and given to `StatusBar` through listeners.
*/
if (objectiveCRS == null) {
objectiveCRS = crs;
}
position.setMinWidth(0);
maximalPositionLength = 0;
resetPositionCRS(Styles.NORMAL_TEXT);
}
}
/**
* Invoked after the background thread computed the new coordinate operation.
* This method rewrites the coordinates on the assumption that {@link #lastX}
* and {@link #lastY} are still valid. This assumption should be correct when
* only the format CRS has been updated and not {@link #localToObjectiveCRS}.
*
* @param finder the completed task with the new {@link #objectiveToPositionCRS}.
* @param accuracy the accuracy to show on the status bar, or {@code null} if none.
*
* @see #setPositionRID(ReferencingByIdentifiers.Coder, String, DirectPosition)
*/
private void setPositionCRS(final OperationFinder finder, final Quantity<Length> accuracy) {
worker = null;
setErrorMessage(null, null);
fullOperationSearchRequired = finder.fullOperationSearchRequired();
localToPositionCRS = localToObjectiveCRS.get();
objectiveToPositionCRS = finder.getValue();
if (objectiveToPositionCRS != null) {
localToPositionCRS = MathTransforms.concatenate(localToPositionCRS, objectiveToPositionCRS);
}
setFormatCRS(finder.getTargetCRS(), accuracy);
rewritePosition(null);
}
/**
* Invoked after a new reference system has been set. This method rewrites the coordinates
* on the assumption that {@link #lastX} and {@link #lastY} are still valid.
*
* @param current the local coordinates used for current text, or {@code null} if not valid.
*/
private void rewritePosition(final DirectPosition current) {
position.setTextFill(Styles.NORMAL_TEXT);
position.setMinWidth(0);
maximalPositionLength = 0;
if (isPositionVisible()) {
final double x = lastX;
final double y = lastY;
lastX = lastY = Double.NaN;
if (!Double.isNaN(x) && !Double.isNaN(y)) {
if (current == null || current.getOrdinate(0) != x || current.getOrdinate(1) != y) {
setLocalCoordinates(x, y);
}
}
}
}
/**
* Invoked in JavaFX thread when a background task finished its work, either successfully or on error.
*/
private void terminated(final Task<?> caller) {
if (caller == worker) {
worker = null;
}
}
/**
* If a background task was in progress, cancels it. This is invoked before a new background task is launched.
*/
private void cancelWorker() {
if (worker != null) {
worker.cancel();
worker = null;
}
}
/**
* Sets the {@link CoordinateFormat} default CRS together with the tool tip text.
* Caller is responsible to setup transforms ({@link #localToPositionCRS}, <i>etc</i>).
* For method that applies required changes on transforms before to set the format CRS,
* see {@link #setPositionCRS(CoordinateReferenceSystem)}.
*
* @param crs the new {@link #format} reference system.
* @param accuracy positional accuracy of the transformation from local coordinates to the given CRS,
* or {@code null} if none. This is an accuracy computed by this {@code StatusBar} class,
* as opposed to {@link #lowestAccuracy} which has causes external to {@code StatusBar}.
*
* @see #positionReferenceSystem
*/
private void setFormatCRS(final CoordinateReferenceSystem crs, final Quantity<Length> accuracy) {
int dimension = localToPositionCRS.getTargetDimensions();
GeneralDirectPosition target = targetCoordinates;
if (dimension != target.getDimension()) {
target = new GeneralDirectPosition(dimension);
precisions = null;
}
target.setCoordinateReferenceSystem(crs);
format.setDefaultCRS(crs);
targetCoordinates = target; // Assign only after above succeed.
formatAsIdentifiers = null;
format.setGroundAccuracy(Quantities.max(accuracy, lowestAccuracy.get()));
setTooltip(crs);
/*
* Prepare the text to show when the mouse is outside the canvas area.
* We will write axis abbreviations, for example "(φ, λ)".
* Also fetch the unit of measurement of first axes.
*/
compatiblePrecisionCount = 0;
precisionUnit = null;
String text = null;
if (crs != null) {
final StringBuilder b = new StringBuilder().append('(');
final CoordinateSystem cs = crs.getCoordinateSystem();
dimension = (cs != null) ? cs.getDimension() : 0; // Paranoiac check (should never be null).
for (int i=0; i<dimension; i++) {
if (i != 0) b.append(", ");
final CoordinateSystemAxis axis = cs.getAxis(i);
if (axis != null) { // Paranoiac check (should never be null).
if (i == compatiblePrecisionCount) { // Require consecutive axes for unit test.
final Unit<?> unit = axis.getUnit();
if (i == 0 || Objects.equals(precisionUnit, unit)) {
compatiblePrecisionCount = i+1;
precisionUnit = unit;
}
}
final String abbr = Strings.trimOrNull(axis.getAbbreviation());
if (abbr != null) {
b.append(abbr);
continue;
}
}
b.append('?');
}
b.append(')');
format.getGroundAccuracyText().ifPresent(b::append);
text = b.toString();
}
/*
* 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;
((PositionSystem) positionReferenceSystem).fireValueChangedEvent();
}
/**
* Implementation of {@link #positionReferenceSystem} property.
*/
private final class PositionSystem extends ReadOnlyObjectPropertyBase<ReferenceSystem> {
@Override public Object getBean() {return StatusBar.this;}
@Override public String getName() {return "positionReferenceSystem";}
@Override public ReferenceSystem get() {
final ReferencingByIdentifiers.Coder f = formatAsIdentifiers;
return (f != null) ? f.getReferenceSystem() : getPositionCRS();
}
@Override protected void fireValueChangedEvent() {super.fireValueChangedEvent();}
}
/**
* Returns the coordinate reference system of the position shown in this status bar.
* This is valid only if {@link #formatAsIdentifiers} is null.
*
* @see #setPositionCRS(CoordinateReferenceSystem)
*/
private CoordinateReferenceSystem getPositionCRS() {
return format.getDefaultCRS();
}
/**
* Resets {@link #localToPositionCRS} to its default value. This is invoked either when the
* target CRS is {@link #objectiveCRS}, or when an attempt to use another CRS failed.
*
* @param textFill the color to assign to position text. It depends on the reason why we reset the position.
*/
private void resetPositionCRS(final Color textFill) {
objectiveToPositionCRS = null;
localToPositionCRS = localToObjectiveCRS.get();
setFormatCRS(objectiveCRS, null);
position.setTextFill(textFill);
}
/**
* Implementation of {@link #localToObjectiveCRS} property performing argument check before to change its value.
* When a new value is set, the given transform must have the same number of source and target dimensions than
* the previous value. The status bar is updated as if the new conversion was applied <em>before</em> any CRS
* changes resulting from user selecting a different CRS in the contextual menu.
*
* @see #localToObjectiveCRS
*/
private final class LocalToObjective extends ObjectPropertyBase<MathTransform> {
LocalToObjective() {super(MathTransforms.identity(BIDIMENSIONAL));}
@Override public Object getBean() {return StatusBar.this;}
@Override public String getName() {return "localToObjectiveCRS";}
/**
* Overwrite previous value without any check. This method is invoked when the {@link #objectiveCRS}
* is changed at the same time that the {@link #localToObjectiveCRS} transform, so the number of dimensions
* may be temporarily mismatched. This method does not update {@link #localToPositionCRS};
* that update must be done by the caller when ready.
*/
final void setNoCheck(final MathTransform newValue) {
super.set(newValue);
}
/**
* Sets the conversion from local coordinates to geographic or projected coordinates of rendered data.
*
* @param newValue the new conversion from local coordinates to "real world" coordinates of rendered data.
* @throws MismatchedDimensionException if the number of dimensions is not the same as previous conversion.
*/
@Override public void set(MathTransform newValue) {
final MathTransform oldValue = get();
ArgumentChecks.ensureDimensionsMatch("newValue",
oldValue.getSourceDimensions(),
oldValue.getTargetDimensions(),
Objects.requireNonNull(newValue));
final MathTransform tr = objectiveToPositionCRS;
if (tr != null) {
newValue = MathTransforms.concatenate(newValue, tr);
}
localToPositionCRS = newValue;
super.set(newValue);
}
}
/**
* Sets the reference system of the position shown in this status bar.
* This is similar to {@link #setPositionCRS(CoordinateReferenceSystem)} but for referencing by identifiers.
* This method tries to format the current position in a background thread because the first invocation of
* {@link ReferencingByIdentifiers.Coder#encode(DirectPosition)} may require an access to the EPSG database.
*
* @param system the new reference system (shall not be {@code null}).
*/
private void setPositionRID(final ReferencingByIdentifiers system) {
resetPositionCRS(Styles.OUTDATED_TEXT);
final DirectPosition poi;
final MathTransform toObjective;
final CoordinateReferenceSystem crs = objectiveCRS;
final Quantity<Length> accuracy = lowestAccuracy.get();
if (Double.isFinite(lastX) && Double.isFinite(lastY)) {
poi = new DirectPosition2D(lastX, lastY);
toObjective = localToPositionCRS;
} else {
poi = (canvas != null) ? canvas.getPointOfInterest(true) : null;
toObjective = null;
}
cancelWorker();
BackgroundThreads.execute(worker = new Task<String>() {
/**
* The object to use for formatting identifiers. This is the value to assign
* to {@link StatusBar#formatAsIdentifiers} after successful task completion.
*/
private ReferencingByIdentifiers.Coder coder;
/**
* Invoked in a background thread for formatting the identifier. The point to format is transformed
* from local coordinates to objective CRS. The CRS needs to be specified for allowing the coder to
* transform again the point to whatever internal CRS it needs for encoding purpose.
*/
@Override protected String call() {
coder = system.createCoder();
try {
DirectPosition p = poi;
if (p != null && toObjective != null) {
p = toObjective.transform(p, new GeneralDirectPosition(crs));
}
if (accuracy != null) {
coder.setPrecision(accuracy, p);
}
if (p != null) {
return coder.encode(p);
}
} catch (IncommensurableException | TransformException e) {
recoverableException("setPositionRID", e);
}
return null;
}
/**
* Invoked in JavaFX thread for reporting a failure.
* The reference system in use stay the previous one.
*/
@Override protected void failed() {
terminated(this);
setReferenceSystemError(system, getException());
}
/** Invoked in JavaFX thread on success for applying the actual reference system change. */
@Override protected void succeeded() {
terminated(this);
setPositionRID(coder, getValue(), (toObjective != null) ? poi : null);
}
});
}
/**
* Invoked after the background thread prepared the new reference system.
* The identifier formatted by the background thread is written, but needs to be rewritten
* again if {@link #lastX} or {@link #lastY} changed since background task execution.
*
* @param coder the coder to use for formatting identifiers.
* @param identifier identifier formatted using mouse position.
* @param current the local coordinates used for current text, or {@code null} if not valid.
*
* @see #setPositionCRS(OperationFinder, Quantity)
*/
private void setPositionRID(final ReferencingByIdentifiers.Coder coder, final String identifier, final DirectPosition current) {
formatAsIdentifiers = coder;
fullOperationSearchRequired = null;
outsideText = null;
setErrorMessage(null, null);
setTooltip(coder.getReferenceSystem());
position.setText(identifier);
((PositionSystem) positionReferenceSystem).fireValueChangedEvent();
rewritePosition(current);
}
/**
* Returns the indices of <var>x</var> and <var>y</var> coordinate values in a grid coordinate tuple.
* They are the indices where to assign the values of the <var>x</var> and <var>y</var> arguments in
* calls to <code>{@linkplain #setLocalCoordinates(double, double) setLocalCoordinates}(x,y)</code>.
*
* <p>The default value is {0,1}, i.e. the 2 first dimensions in a coordinate tuple.
* The value can be changed by call to {@link #applyCanvasGeometry(GridGeometry, GridExtent, int, int)}.</p>
*
* @return indices of <var>x</var> and <var>y</var> coordinate values in a grid coordinate tuple.
*
* @since 1.3
*/
public final int[] getXYDimensions() {
return new int[] {xDimension, yDimension};
}
/**
* Returns the coordinates given to the last call to {@link #setLocalCoordinates(double, double)},
* or an empty value if those coordinates are not visible.
*
* @return the local coordinates currently shown in the status bar.
*/
public Optional<Point2D> getLocalCoordinates() {
if (isPositionVisible() && !Double.isNaN(lastX) && !Double.isNaN(lastY)) {
return Optional.of(new Point2D(lastX, lastY));
}
return Optional.empty();
}
/**
* Converts and formats the given pixel coordinates. Those coordinates will be automatically
* converted to geographic or projected coordinates if a "local to CRS" conversion is available.
*
* <h4>Supplemental dimensions</h4>
* If local coordinates have more than 2 dimensions, then the given (x,y) values will be assigned
* to the dimensions specified by {@link #getXYDimensions()}. Coordinates in all other dimensions
* will have the values given by {@link GridExtent#getPointOfInterest(PixelInCell)} from the extent
* of the grid geometry given to {@link #applyCanvasGeometry(GridGeometry)}.
*
* @param x the <var>x</var> coordinate local to the view.
* @param y the <var>y</var> coordinate local to the view.
*
* @see #handle(MouseEvent)
*/
public void setLocalCoordinates(final double x, final double y) {
if (x != lastX || y != lastY) {
if (isSampleValuesVisible) {
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.
*/
if (text.length() > maximalPositionLength) {
maximalPositionLength = text.length();
position.setMinWidth(Math.min(view.getWidth() / 2, Math.ceil(position.prefWidth(position.getHeight()))));
}
}
}
/**
* Unconditionally converts and formats the given local coordinates, but without modifying any control.
* It is caller's responsibility to either change the text shown in the status bar, or to use the returned
* text for something else (for example for copying in the clipboard).
*
* @param x the <var>x</var> coordinate local to the view.
* @param y the <var>y</var> coordinate local to the view.
* @return string representation of coordinates or an error message.
*/
private String formatLocalCoordinates(final double x, final double y) {
sourceCoordinates[xDimension] = x;
sourceCoordinates[yDimension] = y;
Matrix derivative;
try {
derivative = MathTransforms.derivativeAndTransform(localToPositionCRS,
sourceCoordinates, 0, targetCoordinates.coordinates, 0);
} catch (TransformException ignore) {
/*
* If above operation failed, it may be because the MathTransform does not support
* derivative calculation. Try again without derivative (the precision will be set
* to the default resolution computed in `setCanvasGeometry(…)`).
*/
try {
localToPositionCRS.transform(sourceCoordinates, 0, targetCoordinates.coordinates, 0, 1);
} catch (TransformException e) {
return cause(e);
}
derivative = null;
}
if (derivative == null) {
precisions = null;
} else {
if (precisions == null) {
precisions = new double[targetCoordinates.getDimension()];
}
/*
* Estimate the precision by looking at the maximal displacement in the CRS caused by
* a displacement of one cell (i.e. when moving by one row or column). We search for
* maximal displacement because we expect the displacement to be zero along some axes
* (e.g. one row down does not change longitude value in a Plate Carrée projection).
*/
for (int j=derivative.getNumRow(); --j >= 0;) {
double p = 0;
for (int i=derivative.getNumCol(); --i >= 0;) {
double e = Math.abs(derivative.getElement(j, i));
if (inflatePrecisions != null) {
e *= inflatePrecisions[i];
}
if (e > p) p = e;
}
precisions[j] = p;
}
}
targetCoordinates.normalize();
/*
* Format as an identifier or as a coordinate tuple, depending on the type of the reference system.
* The precision is determined by the size of a pixel on screen and controls the number of fraction
* digits to print. Precision should not be confused with accuracy, which depends on transformation
* applied on coordinate values and determines the "± accuracy" text shown after coordinates.
*/
try {
if (formatAsIdentifiers != null) {
double precision = 0;
for (int i = compatiblePrecisionCount; --i >= 0;) {
final double p = precisions[i];
if (p > precision) precision = p;
}
return formatAsIdentifiers.encode(targetCoordinates,
(precision > 0) ? Quantities.create(precision, precisionUnit) : null);
} else {
format.setPrecisions(precisions);
return format.format(targetCoordinates);
}
} catch (Exception e) {
return cause(e);
}
}
/**
* Converts and formats the given local coordinates, but without modifying text shown in this status bar.
* This is used for copying the coordinates somewhere else, for example on the clipboard.
*
* @param x the <var>x</var> coordinate local to the view.
* @param y the <var>y</var> coordinate local to the view.
*/
final String formatTabSeparatedCoordinates(final double x, final double y) throws TransformException {
final String separator = format.getSeparator();
try {
format.setSeparator("\t");
return formatLocalCoordinates(x, y);
} finally {
format.setSeparator(separator);
}
}
/**
* Updates the coordinates shown in the status bar with the value given by the mouse event.
* This method handles the following events:
*
* <ul>
* <li>{@link MouseEvent#MOUSE_ENTERED}: show the coordinates.</li>
* <li>{@link MouseEvent#MOUSE_EXITED}: hide the coordinates.</li>
* <li>{@link MouseEvent#MOUSE_MOVED}: delegate to {@link #setLocalCoordinates(double, double)}.</li>
* </ul>
*
* @param event the enter, exit or move event. For the convenience of programmatic calls,
* a null value is synonymous to a mouse exit event.
*/
@Override
public void handle(final MouseEvent event) {
if (event != null) {
final EventType<? extends MouseEvent> type = event.getEventType();
if (type == MouseEvent.MOUSE_MOVED || type == MouseEvent.MOUSE_ENTERED) {
setLocalCoordinates(event.getX(), event.getY());
return;
}
if (type != MouseEvent.MOUSE_EXITED) {
return;
}
}
/*
* Mouse exited the canvas. Use substitution texts.
* Do not use `position.setVisible(false)` because
* we want the Tooltip to continue to be available.
*/
lastX = lastY = Double.NaN;
position.setText(outsideText);
if (isSampleValuesVisible) {
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.
*/
private boolean isPositionVisible() {
if (position.isVisible()) {
final String text = position.getText();
return text != null && text != outsideText; // Identity comparison is okay for that value.
}
return false;
}
/**
* 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();
Label view = sampleValues;
if (visible) {
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(view) < 0) {
final Separator separator = new Separator(Orientation.VERTICAL);
c.addAll(separator, view);
}
} else if (view != null) {
view.setText(null);
int i = c.lastIndexOf(view);
if (i >= 0) {
c.remove(i);
if (--i >= 0) {
final Node last = c.remove(i);
assert last instanceof Separator : last;
}
}
}
isSampleValuesVisible = visible;
}
/**
* Given the longest expected text for values under the cursor, computes the {@link #sampleValues} minimal width.
* 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, 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.
*
* @see ValuesUnderCursor#prototype(String, Iterable)
*/
final boolean computeSizeOfSampleValues(final String prototype, final Iterable<String> others) {
setSampleValuesVisible(prototype != null && !prototype.isEmpty());
if (isSampleValuesVisible) {
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) {
view.setText(other);
final double cw = view.prefWidth(view.getHeight());
if (cw > width) {
width = cw;
if (width > max) {
width = max;
break;
}
}
}
view.setText(null);
if (!(width > 0)) { // May be 0 if canvas is not yet added to scene graph.
return false;
}
view.setPrefWidth(width + VALUES_PADDING);
}
return true;
}
/**
* Sets the tooltip text to show when the mouse cursor is over the coordinate values.
*/
private void setTooltip(final ReferenceSystem crs) {
String text = IdentifiedObjects.getDisplayName(crs, getLocale());
Tooltip tp = null;
if (text != null) {
tp = position.getTooltip();
if (tp == null) {
tp = new Tooltip(text);
} else {
tp.setText(text);
}
}
position.setTooltip(tp);
}
/**
* Returns the message currently shown. It may be an error message or an informative message.
*
* @return the current message, or an empty value if none.
*
* @since 1.3
*/
public Optional<String> getMessage() {
return Optional.ofNullable(message.getText());
}
/**
* Shows or hides an informative message on the status bar.
* The message should be temporary, for example for telling that a loading is in progress.
*
* @param text the message to show, or {@code null} if none.
*
* @since 1.3
*/
public void setInfoMessage(String text) {
text = Strings.trimOrNull(text);
message.setVisible(text != null);
message.setGraphic(null);
message.setText(text);
message.setTextFill(Styles.LOADING_TEXT);
}
/**
* Shows or hides an error message on the status bar, optionally with a button showing details in a dialog box.
* The {@code text} argument specifies the message to show on the status bar.
* If {@code text} is null, the message will be taken from the {@code details} if non-null.
* If {@code details} is also null, then the error message will be hidden.
*
* @param text the error message to show, or {@code null} if none.
* @param details the exception that caused the error, or {@code null} if none.
*/
public void setErrorMessage(String text, final Throwable details) {
text = Strings.trimOrNull(text);
Button more = null;
if (details != null) {
final String alert = (text != null) ? text : cause(details);
more = new Button(Styles.ERROR_DETAILS_ICON);
more.setOnAction((e) -> ExceptionReporter.show(getView(),
Resources.forLocale(getLocale()).getString(Resources.Keys.ErrorDetails), alert, details));
}
message.setVisible(text != null);
message.setGraphic(more);
message.setText(text);
message.setTextFill(Styles.ERROR_TEXT);
}
/**
* Shows an error message for a reference system that cannot be set.
* The previous reference system is kept unchanged but the coordinates
* will appear in red for telling user that there is a problem.
*
* @param system the reference system that we failed to set.
* @param exception the exception that occurred while attempting to set the CRS.
*/
private void setReferenceSystemError(final ReferenceSystem system, final Throwable exception) {
final Locale locale = getLocale();
setErrorMessage(Resources.forLocale(locale).getString(Resources.Keys.CanNotUseRefSys_1,
IdentifiedObjects.getDisplayName(system, locale)), exception);
if (selectedSystem != null) {
selectedSystem.set(positionReferenceSystem.get());
}
resetPositionCRS(Styles.ERROR_TEXT);
}
/**
* Shown an error message that occurred in the context of rendering the {@link #canvas} content.
* This method should not be invoked for other context like an error during transformation of
* coordinates shown is the status bar.
*/
private void setRenderingError(final Throwable details) {
String text = null;
if (details != null) {
text = Resources.forLocale(getLocale()).getString(Resources.Keys.CanNotRender);
}
setErrorMessage(text, details);
}
/**
* Returns a string representation of the message of the given exception.
* If the exception is a wrapper, the exception cause is taken.
* If there is no message, the exception class name is returned.
*
* @param e the exception.
* @return the exception message or class name.
*/
final String cause(Throwable e) {
if (e instanceof Exception) {
e = Exceptions.unwrap((Exception) e);
}
String text = Exceptions.getLocalizedMessage(e, getLocale());
if (text == null) {
text = Classes.getShortClassName(e);
}
return text;
}
/**
* Logs an error considered too minor for reporting on the status bar.
*/
private static void recoverableException(final String caller, final Exception e) {
Logging.recoverableException(LOGGER, StatusBar.class, caller, e);
}
}