// License: GPL. For details, see LICENSE file.
package org.openstreetmap.josm.gui.layer;
import static org.openstreetmap.josm.tools.I18n.tr;
import java.awt.Color;
import java.awt.Component;
import java.awt.event.ActionEvent;
import java.beans.PropertyChangeListener;
import java.beans.PropertyChangeSupport;
import java.io.File;
import java.util.List;
import java.util.Optional;
import javax.swing.AbstractAction;
import javax.swing.Action;
import javax.swing.Icon;
import javax.swing.JOptionPane;
import javax.swing.JSeparator;
import javax.swing.SwingUtilities;
import org.openstreetmap.josm.Main;
import org.openstreetmap.josm.actions.GpxExportAction;
import org.openstreetmap.josm.actions.SaveAction;
import org.openstreetmap.josm.actions.SaveActionBase;
import org.openstreetmap.josm.actions.SaveAsAction;
import org.openstreetmap.josm.data.ProjectionBounds;
import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor;
import org.openstreetmap.josm.data.preferences.AbstractProperty;
import org.openstreetmap.josm.data.preferences.AbstractProperty.ValueChangeListener;
import org.openstreetmap.josm.data.preferences.ColorProperty;
import org.openstreetmap.josm.data.projection.Projection;
import org.openstreetmap.josm.data.projection.ProjectionChangeListener;
import org.openstreetmap.josm.tools.Destroyable;
import org.openstreetmap.josm.tools.ImageProvider;
import org.openstreetmap.josm.tools.Utils;
/**
* A layer encapsulates the gui component of one dataset and its representation.
*
* Some layers may display data directly imported from OSM server. Other only
* display background images. Some can be edited, some not. Some are static and
* other changes dynamically (auto-updated).
*
* Layers can be visible or not. Most actions the user can do applies only on
* selected layers. The available actions depend on the selected layers too.
*
* All layers are managed by the MapView. They are displayed in a list to the
* right of the screen.
*
* @author imi
*/
public abstract class Layer extends AbstractMapViewPaintable implements Destroyable, ProjectionChangeListener {
/**
* Action related to a single layer.
*/
public interface LayerAction {
/**
* Determines if this action supports a given list of layers.
* @param layers list of layers
* @return {@code true} if this action supports the given list of layers, {@code false} otherwise
*/
boolean supportLayers(List<Layer> layers);
/**
* Creates and return the menu component.
* @return the menu component
*/
Component createMenuComponent();
}
/**
* Action related to several layers.
* @since 10600 (functional interface)
*/
@FunctionalInterface
public interface MultiLayerAction {
/**
* Returns the action for a given list of layers.
* @param layers list of layers
* @return the action for the given list of layers
*/
Action getMultiLayerAction(List<Layer> layers);
}
/**
* Special class that can be returned by getMenuEntries when JSeparator needs to be created
*/
public static class SeparatorLayerAction extends AbstractAction implements LayerAction {
/** Unique instance */
public static final SeparatorLayerAction INSTANCE = new SeparatorLayerAction();
@Override
public void actionPerformed(ActionEvent e) {
throw new UnsupportedOperationException();
}
@Override
public Component createMenuComponent() {
return new JSeparator();
}
@Override
public boolean supportLayers(List<Layer> layers) {
return false;
}
}
public static final String VISIBLE_PROP = Layer.class.getName() + ".visible";
public static final String OPACITY_PROP = Layer.class.getName() + ".opacity";
public static final String NAME_PROP = Layer.class.getName() + ".name";
public static final String FILTER_STATE_PROP = Layer.class.getName() + ".filterstate";
/**
* keeps track of property change listeners
*/
protected PropertyChangeSupport propertyChangeSupport;
/**
* The visibility state of the layer.
*/
private boolean visible = true;
/**
* The opacity of the layer.
*/
private double opacity = 1;
/**
* The layer should be handled as a background layer in automatic handling
*/
private boolean background;
/**
* The name of this layer.
*/
private String name;
/**
* This is set if user renamed this layer.
*/
private boolean renamed;
/**
* If a file is associated with this layer, this variable should be set to it.
*/
private File associatedFile;
private final ValueChangeListener<Object> invalidateListener = change -> invalidate();
/**
* Create the layer and fill in the necessary components.
* @param name Layer name
*/
public Layer(String name) {
this.propertyChangeSupport = new PropertyChangeSupport(this);
setName(name);
}
/**
* Initialization code, that depends on Main.map.mapView.
*
* It is always called in the event dispatching thread.
* Note that Main.map is null as long as no layer has been added, so do
* not execute code in the constructor, that assumes Main.map.mapView is
* not null.
*
* If you need to execute code when this layer is added to the map view, use
* {@link #attachToMapView(org.openstreetmap.josm.gui.layer.MapViewPaintable.MapViewEvent)}
*/
public void hookUpMapView() {
}
/**
* Return a representative small image for this layer. The image must not
* be larger than 64 pixel in any dimension.
* @return layer icon
*/
public abstract Icon getIcon();
/**
* Gets the color property to use for this layer.
* @return The color property.
* @since 10824
*/
public AbstractProperty<Color> getColorProperty() {
ColorProperty base = getBaseColorProperty();
if (base != null) {
// cannot cache this - name may change.
return base.getChildColor("layer " + getName());
} else {
return null;
}
}
/**
* Gets the color property that stores the default color for this layer.
* @return The property or <code>null</code> if this layer is not colored.
* @since 10824
*/
protected ColorProperty getBaseColorProperty() {
return null;
}
private void addColorPropertyListener() {
AbstractProperty<Color> colorProperty = getColorProperty();
if (colorProperty != null) {
colorProperty.addListener(invalidateListener);
}
}
private void removeColorPropertyListener() {
AbstractProperty<Color> colorProperty = getColorProperty();
if (colorProperty != null) {
colorProperty.removeListener(invalidateListener);
}
}
/**
* @return A small tooltip hint about some statistics for this layer.
*/
public abstract String getToolTipText();
/**
* Merges the given layer into this layer. Throws if the layer types are
* incompatible.
* @param from The layer that get merged into this one. After the merge,
* the other layer is not usable anymore and passing to one others
* mergeFrom should be one of the last things to do with a layer.
*/
public abstract void mergeFrom(Layer from);
/**
* @param other The other layer that is tested to be mergable with this.
* @return Whether the other layer can be merged into this layer.
*/
public abstract boolean isMergable(Layer other);
public abstract void visitBoundingBox(BoundingXYVisitor v);
public abstract Object getInfoComponent();
/**
* Determines if info dialog can be resized (false by default).
* @return {@code true} if the info dialog can be resized, {@code false} otherwise
* @since 6708
*/
public boolean isInfoResizable() {
return false;
}
/**
* Returns list of actions. Action can implement LayerAction interface when it needs to be represented by other
* menu component than JMenuItem or when it supports multiple layers. Actions that support multiple layers should also
* have correct equals implementation.
*
* Use {@link SeparatorLayerAction#INSTANCE} instead of new JSeparator
* @return menu actions for this layer
*/
public abstract Action[] getMenuEntries();
/**
* Called, when the layer is removed from the mapview and is going to be destroyed.
*
* This is because the Layer constructor can not add itself safely as listener
* to the layerlist dialog, because there may be no such dialog yet (loaded
* via command line parameter).
*/
@Override
public void destroy() {
// Override in subclasses if needed
removeColorPropertyListener();
}
public File getAssociatedFile() {
return associatedFile;
}
public void setAssociatedFile(File file) {
associatedFile = file;
}
/**
* Replies the name of the layer
*
* @return the name of the layer
*/
public String getName() {
return name;
}
/**
* Sets the name of the layer
*
* @param name the name. If null, the name is set to the empty string.
*/
public final void setName(String name) {
if (this.name != null) {
removeColorPropertyListener();
}
String oldValue = this.name;
this.name = Optional.ofNullable(name).orElse("");
if (!this.name.equals(oldValue)) {
propertyChangeSupport.firePropertyChange(NAME_PROP, oldValue, this.name);
}
// re-add listener
addColorPropertyListener();
invalidate();
}
/**
* Rename layer and set renamed flag to mark it as renamed (has user given name).
*
* @param name the name. If null, the name is set to the empty string.
*/
public final void rename(String name) {
renamed = true;
setName(name);
}
/**
* Replies true if this layer was renamed by user
*
* @return true if this layer was renamed by user
*/
public boolean isRenamed() {
return renamed;
}
/**
* Replies true if this layer is a background layer
*
* @return true if this layer is a background layer
*/
public boolean isBackgroundLayer() {
return background;
}
/**
* Sets whether this layer is a background layer
*
* @param background true, if this layer is a background layer
*/
public void setBackgroundLayer(boolean background) {
this.background = background;
}
/**
* Sets the visibility of this layer. Emits property change event for
* property {@link #VISIBLE_PROP}.
*
* @param visible true, if the layer is visible; false, otherwise.
*/
public void setVisible(boolean visible) {
boolean oldValue = isVisible();
this.visible = visible;
if (visible && opacity == 0) {
setOpacity(1);
} else if (oldValue != isVisible()) {
fireVisibleChanged(oldValue, isVisible());
}
}
/**
* Replies true if this layer is visible. False, otherwise.
* @return true if this layer is visible. False, otherwise.
*/
public boolean isVisible() {
return visible && opacity != 0;
}
/**
* Gets the opacity of the layer, in range 0...1
* @return The opacity
*/
public double getOpacity() {
return opacity;
}
/**
* Sets the opacity of the layer, in range 0...1
* @param opacity The opacity
* @throws IllegalArgumentException if the opacity is out of range
*/
public void setOpacity(double opacity) {
if (!(opacity >= 0 && opacity <= 1))
throw new IllegalArgumentException("Opacity value must be between 0 and 1");
double oldOpacity = getOpacity();
boolean oldVisible = isVisible();
this.opacity = opacity;
if (!Utils.equalsEpsilon(oldOpacity, getOpacity())) {
fireOpacityChanged(oldOpacity, getOpacity());
}
if (oldVisible != isVisible()) {
fireVisibleChanged(oldVisible, isVisible());
}
}
/**
* Sets new state to the layer after applying {@link ImageProcessor}.
*/
public void setFilterStateChanged() {
fireFilterStateChanged();
}
/**
* Toggles the visibility state of this layer.
*/
public void toggleVisible() {
setVisible(!isVisible());
}
/**
* Adds a {@link PropertyChangeListener}
*
* @param listener the listener
*/
public void addPropertyChangeListener(PropertyChangeListener listener) {
propertyChangeSupport.addPropertyChangeListener(listener);
}
/**
* Removes a {@link PropertyChangeListener}
*
* @param listener the listener
*/
public void removePropertyChangeListener(PropertyChangeListener listener) {
propertyChangeSupport.removePropertyChangeListener(listener);
}
/**
* fires a property change for the property {@link #VISIBLE_PROP}
*
* @param oldValue the old value
* @param newValue the new value
*/
protected void fireVisibleChanged(boolean oldValue, boolean newValue) {
propertyChangeSupport.firePropertyChange(VISIBLE_PROP, oldValue, newValue);
}
/**
* fires a property change for the property {@link #OPACITY_PROP}
*
* @param oldValue the old value
* @param newValue the new value
*/
protected void fireOpacityChanged(double oldValue, double newValue) {
propertyChangeSupport.firePropertyChange(OPACITY_PROP, oldValue, newValue);
}
/**
* fires a property change for the property {@link #FILTER_STATE_PROP}.
*/
protected void fireFilterStateChanged() {
propertyChangeSupport.firePropertyChange(FILTER_STATE_PROP, null, null);
}
/**
* Check changed status of layer
*
* @return True if layer was changed since last paint
* @deprecated This is not supported by multiple map views.
* Fire an {@link #invalidate()} to trigger a repaint.
* Let this method return false if you only use invalidation events.
*/
@Deprecated
public boolean isChanged() {
return true;
}
/**
* allows to check whether a projection is supported or not
* @param proj projection
*
* @return True if projection is supported for this layer
*/
public boolean isProjectionSupported(Projection proj) {
return proj != null;
}
/**
* Specify user information about projections
*
* @return User readable text telling about supported projections
*/
public String nameSupportedProjections() {
return tr("All projections are supported");
}
/**
* The action to save a layer
*/
public static class LayerSaveAction extends AbstractAction {
private final transient Layer layer;
public LayerSaveAction(Layer layer) {
putValue(SMALL_ICON, ImageProvider.get("save"));
putValue(SHORT_DESCRIPTION, tr("Save the current data."));
putValue(NAME, tr("Save"));
setEnabled(true);
this.layer = layer;
}
@Override
public void actionPerformed(ActionEvent e) {
SaveAction.getInstance().doSave(layer);
}
}
public static class LayerSaveAsAction extends AbstractAction {
private final transient Layer layer;
public LayerSaveAsAction(Layer layer) {
putValue(SMALL_ICON, ImageProvider.get("save_as"));
putValue(SHORT_DESCRIPTION, tr("Save the current data to a new file."));
putValue(NAME, tr("Save As..."));
setEnabled(true);
this.layer = layer;
}
@Override
public void actionPerformed(ActionEvent e) {
SaveAsAction.getInstance().doSave(layer);
}
}
public static class LayerGpxExportAction extends AbstractAction {
private final transient Layer layer;
public LayerGpxExportAction(Layer layer) {
putValue(SMALL_ICON, ImageProvider.get("exportgpx"));
putValue(SHORT_DESCRIPTION, tr("Export the data to GPX file."));
putValue(NAME, tr("Export to GPX..."));
setEnabled(true);
this.layer = layer;
}
@Override
public void actionPerformed(ActionEvent e) {
new GpxExportAction().export(layer);
}
}
/* --------------------------------------------------------------------------------- */
/* interface ProjectionChangeListener */
/* --------------------------------------------------------------------------------- */
@Override
public void projectionChanged(Projection oldValue, Projection newValue) {
if (!isProjectionSupported(newValue)) {
final String message = "<html><body><p>" +
tr("The layer {0} does not support the new projection {1}.",
Utils.escapeReservedCharactersHTML(getName()), newValue.toCode()) + "</p>" +
"<p style='width: 450px;'>" + tr("Supported projections are: {0}", nameSupportedProjections()) + "</p>" +
tr("Change the projection again or remove the layer.");
// run later to not block loading the UI.
SwingUtilities.invokeLater(() -> JOptionPane.showMessageDialog(Main.parent,
message,
tr("Warning"),
JOptionPane.WARNING_MESSAGE));
}
}
/**
* Initializes the layer after a successful load of data from a file
* @since 5459
*/
public void onPostLoadFromFile() {
// To be overriden if needed
}
/**
* Replies the savable state of this layer (i.e if it can be saved through a "File->Save" dialog).
* @return true if this layer can be saved to a file
* @since 5459
*/
public boolean isSavable() {
return false;
}
/**
* Checks whether it is ok to launch a save (whether we have data, there is no conflict etc.)
* @return <code>true</code>, if it is safe to save.
* @since 5459
*/
public boolean checkSaveConditions() {
return true;
}
/**
* Creates a new "Save" dialog for this layer and makes it visible.<br>
* When the user has chosen a file, checks the file extension, and confirms overwrite if needed.
* @return The output {@code File}
* @see SaveActionBase#createAndOpenSaveFileChooser
* @since 5459
*/
public File createAndOpenSaveFileChooser() {
return SaveActionBase.createAndOpenSaveFileChooser(tr("Save Layer"), "lay");
}
/**
* Gets the strategy that specifies where this layer should be inserted in a layer list.
* @return That strategy.
* @since 10008
*/
public LayerPositionStrategy getDefaultLayerPosition() {
if (isBackgroundLayer()) {
return LayerPositionStrategy.BEFORE_FIRST_BACKGROUND_LAYER;
} else {
return LayerPositionStrategy.AFTER_LAST_VALIDATION_LAYER;
}
}
/**
* Gets the {@link ProjectionBounds} for this layer to be visible to the user. This can be the exact bounds, the UI handles padding. Return
* <code>null</code> if you cannot provide this information. The default implementation uses the bounds from
* {@link #visitBoundingBox(BoundingXYVisitor)}.
* @return The bounds for this layer.
* @since 10371
*/
public ProjectionBounds getViewProjectionBounds() {
BoundingXYVisitor v = new BoundingXYVisitor();
visitBoundingBox(v);
return v.getBounds();
}
@Override
public String toString() {
return getClass().getSimpleName() + " [name=" + name + ", associatedFile=" + associatedFile + ']';
}
}