blob: 78c37612f6e044868255162ac9627325d4510538 [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.Locale;
import java.util.Arrays;
import java.util.Objects;
import java.util.Formatter;
import java.awt.geom.AffineTransform;
import java.awt.geom.NoninvertibleTransformException;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import javafx.application.Platform;
import javafx.geometry.Bounds;
import javafx.geometry.Point2D;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import javafx.scene.input.GestureEvent;
import javafx.scene.Cursor;
import javafx.event.EventType;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.ReadOnlyBooleanWrapper;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.beans.value.WritableValue;
import javafx.concurrent.Task;
import javafx.concurrent.Worker;
import javafx.event.EventHandler;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.ToggleGroup;
import javafx.scene.transform.Affine;
import javafx.scene.transform.NonInvertibleTransformException;
import org.opengis.geometry.Envelope;
import org.opengis.geometry.DirectPosition;
import org.opengis.geometry.MismatchedDimensionException;
import org.opengis.referencing.ReferenceSystem;
import org.opengis.referencing.cs.AxisDirection;
import org.opengis.referencing.datum.PixelInCell;
import org.opengis.referencing.crs.CoordinateReferenceSystem;
import org.opengis.referencing.operation.TransformException;
import org.apache.sis.referencing.operation.matrix.Matrices;
import org.apache.sis.referencing.operation.matrix.MatrixSIS;
import org.apache.sis.referencing.operation.transform.MathTransforms;
import org.apache.sis.referencing.operation.transform.LinearTransform;
import org.apache.sis.referencing.cs.CoordinateSystems;
import org.apache.sis.referencing.IdentifiedObjects;
import org.apache.sis.geometry.DirectPosition2D;
import org.apache.sis.geometry.Envelope2D;
import org.apache.sis.geometry.AbstractEnvelope;
import org.apache.sis.geometry.ImmutableEnvelope;
import org.apache.sis.coverage.grid.GridGeometry;
import org.apache.sis.coverage.grid.GridExtent;
import org.apache.sis.gui.referencing.PositionableProjection;
import org.apache.sis.gui.referencing.RecentReferenceSystems;
import org.apache.sis.util.ArraysExt;
import org.apache.sis.util.ArgumentChecks;
import org.apache.sis.util.logging.Logging;
import org.apache.sis.internal.util.Numerics;
import org.apache.sis.internal.system.DelayedExecutor;
import org.apache.sis.internal.system.DelayedRunnable;
import org.apache.sis.internal.gui.BackgroundThreads;
import org.apache.sis.internal.gui.ExceptionReporter;
import org.apache.sis.internal.gui.GUIUtilities;
import org.apache.sis.internal.gui.MouseDrags;
import org.apache.sis.internal.gui.Resources;
import org.apache.sis.internal.referencing.AxisDirections;
import org.apache.sis.internal.referencing.j2d.AffineTransform2D;
import org.apache.sis.portrayal.PlanarCanvas;
import org.apache.sis.portrayal.RenderException;
import org.apache.sis.portrayal.TransformChangeEvent;
import static org.apache.sis.internal.gui.LogHandler.LOGGER;
import static org.apache.sis.internal.util.StandardDateFormat.NANOS_PER_MILLISECOND;
/**
* A canvas for maps to be rendered on screen in a JavaFX application.
* The map may be an arbitrary JavaFX node, typically an {@link javafx.scene.image.ImageView}
* or {@link javafx.scene.canvas.Canvas}, which must be supplied by subclasses.
* This base class provides handlers for keyboard, mouse, track pad or touch screen events
* such as pans, zooms and rotations. The keyboard actions are:
*
* <table class="sis">
* <caption>Keyboard actions</caption>
* <tr><th>Key</th> <th>Action</th></tr>
* <tr><td>⇨</td> <td>Move view to the right</td></tr>
* <tr><td>⇦</td> <td>Move view to the left</td></tr>
* <tr><td>⇧</td> <td>Move view to the top</td></tr>
* <tr><td>⇩</td> <td>Move view to the bottom</td></tr>
* <tr><td>⎇ + ⇨</td> <td>Rotate clockwise</td></tr>
* <tr><td>⎇ + ⇦</td> <td>Rotate anticlockwise</td></tr>
* <tr><td>Page down</td> <td>Zoom in</td></tr>
* <tr><td>Page up</td> <td>Zoom out</td></tr>
* <tr><td>Home</td> <td>{@linkplain #reset() Reset}</td></tr>
* <tr><td>Ctrl + above</td> <td>Above actions as a smaller translation, zoom or rotation</td></tr>
* </table>
*
* <h2>Subclassing</h2>
* Implementations need to add at least one JavaFX node in the {@link #floatingPane} list of children.
* Map rendering involves the following steps:
*
* <ol>
* <li>{@link #createRenderer()} is invoked in the JavaFX thread. That method shall take a snapshot
* of every information needed for performing the rendering in background.</li>
* <li>{@link Renderer#render()} is invoked in a background thread. That method creates or updates
* the nodes to show in this {@code MapCanvas} but without interacting with the canvas yet.</li>
* <li>{@link Renderer#commit(MapCanvas)} is invoked in the JavaFX thread. The nodes prepared by
* {@code render()} can be transferred to {@link #floatingPane} in that method.</li>
* </ol>
*
* @author Martin Desruisseaux (Geomatys)
* @version 1.4
* @since 1.1
*/
public abstract class MapCanvas extends PlanarCanvas {
/**
* Size in pixels of a scroll or translation event. This value should be close to the
* {@linkplain ScrollEvent#getDeltaY() delta of a scroll event done with mouse wheel}.
*/
static final double SCROLL_EVENT_SIZE = 40;
/**
* The zoom factor to apply on scroll event. A value of 0.1 means that a zoom of 10%
* is applied.
*/
private static final double ZOOM_FACTOR = 0.1;
/**
* Division factor to apply on translations and zooms when the control key is down.
*/
private static final double CONTROL_KEY_FACTOR = 10;
/**
* Number of milliseconds to wait before to repaint after gesture events (zooms, rotations, pans).
* This delay allows to collect more events before to run a potentially costly {@link #repaint()}.
* It does not apply to the immediate feedback that the user gets from JavaFX affine transforms
* (an image with lower quality used until the higher quality image become ready).
*
* <p>This value should not be too small for reducing flickering effects that are sometimes visible
* at the moment when image data are replaced.</p>
*
* @see #requestRepaint()
* @see Delayed
*/
private static final long REPAINT_DELAY = 200;
/**
* Number of nanoseconds to wait before to set mouse cursor shape to {@link Cursor#WAIT} during rendering.
* If the rendering complete in a shorter time, the mouse cursor will be unchanged.
*
* @see #renderingStartTime
*/
private static final long WAIT_CURSOR_DELAY = (500 - REPAINT_DELAY) * NANOS_PER_MILLISECOND;
/**
* The pane showing the map and any other JavaFX nodes to scale and translate together with the map.
* This pane is initially empty; subclasses should add nodes (canvas, images, shapes, texts, <i>etc.</i>)
* into the {@link Pane#getChildren()} list.
* All children must specify their coordinates in units relative to the pane (absolute layout).
* Those coordinates can be computed from real world coordinates by {@link #objectiveToDisplay}.
*
* <p>This pane contains an {@link Affine} transform which is updated by user gestures such as pans,
* zooms or rotations. Visual positions of all children move together in response to user's gesture,
* thus giving an appearance of pane floating around. Changes in {@code floatingPane} affine transform
* are temporary; they are applied for producing immediate visual feedback while the map is recomputed
* in a background thread. Once calculation is completed and the content of this pane has been updated,
* the {@code floatingPane} {@link Affine} transform is reset to identity.</p>
*/
protected final Pane floatingPane;
/**
* The pane showing the map and other JavaFX nodes to keep at fixed position regardless pans, zooms or rotations
* applied on the map. This pane contains at least the {@linkplain #floatingPane} (which itself contains the map),
* but more children (shapes, texts, controls, <i>etc.</i>) can be added by subclasses into the
* {@link StackPane#getChildren()} list.
*/
protected final StackPane fixedPane;
/**
* The data bounds to use for computing the initial value of {@link #objectiveToDisplay}.
* We differ this recomputation until all parameters are known.
*
* @see #setObjectiveBounds(Envelope)
* @see #invalidObjectiveToDisplay
*/
private Envelope objectiveBounds;
/**
* The data bounds to use for computing the initial value of {@link #objectiveToDisplay}.
* Optionally contains the initial "objective to display" CRS to use if a predetermined
* value is desired instead of an automatically computed one. The grid extent is ignored,
* except for fetching the grid center if a non-linear transform needs to be linearized.
*
* @see #initialize(GridGeometry)
* @see #invalidObjectiveToDisplay
*/
private GridGeometry initialState;
/**
* Incremented when the map needs to be rendered again.
*
* @see #renderedContentStamp
* @see #contentsChanged()
*/
private int contentChangeCount;
/**
* Value of {@link #contentChangeCount} last time the data have been rendered. This is used for deciding
* if a call to {@link #repaint()} should be done with the next layout operation. We need this check for
* avoiding never-ending repaint events caused by calls to {@code ImageView.setImage(Image)} causing
* themselves new layout events. It is okay if this value overflows.
*/
private int renderedContentStamp;
/**
* Value of {@link System#nanoTime()} when the last rendering started. This is used together with
* {@link #WAIT_CURSOR_DELAY} for deciding if mouse cursor should be {@link Cursor#WAIT}.
*/
private long renderingStartTime;
/**
* Non-null if a rendering task is in progress. Used for avoiding to send too many {@link #repaint()}
* requests; we will wait for current repaint event to finish before to send another painting request.
*/
private Task<?> renderingInProgress;
/**
* User-specified task of execute after rendering is completed, or {@code null} if none.
* {@code MapCanvas} does not use this mechanism for itself, but some subclasses need it.
*
* @see #runAfterRendering(Runnable)
*/
private Runnable afterRendering;
/**
* Whether the size of this canvas changed.
*/
private boolean sizeChanged;
/**
* Whether {@link #objectiveToDisplay} needs to be recomputed.
* We differ this recomputation until all parameters are known.
*
* @see #objectiveBounds
* @see #objectiveToDisplay
*/
private boolean invalidObjectiveToDisplay;
/**
* The zooms, pans and rotations applied on {@link #floatingPane} since last time the map has been painted.
* This is the identity transform except during the short time between a gesture (zoom, pan, <i>etc.</i>)
* and the completion of latest {@link #repaint()} event. This is used for giving immediate feedback to user
* while waiting for the new rendering to be ready. Since this transform is a member of {@link #floatingPane}
* {@linkplain Pane#getTransforms() transform list}, changes in this transform are immediately visible to user.
*
* @see #getInterimTransform(boolean)
*/
private final Affine transform;
/**
* The {@link #transform} values at the time the {@link #repaint()} method has been invoked.
* This is a change applied on {@link #objectiveToDisplay} but not yet visible in the map.
*/
private final Affine changeInProgress;
/**
* Cursor position at the time pan event started.
* This is used for computing the {@linkplain #floatingPane} translation to apply during drag events.
*
* @see #onDrag(MouseEvent)
*/
private double xPanStart, yPanStart;
/**
* {@code true} if a drag event is in progress.
*
* @see #onDrag(MouseEvent)
*/
private boolean isDragging;
/**
* Whether a {@link CursorChange} is already scheduled, in which case there is no need to schedule more.
*/
private boolean isCursorChangeScheduled;
/**
* {@code true} if navigation should be disabled.
*
* @see #setNavigationDisabled(boolean)
*/
private boolean isNavigationDisabled;
/**
* Whether a rendering is in progress. This property is set to {@code true} when {@code MapCanvas}
* is about to start a background thread for performing a rendering, and is reset to {@code false}
* after the {@code MapCanvas} has been updated with new rendering result.
*
* @see #renderingProperty()
*/
private final ReadOnlyBooleanWrapper isRendering;
/**
* The exception or error that occurred during last rendering operation.
* This is reset to {@code null} when a rendering operation completes successfully.
*
* @see #errorProperty()
*/
private final ReadOnlyObjectWrapper<Throwable> error;
/**
* Whether the {@link #error} value should be considered non-null.
* This is set to {@code false} when a new painting start.
* If the value is still {@code false} after painting finished, we can clear {@link #error}.
*
* <h4>Rational</h4>
* A simpler approach would have been to clear {@link #error} when painting start, but this action
* fires an event which may resize the {@link StatusBar} height, which in turn may change the size
* of this {@code MapCanvas} and cause a new painting. The new painting may fail again, which causes
* an error to be reported, which may cause the status bar to change its height again, <i>etc.</i>
* It can cause a loop with hundred of repaints before the system stabilize.
* Using this flag avoids above problem.
*/
private boolean hasError;
/**
* If a contextual menu is currently visible, that menu. Otherwise {@code null}.
*/
private ContextMenu menuShown;
/**
* Creates a new canvas for JavaFX application.
*
* @param locale the locale to use for labels and some messages, or {@code null} for default.
*/
public MapCanvas(final Locale locale) {
super(locale);
transform = new Affine();
changeInProgress = new Affine();
final Pane view = new Pane() {
@Override protected void layoutChildren() {
super.layoutChildren();
if (contentsChanged()) {
repaint();
}
}
};
view.getTransforms().add(transform);
view.setOnZoom ((e) -> applyZoomOrRotate(e, e.getZoomFactor(), 0));
view.setOnRotate((e) -> applyZoomOrRotate(e, 1, e.getAngle()));
view.setOnScroll(this::onScroll);
view.setFocusTraversable(true);
view.addEventHandler(KeyEvent.KEY_PRESSED, this::onKeyTyped);
MouseDrags.setHandlers(view, this::onDrag);
/*
* Do not set a preferred size, otherwise `repaint()` is invoked twice: once with the preferred size
* and once with the actual size of the parent window. Actually the `repaint()` method appears to be
* invoked twice anyway, but without preferred size the width appears to be 0, in which case nothing
* is repainted.
*/
view.layoutBoundsProperty().addListener((p) -> onSizeChanged());
view.setCursor(Cursor.CROSSHAIR);
floatingPane = view;
fixedPane = new StackPane(view);
GUIUtilities.setClipToBounds(fixedPane);
isRendering = new ReadOnlyBooleanWrapper(this, "isRendering");
error = new ReadOnlyObjectWrapper<>(this, "error");
}
/**
* Sets the objective bounds and/or the zoom level and objective CRS to use for the initial view of data.
* The {@code visibleArea} {@linkplain GridGeometry#getCoordinateReferenceSystem() CRS} defines the initial
* {@linkplain #setObjectiveCRS(CoordinateReferenceSystem, DirectPosition) objective CRS} of this canvas.
* The {@code visibleArea} {@linkplain GridGeometry#getEnvelope() envelope} defines the (usually constant)
* {@linkplain #setObjectiveBounds(Envelope) objective bounds} of this canvas.
* In addition if {@code visibleArea} contains a {@linkplain GridGeometry#getGridToCRS grid to CRS} transform,
* its inverse will define the initial {@linkplain #setObjectiveToDisplay objective to display} transform
* (which in turn defines the initial viewed area and zoom level).
*
* <p>This method should be invoked only when new data have been loaded, or when the caller wants
* to discard any zoom or translation and reset the view to the given bounds. This method does not
* cause new repaint event; {@link #requestRepaint()} must be invoked by the caller if desired.</p>
*
* @param visibleArea bounding box, objective CRS and or initial zoom level,
* or {@code null} if unknown (in which case an identity transform will be set).
* @throws MismatchedDimensionException if the given grid geometry is not two-dimensional.
*
* @see #setObjectiveBounds(Envelope)
* @see #getGridGeometry()
*
* @since 1.3
*/
protected void initialize(final GridGeometry visibleArea) {
Envelope bounds = null;
if (visibleArea != null) {
if (visibleArea.isDefined(GridGeometry.ENVELOPE)) {
bounds = visibleArea.getEnvelope();
ArgumentChecks.ensureDimensionMatches("visibleArea", BIDIMENSIONAL, bounds);
}
if (visibleArea.isDefined(GridGeometry.GRID_TO_CRS)) {
ArgumentChecks.ensureDimensionsMatch("visibleArea", BIDIMENSIONAL, BIDIMENSIONAL,
visibleArea.getGridToCRS(PixelInCell.CELL_CENTER));
}
}
objectiveBounds = bounds;
initialState = visibleArea;
invalidObjectiveToDisplay = true;
}
/**
* Returns the bounds of the content in {@link #floatingPane} coordinates, or {@code null} if unknown.
* Some subclasses may compute a larger image than the widget size for better visual transition during
* pan or zoom-out. If such margin exists, it may not be necessary to repaint the canvas on size change.
*/
Bounds getBoundsInParent() {
return null;
}
/**
* Invoked when the size of the {@linkplain #floatingPane} has changed.
* This method requests a new repaint after a short wait, in order to collect more resize events.
*/
private void onSizeChanged() {
sizeChanged = true;
final Bounds bp = getBoundsInParent();
if (bp != null) {
if (bp.getMinX() <= 0 && bp.getMinY() <= 0 &&
bp.getMaxX() >= floatingPane.getWidth() &&
bp.getMaxY() >= floatingPane.getHeight())
{
return;
}
}
requestRepaint();
}
/**
* Invoked when the user presses the button, drags the map and releases the button.
* This is interpreted as a translation applied in pixel units on the map.
*/
private void onDrag(final MouseEvent event) {
final double x = event.getX();
final double y = event.getY();
final EventType<? extends MouseEvent> type = event.getEventType();
if (type == MouseEvent.MOUSE_PRESSED) {
switch (event.getButton()) {
case PRIMARY: {
hideContextMenu();
if (!isNavigationDisabled) {
floatingPane.setCursor(Cursor.CLOSED_HAND);
floatingPane.requestFocus();
isDragging = true;
xPanStart = x;
yPanStart = y;
}
event.consume();
break;
}
// Future version may add cases for FORWARD and BACK buttons.
}
} else if (isDragging) {
if (type != MouseEvent.MOUSE_DRAGGED) {
if (floatingPane.getCursor() == Cursor.CLOSED_HAND) {
floatingPane.setCursor(Cursor.CROSSHAIR);
}
isDragging = false;
}
applyTranslation(x - xPanStart, y - yPanStart, type == MouseEvent.MOUSE_RELEASED);
event.consume();
}
}
/**
* Restores the cursor to its normal state after rendering completion.
* The purpose of this method is to hide the {@link Cursor#WAIT} shape.
*/
private void restoreCursorAfterPaint() {
floatingPane.setCursor(isDragging ? Cursor.CLOSED_HAND : Cursor.CROSSHAIR);
}
/**
* Translates the map in response to user event (keyboard, mouse, track pad, touch screen).
*
* @param tx horizontal translation in pixel units.
* @param ty vertical translation in pixel units.
* @param isFinal {@code false} if more translations are expected soon, or
* {@code true} if this is the last translation for now.
*
* @see #applyZoomOrRotate(GestureEvent, double, double)
*/
private void applyTranslation(final double tx, final double ty, final boolean isFinal) {
if (tx != 0 || ty != 0) {
final AffineTransform2D interim = getInterimTransformForListeners();
transform.appendTranslation(tx, ty);
if (interim != null) {
fireInterimTransform(interim, AffineTransform.getTranslateInstance(tx, ty));
}
if (!isFinal) {
requestRepaint();
return;
}
}
if (isFinal && !transform.isIdentity()) {
repaint();
}
}
/**
* Invoked when the user rotates the mouse wheel.
* This method performs a zoom-in or zoom-out event.
*/
private void onScroll(final ScrollEvent event) {
if (event.getTouchCount() != 0) {
// Do not interpret scroll events on touch pad as a zoom.
return;
}
if (isNavigationDisabled) {
event.consume();
return;
}
final double delta = event.getDeltaY();
double zoom = Math.abs(delta) / SCROLL_EVENT_SIZE * ZOOM_FACTOR;
if (event.isControlDown()) {
zoom /= CONTROL_KEY_FACTOR;
}
zoom++;
if (delta < 0) {
zoom = 1/zoom;
}
applyZoomOrRotate(event, zoom, 0);
}
/**
* Zooms or rotates the map in response to user event (keyboard, mouse, track pad, touch screen).
* If the given event is non-null, it will be consumed.
*
* @param event the mouse, track pad or touch screen event, or {@code null} if the event was a keyboard event.
* @param zoom the zoom factor to apply, or 1 if none.
* @param angle the rotation angle in degrees, or 0 if nine.
*
* @see #applyTranslation(double, double, boolean)
*/
private void applyZoomOrRotate(final GestureEvent event, final double zoom, final double angle) {
if (!isNavigationDisabled && (zoom != 1 || angle != 0)) {
double x, y;
if (event != null) {
x = event.getX();
y = event.getY();
} else {
final Bounds bounds = floatingPane.getLayoutBounds();
x = bounds.getCenterX();
y = bounds.getCenterY();
try {
final Point2D p = transform.inverseTransform(x, y);
x = p.getX();
y = p.getY();
} catch (NonInvertibleTransformException e) {
/*
* `event` is null only when this method is invoked from `onKeyTyped(…)`.
* Keep old coordinates. The map may appear shifted, but its location will
* be fixed when `repaint()` completes its work.
*/
unexpectedException("onKeyTyped", e);
}
}
final AffineTransform2D interim = getInterimTransformForListeners();
if (zoom != 1) {
transform.appendScale(zoom, zoom, x, y);
}
if (angle != 0) {
transform.appendRotation(angle, x, y);
}
if (interim != null) {
fireInterimTransform(interim, null);
}
requestRepaint();
}
if (event != null) {
event.consume();
}
}
/**
* Invoked when the user presses a key. This handler provides navigation in the direction of arrow keys,
* or zoom-in / zoom-out with page-down / page-up keys. If the control key is down, navigation is finer.
*/
private void onKeyTyped(final KeyEvent event) {
if (isNavigationDisabled) {
event.consume();
return;
}
double tx = 0, ty = 0, zoom = 1, angle = 0;
if (event.isAltDown()) {
switch (event.getCode()) {
case RIGHT: case KP_RIGHT: angle = +7.5; break;
case LEFT: case KP_LEFT: angle = -7.5; break;
default: return;
}
} else {
switch (event.getCode()) {
case RIGHT: case KP_RIGHT: tx = -SCROLL_EVENT_SIZE; break;
case LEFT: case KP_LEFT: tx = +SCROLL_EVENT_SIZE; break;
case DOWN: case KP_DOWN: ty = -SCROLL_EVENT_SIZE; break;
case UP: case KP_UP: ty = +SCROLL_EVENT_SIZE; break;
case PAGE_UP: zoom = 1/(1 + ZOOM_FACTOR); break;
case PAGE_DOWN: zoom = (1 + ZOOM_FACTOR); break;
case HOME: reset(); break;
default: return;
}
}
if (event.isControlDown()) {
tx /= CONTROL_KEY_FACTOR;
ty /= CONTROL_KEY_FACTOR;
angle /= CONTROL_KEY_FACTOR;
zoom = (zoom - 1) / CONTROL_KEY_FACTOR + 1;
}
try {
final Point2D p = transform.inverseDeltaTransform(tx, ty);
tx = p.getX();
ty = p.getY();
} catch (NonInvertibleTransformException e) {
/*
* Should never happen. If happen anyway, keep old coordinates. The map may appear
* shifted, but its location will be fixed when `repaint()` completes its work.
*/
unexpectedException("onKeyTyped", e);
}
applyZoomOrRotate(null, zoom, angle);
applyTranslation(tx, ty, false);
event.consume();
}
/**
* Resets the map view to its default zoom level and default position with no rotation.
* Contrarily to {@link #clear()}, this method does not remove the map content.
*/
public void reset() {
invalidObjectiveToDisplay = true;
requestRepaint();
}
/**
* Disables or re-enable navigation. Navigation is disabled when an error occurred while
* rendering the image, and navigating is likely to cause the error to happen again.
*/
final void setNavigationDisabled(final boolean disabled) {
isNavigationDisabled = disabled;
if (disabled) isDragging = false;
floatingPane.setCursor(disabled ? Cursor.DEFAULT : Cursor.CROSSHAIR);
}
/**
* If a context menu is currently shown, hide that menu. Otherwise does nothing.
*/
private void hideContextMenu() {
if (menuShown != null) {
menuShown.hide();
menuShown = null;
}
}
/**
* Shows or hides the contextual menu when the right mouse button is clicked. This handler can determine
* the geographic location where the click occurred. This information is used for changing the projection
* while preserving approximately the location, scale and rotation of pixels around the mouse cursor.
*/
@SuppressWarnings({"serial","CloneableImplementsClone"}) // Not intended to be serialized.
final class MenuHandler extends DirectPosition2D
implements EventHandler<MouseEvent>, ChangeListener<ReferenceSystem>, PropertyChangeListener
{
/**
* The contextual menu to show or hide when mouse button is clicked on the canvas.
*/
private final ContextMenu menu;
/**
* The property to update if a change of CRS occurs in the enclosing canvas. This property is provided
* by {@link RecentReferenceSystems}, which listen to changes. Setting this property to a new value
* causes the "Referencing systems" radio menus to change the item where the check mark appear.
*
* <p>This field is initialized by {@link MapMenu#addReferenceSystems(RecentReferenceSystems)}
* and should be considered final after initialization.</p>
*/
ObjectProperty<ReferenceSystem> selectedCrsProperty;
/**
* The group of {@link PositionableProjection} items for projections created on-the-fly at mouse position.
* Those items are not managed by {@link RecentReferenceSystems} so they need to be handled there.
*
* <p>This field is initialized by {@link MapMenu#addReferenceSystems(RecentReferenceSystems)}
* and should be considered final after initialization.</p>
*/
ToggleGroup positionables;
/**
* {@code true} if we are in the process of setting a CRS generated by {@link PositionableProjection}.
*/
private boolean isPositionableProjection;
/**
* Creates and registers a new handler for showing a contextual menu in the enclosing canvas.
* It is caller responsibility to ensure that this method is invoked only once.
*/
@SuppressWarnings("ThisEscapedInObjectConstruction")
MenuHandler(final ContextMenu menu) {
super(getDisplayCRS());
this.menu = menu;
fixedPane.setOnMousePressed (this);
fixedPane.setOnMouseReleased(this); // As recommended by MouseEvent.isPopupTrigger().
}
/**
* Invoked when the user clicks on the canvas.
* Shows the menu on right mouse click, hide otherwise.
*/
@Override
public void handle(final MouseEvent event) {
if (event.isPopupTrigger()) {
hideContextMenu();
x = event.getX();
y = event.getY();
menu.show((Pane) event.getSource(), event.getScreenX(), event.getScreenY());
menuShown = menu;
event.consume();
}
}
/**
* Invoked when user selected a new coordinate reference system among the choices of predefined CRS.
* Those CRS are the ones managed by {@link RecentReferenceSystems}, not the ones created on-the-fly.
*/
@Override
public void changed(final ObservableValue<? extends ReferenceSystem> property,
final ReferenceSystem oldValue, final ReferenceSystem newValue)
{
if (newValue instanceof CoordinateReferenceSystem) {
setObjectiveCRS((CoordinateReferenceSystem) newValue, this, property);
}
}
/**
* Invoked when user selected a projection centered on mouse position. Those CRS are generated on-the-fly
* and are generally not on the list of CRS managed by {@link RecentReferenceSystems}.
*/
final void createProjectedCRS(final PositionableProjection projection) {
try {
DirectPosition2D center = new DirectPosition2D();
center = (DirectPosition2D) objectiveToDisplay.inverseTransform(this, center);
center.setCoordinateReferenceSystem(getObjectiveCRS());
CoordinateReferenceSystem crs = projection.createProjectedCRS(center);
try {
isPositionableProjection = true;
setObjectiveCRS(crs, this, null);
} finally {
isPositionableProjection = false;
}
} catch (Exception e) {
errorOccurred(e);
final Resources i18n = Resources.forLocale(getLocale());
ExceptionReporter.show(fixedPane, null, i18n.getString(Resources.Keys.CanNotUseRefSys_1, projection), e);
}
}
/**
* Invoked when a canvas property changed, typically after new data are shown.
* The property of interest is {@value MapCanvas#OBJECTIVE_CRS_PROPERTY}.
* This method updates the CRS selected in the contextual menu.
*/
@Override
public void propertyChange(final PropertyChangeEvent event) {
if (OBJECTIVE_CRS_PROPERTY.equals(event.getPropertyName())) {
final Object value = event.getNewValue();
if (value instanceof CoordinateReferenceSystem) {
selectedCrsProperty.set((CoordinateReferenceSystem) value);
}
if (!isPositionableProjection) {
positionables.selectToggle(null);
}
}
}
}
/**
* Invoked when the user changed the CRS from a JavaFX control. If the CRS cannot be set to the specified
* value, then an error message is shown in the status bar and the property is reset to its previous value.
*
* @param crs the new Coordinate Reference System in which to transform all data before displaying.
* @param anchor the point to keep at fixed display coordinates, or {@code null} for default value.
* @param property the property to reset if the operation fails.
*/
private void setObjectiveCRS(final CoordinateReferenceSystem crs, DirectPosition anchor,
final ObservableValue<? extends ReferenceSystem> property)
{
final CoordinateReferenceSystem previous = getObjectiveCRS();
if (crs != previous) try {
/*
* If no anchor is specified, the first default is the center of the region currently visible
* in the canvas. If that center cannot be determined neither, null anchor defaults to the
* point of interest (POI) managed by the Canvas parent class.
*/
if (anchor == null) {
final Envelope2D bounds = getDisplayBounds();
if (bounds != null) {
anchor = AbstractEnvelope.castOrCopy(bounds).getMedian();
}
}
setObjectiveCRS(crs, anchor);
requestRepaint();
} catch (Exception e) {
if (property instanceof WritableValue<?>) {
((WritableValue<ReferenceSystem>) property).setValue(previous);
}
errorOccurred(e);
final Locale locale = getLocale();
final Resources i18n = Resources.forLocale(locale);
ExceptionReporter.show(fixedPane, null, i18n.getString(Resources.Keys.CanNotUseRefSys_1,
IdentifiedObjects.getDisplayName(crs, locale)), e);
}
}
/**
* Returns the data bounds to use for computing the initial "objective to display" transform.
* This is the value specified by the last call to {@link #setObjectiveBounds(Envelope)}.
* The coordinate reference system of the returned envelope defines also the CRS which
* is restored when the {@link #reset()} method is invoked.
*
* @return the data bounds to use for computing the initial "objective to display" transform,
* or {@code null} if unspecified.
*
* @since 1.3
*/
public Envelope getObjectiveBounds() {
return objectiveBounds;
}
/**
* Sets the data bounds to use for computing the initial value of {@link #objectiveToDisplay}.
* Invoking this method also sets the initial {@linkplain #getObjectiveCRS() objective CRS}
* of this canvas to the CRS of given envelope.
*
* <p>This method should be invoked only when new data have been loaded, or when the caller wants
* to discard any zoom or translation and reset the view to the given bounds. This method does not
* cause new repaint event; {@link #requestRepaint()} must be invoked by the caller if desired.</p>
*
* @param visibleArea bounding box in (new) objective CRS of the initial area to show,
* or {@code null} if unknown (in which case an identity transform will be set).
* @throws MismatchedDimensionException if the given envelope is not two-dimensional.
*
* @see #setObjectiveCRS(CoordinateReferenceSystem, DirectPosition)
*/
public void setObjectiveBounds(final Envelope visibleArea) {
ArgumentChecks.ensureDimensionMatches("visibleArea", BIDIMENSIONAL, visibleArea);
objectiveBounds = ImmutableEnvelope.castOrCopy(visibleArea);
invalidObjectiveToDisplay = true;
}
/**
* Sets the conversion from objective CRS to display coordinate system.
* Invoking this method has the effect of changing the viewed area, the zoom level or the rotation of the map.
* Caller needs to invoke {@link #requestRepaint()} after this method call (this is not done automatically).
*
* @param newValue the new <cite>objective to display</cite> conversion.
* @throws IllegalArgumentException if given the transform does not have the expected number of dimensions or is not affine.
* @throws RenderException if the <cite>objective to display</cite> transform cannot be set to the given value for another reason.
*/
@Override
public void setObjectiveToDisplay(final LinearTransform newValue) throws RenderException {
super.setObjectiveToDisplay(newValue);
invalidObjectiveToDisplay = false;
}
/**
* Given axis directions in the objective CRS, returns axis directions in display CRS.
* This method will typically reverse the North direction to a South direction because
* <var>y</var> axis is oriented toward down. It may also swap axis order.
*
* <p>The rules implemented in this method are empirical and may be augmented in any future version.
* This method may become {@code protected} in a future version if we want to allow user to override
* with her own rules.</p>
*
* @param srcAxes axis directions in objective CRS.
* @return axis directions in display CRS.
*/
private static AxisDirection[] toDisplayDirections(final AxisDirection[] srcAxes) {
final AxisDirection[] dstAxes = Arrays.copyOf(srcAxes, 2);
if (AxisDirections.absolute(dstAxes[0]) == AxisDirection.NORTH &&
AxisDirections.absolute(dstAxes[1]) == AxisDirection.EAST)
{
ArraysExt.swap(dstAxes, 0, 1);
}
if (AxisDirections.absolute(dstAxes[0]) == AxisDirection.WEST) dstAxes[0] = AxisDirection.EAST;
if (AxisDirections.absolute(dstAxes[1]) == AxisDirection.NORTH) dstAxes[1] = AxisDirection.SOUTH;
return dstAxes;
}
/**
* Updates the <cite>objective to display</cite> transform with the given transform in objective coordinates.
* This method must be invoked in the JavaFX thread. The visual is updated immediately by transforming
* the current image, then a more accurate image is prepared in a background thread.
*
* <h4>Transform events</h4>
* This method fires immediately an {@value #OBJECTIVE_TO_DISPLAY_PROPERTY} event with
* {@link TransformChangeEvent.Reason#INTERIM}. This event does not yet reflect the state of the
* {@linkplain #getObjectiveToDisplay() objective to display} transform. At some arbitrary time in the future,
* another {@value #OBJECTIVE_TO_DISPLAY_PROPERTY} event will occur (still in JavaFX thread)
* with {@link TransformChangeEvent.Reason#DISPLAY_NAVIGATION} (really display, not objective).
* That event will consolidate all {@code INTERIM} events that happened since the last non-interim event.
*
* @param before coordinate conversion to apply before the current <cite>objective to display</cite> transform.
*
* @since 1.3
*/
@Override
public void transformObjectiveCoordinates(final AffineTransform before) {
if (!before.isIdentity()) try {
final AffineTransform2D interim = getInterimTransformForListeners();
AffineTransform t = objectiveToDisplay.createInverse();
t.preConcatenate(before);
t.preConcatenate(objectiveToDisplay);
transform.prepend(t.getScaleX(), t.getShearX(), t.getTranslateX(),
t.getShearY(), t.getScaleY(), t.getTranslateY());
if (interim != null) {
fireInterimTransform(interim, null);
}
requestRepaint();
} catch (NoninvertibleTransformException e) {
errorOccurred(e);
}
}
/**
* Updates the <cite>objective to display</cite> transform with the given transform in pixel coordinates.
* This method must be invoked in the JavaFX thread. The visual is updated immediately by transforming
* the current image, then a more accurate image is prepared in a background thread.
*
* <h4>Transform events</h4>
* This method fires immediately an {@value #OBJECTIVE_TO_DISPLAY_PROPERTY} event with
* {@link TransformChangeEvent.Reason#INTERIM}. This event does not yet reflect the state of the
* {@linkplain #getObjectiveToDisplay() objective to display} transform. At some arbitrary time in the future,
* another {@value #OBJECTIVE_TO_DISPLAY_PROPERTY} event will occur (still in JavaFX thread)
* with {@link TransformChangeEvent.Reason#DISPLAY_NAVIGATION}. That event will consolidate
* all {@code INTERIM} events that happened since the last non-interim event.
*
* @param after coordinate conversion to apply after the current <cite>objective to display</cite> transform.
*
* @since 1.3
*/
@Override
public void transformDisplayCoordinates(final AffineTransform after) {
if (!after.isIdentity()) {
final AffineTransform2D interim = getInterimTransformForListeners();
transform.append(after.getScaleX(), after.getShearX(), after.getTranslateX(),
after.getShearY(), after.getScaleY(), after.getTranslateY());
if (interim != null) {
fireInterimTransform(interim, after);
}
requestRepaint();
}
}
/**
* Fires a {@link TransformChangeEvent} for a change in the {@link #transform}.
* This method needs a modifiable {@code before} instance; it will be modified.
*
* @param before value of {@link #getInterimTransform(boolean)} before the change.
* @param change change in pixel coordinates, or {@code null} for lazy computation.
*/
private void fireInterimTransform(final AffineTransform2D before, final AffineTransform change) {
final AffineTransform2D after = getInterimTransform(true);
after .concatenate(objectiveToDisplay); after .freeze();
before.concatenate(objectiveToDisplay); before.freeze();
firePropertyChange(new TransformChangeEvent(this, before, after, null, change,
TransformChangeEvent.Reason.INTERIM));
}
/**
* Returns the {@linkplain #getInterimTransform(boolean) interim transform} if at least one listener
* is registered, or {@code null} otherwise. This method should be used with the following pattern:
*
* {@snippet lang="java" :
* AffineTransform2D interim = getInterimTransformForListeners();
* transform.something(…);
* if (interim != null) {
* fireInterimTransform(interim, change);
* }
* }
*
* @return a copy of {@link #transform} as a modifiable Java2D object, or {@code null} if not needed.
*/
private AffineTransform2D getInterimTransformForListeners() {
return hasPropertyChangeListener(OBJECTIVE_TO_DISPLAY_PROPERTY) ? getInterimTransform(true) : null;
}
/**
* Returns {@link #transform} as a Java2D affine transform. This is the change to append to
* {@link #objectiveToDisplay} for getting the transform that user currently see on screen.
* This is a temporary transform, for immediate feedback to user before the map is re-rendered.
*
* @param modifiable whether the returned transform should be modifiable.
* If true, then it is caller's responsibility to invoke {@link AffineTransform2D#freeze()}.
* @return a copy of {@link #transform} as a (potentially immutable) Java2D object.
*/
private AffineTransform2D getInterimTransform(final boolean modifiable) {
return new AffineTransform2D(transform.getMxx(), transform.getMyx(),
transform.getMxy(), transform.getMyy(),
transform.getTx(), transform.getTy(), modifiable);
}
/**
* Invoked in JavaFX thread for creating a renderer to be executed in a background thread.
* Subclasses shall copy in this method all {@code MapCanvas} properties that the background thread
* will need for performing the rendering process.
*
* @return rendering process to be executed in background thread,
* or {@code null} if there is nothing to paint.
*/
protected abstract Renderer createRenderer();
/**
* A snapshot of {@link MapCanvas} state to render as a map, together with rendering code.
* This class is instantiated and used as below:
*
* <ol>
* <li>{@link MapCanvas} invokes {@link MapCanvas#createRenderer()} in the JavaFX thread.
* That method shall take a snapshot of every information needed for performing the rendering
* in a background thread.</li>
* <li>{@link MapCanvas} invokes {@link #render()} in a background thread. That method creates or
* updates the nodes to show in the canvas but without reading or writing any canvas property;
* that method should use only the snapshot taken in step 1.</li>
* <li>{@link MapCanvas} invokes {@link #commit(MapCanvas)} in the JavaFX thread. The nodes prepared
* at step 2 can be transferred to {@link MapCanvas#floatingPane} in that method.</li>
* </ol>
*
* @author Martin Desruisseaux (Geomatys)
* @version 1.4
* @since 1.1
*/
protected abstract static class Renderer {
/**
* The canvas size.
*/
private int width, height;
/**
* Creates a new renderer. The {@linkplain #getWidth() width} and {@linkplain #getHeight() height}
* are initially zero; they will get a non-zero values before {@link #render()} is invoked.
*/
protected Renderer() {
}
/**
* Sets the width and height to the size of the given view,
* then returns {@code true} if the view is non-empty.
*
* <p>This method is invoked after {@link #createRenderer()}
* and before {@link #createWorker(Renderer)}.</p>
*/
private boolean initialize(final Pane view) {
width = Numerics.clamp(Math.round(view.getWidth()));
height = Numerics.clamp(Math.round(view.getHeight()));
return width > 0 && height > 0;
}
/**
* Returns the width (number of columns) of the view, in pixels.
*
* @return number of pixels to render horizontally.
*/
public int getWidth() {
return width;
}
/**
* Returns the height (number of rows) of the view, in pixels.
*
* @return number of pixels to render vertically.
*/
public int getHeight() {
return height;
}
/**
* Invoked in a background thread for rendering the map. This method should not access any
* {@link MapCanvas} property; if some canvas properties are needed, they should have been
* copied at construction time.
*
* @throws Exception if an error occurred while preparing data or rendering them.
*/
protected abstract void render() throws Exception;
/**
* Invoked in JavaFX thread after {@link #render()} completion. This method can update the
* {@link #floatingPane} children with the nodes (images, shaped, <i>etc.</i>) created by
* {@link #render()}.
*
* @param canvas the canvas where drawing has been done.
* @return {@code true} on success, or {@code false} if the rendering should be redone
* (for example because a change has been detected in the data).
*/
protected abstract boolean commit(MapCanvas canvas);
}
/**
* Returns {@code true} if content changed since the last {@link #repaint()} execution.
* This is used for checking if a new call to {@link #repaint()} is necessary.
*/
final boolean contentsChanged() {
return contentChangeCount != renderedContentStamp;
}
/**
* Requests the map to be rendered again, possibly with new data. Invoking this
* method does not necessarily causes the repaint process to start immediately.
* The request will be queued and executed at an arbitrary (short) time later.
*/
public final void requestRepaint() {
contentChangeCount++;
if (renderingInProgress == null && !isRendering.get()) {
final Delayed delay = new Delayed();
BackgroundThreads.execute(delay);
renderingInProgress = delay; // Set last after we know that the task has been scheduled.
}
}
/**
* Invoked when the map content needs to be rendered again.
* It may be because the map has new content, or because the viewed region moved or has been zoomed.
* This method starts the rendering process immediately, unless a rendering is already in progress.
*
* @see #requestRepaint()
*/
final void repaint() {
assert Platform.isFxApplicationThread();
/*
* If a rendering is already in progress, do not send a new request now.
* Wait for current rendering to finish; a new one will be automatically
* requested if content changes are detected after the rendering.
*/
if (renderingInProgress != null) {
if (renderingInProgress instanceof Delayed) {
renderingInProgress.cancel(true);
renderingInProgress = null;
} else {
contentChangeCount++;
return;
}
}
hasError = false;
isRendering.set(true); // Avoid that `requestRepaint(…)` trig new paints.
renderingStartTime = System.nanoTime();
try {
/*
* If a new canvas size is known, inform the parent `PlanarCanvas` about that.
* It may cause a recomputation of the "objective to display" transform.
*/
if (sizeChanged) {
sizeChanged = false;
final Pane view = floatingPane;
Envelope2D bounds = new Envelope2D(null, view.getLayoutX(), view.getLayoutY(), view.getWidth(), view.getHeight());
if (bounds.isEmpty()) return;
setDisplayBounds(bounds);
}
/*
* Compute the `objectiveToDisplay` only before the first rendering, because the display
* bounds may not be known before (it may be zero at the time `MapCanvas` is initialized).
* This code is executed only once for a new map.
*/
if (invalidObjectiveToDisplay) {
final Envelope2D target = getDisplayBounds();
if (target == null) {
// Bounds are still unknown. Another repaint event will happen when they will become known.
return;
}
invalidObjectiveToDisplay = false;
final GridExtent extent = new GridExtent(null,
new long[] {Math.round(target.getMinX()), Math.round(target.getMinY())},
new long[] {Math.round(target.getMaxX()), Math.round(target.getMaxY())}, false);
/*
* The main purpose of this block is to find the initial value of the `objectiveToDisplay` transform
* (named `crsToDisplay` here). If that value was explicitly specified by a call to `initialize(…)`,
* use it as-is. Otherwise we will compute it from the bounds of data.
*/
CoordinateReferenceSystem objectiveCRS;
LinearTransform crsToDisplay;
final GridGeometry init = initialState;
initialState = null; // For using `objectiveBounds` next times.
if (init != null && init.isDefined(GridGeometry.GRID_TO_CRS)) {
crsToDisplay = init.getLinearGridToCRS(PixelInCell.CELL_CORNER).inverse();
objectiveCRS = null; // Value will be fetched after the `else` block.
} else {
/*
* If `setObjectiveBounds(…)` has been invoked (as it should be), initialize the affine
* transform to values which will allow this canvas to contain fully the objective bounds.
* Otherwise the transform is initialized to an identity transform (should not happen often).
* If a CRS is present, it is used for deciding if we need to swap or flip axes.
*/
final Envelope objectiveBounds = getObjectiveBounds();
if (objectiveBounds != null) {
final MatrixSIS m;
objectiveCRS = objectiveBounds.getCoordinateReferenceSystem();
if (objectiveCRS != null) {
AxisDirection[] srcAxes = CoordinateSystems.getAxisDirections(objectiveCRS.getCoordinateSystem());
m = Matrices.createTransform(objectiveBounds, srcAxes, target, toDisplayDirections(srcAxes));
} else {
m = Matrices.createTransform(objectiveBounds, target);
}
Matrices.forceUniformScale(m, 0, new double[] {target.getCenterX(), target.getCenterY()});
crsToDisplay = MathTransforms.linear(m);
} else {
objectiveCRS = getDisplayCRS();
crsToDisplay = MathTransforms.identity(BIDIMENSIONAL);
}
}
if (objectiveCRS == null) {
if (init.isDefined(GridGeometry.CRS)) {
objectiveCRS = init.getCoordinateReferenceSystem();
} else {
objectiveCRS = extent.toEnvelope(crsToDisplay.inverse()).getCoordinateReferenceSystem();
}
/*
* Above code tried to provide a non-null CRS on a "best effort" basis. The objective CRS
* may still be null, there is no obvious answer against that. It is not the display CRS
* if the "display to objective" transform is not identity. A grid CRS is not appropriate
* neither, otherwise `extent.toEnvelope(…)` would have found it.
*/
}
setGridGeometry(new GridGeometry(extent, PixelInCell.CELL_CORNER, crsToDisplay.inverse(), objectiveCRS));
transform.setToIdentity();
}
} catch (TransformException | RenderException ex) {
restoreCursorAfterPaint();
isRendering.set(false);
errorOccurred(ex);
return;
}
/*
* If a temporary zoom, rotation or translation has been applied using JavaFX transform API,
* replace that temporary transform by a "permanent" adjustment of the `objectiveToDisplay`
* transform. It allows SIS to get new data for the new visible area and resolution.
* Do not reset `transform` to identity now; we need to continue accumulating gestures
* that may happen while the rendering is done in a background thread.
*/
changeInProgress.setToTransform(transform);
if (!transform.isIdentity()) {
super.transformDisplayCoordinates(getInterimTransform(false));
}
/*
* Invoke `createWorker(…)` only after we finished above configuration, because that method
* may take a snapshot of current canvas state in preparation for use in background threads.
* Take the value of `contentChangeCount` only now because above code may have indirect calls
* to `requestRepaint()`.
*/
renderedContentStamp = contentChangeCount;
final Renderer context = createRenderer();
if (context != null && context.initialize(floatingPane)) {
final RenderingTask<?> worker = createWorker(context);
assert renderingInProgress == null : renderingInProgress;
BackgroundThreads.execute(worker);
renderingInProgress = worker; // Set after we know that the task has been scheduled.
if (!isCursorChangeScheduled) {
DelayedExecutor.schedule(new CursorChange());
isCursorChangeScheduled = true;
}
} else {
if (!hasError) {
clearError();
}
isRendering.set(false);
restoreCursorAfterPaint();
}
}
/**
* Creates the background task which will invoke {@link Renderer#render()} in a background thread.
* The tasks must invoke {@link #renderingCompleted(RenderingTask)} in JavaFX thread after completion,
* either successful or not.
*
* <p><b>Note:</b> it is important that no other worker is in progress at the time this method is invoked
* ({@code assert renderingInProgress == null}), otherwise conflicts may happen when workers will update
* the {@code MapCanvas} fields after they completed their task.</p>
*/
RenderingTask<?> createWorker(final Renderer renderer) {
return new RenderingTask<Void>() {
/** Invoked in background thread. */
@Override protected Void call() throws Exception {
renderer.render();
return null;
}
/** Invoked in JavaFX thread on success. */
@Override protected void succeeded() {
final boolean done = renderer.commit(MapCanvas.this);
renderingCompleted(this);
if (!done || contentsChanged()) {
repaint();
}
}
/** Invoked in JavaFX thread on failure. */
@Override protected void failed() {renderingCompleted(this);}
@Override protected void cancelled() {renderingCompleted(this);}
};
}
/**
* Invoked after the background thread created by {@link #repaint()} finished to update map content.
* The {@link #changeInProgress} is the JavaFX transform at the time the repaint event was trigged and
* which is now integrated in the map. That transform will be removed from {@link #floatingPane} transforms.
* The {@link #transform} result is identity if no zoom, rotation or pan gesture has been applied since last
* rendering.
*
* <h4>Use case</h4>
* <p>Suppose that the {@link RenderingTask} has been started in response to some user gestures.
* For example, the user has zoomed on the map. The renderer has been initialized with a snapshot
* of this {@code MapCanvas} state at the time when the {@link Renderer} has been constructed.
* From this state, the renderer infers which data region to load at which resolution.</p>
*
* <p>Suppose that the rendering takes a long time, and during that time the user continues to do
* zoom and pan gestures. {@code MapCanvas} records those gestures in an {@link Affine} transform
* and will apply those changes on the image created by the <em>previous</em> rendering.
* For example if the user did a pan gesture of 100 pixels while the {@link Renderer} was working,
* then after the renderer finished to produce an image, that new image will also be translated by
* 100 pixels and a new call to {@link #repaint()} will happen later.</p>
*
* <p>Suppose that a zoom-in gesture caused the {@link Rendeder} to paint an image having a resolution
* twice finer than the resolution used in previous rendering. If the user does a translation of 100 pixels
* while this rendering is in progress, that "100 pixels" measurement is in units of the old rendering.
* It will need to be converted to 200 pixels after the rendering completed.</p>
*
* @param task the background task which has been completed, successfully or not.
*/
final void renderingCompleted(final RenderingTask<?> task) {
assert Platform.isFxApplicationThread();
assert renderingInProgress == task : "Expected " + renderingInProgress + " but was " + task;
// Keep cursor unchanged if contents changed, because caller will invoke `repaint()` again.
if (!contentsChanged() || task.getState() != Worker.State.SUCCEEDED) {
restoreCursorAfterPaint();
}
renderingInProgress = null;
/*
* Display coordinates stored in this `MapCanvas` need to be converted to the
* new display coordinates, as expected by the new "objective to display" CRS.
*/
final Point2D p = changeInProgress.transform(xPanStart, yPanStart);
xPanStart = p.getX();
yPanStart = p.getY();
try {
changeInProgress.invert();
transform.append(changeInProgress);
/*
* Note: intuitively one may expect `prepend(…)` instead of `append(…)` above.
* The use of `prepend(…)` would give a `transform` result which would be as if
* the transform was the identity transform at the time that rendering started,
* and all operations on it are gesture events that occurred while the renderer
* was working in background. But actually this is not quite correct.
* See the zoom-in discussion in "use case" section in method javadoc.
*/
} catch (NonInvertibleTransformException e) {
unexpectedException("repaint", e);
}
/*
* At this point the rendering is completed. If some error occurred, report them.
*/
isRendering.set(false);
final Throwable ex = task.getException();
if (ex != null) {
errorOccurred(ex);
} else if (!hasError) {
clearError();
}
/*
* Run user-specified task if any. `MapCanvas` does not use this mechanism for itself,
* but some subclasses need it. User is responsible for providing tasks that do not fail.
*/
final Runnable t = afterRendering;
if (t != null) try {
afterRendering = null;
t.run();
} catch (Exception e) {
// `runAfterRendering(…)` is the documented method providing this feature.
unexpectedException("runAfterRendering", e);
}
}
/**
* A pseudo-rendering task which wait for some delay before to perform the real repaint.
* The intent is to collect some more gesture events (pans, zooms, <i>etc.</i>) before consuming CPU time.
* This is especially useful when the first gesture event is a tiny change because the user just started
* panning or zooming.
*
* <h4>Design note:</h4>
* using a thread for waiting seems a waste of resources, but a thread (likely this one) is going to be used
* for real after the waiting time is elapsed. That thread usually exists anyway in {@link BackgroundThreads}
* as an idle thread, and it is unlikely that other parts of this JavaFX application need that thread in same
* time (if it happens, other threads will be created).
*
* @see #requestRepaint()
*/
private final class Delayed extends Task<Void> {
@Override protected Void call() {
try {
Thread.sleep(REPAINT_DELAY);
} catch (InterruptedException e) {
// Task.cancel(true) has been invoked: do nothing and terminate now.
}
return null;
}
@Override protected void succeeded() {paintAfterDelay();}
@Override protected void failed() {paintAfterDelay();}
// Do not override `cancelled()` because a repaint is already in progress.
}
/**
* Invoked after {@link #REPAINT_DELAY} has been elapsed for performing the real repaint request.
*
* @see #requestRepaint()
*/
private void paintAfterDelay() {
if (renderingInProgress instanceof Delayed) {
renderingInProgress = null;
repaint();
}
}
/**
* The action to execute if rendering appears to be slow. If the rendering did not completed
* after about one second, the mouse cursor shape will be set to the wait cursor. We do not
* do this change immediately because the mouse cursor changes become disturbing if applied
* continuously for a series of fast renderings.
*/
private final class CursorChange extends DelayedRunnable {
/**
* Value of {@link #renderingStartTime} when this delayed task has been created.
*/
private final long startTime;
/**
* Creates a new action to execute if rendering takes longer than
* {@link #WAIT_CURSOR_DELAY} nanoseconds.
*/
CursorChange() {
super(renderingStartTime + WAIT_CURSOR_DELAY);
startTime = renderingStartTime;
}
/**
* Invoked in a daemon thread after the delay elapsed.
* The mouse cursor change must be done in JavaFX thread.
*/
@Override public void run() {
Platform.runLater(() -> setWaitCursor(startTime));
}
}
/**
* Invoked in JavaFX thread {@link #WAIT_CURSOR_DELAY} nanoseconds after a rendering started.
* If the same rendering is still under progress, the mouse cursor is set to {@link Cursor#WAIT}.
* If a different rendering is in progress, do not set the cursor because the GUI was fast enough
* for the rendering just done but scheduled a new {@link CursorChange} in case the next rendering
* will be slow.
*/
private void setWaitCursor(final long startTime) {
isCursorChangeScheduled = false;
if (renderingInProgress != null) {
if (startTime == renderingStartTime) {
floatingPane.setCursor(Cursor.WAIT);
} else {
DelayedExecutor.schedule(new CursorChange());
isCursorChangeScheduled = true;
}
}
}
/**
* Returns a property telling whether a rendering is in progress. This property become {@code true}
* when this {@code MapCanvas} is about to start a background thread for performing a rendering, and
* is reset to {@code false} after this {@code MapCanvas} has been updated with new rendering result.
*
* @return a property telling whether a rendering is in progress.
*
* @see #runAfterRendering(Runnable)
*/
public final ReadOnlyBooleanProperty renderingProperty() {
return isRendering.getReadOnlyProperty();
}
/**
* Returns a property giving the exception or error that occurred during last rendering operation.
* The property value is reset to {@code null} when a rendering operation completed successfully.
*
* @return a property giving the exception or error that occurred during last rendering operation.
*/
public final ReadOnlyObjectProperty<Throwable> errorProperty() {
return error.getReadOnlyProperty();
}
/**
* Clears the error message in status bar.
*/
protected final void clearError() {
hasError = false;
error.set(null);
}
/**
* Sets the error property to the given value. This method is provided for subclasses that perform
* processing outside the {@link Renderer}. It does not need to be invoked if the error occurred
* during the rendering process.
*
* <p>If the error property already has a value, then the new error will be to the current error
* as a {@linkplain Throwable#addSuppressed(Throwable) suppressed exception}. The error property
* is cleared when a rendering operation completed successfully.</p>
*
* @param ex the exception that occurred (cannot be null).
*/
protected void errorOccurred(final Throwable ex) {
if (hasError) {
final Throwable current = error.get();
if (current != null) {
current.addSuppressed(ex);
return;
}
}
hasError = true;
error.set(Objects.requireNonNull(ex));
}
/**
* Invoked when an unexpected exception occurred but it is okay to continue despite it.
*/
private static void unexpectedException(final String method, final Exception e) {
Logging.unexpectedException(LOGGER, MapCanvas.class, method, e);
}
/**
* Registers a task to execute after the background thread finished its current rendering task.
* This method shall be invoked in JavaFX thread. If there is a {@linkplain #renderingProperty()
* rendering in progress} at the time this method is invoked, then the given task is queued for
* execution in JavaFX thread after the rendering finished.
* Otherwise the given task is executed immediately.
*
* <p>Exceptions are propagated if the given task has been executed immediately,
* or logged if execution has been deferred.</p>
*
* <p>This method is useful for subclasses when modifying the {@code MapCanvas} state during
* a rendering process may cause inconsistent state.</p>
*
* @param task the task to execute.
* @return {@code true} if the task has been executed immediately, or
* {@code false} if it has been queued for later execution.
*
* @see #renderingProperty()
* @see Platform#runLater(Runnable)
*
* @since 1.2
*/
protected boolean runAfterRendering(final Runnable task) {
ArgumentChecks.ensureNonNull("task", task);
assert Platform.isFxApplicationThread();
if (renderingInProgress == null || renderingInProgress instanceof Delayed) {
task.run();
return true;
}
final Runnable before = afterRendering;
if (before == null) {
afterRendering = task;
} else {
afterRendering = () -> {
try {
before.run();
} catch (Exception e) {
unexpectedException("runAfterRendering", e);
}
task.run();
};
}
return false;
}
/**
* Removes map content and clears all properties of this canvas.
*
* <h4>Usage</h4>
* Overriding methods in subclasses should invoke {@code super.clear()}.
* Other methods should generally not invoke this method directly,
* and use the following code instead:
*
* {@snippet lang="java" :
* runAfterRendering(this::clear);
* }
*
* @see #reset()
* @see #runAfterRendering(Runnable)
*/
protected void clear() {
assert Platform.isFxApplicationThread();
transform.setToIdentity();
changeInProgress.setToIdentity();
invalidObjectiveToDisplay = true;
initialState = null;
clearError();
isDragging = false;
isNavigationDisabled = false;
isRendering.set(false);
requestRepaint();
}
/**
* Returns a string representation of this canvas for debugging purposes.
* This string spans multiple lines.
*
* @return debug string (may change in any future version).
*
* @since 1.3
*/
@Override
public String toString() {
final Formatter buffer = new Formatter();
final double tx = transform.getTx();
final double ty = transform.getTy();
try {
final AffineTransform displayToObjective = objectiveToDisplay.createInverse();
java.awt.geom.Point2D p = new java.awt.geom.Point2D.Double(-tx, -ty);
p = displayToObjective.transform(p, p);
buffer.format("Upper-left corner: %+7.2f %+7.2f%n", p.getX(), p.getY());
} catch (NoninvertibleTransformException e) {
buffer.format("%s%n", e);
}
buffer.format("Pending translation: %+7.2f %+7.2f px%n", tx, ty);
return buffer.toString();
}
}