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 f583861e3dd183c67aec1c27aa5ef0343b5c4adc Author: Martin Desruisseaux <[email protected]> AuthorDate: Sun Apr 5 18:22:08 2026 +0200 Initial rewrite of the system for showing data in separated windows. The new `MapWindows` class is intended to replace `WindowManager`. This first draft can show many `MapCanvas` in a mosaic. --- .../sis/storage/base/URIDataStoreProvider.java | 3 + .../main/org/apache/sis/gui/DataViewer.java | 23 +- .../apache/sis/gui/coverage/CoverageCanvas.java | 40 +- .../org/apache/sis/gui/dataset/PathAction.java | 10 +- .../org/apache/sis/gui/dataset/ResourceCell.java | 122 ++-- .../apache/sis/gui/dataset/ResourceExplorer.java | 19 +- .../org/apache/sis/gui/dataset/ResourceTree.java | 23 +- .../apache/sis/gui/internal/BackgroundThreads.java | 2 + .../apache/sis/gui/internal/ExceptionReporter.java | 4 +- .../main/org/apache/sis/gui/internal/FontGIS.java | 2 +- .../org/apache/sis/gui/internal/GUIUtilities.java | 10 +- .../sis/gui/internal/ImmutableObjectProperty.java | 2 + .../org/apache/sis/gui/internal/Resources.java | 22 +- .../main/org/apache/sis/gui/map/MapCanvas.java | 100 +++- .../main/org/apache/sis/gui/map/MapCanvasAWT.java | 14 +- .../main/org/apache/sis/gui/map/MapMenu.java | 5 +- .../main/org/apache/sis/gui/map/MapWindows.java | 185 ++++++ .../main/org/apache/sis/gui/map/MultiCanvas.java | 644 +++++++++++++++++++++ .../main/org/apache/sis/gui/map/StatusBar.java | 32 +- .../org/apache/sis/gui/map/ValuesUnderCursor.java | 9 +- .../main/org/apache/sis/gui/package-info.java | 2 +- .../apache/sis/gui/coverage/CoverageCanvasApp.java | 27 +- .../test/org/apache/sis/gui/map/MapWindowsApp.java | 111 ++++ 23 files changed, 1252 insertions(+), 159 deletions(-) diff --git a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/URIDataStoreProvider.java b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/URIDataStoreProvider.java index c34d659644..dfb80acdef 100644 --- a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/URIDataStoreProvider.java +++ b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/URIDataStoreProvider.java @@ -151,6 +151,9 @@ public abstract class URIDataStoreProvider extends DataStoreProvider { * @throws DataStoreException if an error on the file system prevent the creation of the path. */ public static Object location(final Resource resource) throws DataStoreException { + if (resource == null) { + return null; + } if (resource instanceof DataStore) { final Optional<ParameterValueGroup> p = ((DataStore) resource).getOpenParameters(); if (p.isPresent()) try { diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/DataViewer.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/DataViewer.java index 8fd1d72eec..bfe2db2034 100644 --- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/DataViewer.java +++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/DataViewer.java @@ -49,6 +49,7 @@ import org.apache.sis.gui.internal.ExceptionReporter; import org.apache.sis.gui.internal.LogHandler; import org.apache.sis.gui.internal.Resources; import org.apache.sis.gui.internal.RecentChoices; +import org.apache.sis.gui.map.MapWindows; import org.apache.sis.storage.DataStoreProvider; import org.apache.sis.storage.DataStores; import org.apache.sis.storage.DataStore; @@ -65,7 +66,7 @@ import org.apache.sis.util.resources.Vocabulary; * * @author Smaniotto Enzo (GSoC) * @author Martin Desruisseaux (Geomatys) - * @version 1.3 + * @version 1.7 * @since 1.1 */ public class DataViewer extends Application { @@ -143,7 +144,7 @@ public class DataViewer extends Application { @Override public void start(final Stage window) { this.window = window; - content = new ResourceExplorer(); + content = new ResourceExplorer(new MapWindows(null)); final Resources localized = Resources.getInstance(); final Vocabulary vocabulary = Vocabulary.forLocale(localized.getLocale()); /* @@ -186,7 +187,7 @@ public class DataViewer extends Application { /* * Set the main content and show. */ - final BorderPane pane = new BorderPane(); + final var pane = new BorderPane(); pane.setTop(menus); pane.setCenter(content.getView()); final Scene scene = new Scene(pane); @@ -221,10 +222,10 @@ public class DataViewer extends Application { */ private void createFileFilters() { final Resources res = Resources.getInstance(); - final Set<String> suffixes = new LinkedHashSet<>(); - final Set<String> allSuffixes = new LinkedHashSet<>(); - final List<FileChooser.ExtensionFilter> open = new ArrayList<>(); - final List<FileChooser.ExtensionFilter> save = new ArrayList<>(); + final var suffixes = new LinkedHashSet<String>(); + final var allSuffixes = new LinkedHashSet<String>(); + final var open = new ArrayList<FileChooser.ExtensionFilter>(); + final var save = new ArrayList<FileChooser.ExtensionFilter>(); /* * Add an "All files (*.*)" filter only for the Open action. * The Save action will need to specify a specific filter. @@ -291,7 +292,7 @@ public class DataViewer extends Application { createFileFilters(); lastFilter = openFilters[1]; } - final FileChooser chooser = new FileChooser(); + final var chooser = new FileChooser(); chooser.setTitle(Resources.format(Resources.Keys.OpenDataFile)); chooser.getExtensionFilters().addAll(openFilters); chooser.setSelectedExtensionFilter(lastFilter); @@ -308,8 +309,8 @@ public class DataViewer extends Application { * Invoked when the user selects "File" ▶ "Open URL" menu. */ private void showOpenURLDialog() { - final TextInputDialog chooser = new TextInputDialog(); - final ListView<String> recents = new ListView<>(); + final var chooser = new TextInputDialog(); + final var recents = new ListView<String>(); RecentChoices.getURLs(recents.getItems()); recents.setPrefWidth (500); recents.setPrefHeight(200); @@ -323,7 +324,7 @@ public class DataViewer extends Application { chooser.showAndWait().ifPresent((choice) -> { try { final URI url = new URI(choice); - final Set<String> save = new LinkedHashSet<>(16); + final var save = new LinkedHashSet<String>(16); save.add(url.toString()); for (final String old : recents.getItems()) { save.add(old); diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/CoverageCanvas.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/CoverageCanvas.java index ba2b11181a..b7857cf7e7 100644 --- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/CoverageCanvas.java +++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/CoverageCanvas.java @@ -358,7 +358,7 @@ public class CoverageCanvas extends MapCanvasAWT { } /** - * Returns the source of coverages for this viewer. + * Returns the source of coverages for this viewer, or {@code null} if none. * This method, like all other methods in this class, shall be invoked from the JavaFX thread. * * @return the source of coverages shown in this viewer, or {@code null} if none. @@ -659,7 +659,7 @@ public class CoverageCanvas extends MapCanvasAWT { if (resource != null) resource.addListener (TileReadEvent.class, tileReadListener); } if (resource == null && coverage == null) { - runAfterRendering(this::clear); + clearLater(); } else if (controls != null && controls.isAdjustingSlice) { runAfterRendering(() -> { clearRenderedImage(); @@ -1229,21 +1229,18 @@ public class CoverageCanvas extends MapCanvasAWT { * Adjust the accuracy of coordinates shown in the status bar. * The number of fraction digits depend on the zoom factor. */ - if (controls != null) { - final Object value = resampledImage.getProperty(PlanarImage.POSITIONAL_ACCURACY_KEY); - Quantity<Length> accuracy = null; - if (value instanceof Quantity<?>[]) { - for (final Quantity<?> q : (Quantity<?>[]) value) { - if (Units.isLinear(q.getUnit())) { - accuracy = q.asType(Length.class); - accuracy = GUIUtilities.shorter(accuracy, accuracy.getUnit().getConverterTo(Units.METRE) - .convert(accuracy.getValue().doubleValue())); - break; - } + Quantity<Length> accuracy = null; + if (resampledImage.getProperty(PlanarImage.POSITIONAL_ACCURACY_KEY) instanceof Quantity<?>[] values) { + for (final Quantity<?> q : values) { + if (Units.isLinear(q.getUnit())) { + accuracy = q.asType(Length.class); + double m = accuracy.getUnit().getConverterTo(Units.METRE).convert(accuracy.getValue().doubleValue()); + accuracy = GUIUtilities.shorter(accuracy, m); + break; } } - controls.status.lowestAccuracy.set(accuracy); } + setPositionalAccuracy(accuracy); /* * If error(s) occurred during calls to `RenderedImage.getTile(tx, ty)`, reports those errors. * The `errorReport` field is reset to `null` in preparation for the next rendering operation. @@ -1469,18 +1466,13 @@ public class CoverageCanvas extends MapCanvasAWT { } /** - * Removes the image shown and releases memory. - * - * <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: + * Removes the image which was shown and releases memory. + * Invoking this method may help to release memory when the map is no longer shown. * - * {@snippet lang="java" : - * runAfterRendering(this::clear); - * } + * <p>Subclasses should override this method for cleaning their fields. + * Implementations in subclasses shall invoke {@code super.clear()}.</p> * - * @see #runAfterRendering(Runnable) + * @see #clearLater() */ @Override protected void clear() { diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/PathAction.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/PathAction.java index e9d5e10d9f..0255786d63 100644 --- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/PathAction.java +++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/PathAction.java @@ -66,16 +66,16 @@ final class PathAction implements EventHandler<ActionEvent> { } /** - * Whether the "Open containing folder" operation is disabled. + * Whether the "Open containing folder" operation is enabled. */ - private static final boolean isBrowseDisabled = !(Desktop.isDesktopSupported() && - Desktop.getDesktop().isSupported(Desktop.Action.BROWSE_FILE_DIR)); + static final boolean isBrowseEnabled = Desktop.isDesktopSupported() && + Desktop.getDesktop().isSupported(Desktop.Action.BROWSE_FILE_DIR); /** * Returns {@code true} if {@code PathAction} cannot handle the given path for browsing. */ - static boolean isBrowseDisabled(final Object path) { - return PathAction.isBrowseDisabled || IOUtilities.toPathOrNull(path) == null; + static boolean isBrowseEnabled(final Object path) { + return isBrowseEnabled && IOUtilities.toPathOrNull(path) != null; } /** diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/ResourceCell.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/ResourceCell.java index 5192d2e5ff..e9fc07f73c 100644 --- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/ResourceCell.java +++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/ResourceCell.java @@ -16,7 +16,7 @@ */ package org.apache.sis.gui.dataset; -import javafx.collections.ObservableList; +import java.util.ArrayList; import javafx.scene.control.Button; import javafx.scene.control.ContextMenu; import javafx.scene.control.MenuItem; @@ -49,10 +49,21 @@ import org.apache.sis.util.resources.Vocabulary; */ final class ResourceCell extends TreeCell<Resource> { /** - * Position of menu items in the contextual menu built by {@link #updateItem(Resource, boolean)}. - * Above method assumes that {@link #CLOSE} is the last menu item. + * Identifier of menu items in the contextual menu built by {@link #updateItem(Resource, boolean)}. */ - private static final int COPY_PATH = 0, OPEN_FOLDER = 1, AGGREGATED = 2, CLOSE = 3; + private static final String + VIEW_IN_MOSAIC = "VIEW_IN_MOSAIC", + OPEN_FOLDER = "OPEN_FOLDER", + COPY_PATH = "COPY_PATH", + AGGREGATED = "AGGREGATED", + CLOSE = "CLOSE"; + + /** + * Whether this cell is used for showing the node of a data store. + * Those nodes are direct children of the tree root node. + * This information is kept because the contextual menus are different. + */ + private boolean forRootResource; /** * Creates a new cell with initially no data. @@ -64,6 +75,13 @@ final class ResourceCell extends TreeCell<Resource> { ResourceCell(final TreeView<Resource> tree) { } + /** + * Returns the tree where this cell is shown. + */ + private ResourceTree getResourceTree() { + return (ResourceTree) getTreeView(); + } + /** * Invoked when a new resource needs to be shown in the tree view. * This method sets the text to a label that describes the resource, @@ -80,7 +98,7 @@ final class ResourceCell extends TreeCell<Resource> { Button more = null; ContextMenu menu = null; if (!empty && getTreeItem() instanceof ResourceItem item) { - final var tree = (ResourceTree) getTreeView(); + final ResourceTree tree = getResourceTree(); textProperty().bind(item.label); if (item.isLoading()) { /* @@ -109,47 +127,61 @@ final class ResourceCell extends TreeCell<Resource> { } } /* - * Following block is for the contextual menu. In current version, - * we provide menu only for "root" resources (usually data stores). + * Contextual menu. The menu items depend on whether the resource + * is a data store in the root, or a child resource of a data store. */ - if (tree.findOrRemove(resource, false) != null) { - /* - * "Copy file path" menu item should be enabled only if we can - * get some kind of file path or URI from the specified resource. - * "Aggregated view" should be enabled only on supported resources. - */ - Object path; - try { - path = URIDataStoreProvider.location(resource); - } catch (DataStoreException e) { - path = null; - ResourceTree.unexpectedException("updateItem", e); + final boolean isRootResource = tree.findOrRemove(resource, false) != null; + final boolean aggregatable = isRootResource && item.isViewSelectable(resource, TreeViewType.AGGREGATION); + Object path; + try { + path = URIDataStoreProvider.location(resource); + } catch (DataStoreException e) { + path = null; + ResourceTree.unexpectedException("updateItem", e); + } + /* + * Create (if not already done) and configure contextual menu using above information. + */ + menu = getContextMenu(); + if (menu == null || isRootResource != forRootResource) { + menu = new ContextMenu(); + final Resources localized = tree.localized(); + final var items = new ArrayList<MenuItem>(); + if (tree.windows != null) { + items.add(localized.menu(VIEW_IN_MOSAIC, Resources.Keys.View, (e) -> { + getResourceTree().windows.addResource(getItem()); + })); } - final boolean aggregatable = item.isViewSelectable(resource, TreeViewType.AGGREGATION); - /* - * Create (if not already done) and configure contextual menu using above information. - */ - menu = getContextMenu(); - if (menu == null) { - menu = new ContextMenu(); - final Resources localized = tree.localized(); - final MenuItem[] items = new MenuItem[CLOSE + 1]; - items[COPY_PATH] = localized.menu(Resources.Keys.CopyFilePath, new PathAction(this, false)); - items[OPEN_FOLDER] = localized.menu(Resources.Keys.OpenContainingFolder, new PathAction(this, true)); - items[AGGREGATED] = localized.menu(Resources.Keys.AggregatedView, false, (p,o,n) -> { + if (PathAction.isBrowseEnabled) { + items.add(localized.menu(OPEN_FOLDER, Resources.Keys.OpenContainingFolder, new PathAction(this, true))); + } + items.add(localized.menu(COPY_PATH, Resources.Keys.CopyFilePath, new PathAction(this, false))); + if (isRootResource) { + items.add(localized.menu(AGGREGATED, Resources.Keys.AggregatedView, false, (p,o,n) -> { setView(n ? TreeViewType.AGGREGATION : TreeViewType.SOURCE); - }); - items[CLOSE] = localized.menu(Resources.Keys.Close, (e) -> { - ((ResourceTree) getTreeView()).removeAndClose(getItem()); - }); - menu.getItems().setAll(items); + })); + items.add(localized.menu(CLOSE, Resources.Keys.Close, (e) -> { + getResourceTree().removeAndClose(getItem()); + })); + } + menu.getItems().setAll(items); + forRootResource = isRootResource; + } + for (final MenuItem m : menu.getItems()) { + final boolean enabled; + switch (m.getId()) { + default: continue; + case VIEW_IN_MOSAIC: enabled = getResourceTree().windows.isSupported(getItem()); break; + case COPY_PATH: enabled = IOUtilities.isKindOfPath(path); break; + case OPEN_FOLDER: enabled = PathAction.isBrowseEnabled(path); break; + case AGGREGATED: { + final var aggregated = (CheckMenuItem) m; + enabled = aggregatable; + aggregated.setSelected(aggregatable && item.isView(TreeViewType.AGGREGATION)); + break; + } } - final ObservableList<MenuItem> items = menu.getItems(); - items.get(COPY_PATH).setDisable(!IOUtilities.isKindOfPath(path)); - items.get(OPEN_FOLDER).setDisable(PathAction.isBrowseDisabled(path)); - final CheckMenuItem aggregated = (CheckMenuItem) items.get(AGGREGATED); - aggregated.setDisable(!aggregatable); - aggregated.setSelected(aggregatable && item.isView(TreeViewType.AGGREGATION)); + m.setDisable(!enabled); } } else { textProperty().unbind(); @@ -173,7 +205,7 @@ final class ResourceCell extends TreeCell<Resource> { Color color = Styles.NORMAL_TEXT; if (error != null) { color = Styles.ERROR_TEXT; - setGraphic(createErrorDetails((ResourceTree) getTreeView(), item, error)); + setGraphic(createErrorDetails(getResourceTree(), item, error)); } setTextFill(isSelected() ? Styles.SELECTED_TEXT : color); } @@ -183,7 +215,7 @@ final class ResourceCell extends TreeCell<Resource> { * Creates a button for providing details about an exception that occurred while loading the data. * This method also updates the label of the given item with a text that summarizes the error. * - * @param tree value of {@link #getTreeView()}. + * @param tree value of {@link #getResourceTree()}. * @param item the item in which the error occurred. * @param error the error that occurred. */ @@ -219,6 +251,6 @@ final class ResourceCell extends TreeCell<Resource> { * we can create an aggregated view of all components. */ private void setView(final TreeViewType type) { - ((ResourceItem) getTreeItem()).setView(this, type, ((ResourceTree) getTreeView()).locale); + ((ResourceItem) getTreeItem()).setView(this, type, getResourceTree().locale); } } diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/ResourceExplorer.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/ResourceExplorer.java index 0e3d3170fa..ed1c996477 100644 --- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/ResourceExplorer.java +++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/ResourceExplorer.java @@ -45,6 +45,8 @@ import org.apache.sis.storage.DataStore; import org.apache.sis.storage.DataStoreProvider; import org.apache.sis.storage.DataStoreException; import org.apache.sis.storage.tiling.TiledResource; +import org.apache.sis.util.collection.TreeTable; +import org.apache.sis.util.resources.Vocabulary; import org.apache.sis.gui.Widget; import org.apache.sis.gui.metadata.MetadataSummary; import org.apache.sis.gui.metadata.MetadataTree; @@ -52,11 +54,10 @@ import org.apache.sis.gui.metadata.StandardMetadataTree; import org.apache.sis.gui.coverage.ImageRequest; import org.apache.sis.gui.coverage.CoverageExplorer; import org.apache.sis.gui.coverage.TileMatrixSetPane; -import org.apache.sis.util.collection.TreeTable; -import org.apache.sis.util.resources.Vocabulary; import org.apache.sis.gui.internal.BackgroundThreads; import org.apache.sis.gui.internal.ExceptionReporter; import org.apache.sis.gui.internal.LogHandler; +import org.apache.sis.gui.map.MapWindows; /** @@ -182,11 +183,23 @@ public class ResourceExplorer extends Widget { */ @SuppressWarnings("this-escape") // `this` appears in a cyclic graph. public ResourceExplorer() { + this(null); + } + + /** + * Creates a new panel for exploring resources and viewing them in new windows. + * If {@code windows} is non-null, the contextual menu will contain a "Show in mosaic" menu item. + * + * @param windows manager of windows for map canvases, or {@code null} if none. + * @since 1.7 + */ + @SuppressWarnings("this-escape") // `this` appears in a cyclic graph. + public ResourceExplorer(final MapWindows windows) { /* * Build the controls on the left side, which will initially contain only the resource explorer. * The various tabs will be next (on the right side). */ - resources = new ResourceTree(); + resources = new ResourceTree(windows); resources.getSelectionModel().getSelectedItems().addListener(this::onResourceSelected); resources.setPrefWidth(400); final Vocabulary vocabulary = Vocabulary.forLocale(resources.locale); diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/ResourceTree.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/ResourceTree.java index 4bbe0a74e4..3d71b8e07d 100644 --- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/ResourceTree.java +++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/dataset/ResourceTree.java @@ -49,6 +49,7 @@ import org.apache.sis.gui.internal.ExceptionReporter; import org.apache.sis.gui.internal.Resources; import org.apache.sis.util.logging.Logging; import static org.apache.sis.gui.internal.LogHandler.LOGGER; +import org.apache.sis.gui.map.MapWindows; /** @@ -118,6 +119,14 @@ public class ResourceTree extends TreeView<Resource> { */ private final Queue<ResourceItem.Completer> pendingItems; + /** + * The manager of windows where to show the resources, or {@code null} if none. + * If non-null, the contextual menu will contain a "Show in mosaic" item. + * + * @since 1.7 + */ + protected final MapWindows windows; + /** * Creates a new tree of resources with initially no resource to show. * For showing a resource, invoke @@ -125,8 +134,19 @@ public class ResourceTree extends TreeView<Resource> { * {@link #addResource(Resource)} or * {@link #loadResource(Object)} after construction. */ - @SuppressWarnings("this-escape") // `this` appears in a cyclic graph. public ResourceTree() { + this(null); + } + + /** + * Creates a new tree of resources which can show the resources using the given manager of windows. + * If {@code windows} is non-null, the contextual menu will contain a "Show in mosaic" menu item. + * + * @param windows manager of windows for map canvases, or {@code null} if none. + * @since 1.7 + */ + @SuppressWarnings("this-escape") // `this` appears in a cyclic graph. + public ResourceTree(final MapWindows windows) { locale = Locale.getDefault(); pendingItems = new LinkedList<>(); setCellFactory(ResourceCell::new); @@ -134,6 +154,7 @@ public class ResourceTree extends TreeView<Resource> { setOnDragDropped(this::onDragDropped); onResourceLoaded = new SimpleObjectProperty<>(this, "onResourceLoaded"); onResourceClosed = new SimpleObjectProperty<>(this, "onResourceClosed"); + this.windows = windows; } /** diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/BackgroundThreads.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/BackgroundThreads.java index 32e4a18bb6..354941cca0 100644 --- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/BackgroundThreads.java +++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/BackgroundThreads.java @@ -25,6 +25,7 @@ import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; import java.util.concurrent.FutureTask; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.atomic.AtomicInteger; import javafx.application.Platform; import org.apache.sis.gui.DataViewer; @@ -97,6 +98,7 @@ public final class BackgroundThreads extends AtomicInteger implements ThreadFact * given task in that thread. * * @param task the task to execute. + * @throws RejectedExecutionException if the application is shutting down. */ public static void execute(final Runnable task) { if (Platform.isFxApplicationThread()) { diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/ExceptionReporter.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/ExceptionReporter.java index 94e10f7ffb..be2be4d25c 100644 --- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/ExceptionReporter.java +++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/ExceptionReporter.java @@ -104,7 +104,7 @@ public final class ExceptionReporter extends Widget { final Resources localized = Resources.getInstance(); final Menu sendTo = new Menu(localized.getString(Resources.Keys.SendTo)); sendTo.getItems().add(localized.menu(Resources.Keys.StandardErrorStream, this::printStackTrace)); - final ContextMenu menu = new ContextMenu(localized.menu(Resources.Keys.Copy, this::copy), sendTo); + final var menu = new ContextMenu(localized.menu(Resources.Keys.Copy, this::copy), sendTo); pane.setContextMenu(menu); final Label header = new Label(localized.getString(Resources.Keys.ErrorAt)); @@ -321,7 +321,7 @@ public final class ExceptionReporter extends Widget { pane.setPrefWidth(650); pane.setUserData(content); } else { - final ExceptionReporter content = (ExceptionReporter) alert.getDialogPane().getUserData(); + final var content = (ExceptionReporter) alert.getDialogPane().getUserData(); content.setException(exception); } if (title != null) alert.setTitle(title); diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/FontGIS.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/FontGIS.java index c937fad6b0..ac024dc940 100644 --- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/FontGIS.java +++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/FontGIS.java @@ -101,7 +101,7 @@ public final class FontGIS { } /** - * Creates a taggle button with a Font-GIS glyph for the given code. + * Creates a toggle button with a Font-<abbr>GIS</abbr> glyph for the given code. * The code should be one of the {@link Code} constants. * The font size and the padding are set to empirical values. * diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/GUIUtilities.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/GUIUtilities.java index 5398c28e2f..e401730f63 100644 --- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/GUIUtilities.java +++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/GUIUtilities.java @@ -121,7 +121,7 @@ public final class GUIUtilities { * @param pane the pane on which to set the clip. */ public static void setClipToBounds(final Pane pane) { - final Rectangle clip = new Rectangle(); + final var clip = new Rectangle(); clip.widthProperty() .bind(pane.widthProperty()); clip.heightProperty().bind(pane.heightProperty()); pane.setClip(clip); @@ -192,7 +192,7 @@ walk: for (final T search : path) { } /** - * Sets the selected value or {@code target} to the same item as the selected item of {@code source}. + * Sets the selected value of {@code target} to the same item as the selected item of {@code source}. * * @param <T> type of items. * @param source the control from which to copy the selected item. @@ -365,7 +365,7 @@ walk: for (final T search : path) { * Following loop is the "traceback" procedure: starting from last cell, follows * the direction where the length decrease. */ - final List<E> lcs = new ArrayList<>(lengths[nx][ny] + prefix.size() + suffix.size()); + final var lcs = new ArrayList<E>(lengths[nx][ny] + prefix.size() + suffix.size()); while (nx > 0 && ny > 0) { final int lg = lengths[nx][ny]; if (lengths[nx-1][ny] >= lg) { @@ -383,7 +383,7 @@ walk: for (final T search : path) { } /** - * Modify the quantity unit for showing a smaller value. + * Modifies the quantity unit for showing a smaller value. * * @param quantity the quantity to modify, or {@code null}. * @param m the quantity value in metres. @@ -434,6 +434,6 @@ walk: for (final T search : path) { * Converts a floating point value in the 0 … 1 range to an integer value in the 0 … 255 range. */ private static int toByte(final double value) { - return (int) Math.round(value * 255); + return (int) Math.rint(value * 255); } } diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/ImmutableObjectProperty.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/ImmutableObjectProperty.java index f5696d66b7..ce56c7fa58 100644 --- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/ImmutableObjectProperty.java +++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/ImmutableObjectProperty.java @@ -23,6 +23,8 @@ import javafx.beans.property.ReadOnlyObjectProperty; /** * A property for a value that never change. + * The {@code addListener(…)} methods in this class ignore listeners + * because these listeners will never be notified of any change. * * @author Martin Desruisseaux (Geomatys) * diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/Resources.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/Resources.java index 0d2fd76e96..d62c85744c 100644 --- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/Resources.java +++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/Resources.java @@ -581,21 +581,37 @@ public class Resources extends IndexedResourceBundle { * @return the menu item with the specified text and action. */ public MenuItem menu(final short key, final EventHandler<ActionEvent> onAction) { - final MenuItem item = new MenuItem(getString(key)); + final var item = new MenuItem(getString(key)); item.setOnAction(onAction); return item; } + /** + * Creates a new menu item with a localized text specified by the given key. + * + * @param id identifier for finding the menu item in a list, or {@code null} if not needed. + * @param key the key for the text of the menu item. + * @param onAction action to execute when the menu is selected. + * @return the menu item with the specified text and action. + */ + public MenuItem menu(final String id, final short key, final EventHandler<ActionEvent> onAction) { + final var item = menu(key, onAction); + item.setId(id); + return item; + } + /** * Creates a new check menu item with a localized text specified by the given key. * + * @param id identifier for finding the menu item in a list, or {@code null} if not needed. * @param key the key for the text of the menu item. * @param selected initial state of the check menu item. * @param onAction action to execute when the menu is selected or unselected. * @return the menu item with the specified text and action. */ - public CheckMenuItem menu(final short key, final boolean selected, final ChangeListener<Boolean> onAction) { - final CheckMenuItem item = new CheckMenuItem(getString(key)); + public CheckMenuItem menu(final String id, final short key, final boolean selected, final ChangeListener<Boolean> onAction) { + final var item = new CheckMenuItem(getString(key)); + item.setId(id); item.setSelected(selected); item.selectedProperty().addListener(onAction); return item; diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapCanvas.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapCanvas.java index a3e9114511..a54039f8ff 100644 --- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapCanvas.java +++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapCanvas.java @@ -51,6 +51,8 @@ import javafx.scene.control.ContextMenu; import javafx.scene.control.ToggleGroup; import javafx.scene.transform.Affine; import javafx.scene.transform.NonInvertibleTransformException; +import javax.measure.Quantity; +import javax.measure.quantity.Length; import org.opengis.geometry.Envelope; import org.opengis.geometry.DirectPosition; import org.opengis.referencing.ReferenceSystem; @@ -63,6 +65,9 @@ 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.internal.shared.AxisDirections; +import org.apache.sis.referencing.internal.shared.AffineTransform2D; +import org.apache.sis.measure.Quantities; import org.apache.sis.geometry.DirectPosition2D; import org.apache.sis.geometry.Envelope2D; import org.apache.sis.geometry.AbstractEnvelope; @@ -70,8 +75,6 @@ import org.apache.sis.geometry.ImmutableEnvelope; import org.apache.sis.coverage.grid.GridGeometry; import org.apache.sis.coverage.grid.GridExtent; import org.apache.sis.coverage.grid.PixelInCell; -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; @@ -83,8 +86,8 @@ import org.apache.sis.gui.internal.ExceptionReporter; import org.apache.sis.gui.internal.GUIUtilities; import org.apache.sis.gui.internal.MouseDrags; import org.apache.sis.gui.internal.Resources; -import org.apache.sis.referencing.internal.shared.AxisDirections; -import org.apache.sis.referencing.internal.shared.AffineTransform2D; +import org.apache.sis.gui.referencing.PositionableProjection; +import org.apache.sis.gui.referencing.RecentReferenceSystems; import org.apache.sis.portrayal.PlanarCanvas; import org.apache.sis.portrayal.RenderException; import org.apache.sis.portrayal.TransformChangeEvent; @@ -321,6 +324,14 @@ public abstract class MapCanvas extends PlanarCanvas { */ private final ReadOnlyBooleanWrapper isRendering; + /** + * An estimation of positional accuracy, typically in metres. + * + * @see #setPositionalAccuracy(Quantity) + * @see #positionalAccuracyProperty() + */ + private final ReadOnlyObjectWrapper<Length> positionalAccuracy; + /** * The exception or error that occurred during last rendering operation. * This is reset to {@code null} when a rendering operation completes successfully. @@ -386,6 +397,7 @@ public abstract class MapCanvas extends PlanarCanvas { fixedPane = new StackPane(view); GUIUtilities.setClipToBounds(fixedPane); isRendering = new ReadOnlyBooleanWrapper(this, "isRendering"); + positionalAccuracy = new ReadOnlyObjectWrapper<>(this, "positionalAccuracy"); error = new ReadOnlyObjectWrapper<>(this, "error"); } @@ -652,9 +664,23 @@ public abstract class MapCanvas extends PlanarCanvas { event.consume(); } + /** + * Removes map content and clears the properties of this canvas. + * Invoking this method may help to release memory when the map is no longer shown. + * The effect of the cleanup action may not be immediate, but may happen an arbitrary + * time after this method call. + * + * <p>For overriding this method in subclasses, see {@link #clear()}.</p> + * + * @since 1.7 + */ + public final void clearLater() { + runAfterRendering(this::clear); + } + /** * 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. + * Contrarily to {@link #clearLater()}, this method does not remove the map content. */ public void reset() { invalidObjectiveToDisplay = true; @@ -982,6 +1008,25 @@ public abstract class MapCanvas extends PlanarCanvas { } } + /** + * Sets an estimation of the positional accuracy of the pixels or features that are rendered on the map. + * This method sets the value returned by the read-only + * {@linkplain #positionalAccuracyProperty() positional accuracy property}. + * The value should be non-zero when the positions are computed by a + * {@linkplain org.opengis.referencing.operation.Transformation coordinate transformations}. + * + * <p>This method may be invoked in the {@code commit()} method + * of the renderer returned by {@link #createRenderer()}.</p> + * + * @param accuracy an estimation of positional accuracy, or {@code null} if none. + * + * @see #positionalAccuracyProperty() + * @since 1.7 + */ + protected void setPositionalAccuracy(final Quantity<Length> accuracy) { + positionalAccuracy.set(Quantities.castOrCopy(accuracy)); + } + /** * Fires a {@link TransformChangeEvent} for a change in the {@link #transform}. * This method needs a modifiable {@code before} instance; it will be modified. @@ -1459,13 +1504,16 @@ public abstract class MapCanvas extends PlanarCanvas { /** * Invoked after {@link #REPAINT_DELAY} has been elapsed for performing the real repaint request. + * This method must be invoked in the JavaFX event thread. * * @see #requestRepaint() */ private void paintAfterDelay() { if (renderingInProgress instanceof Delayed) { renderingInProgress = null; - repaint(); + if (!BackgroundThreads.EXECUTOR.isShutdown()) { + repaint(); + } } } @@ -1532,6 +1580,28 @@ public abstract class MapCanvas extends PlanarCanvas { return isRendering.getReadOnlyProperty(); } + /** + * Returns a property which gives an estimation of positional accuracy, typically in metres. + * The positions of pixels or features rendered on the map may have limited accuracy when the + * positions are computed by a + * {@linkplain org.opengis.referencing.operation.Transformation coordinate transformations}. + * The position may also be inaccurate because of approximation applied for faster rendering. + * The property value may be {@code null} if the accuracy is not specified. + * + * <p><b>Usage example</b></p> + * The {@link StatusBar#lowestAccuracy} property can bind to this property for reporting + * the positional accuracy on the status bar. This is done automatically by the + * {@link StatusBar#track(MapCanvas)} method. + * + * @return a property giving an estimation of positional accuracy. + * + * @see StatusBar#lowestAccuracy + * @since 1.7 + */ + public final ReadOnlyObjectProperty<Length> positionalAccuracyProperty() { + return positionalAccuracy.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. @@ -1626,19 +1696,14 @@ public abstract class MapCanvas extends PlanarCanvas { } /** - * Removes map content and clears all properties of this canvas. + * Removes map content and clears the properties of this canvas. + * This method should not be invoked directly by users. + * The public <abbr>API</abbr> is {@link #clearLater()}. * - * <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: + * <p>Subclasses should override this method for cleaning their fields. + * Implementations in subclasses shall invoke {@code super.clear()}.</p> * - * {@snippet lang="java" : - * runAfterRendering(this::clear); - * } - * - * @see #reset() - * @see #runAfterRendering(Runnable) + * @see #clearLater() */ protected void clear() { assert Platform.isFxApplicationThread(); @@ -1649,6 +1714,7 @@ public abstract class MapCanvas extends PlanarCanvas { clearError(); isDragging = false; isNavigationDisabled = false; + positionalAccuracy.set(null); isRendering.set(false); requestRepaint(); } diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapCanvasAWT.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapCanvasAWT.java index c5caa6417e..a3f7f002f4 100644 --- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapCanvasAWT.java +++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapCanvasAWT.java @@ -669,19 +669,13 @@ public abstract class MapCanvasAWT extends MapCanvas { } /** - * Clears the image and all intermediate buffer. + * Removes the image which was shown and releases intermediate buffers. * Invoking this method may help to release memory when the map is no longer shown. * - * <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: + * <p>Subclasses should override this method for cleaning their fields. + * Implementations in subclasses shall invoke {@code super.clear()}.</p> * - * {@snippet lang="java" : - * runAfterRendering(this::clear); - * } - * - * @see #runAfterRendering(Runnable) + * @see #clearLater() */ @Override protected void clear() { diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapMenu.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapMenu.java index 23b28cb580..2c7010f7e7 100644 --- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapMenu.java +++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapMenu.java @@ -45,9 +45,12 @@ import org.apache.sis.referencing.IdentifiedObjects; * * <ul> * <li>{@link #addReferenceSystems(RecentReferenceSystems)}:<ul> - * <li><i>Reference system</i> with some items from EPSG database.</li> + * <li><i>Reference system</i> with some items from the <abbr>EPSG</abbr> registry if available.</li> * <li><i>Centered projection</i> with the list of {@link PositionableProjection} items.</li> * </ul></li> + * <li>{@link #addCopyOptions(StatusBar)}:<ul> + * <li>Coordinates at the mouse position where right click occurred.</li> + * </ul></li> * </ul> * * More choices may be added in a future versions. diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapWindows.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapWindows.java new file mode 100644 index 0000000000..e6c7bbc5b9 --- /dev/null +++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MapWindows.java @@ -0,0 +1,185 @@ +/* + * 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.Map; +import java.util.LinkedHashMap; +import javafx.stage.Screen; +import javafx.stage.Window; +import javafx.stage.Stage; +import javafx.scene.Scene; +import javafx.geometry.Rectangle2D; +import org.apache.sis.storage.Resource; +import org.apache.sis.util.collection.Containers; +import org.apache.sis.util.collection.FrequencySortedSet; +import org.apache.sis.util.resources.Vocabulary; + + +/** + * A group of windows showing map canvases. + * Each window can show a mosaic of {@link MapCanvas} instances of the same size and a single {@link StatusBar}. + * User's navigation can optionally by synchronized so that panning or zooming in one map causes the same pan or + * zoom to be applied in the other maps. + * + * @author Martin Desruisseaux (Geomatys) + * @version 1.7 + * @since 1.7 + */ +public class MapWindows implements AutoCloseable { + /** + * The owner, or {@code null} if none. + */ + private final Window owner; + + /** + * All windows created by this {@code MapWindows} instance. + * Values may be {@code null} before the window is created. + * Entries are removed when windows are closed. + */ + private final Map<MultiCanvas, Stage> windows; + + /** + * Creates an initially empty group of windows. + * If {@code owner} is non-null, then the windows created by this class will always be on top of the owner + * and closing or minimizing {@code owner} will also close or minimize the windows created by this class. + * + * @param owner the parent stage, or {@code null} if none. + */ + public MapWindows(final Window owner) { + this.owner = owner; + windows = new LinkedHashMap<>(); + } + + /** + * Creates a new window for the specified {@code MultiCanvas}. + * This method shall be invoked at most once per {@code canvas}. + */ + private Stage newWindow(final MultiCanvas canvas) { + final Stage window = new Stage(); + if (owner != null) window.initOwner(owner); + window.setScene(new Scene(canvas.getView())); + /* + * We use an initial size covering a large fraction of the screen because + * this window is typically used for showing image or large tabular data. + */ + final Rectangle2D bounds = Screen.getPrimary().getVisualBounds(); + window.setWidth (0.8 * bounds.getWidth()); + window.setHeight(0.8 * bounds.getHeight()); + window.setOnHidden((event) -> { + windows.remove(canvas); + canvas.dispose(); + }); + return window; + } + + /** + * Returns whether the given resource is supported. + * + * @param resource the resource to show, or {@code null}. + * @return whether the given resource can be shown. + */ + public boolean isSupported(final Resource resource) { + return MultiCanvas.isSupported(resource); + } + + /** + * Tries to allocate a canvas for the given resource and to show it. + * The first time that this method is invoked, it will show a new window. + * On next invocations, the resources are added in the existing window. + * The same resource may be shown many times. Null resources are ignored. + * + * @param resource the resource to add, or {@code null}. + * @return whether the given resource has been accepted. + */ + public boolean addResource(final Resource resource) { + if (windows.isEmpty()) { + final var canvas = new MultiCanvas(); + canvas.addListener((c) -> updateWindowTitles()); + windows.put(canvas, null); + } + for (final Map.Entry<MultiCanvas, Stage> entry : windows.entrySet()) { + final MultiCanvas canvas = entry.getKey(); + if (canvas.addResource(resource)) { + Stage window = entry.getValue(); + if (window == null) { + window = newWindow(canvas); + entry.setValue(window); + } + window.show(); + window.toFront(); + return true; + } + } + return false; + } + + /** + * Recomputes the titles of all windows by using the title of one of the canvas. + * This method prefers titles that are not shared by canvases in different windows, + * in order to use a distinct title for each window. + */ + private void updateWindowTitles() { + final var allTitles = new FrequencySortedSet<String>(); + final var perWindow = new LinkedHashMap<Stage, FrequencySortedSet<String>>(); + for (final Map.Entry<MultiCanvas, Stage> entry : windows.entrySet()) { + FrequencySortedSet<String> titles = entry.getKey().getCanvasTitles(); + perWindow.put(entry.getValue(), titles); + allTitles.addAll(titles); + } + /* + * Iterate over the less frequently used titles first. In the common case where a + * title is used only once, this strategy gives a distinct title for each window. + */ + for (final String title : allTitles) { + int frequency = 0; + Stage window = null; // Window where to set the title. + for (final Map.Entry<Stage, FrequencySortedSet<String>> entry : perWindow.entrySet()) { + final FrequencySortedSet<String> titles = entry.getValue(); + final int n = titles.frequency(title); + if (n > frequency) { + frequency = n; + window = entry.getKey(); + } + } + if (window != null) { + window.setTitle(title.concat(" — Apache SIS")); + perWindow.remove(window); + } + } + /* + * If any window got no name, assigne a fallback name. + */ + for (final Map.Entry<Stage, FrequencySortedSet<String>> entry : perWindow.entrySet()) { + String title = Containers.peekFirst(entry.getValue()); + if (title == null) title = Vocabulary.format(Vocabulary.Keys.Unnamed); + entry.getKey().setTitle(title); + } + } + + /** + * Hides all windows and releases resources. Invoking this method is not strictly necessary + * (waiting for the garbage-collector is sufficient), but may help to release memory faster. + */ + @Override + public void close() { + for (final Map.Entry<MultiCanvas, Stage> entry : windows.entrySet()) { + entry.getKey().dispose(); + entry.getValue().close(); + } + windows.clear(); + } +} diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MultiCanvas.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MultiCanvas.java new file mode 100644 index 0000000000..ca9c820576 --- /dev/null +++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/MultiCanvas.java @@ -0,0 +1,644 @@ +/* + * 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.Collection; +import java.util.List; +import java.util.Map; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.NoSuchElementException; +import java.util.Objects; +import javafx.application.Platform; +import javafx.beans.InvalidationListener; +import javafx.beans.Observable; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.control.Button; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.Region; +import javafx.scene.layout.Priority; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.ConstraintsBase; +import javafx.scene.layout.RowConstraints; +import javafx.scene.layout.ColumnConstraints; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.geometry.Pos; +import javafx.scene.layout.Border; +import javafx.scene.paint.Color; +import org.apache.sis.gui.Widget; +import org.apache.sis.gui.coverage.CoverageCanvas; +import org.apache.sis.gui.internal.BackgroundThreads; +import org.apache.sis.gui.internal.DataStoreOpener; +import static org.apache.sis.gui.internal.LogHandler.LOGGER; +import org.apache.sis.gui.referencing.RecentReferenceSystems; +import org.apache.sis.storage.Resource; +import org.apache.sis.storage.GridCoverageResource; +import org.apache.sis.storage.DataStoreException; +import org.apache.sis.storage.event.CloseEvent; +import org.apache.sis.storage.event.StoreListener; +import org.apache.sis.util.ArraysExt; +import org.apache.sis.util.collection.FrequencySortedSet; +import org.apache.sis.util.logging.Logging; + + +/** + * A grid of {@link MapCanvas} instances shown side-by-side with the same visual size. + * User's navigation can optionally by synchronized so that panning or zooming + * in one map causes the same pan or zoom to be applied in the other maps. + * + * @author Martin Desruisseaux (Geomatys) + */ +final class MultiCanvas extends Widget implements Observable { + /** + * The border around each canvas. This border is applied only on {@link BorderPane}. + * Therefore, it does not appear when {@code MultiCanvas} is showing only one canvas, + * because the canvas view is shown directly (without {@link BorderPane}) in such case. + */ + private static final Border CANVAS_BORDER = Border.stroke(Color.LIGHTBLUE); + + /** + * Handles the {@link javafx.scene.control.ChoiceBox} and menu items for selecting a <abbr>CRS</abbr>. + */ + private final RecentReferenceSystems referenceSystems; + + /** + * The view where canvases are shown. All children of this pane shall be either {@link MapCanvas#fixedPane} + * values, or instances of {@link BorderPane} wrapping the above-cited values as their center component. + * + * @see #getCanvasView(Node) + * @see #addCanvasView(MapCanvas) + */ + private final GridPane canvasGrid; + + /** + * The constraint applied on all rows. Contains the height as a percentage. + */ + private final RowConstraints rowSize; + + /** + * The constraint applied on all columns. Contains the width as a percentage. + */ + private final ColumnConstraints colSize; + + /** + * The combination of canvases and status bar. + * The status bar shall be the last element. + */ + private final VBox view; + + /** + * All created canvases, visible or not, associated to their title and status bar. + * + * @see #addResource(Resource) + * @see #removeResource(Resource) + * @see #createOrReuseCanvas(Resource, Collection) + */ + private final Map<MapCanvas, Controls> canvasPool; + + /** + * Controls associated to each map canvas. + */ + private static final class Controls { + /** + * The title of the associated map canvas. This label is not necessarily shown. + * If the enclosing {@link MultiCanvas} contains only one {@link MapCanvas}, + * the text of this label may be shown in the window title instead. + */ + final Label title; + + /** + * The status bar of the associated map canvas. Only one status bar will be shown at any given time, + * but it is easier to nevertheless create a separated instance for each canvas for avoiding the need + * to unregister and register again numerous listeners every time that the mouse moves over a new canvas. + * Also because the warning message, coordinate format, and the set of sample dimensions to show depend + * on the {@link MapCanvas} content. + */ + final StatusBar status; + + /** + * Creates new controls for a new map canvas, then registers the listeners. + * Note that these listeners create cyclic references: most references are from {@code canvas} to {@code owner} + * (therefore could be garbage-collected), but there are also some references from {@link #referenceSystems} to + * {@linkplain #status} bar, which itself has a reference to the canvas, therefore preventing garbage collection. + * + * @param owner the enclosing {@code MultiCanvas}. + * @param canvas the new canvas. + */ + Controls(final MultiCanvas owner, final MapCanvas canvas) { + title = new Label(); + status = new StatusBar(owner.referenceSystems); + status.track(canvas); + final var menu = new MapMenu(canvas); + menu.addReferenceSystems(owner.referenceSystems); + menu.addCopyOptions(status); + getView(canvas).addEventHandler(MouseEvent.MOUSE_ENTERED, (event) -> owner.showStatusBar(canvas)); + } + } + + /** + * Whether the status bar is visible. If {@code true}, the status bar + * shall be the last element of the {@linkplain #view} children. + */ + private boolean isStatusBarVisible; + + /** + * The action to execute when any resource contained in this {@code MultiCanvas} is closed. + * This listener delegates to {@link #removeResource(Resource)}. + * + * @see #removeResource(Resource) + */ + private final OnClose closer; + + /** + * The listeners to notify when the state of this {@code MultiCanvas} changed. + * This is a copy-on-change array: a new array is created every time that a listener is added or removed. + * + * @see #addListener(InvalidationListener) + * @see #removeListener(InvalidationListener) + * @see #invalidate() + */ + private InvalidationListener[] listeners; + + /** + * Creates an initially empty grid of map canvases. + */ + public MultiCanvas() { + canvasPool = new LinkedHashMap<>(); + canvasGrid = new GridPane(); + VBox.setVgrow(canvasGrid, Priority.ALWAYS); + rowSize = new RowConstraints(); + colSize = new ColumnConstraints(); + rowSize.setVgrow(Priority.ALWAYS); + colSize.setHgrow(Priority.ALWAYS); + referenceSystems = new RecentReferenceSystems(); + referenceSystems.addUserPreferences(); + referenceSystems.addAlternatives("EPSG:4326", "EPSG:3395", "MGRS"); // WGS 84 / World Mercator + view = new VBox(canvasGrid); + closer = new OnClose(); + } + + /** + * Returns the encapsulated JavaFX component to add in a scene graph for making the canvases visible. + * The {@code Region} subclass is implementation dependent and may change in any future SIS version. + * + * @return the JavaFX component to insert in a scene graph. + */ + @Override + public Region getView() { + return view; + } + + /** + * Returns the JavaFX node to show for the given canvas. This method is defined + * for having a central place where this choice is made, for ensuring consistency. + */ + private static Region getView(final MapCanvas canvas) { + return canvas.fixedPane; + } + + /** + * Returns the view of the map canvas which is contained in the given node of the map canvas grid. + * The given {@code child} argument shall be one of the children of {@link #canvasGrid}. + * + * @param child an element of the {@code canvasGrid.getChildren()} list. + * @return the {@link MapCanvas#fixedPane} value in the given node. + * @throws ClassCastException if {@code child} does not comply with the conditions documented in {@link #canvasGrid}. + */ + private static Region getCanvasView(final Node child) { + return (Region) ((child instanceof BorderPane pane) ? pane.getCenter() : child); + } + + /** + * Returns the controls of a canvas when only its view is known. + * This method is inefficient, but is invoked in contextes where the map has only one element. + * + * @param canvasView the result of a call to {@link #getCanvasView(Node)}. + * @return the controls for the canvas having the given view. + * @throws NoSuchElementException if no canvas with the given view has been found. + */ + private Controls getControls(final Region canvasView) { + for (final Map.Entry<MapCanvas, Controls> entry : canvasPool.entrySet()) { + if (getView(entry.getKey()) == canvasView) { + return entry.getValue(); + } + } + throw new NoSuchElementException(); + } + + /** + * Adds a view for the given map canvas. If the given {@code canvas} is the only map canvas which is shown, + * then its view will be added directly. Otherwise, the {@code canvas} view will be wrapped as the center of + * a {@link BorderPane} with a title on top of it. In the latter case, this method may update previous child + * for adding also a title bar to them. + * + * <p>Callers should start a background thread for setting the text of the returned label to a map canvas title. + * It may be, for example, the label of the resource which is shown. That label will not necessarily be visible + * now (it depends how many canvases are shown). Its text may be shown in the window title bar instead.</p> + * + * @param canvas the canvas for which to add a view. + * @return the label on which the caller should set the map canvas title. + */ + @SuppressWarnings("fallthrough") + private Label addCanvasView(final MapCanvas canvas) { + final Controls controls = canvasPool.computeIfAbsent(canvas, (key) -> new Controls(this, key)); + Region canvasView = getView(canvas); + final List<Node> children = canvasGrid.getChildren(); + switch (children.size()) { + case 0: break; + case 1: var previous = (Region) children.removeLast(); + previous = addTitleBar(previous, getControls(previous).title); + children.add(previous); + // Fall through + default: canvasView = addTitleBar(canvasView, controls.title); + } + children.add(canvasView); + return controls.title; + } + + /** + * Removes the view of the given map canvas. + * Because of bidirectional references between {@link MapCanvas} and {@link MultiCanvas} through listeners, + * instead of trying to remove entries from the {@link #canvasPool} map, current implementation rather just + * hides unused canvases and may reuse them later. + * + * @param canvas the canvas for which to remove the view. + * @return whether the view has been found and removed. + */ + private boolean removeCanvasView(final MapCanvas canvas) { + boolean changed = false; + final Region canvasView = getView(canvas); + final List<Node> children = canvasGrid.getChildren(); + for (int i = children.size(); --i >= 0;) { + if (getCanvasView(children.get(i)) == canvasView) { + children.remove(i); + changed = true; + } + } + if (children.size() == 1) { + Node previous = children.removeLast(); + previous = getCanvasView(previous); + children.add(previous); // Hide the title bar and the border. + } + clear(canvas); + return changed; + } + + /** + * Removes the specified map canvas view. + * This method is invoked when the user clicks on the close button. + * The {@link MapCanvas} instance is kept in the pool for potential reuse. + * + * @param canvasView view of the canvas to close. + */ + private void removeCanvasView(final Region canvasView) { + boolean changed = false; + for (final MapCanvas canvas : canvasPool.keySet()) { + if (getView(canvas) == canvasView) { + changed |= removeCanvasView(canvas); + clear(canvas); + // Should have only one instance, but continue the loop by paranoia. + } + } + if (changed) { + layoutGrid(); + invalidate(); + } + } + + /** + * Wraps the given {@code MapCanvas} view into a pane with a title. + * + * @param canvasView the {@link MapCanvas} view for which to add a title bar. + * @return a wrapper of {@code canvasView} with the addition of a title bar. + */ + private BorderPane addTitleBar(final Region canvasView, final Label title) { + final var close = new Button("❌"); + close.setOnAction((event) -> removeCanvasView(canvasView)); + HBox.setHgrow(title, Priority.ALWAYS); + HBox.setHgrow(close, Priority.NEVER); + final var bar = new HBox(title, close); + bar.setAlignment(Pos.CENTER); + title.setAlignment(Pos.CENTER); + title.setMaxWidth(Double.MAX_VALUE); + final var pane = new BorderPane(canvasView); + pane.setBorder(CANVAS_BORDER); + pane.setTop(bar); + return pane; + } + + /** + * Returns whether the given resource is supported. + * This is currently a static method because {@link #createOrReuseCanvas(Resource, Collection)} is static. + * But this method would become public and non-static of {@code createOrReuseCanvas(…)} become public and + * non-static. + * + * @param resource the resource to show, or {@code null}. + * @return whether the given resource can be shown. + */ + static boolean isSupported(final Resource resource) { + return (resource instanceof GridCoverageResource); + } + + /** + * Returns a canvas showing the given resource, or {@code null} if none. + * If one of the canvases in the {@code available} collection is suitable, + * this method should configure it for showing the given resource. + * Otherwise, this method should create a new canvas. + * + * <p>There is currently no mechanism for removing a canvas because it is tedious to remove all listeners. + * However, {@code MultiCanvas} may hide a canvas by setting its resource to null and reuse that canvas later + * if a new resource is specified.</p> + * + * @param resource the resource to show. + * @param available previously created canvases that may be reused. + * @return a canvas showing the given resource, or {@code null} if none. + */ + private static MapCanvas createOrReuseCanvas(final Resource resource, final Collection<MapCanvas> available) { + if (resource instanceof GridCoverageResource c) { + for (final MapCanvas canvas : available) { + if (canvas instanceof CoverageCanvas cc) { + cc.setResource(c); + return cc; + } + } + final var canvas = new CoverageCanvas(); + canvas.setResource(c); + return canvas; + } + return null; + } + + /** + * Returns the resource shown by the given canvas, or {@code null} if none. + * Note: there is no {@code MapCanvas.getResource()} method because the base + * {@link MapCanvas} class is not about a single resource. + * It may be about a map context. + */ + private static Resource getResource(final MapCanvas canvas) { + if (canvas instanceof CoverageCanvas c) { + return c.getResource(); + } + return null; + } + + /** + * Tries to allocate a canvas for the given resource and to show it in this multi-canvas view. + * The grid is reorganized for accommodating the new canvas, + * potentially with the addition of a new row or a new column. + * The same resource may be shown many times. Null resources are ignored. + * + * @param resource the resource to add, or {@code null}. + * @return whether the given resource has been accepted. + */ + public boolean addResource(final Resource resource) { + if (resource == null) { + return false; + } + /* + * Get the collection of `MapCanvas` instances which are not currently used. + * These instances may be recycled for the given resource. + */ + final var available = LinkedHashMap.<Node, MapCanvas>newLinkedHashMap(canvasPool.size()); + canvasPool.keySet().forEach((canvas) -> available.put(getView(canvas), canvas)); + canvasGrid.getChildren().forEach((child) -> available.remove(getCanvasView(child))); + final MapCanvas canvas = createOrReuseCanvas(resource, available.values()); + if (canvas == null) { + return false; + } + final Locale locale = canvas.getLocale(); + final var title = addCanvasView(canvas); + BackgroundThreads.execute(() -> { + String label; + try { + label = DataStoreOpener.findLabel(resource, locale, true); + } catch (DataStoreException | RuntimeException e) { + Logging.recoverableException(LOGGER, MultiCanvas.class, "addResource", e); + label = DataStoreOpener.fallbackLabel(resource, locale); + } + final String text = label; // Because lambda functions want final variables. + Platform.runLater(() -> { + title.setText(text); + invalidate(); + }); + }); + resource.addListener(CloseEvent.class, closer); + layoutGrid(); + return true; + } + + /** + * Tries to remove all canvases associated to the given resource. + * The grid is reorganized for accommodating the remaining canvases, + * potentially with the removal of rows or columns. + * + * <p>This method is invoked automatically if the resource added with + * {@link #addResource(Resource)} fired a {@link CloseEvent}.</p> + * + * @param resource the resource to remove, or {@code null}. + * @return whether the given resource has been found and removed. + */ + public boolean removeResource(final Resource resource) { + boolean changed = false; + if (resource != null) { + resource.removeListener(CloseEvent.class, closer); + for (final MapCanvas canvas : canvasPool.keySet()) { + if (getResource(canvas) == resource) { + changed |= removeCanvasView(canvas); + } + } + if (changed) { + layoutGrid(); + invalidate(); + } + } + return changed; + } + + /** + * Sets the grid row index, column index and span on all map canvases. + * The grid size and the indexes of each child depend on the number of children. + */ + private void layoutGrid() { + final List<Node> children = canvasGrid.getChildren(); + final int size = children.size(); + if (size != 0) { + final int numRow = (int) Math.rint(Math.sqrt(size)); + final int numCol = Math.ceilDiv(size, numRow); + rowSize.setPercentHeight(resize(rowSize, canvasGrid.getRowConstraints(), numRow)); + colSize.setPercentWidth (resize(colSize, canvasGrid.getColumnConstraints(), numCol)); + int span = GridPane.REMAINING; // Allocated to the last canvas. + for (int i = size; --i >= 0;) { + final int row = i / numCol; + final int col = i % numCol; + GridPane.setConstraints(children.get(i), col, row, span, 1); + span = 1; + } + } + } + + /** + * Ensures that the given list has the expected size. + * Opportunistically computes the percentage to set for the column width or height. + * + * @param <C> {@link RowConstraints} or {@link ColumnConstraints}. + * @param toAdd the constraint to add if the list is too short. + * @param declared the current list of constraints. + * @param expected the expected size of the list. + * @return the percentage to set for the column width or row height. + */ + private static <C extends ConstraintsBase> double resize(final C toAdd, final List<C> declared, final int expected) { + final int size = declared.size(); + if (size > expected) { + declared.subList(expected, size).clear(); + } else if (size != expected) do { + declared.add(toAdd); + } while (declared.size() < expected); + return 100d / expected; + } + + /** + * Shows the status bar of the given canvas. + * This method is invoked when the mouse enter in a new canvas. + * + * @param canvas the canvas for which to show the status bar. + */ + private void showStatusBar(final MapCanvas canvas) { + final Controls controls = canvasPool.get(canvas); + if (controls != null) { + final Region status = controls.status.getView(); + final List<Node> children = view.getChildren(); + if (isStatusBarVisible) { + children.set(children.size() - 1, status); + } else { + children.add(status); + isStatusBarVisible = true; + } + } + } + + /** + * Returns the titles of all canvases shown in this {@code MultiCanvas}. + * If many canvases have the same title, the most frequently used title will be first. + */ + final FrequencySortedSet<String> getCanvasTitles() { + final var titles = new FrequencySortedSet<String>(true); + for (final Controls controls : canvasPool.values()) { + final String text = controls.title.getText(); + if (text != null) titles.add(text); + } + return titles; + } + + /** + * Adds a listener which will be notified when a map canvas is added or removed. + * The listener is also invoked for a change in the title of a map canvas, + * for example after completion of the background thread fetching the title. + * + * @param listener the listener to add. + */ + @Override + public void addListener(final InvalidationListener listener) { + Objects.requireNonNull(listener); + if (listeners == null) { + listeners = new InvalidationListener[] {listener}; + } else { + listeners = ArraysExt.append(listeners, listener); + } + } + + /** + * Removes a listener which is not longer interested to map canvas addition or removal. + * + * @param listener the listener to remove. + */ + @Override + public void removeListener(final InvalidationListener listener) { + Objects.requireNonNull(listener); + final InvalidationListener[] snapshot = listeners; + if (snapshot != null) { + for (int i=0; i<snapshot.length; i++) { + if (snapshot[i] == listener) { + listeners = ArraysExt.remove(snapshot, i, 1); + break; + } + } + } + } + + /** + * Notifies all listeners that a map canvas has been added or removed. + * May also be invoked if a title changed. + */ + protected void invalidate() { + if (listeners != null) { + for (final InvalidationListener listener : listeners) { + listener.invalidated(this); + } + } + } + + /** + * Clears the content of the given map canvas. + * It is better to remove the canvas from {@link #canvasGrid} before to invoke this method. + * + * @param canvas the map canvas to clear. + */ + private void clear(final MapCanvas canvas) { + final Resource resource = getResource(canvas); + if (resource != null) { + resource.removeListener(CloseEvent.class, closer); + } + canvas.clearLater(); + final Controls controls = canvasPool.get(canvas); // Should never be null, but let be safe. + if (controls != null) controls.title.setText(null); + } + + /** + * Releases resources in an attempt to help the garbage collector. + * This is invoked when the window containing this {@code MultiCanvas} is closed. + */ + final void dispose() { + canvasGrid.getChildren().clear(); + canvasPool.keySet().forEach(this::clear); + canvasPool.clear(); + } + + /** + * The action to execute when a resource is closed. + * A single instance of this listener can be shared by all resources. + */ + private final class OnClose implements StoreListener<CloseEvent> { + /** + * Invoked when a resource is closing. This method can be invoked from any thread, + * but delegation to {@link #removeResource(Resource)} is done in the JavaFX thread. + * This method blocks until the JavaFX thread finished to execute {@code removeResource(…)} + * for avoiding that the background thread closes the resource before we removed its usages. + */ + @Override public void eventOccured(final CloseEvent event) { + final Resource resource = event.getSource(); + if (Platform.isFxApplicationThread()) { + removeResource(resource); + } else BackgroundThreads.runAndWaitDialog(() -> { + removeResource(resource); + return null; + }); + } + } +} diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/StatusBar.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/StatusBar.java index 5032f31028..1336fc9505 100644 --- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/StatusBar.java +++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/StatusBar.java @@ -234,7 +234,7 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent> { * The transform from <i>objective CRS</i> to the CRS of coordinates shown in this status bar. * The {@linkplain CoordinateOperation#getSourceCRS() source CRS} is {@link #objectiveCRS} and * the {@linkplain CoordinateOperation#getTargetCRS() target CRS} is {@link CoordinateFormat#getDefaultCRS()}. - * This transform may be null if there is no CRS change to apply + * This transform may be null if there is no <abbr>CRS</abbr> change to apply * (in which case {@link #localToPositionCRS} is the same instance as {@link #localToObjectiveCRS}) * or if the target is not a CRS (for example it may be a Military Grid Reference System (MGRS) code). * @@ -393,6 +393,7 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent> { * Note that the "± <var>accuracy</var>" text may be shown or hidden depending on the zoom level. * If pixels on screen are larger than the accuracy, then the accuracy text is hidden.</p> * + * @see MapCanvas#positionalAccuracyProperty() * @see CoordinateFormat#setGroundAccuracy(Quantity) * * @since 1.3 @@ -497,11 +498,11 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent> { * Note that in such case, the {@link #localToObjectiveCRS} property value will be overwritten * at any time (for example every time that a gesture event such as pan, zoom or rotation happens). * - * <p>If the {@code systemChooser} argument is non-null, user will be able to select different CRS - * using the contextual menu on the status bar.</p> + * <p>If the {@code systemChooser} argument is non-null, user will be able to select different + * reference systems using the contextual menu on the status bar.</p> * * <h4>Limitations</h4> - * This constructor registers numerous listeners on {@code canvas} and {@code systemChooser}. + * This constructor registers numerous listeners on {@code systemChooser}. * There is currently no unregistration mechanism. The {@code StatusBar} instance is expected * to exist as long as the {@code MapCanvas} and {@code RecentReferenceSystems} instances * given to this constructor. @@ -534,10 +535,10 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent> { view.setPadding(PADDING); view.setAlignment(Pos.CENTER_RIGHT); /* - * Contextual menu can be invoked anywhere on the HBox; we do not register this menu + * Contextual menu can be invoked anywhere on the HBox. We do not register this menu * on `position` or `sampleValues` labels because those regions are relatively small. */ - final ContextMenu menu = new ContextMenu(); + final var menu = new ContextMenu(); view.setOnMousePressed((event) -> { if (event.isSecondaryButtonDown() && !menu.getItems().isEmpty()) { menu.show((HBox) event.getSource(), event.getScreenX(), event.getScreenY()); @@ -591,11 +592,12 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent> { * a contextual menu item to be added or removed. */ final ObservableList<MenuItem> items = menu.getItems(); + final int i = items.size(); // Index where to insert `valueChoices`. sampleValuesProvider = new SimpleObjectProperty<>(this, "valueProvider"); sampleValuesProvider.addListener((p,o,n) -> { ValuesUnderCursor.update(this, o, n); if (o != null) items.remove(o.valueChoices); - if (n != null) items.add(1, n.valueChoices); + if (n != null) items.add(i, n.valueChoices); setSampleValuesVisible(n != null); }); } @@ -607,17 +609,18 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent> { * value may be overwritten at any time, for example after each gesture event such as pan, zoom or rotation. * * <h4>Limitations</h4> - * Current implementation accepts only zero or one {@code MapCanvas}. A future implementation - * may accept a larger number of canvas for tracking many views with a single status bar - * (for example images over the same area but at different times). + * This constructor registers numerous listeners on {@code canvas}. + * There is currently no unregistration mechanism. + * This method can be invoked only once. * * @param canvas the canvas that this status bar is tracking. + * @throws IllegalStateException if this method has already been invoked with another canvas. * * @since 1.3 */ public void track(final MapCanvas canvas) { if (this.canvas != null) { - throw new IllegalArgumentException(Errors.format( + throw new IllegalStateException(Errors.format( Errors.Keys.TooManyCollectionElements_3, "canvas", 1, 2)); } /* @@ -625,7 +628,8 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent> { * We do not allow the canvas to be changed after construction because of the added complexity * (e.g. we would have to remember all registered listeners so we can unregister them). */ - this.canvas = Objects.requireNonNull(canvas); + this.canvas = canvas; + lowestAccuracy.bind(canvas.positionalAccuracyProperty()); sampleValuesProvider.set(ValuesUnderCursor.create(canvas)); canvas.errorProperty().addListener((p,o,n) -> setRenderingError(n)); canvas.renderingProperty().addListener((p,o,n) -> { @@ -761,7 +765,7 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent> { /** * Implementation of {@link #applyCanvasGeometry(GridGeometry)} without changing {@link #position} visibility state. - * Invoking this method usually invalidate the coordinates shown in this status bar. The new coordinates cannot be + * Invoking this method usually invalidates the coordinates shown in this status bar. The new coordinates cannot be * easily recomputed because the {@link #lastX} and {@link #lastY} values may not be valid anymore, as a result of * possible changes in JavaFX local coordinate system. Consequently, the coordinates should be temporarily hidden * until a new {@link MouseEvent} gives us the new local coordinates, unless this method is invoked in a context @@ -1447,7 +1451,7 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent> { } /** - * Converts and formats the given local coordinates, but without modifying text shown in this status bar. + * Converts and formats the given local coordinates, but without modifying the text shown in this status bar. * This is used for copying the coordinates somewhere else, for example on the clipboard. * * @param x the <var>x</var> coordinate local to the view. diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/ValuesUnderCursor.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/ValuesUnderCursor.java index 3909876435..ace282bc0f 100644 --- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/ValuesUnderCursor.java +++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/map/ValuesUnderCursor.java @@ -17,7 +17,6 @@ package org.apache.sis.gui.map; import java.util.concurrent.atomic.AtomicReference; -import javafx.beans.value.WeakChangeListener; import javafx.scene.control.Menu; import javafx.application.Platform; import org.opengis.geometry.DirectPosition; @@ -161,7 +160,7 @@ public abstract class ValuesUnderCursor { */ protected abstract static class Formatter implements Runnable { /** - * Coordinates and CRS of the position where to evaluate values. + * Coordinates and <abbr>CRS</abbr> of the position where to evaluate values. * This position shall not be modified; new coordinates shall be specified in a new instance. * A {@code null} value means that there is no more sample values to format. * @@ -319,7 +318,7 @@ public abstract class ValuesUnderCursor { } /** - * Creates a new instance for the given canvas and registers as a listener by weak reference. + * Creates a new instance for the given canvas and registers as a listener. * Caller must retain the returned reference somewhere, e.g. in {@link StatusBar#sampleValuesProvider}. * * @param canvas the canvas for which to create a {@link ValuesUnderCursor}, or {@code null}. @@ -327,8 +326,8 @@ public abstract class ValuesUnderCursor { */ static ValuesUnderCursor create(final MapCanvas canvas) { if (canvas instanceof CoverageCanvas cc) { - final ValuesFromCoverage listener = new ValuesFromCoverage(); - cc.coverageProperty.addListener(new WeakChangeListener<>(listener)); + final var listener = new ValuesFromCoverage(); + cc.coverageProperty.addListener(listener); cc.sliceExtentProperty.addListener((p,o,n) -> listener.setSlice(n)); final GridCoverage coverage = cc.coverageProperty.get(); if (coverage != null) { diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/package-info.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/package-info.java index 5a157f275a..66256d37f8 100644 --- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/package-info.java +++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/package-info.java @@ -29,7 +29,7 @@ * @author Smaniotto Enzo (GSoC) * @author Johann Sorel (Geomatys) * @author Martin Desruisseaux (Geomatys) - * @version 1.3 + * @version 1.7 * @since 1.1 */ package org.apache.sis.gui; diff --git a/optional/src/org.apache.sis.gui/test/org/apache/sis/gui/coverage/CoverageCanvasApp.java b/optional/src/org.apache.sis.gui/test/org/apache/sis/gui/coverage/CoverageCanvasApp.java index 5a176e52be..557f3aed7d 100644 --- a/optional/src/org.apache.sis.gui/test/org/apache/sis/gui/coverage/CoverageCanvasApp.java +++ b/optional/src/org.apache.sis.gui/test/org/apache/sis/gui/coverage/CoverageCanvasApp.java @@ -21,8 +21,8 @@ import java.awt.Point; import java.awt.image.DataBuffer; import javafx.application.Application; import javafx.scene.Scene; -import javafx.scene.layout.BorderPane; import javafx.stage.Stage; +import javafx.scene.layout.BorderPane; import org.apache.sis.coverage.grid.PixelInCell; import org.apache.sis.coverage.grid.GridCoverage2D; import org.apache.sis.coverage.grid.GridGeometry; @@ -38,7 +38,7 @@ import org.apache.sis.image.TiledImageMock; /** * Shows {@link CoverageCanvas} with random data. The image will have small tiles of size - * {@value #TILE_WIDTH}×{@value #TILE_HEIGHT}. The image will artificially fails to provide + * {@value #TILE_WIDTH}×{@value #TILE_HEIGHT}. The image will artificially fail to provide * some tiles in order to test error controls. * * @author Martin Desruisseaux (Geomatys) @@ -73,11 +73,11 @@ public class CoverageCanvasApp extends Application { */ @Override public void start(final Stage window) { - final CoverageCanvas canvas = new CoverageCanvas(); - final StatusBar statusBar = new StatusBar(null); + final var canvas = new CoverageCanvas(); + final var statusBar = new StatusBar(null); statusBar.track(canvas); - canvas.setCoverage(createImage()); - final BorderPane pane = new BorderPane(canvas.getView()); + canvas.setCoverage(createImage(true)); + final var pane = new BorderPane(canvas.getView()); pane.setBottom(statusBar.getView()); window.setTitle("CoverageCanvas Test"); window.setScene(new Scene(pane)); @@ -98,14 +98,17 @@ public class CoverageCanvasApp extends Application { } /** - * Creates a dummy image for testing purpose. Some tiles will + * Creates a dummy image for testing purpose. Some tiles may * have artificial errors in order to see the error controls. + * + * @param withErrors whether to cause artificial errors in some tiles. + * @return an image to show in the map canvas. */ - private static GridCoverage2D createImage() { + public static GridCoverage2D createImage(final boolean withErrors) { final Random random = new Random(); final int width = TILE_WIDTH * 4; final int height = TILE_HEIGHT * 2; - final TiledImageMock image = new TiledImageMock( + final var image = new TiledImageMock( DataBuffer.TYPE_BYTE, 1, random.nextInt(50) - 25, // minX random.nextInt(50) - 25, // minY @@ -116,7 +119,7 @@ public class CoverageCanvasApp extends Application { false); image.validate(); final double sc = 500d / Math.max(width, height); - final WritablePixelIterator it = WritablePixelIterator.create(image); + final var it = WritablePixelIterator.create(image); while (it.next()) { final Point p = it.getPosition(); final double d = Math.hypot(p.x - width/2, p.y - height/2); @@ -126,7 +129,9 @@ public class CoverageCanvasApp extends Application { } it.setSample(0, value); } - image.failRandomly(random, false); + if (withErrors) { + image.failRandomly(random, false); + } return new GridCoverage2D(new GridGeometry(null, PixelInCell.CELL_CORNER, MathTransforms.identity(2), CommonCRS.Engineering.DISPLAY.crs()), null, image); } diff --git a/optional/src/org.apache.sis.gui/test/org/apache/sis/gui/map/MapWindowsApp.java b/optional/src/org.apache.sis.gui/test/org/apache/sis/gui/map/MapWindowsApp.java new file mode 100644 index 0000000000..0b1c570b30 --- /dev/null +++ b/optional/src/org.apache.sis.gui/test/org/apache/sis/gui/map/MapWindowsApp.java @@ -0,0 +1,111 @@ +/* + * 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 javafx.application.Application; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.stage.Stage; +import javafx.scene.layout.VBox; +import javafx.scene.control.Label; +import javafx.scene.control.Button; +import javafx.event.ActionEvent; +import javafx.event.EventHandler; +import org.apache.sis.gui.coverage.CoverageCanvasApp; +import org.apache.sis.gui.internal.BackgroundThreads; +import org.apache.sis.storage.MemoryGridCoverageResource; +import org.apache.sis.util.iso.Names; + + +/** + * Shows {@link MapWindows} with random data. + * + * @author Martin Desruisseaux (Geomatys) + */ +public class MapWindowsApp extends Application implements EventHandler<ActionEvent> { + /** + * The instance to test. + */ + private MapWindows windows; + + /** + * Number of windows created, used for creating an identifier. + */ + private int count; + + /** + * Creates a widget viewer. + */ + public MapWindowsApp() { + } + + /** + * Starts the test application. + * + * @param args ignored. + */ + public static void main(final String[] args) { + launch(args); + } + + /** + * Creates and starts the test application. + * + * @param window where to show the application. + */ + @Override + public void start(final Stage window) { + final var canvas = new Button("Add a canvas"); + final var label = new Label("Close this window for ending the application."); + final var box = new VBox(24, canvas, label); + box.setAlignment(Pos.CENTER); + window.setTitle("MapWindows Test"); + window.setScene(new Scene(box)); + window.setWidth (400); + window.setHeight(200); + window.setX(0); + window.setY(0); + window.show(); + windows = new MapWindows(window); + canvas.setOnAction(this); + } + + /** + * Invoked when the button is pressed. + * + * @param event the event sent by the button. + */ + @Override + public void handle(final ActionEvent event) { + windows.addResource(new MemoryGridCoverageResource( + null, // Parent resource. + Names.createLocalName(null, null, "Image #" + ++count), + CoverageCanvasApp.createImage(false), + null)); // Grid coverage processor. + } + + /** + * Stops background threads for allowing <abbr>JVM</abbr> to exit. + * + * @throws Exception if an error occurred while stopping the threads. + */ + @Override + public void stop() throws Exception { + BackgroundThreads.stop(); + super.stop(); + } +}
