blob: 8e6f1b9e4bf732ef41443c9d6c6418747ba74639 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.sis.gui.coverage;
import java.util.Map;
import java.util.EnumMap;
import java.util.Objects;
import java.util.Optional;
import java.awt.image.RenderedImage;
import javafx.application.Platform;
import javafx.beans.DefaultProperty;
import javafx.scene.control.Control;
import javafx.scene.control.SplitPane;
import javafx.scene.control.Separator;
import javafx.scene.control.ToggleGroup;
import javafx.scene.control.Toggle;
import javafx.scene.control.TitledPane;
import javafx.scene.layout.Region;
import javafx.event.ActionEvent;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import org.apache.sis.storage.DataStoreException;
import org.apache.sis.storage.GridCoverageResource;
import org.apache.sis.coverage.grid.GridCoverage;
import org.apache.sis.gui.Widget;
import org.apache.sis.gui.internal.FontGIS;
import org.apache.sis.gui.internal.DataStoreOpener;
import org.apache.sis.gui.internal.Resources;
import org.apache.sis.gui.internal.ToolbarButton;
import org.apache.sis.gui.internal.NonNullObjectProperty;
import org.apache.sis.gui.internal.PrivateAccess;
import org.apache.sis.portrayal.RenderException;
import org.apache.sis.gui.referencing.RecentReferenceSystems;
import org.apache.sis.gui.dataset.WindowHandler;
import org.apache.sis.gui.map.StatusBar;
/**
* An image or tabular view of {@link GridCoverage} together with controls for band selection and other operations.
* The class contains two properties:
*
* <ul>
* <li>A {@link GridCoverage} supplied by user.
* May be specified indirectly with an {@link ImageRequest} for loading the coverage.</li>
* <li>A {@link View} type which specify how to show the coverage:
* <ul>
* <li>using {@link GridView} for showing numerical values in a table, or</li>
* <li>using {@link CoverageCanvas} for showing the coverage as an image.</li>
* </ul>
* </li>
* </ul>
*
* Controls are provided for allowing user to customize map projection, number formats, <i>etc.</i>.
* The set of control depends on the view type.
*
* <h2>Limitations</h2>
* Current implementation is restricted to {@link GridCoverage} instances, but a future
* implementation may generalize to {@link org.opengis.coverage.Coverage} instances.
*
* @author Martin Desruisseaux (Geomatys)
* @version 1.5
*
* @see CoverageCanvas
* @see GridView
*
* @since 1.1
*/
@DefaultProperty("coverage")
public class CoverageExplorer extends Widget {
/**
* Type of view shown in the explorer.
* It may be either an image or a table of numerical values.
*
* @see #viewTypeProperty
*/
public enum View {
// Enumeration order is the order in which buttons will appear on the button bar.
/**
* Shows the coverage visual as an image. This view uses {@link CoverageCanvas}.
*/
IMAGE(FontGIS.Code.WORLD_MAP_ALT, "\uD83D\uDDFA\uFE0F", Resources.Keys.TabularData), // 🗺 — World map.
/**
* Shows the coverage numerical value in a table. This view uses {@link GridView}.
*/
TABLE(FontGIS.Code.GRID, "\uD83D\uDD22\uFE0F", Resources.Keys.Visualize); // 🔢 — Input symbol for numbers.
/**
* Number of enumeration values.
*/
static final int COUNT = 2;
/**
* The Unicode character to use as icon, as a {@link FontGIS} code.
*/
final char icon;
/**
* The fallback text if the icon cannot be shown.
*/
final String fallback;
/**
* Key from {@link Resources} bundle for the localized text to use as tooltip.
*/
final short tooltip;
/**
* Creates a new enumeration value.
*/
private View(final char icon, final String fallback, short tooltip) {
this.icon = icon;
this.fallback = fallback;
this.tooltip = tooltip;
}
}
/**
* The type of view (image or tabular data) shown in this explorer.
*
* @see #getViewType()
* @see #setViewType(View)
*/
public final ObjectProperty<View> viewTypeProperty;
/**
* The source of coverage data shown in this explorer. If this property value is non-null,
* then {@link #coverageProperty} value will change at any time (potentially many times)
* depending on the zoom level or other user interaction. Conversely if a value is set
* explicitly on {@link #coverageProperty}, then this {@code resourceProperty} is cleared.
*
* <h4>Relationship with view properties</h4>
* This property is "weakly bound" to {@link CoverageCanvas#resourceProperty}:
* the two properties generally have the same value but are not necessarily updated in same time.
* After a value is set on one property, the other property may be updated only after some background process
* (e.g. loading) finished. If a view is not the {@linkplain #getViewType() currently visible view},
* its property may be updated only when the view become visible.
*
* @see #getResource()
* @see #setResource(GridCoverageResource)
* @see CoverageCanvas#resourceProperty
*
* @since 1.2
*/
public final ObjectProperty<GridCoverageResource> resourceProperty;
/**
* The data shown in this canvas. This property value may be set implicitly or explicitly:
* <ul>
* <li>If the {@link #resourceProperty} value is non-null, then the value will change
* automatically at any time (potentially many times) depending on user interaction.</li>
* <li>Conversely if an explicit value is set on this property,
* then the {@link #resourceProperty} is cleared.</li>
* </ul>
*
* Note that a change in this property value may not modify the canvas content immediately.
* Instead, a background process will request the tiles and update the canvas content later,
* when data are ready.
*
* <p>Current implementation is restricted to {@link GridCoverage} instances, but a future
* implementation may generalize to {@link org.opengis.coverage.Coverage} instances.</p>
*
* <h4>Relationship with view properties</h4>
* This property is "weakly bound" to {@link CoverageCanvas#coverageProperty}:
* the two properties generally have the same value but are not necessarily updated in same time.
* After a value is set on one property, the other property may be updated only after some background process
* (e.g. loading) finished. If a view is not the {@linkplain #getViewType() currently visible view},
* its property may be updated only when the view become visible.
*
* @see #getCoverage()
* @see #setCoverage(GridCoverage)
* @see #setCoverage(ImageRequest)
* @see CoverageCanvas#coverageProperty
*/
public final ObjectProperty<GridCoverage> coverageProperty;
/**
* Whether the {@link #coverageProperty} is in process of being set,
* in which case some listeners should not react.
*/
private boolean isCoverageAdjusting;
/**
* Handles the {@link javafx.scene.control.ChoiceBox} and menu items for selecting a CRS.
*/
final RecentReferenceSystems referenceSystems;
/**
* The different views we can provide on {@link #coverageProperty}, together with associated controls.
* Values in this map are initially null and created when first needed.
* Concrete classes are {@link GridControls} and {@link CoverageControls}.
*
* @see #getDataView(View)
* @see #getControls(View)
*/
private final EnumMap<View,ViewAndControls> views;
/**
* The control that put everything together, created when first requested.
* The type of control may change in any future SIS version.
*
* @see #getView()
* @see #onViewTypeSet(View)
*/
private SplitPane content;
/**
* Handler of the window showing this coverage view. This is used for creating new windows.
* Created when first needed for giving to subclasses a chance to complete initialization.
*
* @see #getWindowHandler()
*/
private WindowHandler window;
/**
* Creates an initially empty explorer with the specified view type.
*
* @param type the way to show coverages in this explorer.
*
* @see #setViewType(View)
*
* @since 1.2
*/
@SuppressWarnings("this-escape")
public CoverageExplorer(final View type) {
views = new EnumMap<>(View.class);
viewTypeProperty = new NonNullObjectProperty<>(this, "viewType", Objects.requireNonNull(type));
resourceProperty = new SimpleObjectProperty<> (this, "resource");
coverageProperty = new SimpleObjectProperty<> (this, "coverage");
referenceSystems = new RecentReferenceSystems();
referenceSystems.addUserPreferences();
referenceSystems.addAlternatives("EPSG:4326", "EPSG:3395", "MGRS"); // WGS 84 / World Mercator
viewTypeProperty.addListener((p,o,n) -> onViewTypeSet(n));
resourceProperty.addListener((p,o,n) -> onPropertySet(n, null, coverageProperty));
coverageProperty.addListener((p,o,n) -> onPropertySet(null, n, resourceProperty));
}
/**
* Creates an explorer initialized with the same coverage or resource than the given explorer.
*
* @param source the source explorer from which to take the initial coverage or resource.
*
* @since 1.2
*/
@SuppressWarnings("this-escape")
public CoverageExplorer(final CoverageExplorer source) {
this(source.getViewType());
window = PrivateAccess.newWindowHandler.apply(source.window, this);
source.getImageRequest().ifPresent(this::setCoverage);
PrivateAccess.finishWindowHandler.accept(window);
if (getViewType() == View.IMAGE) {
getCoverageControls().copyStyling(source.getCoverageControls());
}
}
/**
* Returns the handler of the window showing this coverage view.
* Those windows are created when the user clicks on the "New window" button.
* Each window provides the area where data are shown and where the user interacts.
* The window can be a JavaFX top-level window ({@link javafx.stage.Stage}), but not necessarily.
* It may also be a tile in a mosaic of windows.
*
* @return the handler of the window showing this coverage view.
*
* @since 1.3
*/
public final WindowHandler getWindowHandler() {
assert Platform.isFxApplicationThread();
/*
* Created when first needed for giving to subclass constructors a chance to complete
* their initialization before `this` reference is passed to `WindowHandler` constructor.
*/
if (window == null) {
window = WindowHandler.create(this);
}
return window;
}
/**
* Returns the canvas where the image is shown.
*
* @return the canvas where the image is shown.
*
* @since 1.2
*/
public final CoverageCanvas getCanvas() {
return getCoverageControls().view;
}
/**
* Returns the controls on the canvas where the image is shown.
*/
private CoverageControls getCoverageControls() {
return (CoverageControls) getViewAndControls(View.IMAGE, false);
}
/**
* Returns the view-control pair for the given view type.
* The view-control pair is created when first needed.
* Invoking this method may cause data to be loaded in a background thread.
*
* @param type type of view to obtain.
* @param load whether to force loading of data in the new type.
*/
private ViewAndControls getViewAndControls(final View type, boolean load) {
ViewAndControls c = views.get(type);
if (c == null) {
switch (type) {
case TABLE: c = new GridControls(this); break;
case IMAGE: c = new CoverageControls(this, getWindowHandler()); break;
default: throw new AssertionError(type);
}
views.put(type, c);
load = true;
}
/*
* If this explorer is showing a coverage, load data in the newly created view.
* Data may also be loaded because the view was previously unselected (hidden)
* and became selected (visible).
*/
if (load) {
getImageRequest().ifPresent(c::load);
}
return c;
}
/**
* Returns the region containing the grid or coverage view, band selector and any control managed by this
* {@code CoverageExplorer}. The {@link Region} subclass returned by this method is implementation dependent
* and may change in any future version.
*
* @return the region to show.
*
* @see #getDataView(View)
* @see #getControls(View)
*/
@Override
public final Region getView() {
assert Platform.isFxApplicationThread();
/*
* We build when first requested because `ResourceExplorer` for example will never request this view.
* Instead, it will invoke `getDataView(View)` or `getControls(View)` and layout those regions itself.
*/
if (content == null) {
/*
* Prepare buttons to add on the toolbar. Those buttons are not managed by this class;
* they are managed by org.apache.sis.gui.dataset.WindowHandler. We only declare here
* the text and action for each button.
*/
final ToggleGroup group = new ToggleGroup();
final Control[] buttons = new Control[View.COUNT + 1];
final Resources localized = Resources.forLocale(getLocale());
buttons[0] = new Separator();
for (final View type : View.values()) {
buttons[1 + type.ordinal()] = new Selector(type).createButton(group, type.icon, type.fallback, localized, type.tooltip);
}
final View type = getViewType();
final ViewAndControls c = getViewAndControls(type, false);
group.selectToggle(group.getToggles().get(type.ordinal()));
content = new SplitPane(c.controls(), c.viewAndNavigation);
ToolbarButton.insert(content, buttons);
/*
* The divider position is supposed to be a fraction between 0 and 1. A value of 1 would mean
* to give all the space to controls and no space to data, which is not what we want. However
* experience with JavaFX 14 shows that this setting gives just a reasonable space to controls
* and most space to data. I have not identified the cause of this surprising behavior.
* A smaller value results in too few space for the controls.
*/
content.setDividerPosition(0, 1);
}
return content;
}
/**
* Returns the region containing the data visualization component, without controls other than navigation.
* This is a {@link GridView} or {@link CoverageCanvas} together with their {@link StatusBar}
* and navigation controls for selecting the slice in a <var>n</var>-dimensional data cube.
* The {@link Region} subclass returned by this method is implementation dependent and may change
* in any future version.
*
* @param type whether to obtain a {@link GridView} or {@link CoverageCanvas}.
* @return the requested view for the value of {@link #resourceProperty} or {@link #coverageProperty}.
*/
public final Region getDataView(final View type) {
assert Platform.isFxApplicationThread();
return getViewAndControls(Objects.requireNonNull(type), false).viewAndNavigation;
}
/**
* Returns the panes containing the controls, without data visualization component.
* The {@link TitledPane} contents are implementation dependent and may change in any future version.
*
* @param type whether to obtain controls for {@link GridView} or {@link CoverageCanvas}.
* @return the controls on specified data view.
*/
public final TitledPane[] getControls(final View type) {
assert Platform.isFxApplicationThread();
return getViewAndControls(Objects.requireNonNull(type), false).controlPanes.clone();
}
/**
* The action to execute when the user selects a view.
* This is used by the toolbar buttons in the widget created by {@link #getView()}.
*/
private final class Selector extends ToolbarButton {
/** The view to select when the button is pressed. */
private final View type;
/** Creates a new action which will show the view at the given index. */
Selector(final View type) {
this.type = type;
}
/** Invoked when the user selects another view to show (tabular data or the image). */
@Override public void handle(final ActionEvent event) {
final Toggle button = (Toggle) event.getSource();
if (button.isSelected()) {
setViewType(type);
views.get(type).selector = button; // Should never be null.
} else {
button.setSelected(true); // Prevent situation where all buttons are unselected.
}
}
}
/**
* Returns the type of view (image or tabular data) shown in this explorer.
* The default value is {@link View#TABLE}.
*
* @return the way to show coverages in this explorer.
*
* @see #viewTypeProperty
*/
public final View getViewType() {
return viewTypeProperty.get();
}
/**
* Sets the type of view to show in this explorer.
*
* @param type the new way to show coverages in this explorer.
*
* @see #viewTypeProperty
*/
public final void setViewType(final View type) {
viewTypeProperty.set(type);
}
/**
* Invoked when a new view type has been specified.
*
* @param type the new way to show coverages in this explorer.
*/
private void onViewTypeSet(final View type) {
final ViewAndControls c = getViewAndControls(type, true);
if (content != null) {
content.getItems().setAll(c.controls(), c.viewAndNavigation);
final Toggle selector = c.selector;
if (selector != null) {
selector.setSelected(true);
}
}
}
/**
* Returns the source of coverages for this explorer.
* This method, like all other methods in this class, shall be invoked from the JavaFX thread.
*
* @return the source of coverages shown in this explorer, or {@code null} if none.
*
* @see #resourceProperty
*
* @since 1.2
*/
public final GridCoverageResource getResource() {
return resourceProperty.get();
}
/**
* Sets the source of coverages shown in this explorer.
* This method shall be invoked from JavaFX thread and returns immediately.
* The new data are loaded in a background thread and the {@link #coverageProperty}
* value will be updated after an undetermined amount of time.
*
* @param resource the source of data to show in this explorer, or {@code null} if none.
*
* @see #resourceProperty
*
* @since 1.2
*/
public final void setResource(final GridCoverageResource resource) {
resourceProperty.set(resource);
/*
* `this.onPropertySet(…)` is indirectly invoked,
* which in turn invokes `setCoverage(ImageRequest)`.
*/
}
/**
* Returns the source of sample values for this explorer.
* This method, like all other methods in this class, shall be invoked from the JavaFX thread.
* Note that this value may change at any time (depending on user interaction)
* if the {@link #resourceProperty} has a non-null value.
*
* @return the coverage shown in this explorer, or {@code null} if none.
*
* @see #coverageProperty
* @see CoverageCanvas#getCoverage()
* @see GridView#getImage()
*/
public final GridCoverage getCoverage() {
return coverageProperty.get();
}
/**
* Sets the coverage to show in this explorer.
* This method shall be invoked from JavaFX thread and returns immediately.
* The new data are loaded in a background thread and will appear after an
* undetermined amount of time.
*
* <p>Invoking this method sets the {@link #resourceProperty} value to {@code null}.</p>
*
* @param coverage the data to show in this explorer, or {@code null} if none.
*
* @see #coverageProperty
* @see CoverageCanvas#setCoverage(GridCoverage)
* @see GridView#setImage(RenderedImage)
*/
public final void setCoverage(final GridCoverage coverage) {
coverageProperty.set(coverage);
/*
* `this.onPropertySet(…)` is indirectly invoked,
* which in turn invokes `setCoverage(ImageRequest)`.
*/
}
/**
* Loads coverage in a background thread from the given source.
* This method shall be invoked from JavaFX thread and returns immediately.
* The grid content may appear unmodified after this method returns;
* the modifications will appear after an undetermined amount of time.
*
* @param source the coverage or resource to load, or {@code null} if none.
*
* @see GridView#setImage(ImageRequest)
*/
public final void setCoverage(final ImageRequest source) {
assert Platform.isFxApplicationThread();
final ViewAndControls current = getViewAndControls(getViewType(), false);
for (final ViewAndControls c : views.values()) {
c.load(c == current ? source : null);
}
// `notifyDataChanged(…)` will be invoked later after background thread finishes its work.
}
/**
* Invoked when a new value has been set on {@link #resourceProperty} or {@link #coverageProperty}.
* This method sets the resource or coverage on the currently visible view, which in turn will load
* data in its own background thread.
*
* @param resource the new resource, or {@code null} if none.
* @param coverage the new coverage, or {@code null} if none.
* @param toClear the property which is an alternative to the property that has been set.
*/
private void onPropertySet(final GridCoverageResource resource, final GridCoverage coverage,
final ObjectProperty<?> toClear)
{
if (!isCoverageAdjusting) {
isCoverageAdjusting = true;
try {
toClear.set(null);
} finally {
isCoverageAdjusting = false;
}
// Indirectly start a background thread which will invoke `notifyDataChanged(…)` later.
setCoverage((resource != null || coverage != null) ? new ImageRequest(resource, coverage, null) : null);
}
}
/**
* Invoked in JavaFX thread after the current view finished to load the new coverage.
* It is the responsibility of all {@link ViewAndControls} subclasses to listen to change events
* emitted by their views ({@link GridView} or {@link CoverageCanvas}) and to invoke this method.
* This method will then update the properties of this {@code CoverageExplorer} for the new data.
*
* <p>Note that view data may have been changed either by user changing directly a {@link GridView}
* or {@link CoverageCanvas} property, or indirectly by user changing {@link #resourceProperty} or
* {@link #coverageProperty}. In the latter case, the {@code resource} and {@code coverage} arguments
* given to this method may be the value that the properties already have.</p>
*
* @param resource the new source of coverage, or {@code null} if none.
* @param coverage the new coverage, or {@code null} if none.
*/
final void notifyDataChanged(final GridCoverageResource resource, final GridCoverage coverage) {
if (coverage != null) {
String name;
try {
name = DataStoreOpener.findLabel(resource, getLocale(), true);
} catch (DataStoreException e) {
name = e.getLocalizedMessage();
if (name == null) {
name = e.getClass().getSimpleName();
}
}
referenceSystems.setGridReferencing(true, Map.of(name, coverage.getGridGeometry()));
}
/*
* Following calls will NOT forward the new values to the views because this `notifyDataChanged(…)`
* method is invoked as a consequence of view properties being updated. Those views should already
* have the new property values at this moment.
*/
isCoverageAdjusting = true;
try {
setResource(resource);
setCoverage(coverage);
} finally {
isCoverageAdjusting = false;
}
}
/**
* Returns a request which represent the coverage or resource currently shown in this explorer.
* This request can be used for showing the same data in another {@code CoverageExplorer} instance
* by invoking the {@link #setCoverage(ImageRequest)} method.
*
* @return the request to give to another explorer for showing the same coverage.
*
* @see #setCoverage(ImageRequest)
*/
private Optional<ImageRequest> getImageRequest() {
final GridCoverageResource resource = getResource();
final GridCoverage coverage = getCoverage();
if (resource != null || coverage != null) {
final ImageRequest request = new ImageRequest(resource, coverage, null);
final CoverageControls c = (CoverageControls) views.get(View.IMAGE);
if (c != null) try {
request.zoom = c.view.getGridGeometry();
} catch (RenderException e) {
CoverageCanvas.unexpectedException("getGridGeometry", e);
}
return Optional.of(request);
} else {
return Optional.empty();
}
}
}