This is an automated email from the ASF dual-hosted git repository. desruisseaux pushed a commit to branch geoapi-4.0 in repository https://gitbox.apache.org/repos/asf/sis.git
commit b99960e228d5098f8031aeb1e941657c6a15738d Author: Martin Desruisseaux <[email protected]> AuthorDate: Wed Jun 15 11:21:54 2022 +0200 Show mouse cursor position in target canvas in addition of following zoom/translations/rotations. --- .../org/apache/sis/gui/map/GestureFollower.java | 204 ++++++++++++++ .../sis/internal/gui/control/SyncWindowList.java | 65 ++--- .../org/apache/sis/portrayal/CanvasFollower.java | 297 +++++++++++++++------ .../apache/sis/portrayal/TransformChangeEvent.java | 7 + 4 files changed, 443 insertions(+), 130 deletions(-) diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/GestureFollower.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/GestureFollower.java new file mode 100644 index 0000000000..776e3b2798 --- /dev/null +++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/GestureFollower.java @@ -0,0 +1,204 @@ +/* + * 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.awt.geom.Point2D; +import java.util.logging.Logger; +import javafx.scene.layout.Pane; +import javafx.scene.paint.Color; +import javafx.scene.shape.Path; +import javafx.scene.shape.MoveTo; +import javafx.scene.shape.HLineTo; +import javafx.scene.shape.VLineTo; +import javafx.scene.shape.PathElement; +import javafx.scene.effect.BlurType; +import javafx.scene.effect.DropShadow; +import javafx.event.EventType; +import javafx.event.EventHandler; +import javafx.scene.input.MouseEvent; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import org.opengis.referencing.operation.MathTransform2D; +import org.opengis.referencing.operation.TransformException; +import org.apache.sis.portrayal.CanvasFollower; +import org.apache.sis.internal.system.Modules; +import org.apache.sis.util.logging.Logging; + + +/** + * A listener of mouse or keyboard events in a source canvas which can be reproduced in a target canvas. + * This listener can reproduce the "real world" displacements documented in {@linkplain CanvasFollower parent class}. + * In addition, this class can also follow mouse movements in source canvas and move a cursor in the target canvas + * at the same "real world" position. + * + * <h2>Listeners</h2> + * {@code GestureFollower} listeners need to be registered explicitly by a call to the {@link #initialize()} method. + * The {@link #dispose()} convenience method is provided for unregistering all those listeners. + * + * <h2>Multi-threading</h2> + * This class is <strong>not</strong> thread-safe. + * All events should be processed in the JavaFX thread. + * + * @author Martin Desruisseaux (Geomatys) + * @version 1.3 + * @since 1.3 + * @module + */ +public class GestureFollower extends CanvasFollower implements EventHandler<MouseEvent> { + /** + * Distance from cursor origin (0,0) to the nearest (inner) or farthest (outer) point of the shape + * drawing the cursor. Fractional part should be .5 for locating the lines at pixel center. + */ + private static final double CURSOR_INNER_RADIUS = 7.5, CURSOR_OUTER_RADIUS = 20.5; + + /** + * The path elements that describes the shape of the cursor. + * The same elements are shared by all {@link #cursor} instances. + * The cursor center is at (0.5, 0.5), which maps to a pixel center in JavaFX coordinate system. + */ + private static final PathElement[] CURSOR_SHAPE = { + new MoveTo(0.5, -CURSOR_OUTER_RADIUS), new VLineTo(-CURSOR_INNER_RADIUS), + new MoveTo(0.5, +CURSOR_OUTER_RADIUS), new VLineTo(+CURSOR_INNER_RADIUS), + new MoveTo(-CURSOR_OUTER_RADIUS, 0.5), new HLineTo(-CURSOR_INNER_RADIUS), + new MoveTo(+CURSOR_OUTER_RADIUS, 0.5), new HLineTo(+CURSOR_INNER_RADIUS) + }; + + /** + * The effect applied on the cursor. The intend is to make it more visible if the cursor color + * is close to the color of features rendered on the map. + */ + private static final DropShadow CURSOR_EFFECT = new DropShadow(BlurType.ONE_PASS_BOX, Color.BLACK, 5, 0, 0, 0); + + /** + * Whether changes in the "objective to display" transforms should be propagated from source to target canvas. + * The default value is {@code false}; this property needs to be enabled explicitly by caller if desired. + */ + public final BooleanProperty transformEnabled; + + /** + * Whether mouse position in source canvas should be shown by a cursor in the target canvas. + * The default value is {@code false}; this property needs to be enabled explicitly by caller if desired. + */ + public final BooleanProperty cursorEnabled; + + /** + * Cursor position of the mouse over source canvas, expressed in coordinates of the target canvas. + */ + private final Point2D.Double cursorPosition; + + /** + * The shape used for drawing a cursor on the target canvas. Constructed when first requested. + * + * @see #followCursor(boolean) + */ + private Path cursor; + + /** + * Creates a new listener for synchronizing "objective to display" transform changes and cursor position + * between the specified canvas. This is a unidirectional binding: changes in source are applied on target, + * but not the converse. + * + * <p>Caller needs to register listeners by a call to the {@link #initialize()} method. + * This is not done automatically by this constructor for allowing users to control + * when to start listening to changes.</p> + * + * @param source the canvas which is the source of zoom, pan or rotation events. + * @param target the canvas on which to apply the changes of zoom, pan or rotation. + */ + public GestureFollower(final MapCanvas source, final MapCanvas target) { + super(source, target); + super.setDisabled(true); + cursorPosition = new Point2D.Double(); + transformEnabled = new SimpleBooleanProperty(this, "transformEnabled"); + cursorEnabled = new SimpleBooleanProperty(this, "cursorEnabled"); + transformEnabled.addListener((p,o,n) -> setDisabled(!n)); + cursorEnabled .addListener((p,o,n) -> followCursor(n)); + } + + /** + * Invoked when the {@link #cursorEnabled} property value changed. + * + * @param enabled the new property value. + */ + private void followCursor(final boolean enabled) { + final Pane pane = ((MapCanvas) source).floatingPane; + if (enabled) { + if (cursor == null) { + cursor = new Path(CURSOR_SHAPE); + cursor.setStroke(Color.LIGHTPINK); + cursor.setEffect(CURSOR_EFFECT); + cursor.setMouseTransparent(true); + cursor.setManaged(false); + cursor.setSmooth(false); + cursor.setCache(true); + } + (((MapCanvas) target).floatingPane).getChildren().add(cursor); + pane.addEventHandler(MouseEvent.MOUSE_ENTERED, this); + pane.addEventHandler(MouseEvent.MOUSE_EXITED, this); + pane.addEventHandler(MouseEvent.MOUSE_MOVED, this); + } else { + pane.removeEventHandler(MouseEvent.MOUSE_ENTERED, this); + pane.removeEventHandler(MouseEvent.MOUSE_EXITED, this); + pane.removeEventHandler(MouseEvent.MOUSE_MOVED, this); + if (cursor != null) { + (((MapCanvas) target).floatingPane).getChildren().remove(cursor); + } + } + } + + /** + * Invoked when the mouse position changed. This method should be invoked only if + * {@link #cursorEnabled} is {@code true} (this is not verified by this method). + * + * @param event the enter, exit or move event. + */ + @Override + public void handle(final MouseEvent event) { + final EventType<? extends MouseEvent> type = event.getEventType(); + if (type == MouseEvent.MOUSE_MOVED || type == MouseEvent.MOUSE_ENTERED) { + final MathTransform2D tr = getDisplayTransform().orElse(null); + if (tr != null) try { + cursorPosition.x = event.getX(); + cursorPosition.y = event.getY(); + final Point2D p = tr.transform(cursorPosition, cursorPosition); + cursor.setTranslateX(p.getX()); + cursor.setTranslateY(p.getY()); + if (type == MouseEvent.MOUSE_ENTERED) { + cursor.setVisible(true); + } + return; + } catch (TransformException e) { + Logging.recoverableException(Logger.getLogger(Modules.APPLICATION), GestureFollower.class, "handle", e); + // Handle as a mouse exit. + } + } else if (type != MouseEvent.MOUSE_EXITED) { + return; + } + cursor.setVisible(false); + } + + /** + * Removes all listeners registered by this {@code GestureFollower} instance. + * This method should be invoked when {@code GestureFollower} is no longer needed, + * in order to avoid memory leak. + */ + @Override + public void dispose() { + followCursor(false); + super.dispose(); + } +} diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/SyncWindowList.java b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/SyncWindowList.java index a544a6d542..1c8a824458 100644 --- a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/SyncWindowList.java +++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/SyncWindowList.java @@ -17,10 +17,6 @@ package org.apache.sis.internal.gui.control; import java.util.List; -import javafx.beans.property.BooleanProperty; -import javafx.beans.property.SimpleBooleanProperty; -import javafx.beans.value.ChangeListener; -import javafx.beans.value.ObservableValue; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; import javafx.scene.control.Button; @@ -34,7 +30,7 @@ import javafx.application.Platform; import org.apache.sis.util.resources.Vocabulary; import org.apache.sis.gui.dataset.WindowHandler; import org.apache.sis.gui.map.MapCanvas; -import org.apache.sis.portrayal.CanvasFollower; +import org.apache.sis.gui.map.GestureFollower; import org.apache.sis.internal.gui.Resources; import org.apache.sis.internal.util.UnmodifiableArrayList; @@ -53,12 +49,7 @@ public final class SyncWindowList extends TabularWidget implements ListChangeLis * Window containing a {@link MapCanvas} to follow on gesture events. * Gestures are followed only if {@link #linked} is {@code true}. */ - private static final class Link extends CanvasFollower implements ChangeListener<Boolean> { - /** - * Whether the "foreigner" {@linkplain #view} should be followed. - */ - public final BooleanProperty linked; - + private static final class Link extends GestureFollower { /** * The "foreigner" view for which to follow the gesture. */ @@ -71,50 +62,42 @@ public final class SyncWindowList extends TabularWidget implements ListChangeLis * @param source the canvas which is the source of zoom, pan or rotation events. * @param target the canvas on which to apply the changes of zoom, pan or rotation. */ - @SuppressWarnings("ThisEscapedInObjectConstruction") private Link(final WindowHandler view, final MapCanvas source, final MapCanvas target) { super(source, target); this.view = view; - linked = new SimpleBooleanProperty(this, "linked"); - linked.addListener(this); - source.addPropertyChangeListener(MapCanvas.OBJECTIVE_CRS_PROPERTY, this); - target.addPropertyChangeListener(MapCanvas.OBJECTIVE_CRS_PROPERTY, this); } /** * Converts the given list of handled to a list of table rows. * * @param added list of new items to put in the table. + * @param addTo where to add the converted items. * @param owner item to exclude (because the referenced window is itself). * @param target the canvas on which to apply the changes of zoom, pan or rotation. */ - static List<Link> wrap(final List<? extends WindowHandler> added, final WindowHandler owner, final MapCanvas target) { + static void wrap(final List<? extends WindowHandler> added, final List<Link> addTo, + final WindowHandler owner, final MapCanvas target) + { final Link[] items = new Link[added.size()]; int count = 0; - for (final WindowHandler view : added) { - if (view != owner) { - final MapCanvas source = view.getCanvas().orElse(null); - if (source != null) { - items[count++] = new Link(view, source, target); + try { + for (final WindowHandler view : added) { + if (view != owner) { + final MapCanvas source = view.getCanvas().orElse(null); + if (source != null) { + final Link item = new Link(view, source, target);; + items[count++] = item; // Add now for disposing if an exception is thrown. + item.initialize(); // Invoked outside constructor for allowing disposal. + item.cursorEnabled.set(true); + } } } - } - return UnmodifiableArrayList.wrap(items, 0, count); - } - - /** - * Invoked when the {@link #linked} property value changed. - * - * @param property equivalent to {@link #link}. - * @param oldValue equivalent to {@code !newValue}. - * @param newValue the new checkbox value. - */ - @Override - public void changed(ObservableValue<? extends Boolean> property, Boolean oldValue, Boolean newValue) { - if (newValue) { - source.addPropertyChangeListener(MapCanvas.OBJECTIVE_TO_DISPLAY_PROPERTY, this); - } else { - source.removePropertyChangeListener(MapCanvas.OBJECTIVE_TO_DISPLAY_PROPERTY, this); + addTo.addAll(UnmodifiableArrayList.wrap(items, 0, count)); + } catch (Throwable e) { + while (--count >= 0) { + items[--count].dispose(); // Remove listeners for avoiding memory leak. + } + throw e; } } } @@ -164,7 +147,7 @@ public final class SyncWindowList extends TabularWidget implements ListChangeLis * The first column contains a checkbox for choosing whether the window should be followed or not. */ table.getColumns().setAll( - newBooleanColumn(/* 🔗 (link symbol) */ "\uD83D\uDD17", (cell) -> cell.getValue().linked), + newBooleanColumn(/* 🔗 (link symbol) */ "\uD83D\uDD17", (cell) -> cell.getValue().transformEnabled), newStringColumn (vocabulary.getString(Vocabulary.Keys.Title), (cell) -> cell.getValue().view.title)); table.setRowFactory(SyncWindowList::newRow); /* @@ -264,6 +247,6 @@ public final class SyncWindowList extends TabularWidget implements ListChangeLis if (target == null) { target = owner.getCanvas().get(); } - table.getItems().addAll(Link.wrap(windows, owner, target)); + Link.wrap(windows, table.getItems(), owner, target); } } diff --git a/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/CanvasFollower.java b/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/CanvasFollower.java index 6ec1374a40..b7080ec8d9 100644 --- a/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/CanvasFollower.java +++ b/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/CanvasFollower.java @@ -16,6 +16,7 @@ */ package org.apache.sis.portrayal; +import java.util.Optional; import java.util.logging.Logger; import java.awt.geom.AffineTransform; import java.awt.geom.NoninvertibleTransformException; @@ -25,13 +26,16 @@ import org.opengis.util.FactoryException; import org.opengis.geometry.DirectPosition; import org.opengis.metadata.extent.GeographicBoundingBox; import org.opengis.referencing.operation.MathTransform; +import org.opengis.referencing.operation.MathTransform2D; import org.opengis.referencing.operation.TransformException; import org.opengis.referencing.crs.CoordinateReferenceSystem; import org.apache.sis.referencing.CRS; import org.apache.sis.util.Disposable; import org.apache.sis.util.ArgumentChecks; +import org.apache.sis.util.NullArgumentException; import org.apache.sis.util.logging.Logging; import org.apache.sis.internal.system.Modules; +import org.apache.sis.internal.referencing.j2d.AffineTransform2D; import org.apache.sis.referencing.operation.matrix.AffineTransforms2D; import org.apache.sis.referencing.operation.transform.MathTransforms; @@ -43,18 +47,13 @@ import org.apache.sis.referencing.operation.transform.MathTransforms; * account the differences in zoom levels and map projections. For example a translation of 10 pixels in one * canvas may map to a translation of 20 pixels in the other canvas for reproducing the same "real world" translation. * - * <h2>Listeners</h2> - * This class implements an unidirectional binding: changes in source are applied on target, but not the converse. - * {@code CanvasFollower} listener needs to be registered explicitly as below. This is not done automatically for - * allowing users to control when to listen to changes: - * - * {@preformat java - * source.addPropertyChangeListener(PlanarCanvas.OBJECTIVE_TO_DISPLAY_PROPERTY, this); - * source.addPropertyChangeListener(PlanarCanvas.OBJECTIVE_CRS_PROPERTY, this); - * target.addPropertyChangeListener(PlanarCanvas.OBJECTIVE_CRS_PROPERTY, this); - * } + * <p>This class implements an unidirectional binding: changes in source are applied on target, but not the converse. + * It is recommended to avoid bidirectional binding in current implementation + * (this limitation may be fixed in a future version).</p> * - * The {@link #dispose()} convenience method is provided for unregistering all the above. + * <h2>Listeners</h2> + * {@code CanvasFollower} listeners need to be registered explicitly by a call to the {@link #initialize()} method. + * The {@link #dispose()} convenience method is provided for unregistering all those listeners. * * <h2>Multi-threading</h2> * This class is <strong>not</strong> thread-safe. @@ -76,6 +75,17 @@ public class CanvasFollower implements PropertyChangeListener, Disposable { */ protected final PlanarCanvas target; + /** + * Whether listeners have been registered. + */ + private boolean initialized; + + /** + * Whether to disable the following of source canvas. Other events such as changes + * of objective CRS are still listened in order to update the fields of this class. + */ + private boolean disabled; + /** * Whether to follow the source canvas in "real world" coordinates. * If {@code false}, displacements will be followed in pixel coordinates instead. @@ -83,27 +93,43 @@ public class CanvasFollower implements PropertyChangeListener, Disposable { private boolean followRealWorld; /** - * The effective value of {@link #followRealWorld}. - * May be temporarily set to {@code false} if {@link #sourceToTarget} can not be computed. + * Conversions from source display coordinates to target display coordinates. + * Computed when first needed, and recomputed when the objective CRS or the + * "display to objective" transform change. + * + * @see #getDisplayTransform() */ - private boolean effectiveRealWorld; + private MathTransform2D displayTransform; /** - * The transform from a change in source canvas to a change in target canvas. + * Conversions from source objective coordinates to target objective coordinates. * Computed when first needed, and recomputed when the objective CRS changes. * A {@code null} value means that no change is needed or can not be done. * - * @see #findSourceToTarget() + * @see #findObjectiveTransform(String) + */ + private MathTransform objectiveTransform; + + /** + * Whether an attempt to compute {@link #displayTransform} has already been done. + * The {@code displayTransform} field may still be null if the attempt failed. + * Value can be {@link #VALID}, {@link #OUTDATED}, {@link #UNKNOWN} or {@link #ERROR}. */ - private MathTransform sourceToTarget; + private byte displayTransformStatus; /** - * Whether {@link #sourceToTarget} field is up to date. - * Note that the field can be up-to-date and {@code null}. + * Whether an attempt to compute {@link #objectiveTransform} has already been done. + * Note that the {@link #objectiveTransform} field can be up-to-date and {@code null}. + * Value can be {@link #VALID}, {@link #OUTDATED}, {@link #UNKNOWN} or {@link #ERROR}. * - * @see #findSourceToTarget() + * @see #findObjectiveTransform(String) */ - private boolean isTransformUpdated; + private byte objectiveTransformStatus; + + /** + * Enumeration values for {@link #displayTransformStatus} and {@link #objectiveTransformStatus}. + */ + private static final byte VALID = 0, OUTDATED = 1, UNKNOWN = 2, ERROR = 3; /** * Whether a change is in progress. This is for avoiding never-ending loop @@ -116,14 +142,9 @@ public class CanvasFollower implements PropertyChangeListener, Disposable { * between the specified canvas. This is a unidirectional binding: changes in source * are applied on target, but not the converse. * - * <p>Caller needs to register this listener explicitly as below - * (this is not done automatically by this constructor):</p> - * - * {@preformat java - * source.addPropertyChangeListener(PlanarCanvas.OBJECTIVE_TO_DISPLAY_PROPERTY, this); - * source.addPropertyChangeListener(PlanarCanvas.OBJECTIVE_CRS_PROPERTY, this); - * target.addPropertyChangeListener(PlanarCanvas.OBJECTIVE_CRS_PROPERTY, this); - * } + * <p>Caller needs to register listeners by a call to the {@link #initialize()} method. + * This is not done automatically by this constructor for allowing users to control + * when to start listening to changes.</p> * * @param source the canvas which is the source of zoom, pan or rotation events. * @param target the canvas on which to apply the changes of zoom, pan or rotation. @@ -133,7 +154,59 @@ public class CanvasFollower implements PropertyChangeListener, Disposable { ArgumentChecks.ensureNonNull("target", target); this.source = source; this.target = target; - effectiveRealWorld = followRealWorld = true; + followRealWorld = true; + displayTransformStatus = OUTDATED; + objectiveTransformStatus = OUTDATED; + } + + /** + * Registers all listeners needed by this object. This method must be invoked at least + * once after {@linkplain #CanvasFollower(PlanarCanvas, PlanarCanvas) construction}, + * but not necessarily immediately after (it is okay to defer until first needed). + * The default implementation registers the following listeners: + * + * {@preformat java + * source.addPropertyChangeListener(PlanarCanvas.OBJECTIVE_CRS_PROPERTY, this); + * target.addPropertyChangeListener(PlanarCanvas.OBJECTIVE_CRS_PROPERTY, this); + * source.addPropertyChangeListener(PlanarCanvas.OBJECTIVE_TO_DISPLAY_PROPERTY, this); + * target.addPropertyChangeListener(PlanarCanvas.OBJECTIVE_TO_DISPLAY_PROPERTY, this); + * } + * + * This method is idempotent (it is okay to invoke it twice). + * + * @see #dispose() + */ + public void initialize() { + if (!initialized) { + initialized = true; // Set first in case an exception is thrown below. + source.addPropertyChangeListener(PlanarCanvas.OBJECTIVE_CRS_PROPERTY, this); + target.addPropertyChangeListener(PlanarCanvas.OBJECTIVE_CRS_PROPERTY, this); + source.addPropertyChangeListener(PlanarCanvas.OBJECTIVE_TO_DISPLAY_PROPERTY, this); + target.addPropertyChangeListener(PlanarCanvas.OBJECTIVE_TO_DISPLAY_PROPERTY, this); + } + } + + /** + * Returns {@code true} if this object stopped to replicate changes from source canvas to target canvas. + * If {@code true}, this object continues to listen to changes in order to keep its state consistent, + * but does not replicate those changes on the target canvas. + * + * <p>A non-{@linkplain #initialize() initialized} object is considered disabled.</p> + * + * @return whether this object stopped to replicate changes from source canvas to target canvas. + */ + public boolean isDisabled() { + return disabled | !initialized; + } + + /** + * Sets whether to stop to replicate changes from source canvas to target canvas. + * It does not stop this object to listen to events, because it is necessary for keeping its state consistent. + * + * @param stop {@code true} for stopping to replicate changes from source canvas to target canvas. + */ + public void setDisabled(final boolean stop) { + disabled = stop; } /** @@ -159,10 +232,55 @@ public class CanvasFollower implements PropertyChangeListener, Disposable { */ public void setFollowRealWorld(final boolean real) { if (real != followRealWorld) { - effectiveRealWorld = followRealWorld = real; - isTransformUpdated = false; - sourceToTarget = null; + followRealWorld = real; + displayTransform = null; + objectiveTransform = null; + displayTransformStatus = OUTDATED; + objectiveTransformStatus = OUTDATED; + } + } + + /** + * Returns the transform from source display coordinates to target display coordinates. + * This transform may change every time that a zoom; translation or rotation is applied + * on at least one canvas. The transform may be absent if an error prevent to compute it, + * for example is no coordinate operation has been found between the objective CRS of the + * source and target canvases. + * + * @return transform from source display coordinates to target display coordinates. + */ + public Optional<MathTransform2D> getDisplayTransform() { + if (displayTransformStatus != VALID) { + if (displayTransformStatus != OUTDATED) { + return Optional.empty(); + } + displayTransformStatus = ERROR; // Set now in case an exception is thrown below. + if (objectiveTransformStatus == VALID || findObjectiveTransform("getDisplayTransform")) try { + /* + * Compute (source display to objective) → (map projection) → (target objective to display). + * If we can work directly on `AffineTransform` instances, it should be more efficient than + * the generic code. But the two `if … else` branches below compute the same thing + * (ignoring rounding errors). + */ + final MathTransform objectiveTransform = this.objectiveTransform; + if (objectiveTransform == null || objectiveTransform instanceof AffineTransform) { + AffineTransform tr = source.objectiveToDisplay.createInverse(); + if (objectiveTransform != null) { + tr.preConcatenate((AffineTransform) objectiveTransform); + } + tr.preConcatenate(target.objectiveToDisplay); + displayTransform = new AffineTransform2D(tr); + } else { + displayTransform = MathTransforms.bidimensional(MathTransforms.concatenate( + source.getObjectiveToDisplay().inverse(), objectiveTransform, + target.getObjectiveToDisplay())); + } + displayTransformStatus = VALID; + } catch (NoninvertibleTransformException | org.opengis.referencing.operation.NoninvertibleTransformException e) { + canNotCompute("getDisplayTransform", e); + } } + return Optional.ofNullable(displayTransform); } /** @@ -176,16 +294,29 @@ public class CanvasFollower implements PropertyChangeListener, Disposable { public void propertyChange(final PropertyChangeEvent event) { if (event instanceof TransformChangeEvent) { final TransformChangeEvent te = (TransformChangeEvent) event; - if (!changing && te.getReason().isNavigation()) try { + displayTransformStatus = OUTDATED; + if (!disabled && !changing && te.isSameSource(source) && te.getReason().isNavigation()) try { changing = true; - if (!isTransformUpdated) { - findSourceToTarget(); // May update the `effectiveRealWorld` field. - } - if (effectiveRealWorld) { - AffineTransform before = convertObjectiveChange(te.getObjectiveChange2D().orElse(null)); - if (before != null) { + if (followRealWorld && (objectiveTransformStatus == VALID || findObjectiveTransform("propertyChange"))) { + AffineTransform before = te.getObjectiveChange2D().orElse(null); + if (before != null) try { + /* + * Converts a change from units of the source CRS to units of the target CRS. + * If that change can not be computed, fallback on a change in display units. + * The POI may be null, but this is okay if the transform is linear. + */ + if (objectiveTransform != null) { + DirectPosition poi = target.getPointOfInterest(true); + AffineTransform t = AffineTransforms2D.castOrCopy(MathTransforms.linear(objectiveTransform, poi)); + AffineTransform c = t.createInverse(); + c.preConcatenate(before); + c.preConcatenate(t); + before = c; + } target.transformObjectiveCoordinates(before); return; + } catch (NullArgumentException | TransformException | NoninvertibleTransformException e) { + canNotCompute("propertyChange", e); } } te.getDisplayChange2D().ifPresent(target::transformDisplayCoordinates); @@ -193,21 +324,27 @@ public class CanvasFollower implements PropertyChangeListener, Disposable { changing = false; } } else if (PlanarCanvas.OBJECTIVE_CRS_PROPERTY.equals(event.getPropertyName())) { - isTransformUpdated = false; - sourceToTarget = null; + displayTransform = null; + objectiveTransform = null; + displayTransformStatus = OUTDATED; + objectiveTransformStatus = OUTDATED; } } /** * Finds the transform to use for converting changes from {@linkplain #source} canvas to {@linkplain #target} canvas. - * This method should be invoked only if {@link #isTransformUpdated} is {@code false}. After this method returned, - * {@link #sourceToTarget} contains the transform to use, which may be {@code null} if none. + * This method should be invoked only if {@link #objectiveTransformStatus} is not {@link #VALID}. After this method + * returned, {@link #objectiveTransform} contains the transform to use, which may be {@code null} if none. + * + * @param caller the public method which is invoked this private method. Used only for logging purposes. + * @return whether a transform has been computed. */ - private void findSourceToTarget() { - sourceToTarget = null; - effectiveRealWorld = false; - isTransformUpdated = true; // If an exception occurs, use above setting. - if (followRealWorld) { + private boolean findObjectiveTransform(final String caller) { + if (objectiveTransformStatus == OUTDATED) { + displayTransform = null; + objectiveTransform = null; + displayTransformStatus = OUTDATED; + objectiveTransformStatus = ERROR; // If an exception occurs, use above setting. final CoordinateReferenceSystem sourceCRS = source.getObjectiveCRS(); final CoordinateReferenceSystem targetCRS = target.getObjectiveCRS(); if (sourceCRS != null && targetCRS != null) try { @@ -215,68 +352,50 @@ public class CanvasFollower implements PropertyChangeListener, Disposable { try { aoi = target.getGeographicArea().orElse(null); } catch (RenderException e) { - canNotCompute(e); + canNotCompute(caller, e); aoi = null; } - sourceToTarget = CRS.findOperation(sourceCRS, targetCRS, aoi).getMathTransform(); - if (sourceToTarget.isIdentity()) { - sourceToTarget = null; + objectiveTransform = CRS.findOperation(sourceCRS, targetCRS, aoi).getMathTransform(); + if (objectiveTransform.isIdentity()) { + objectiveTransform = null; } - effectiveRealWorld = true; + objectiveTransformStatus = VALID; + return true; } catch (FactoryException e) { - canNotCompute(e); + canNotCompute(caller, e); // Stay with "changes in display units" mode. + } else { + objectiveTransformStatus = UNKNOWN; } } + return false; } /** - * Converts a change from units of the source CRS to units of the target CRS. - * If that change can not be computed, the caller will fallback on a change - * in display units (typically pixels). - * - * @param before the change in units of the {@linkplain #source} objective CRS. - * @return the same change but in units of the {@linkplain #target} objective CRS, - * or {@code null} if it can not be computed. - */ - private AffineTransform convertObjectiveChange(final AffineTransform before) { - if (sourceToTarget == null) { - return before; - } - if (before != null) { - final DirectPosition poi = target.getPointOfInterest(true); - if (poi != null) try { - AffineTransform t = AffineTransforms2D.castOrCopy(MathTransforms.linear(sourceToTarget, poi)); - AffineTransform c = t.createInverse(); - c.preConcatenate(before); - c.preConcatenate(t); - return c; - } catch (TransformException | NoninvertibleTransformException e) { - canNotCompute(e); - } - } - return null; - } - - /** - * Invoked when the {@link #sourceToTarget} transform can not be computed, + * Invoked when the {@link #objectiveTransform} transform can not be computed, * or when an optional information required for that transform is missing. * This method assumes that the public caller (possibly indirectly) is * {@link #propertyChange(PropertyChangeEvent)}. + * + * @param caller the public method which is invoked this private method. Used only for logging purposes. + * @param e the exception that occurred. */ - private static void canNotCompute(final Exception e) { - Logging.recoverableException(Logger.getLogger(Modules.PORTRAYAL), CanvasFollower.class, "propertyChange", e); + private static void canNotCompute(final String caller, final Exception e) { + Logging.recoverableException(Logger.getLogger(Modules.PORTRAYAL), CanvasFollower.class, caller, e); } /** - * Removes all listeners documented in {@linkplain CanvasFollower class javadoc}. - * This method should be invoked when {@code CanvasFollower} is no longer needed - * for avoiding memory leak. + * Removes all listeners documented in the {@link #initialize()} method. + * This method should be invoked when {@code CanvasFollower} is no longer needed, in order to avoid memory leak. + * + * @see #initialize() */ @Override public void dispose() { source.removePropertyChangeListener(PlanarCanvas.OBJECTIVE_TO_DISPLAY_PROPERTY, this); + target.removePropertyChangeListener(PlanarCanvas.OBJECTIVE_TO_DISPLAY_PROPERTY, this); source.removePropertyChangeListener(PlanarCanvas.OBJECTIVE_CRS_PROPERTY, this); target.removePropertyChangeListener(PlanarCanvas.OBJECTIVE_CRS_PROPERTY, this); + initialized = false; } } diff --git a/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/TransformChangeEvent.java b/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/TransformChangeEvent.java index 53ac2908f8..d4632fcce0 100644 --- a/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/TransformChangeEvent.java +++ b/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/TransformChangeEvent.java @@ -157,6 +157,13 @@ public class TransformChangeEvent extends PropertyChangeEvent { this.reason = reason; } + /** + * Quick and non-overrideable check about whether the specified source is the source of this event. + */ + final boolean isSameSource(final Canvas source) { + return super.getSource() == source; + } + /** * Returns the canvas on which this event initially occurred. *
