001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.bbox;
003
004import java.awt.Color;
005import java.awt.Dimension;
006import java.awt.Graphics;
007import java.awt.Graphics2D;
008import java.awt.Point;
009import java.awt.Rectangle;
010import java.awt.geom.Area;
011import java.awt.geom.Path2D;
012import java.util.ArrayList;
013import java.util.LinkedHashMap;
014import java.util.List;
015import java.util.concurrent.CopyOnWriteArrayList;
016import java.util.stream.Collectors;
017
018import javax.swing.ButtonModel;
019import javax.swing.JToggleButton;
020import javax.swing.SpringLayout;
021import javax.swing.event.ChangeEvent;
022import javax.swing.event.ChangeListener;
023
024import org.openstreetmap.gui.jmapviewer.Coordinate;
025import org.openstreetmap.gui.jmapviewer.MapMarkerDot;
026import org.openstreetmap.gui.jmapviewer.MemoryTileCache;
027import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
028import org.openstreetmap.gui.jmapviewer.interfaces.MapMarker;
029import org.openstreetmap.gui.jmapviewer.interfaces.TileSource;
030import org.openstreetmap.josm.data.Bounds;
031import org.openstreetmap.josm.data.coor.LatLon;
032import org.openstreetmap.josm.data.osm.BBox;
033import org.openstreetmap.josm.data.osm.DataSet;
034import org.openstreetmap.josm.data.preferences.BooleanProperty;
035import org.openstreetmap.josm.data.preferences.StringProperty;
036import org.openstreetmap.josm.gui.MainApplication;
037import org.openstreetmap.josm.gui.layer.ImageryLayer;
038import org.openstreetmap.josm.gui.layer.MainLayerManager;
039import org.openstreetmap.josm.spi.preferences.Config;
040import org.openstreetmap.josm.tools.Logging;
041
042/**
043 * This panel displays a map and lets the user chose a {@link BBox}.
044 */
045public class SlippyMapBBoxChooser extends JosmMapViewer implements BBoxChooser, ChangeListener,
046    MainLayerManager.ActiveLayerChangeListener, MainLayerManager.LayerChangeListener {
047
048    /**
049     * Plugins that wish to add custom tile sources to slippy map choose should call this method
050     * @param tileSourceProvider new tile source provider
051     */
052    public static void addTileSourceProvider(TileSourceProvider tileSourceProvider) {
053        providers.addIfAbsent(tileSourceProvider);
054    }
055
056    private static CopyOnWriteArrayList<TileSourceProvider> providers = new CopyOnWriteArrayList<>();
057    static {
058        addTileSourceProvider(new DefaultOsmTileSourceProvider());
059        addTileSourceProvider(new TMSTileSourceProvider());
060        addTileSourceProvider(new CurrentLayersTileSourceProvider());
061    }
062
063    private static final StringProperty PROP_MAPSTYLE = new StringProperty("slippy_map_chooser.mapstyle", "Mapnik");
064    private static final BooleanProperty PROP_SHOWDLAREA = new BooleanProperty("slippy_map_chooser.show_downloaded_area", true);
065    /**
066     * The property name used for the resize button.
067     * @see #addPropertyChangeListener(java.beans.PropertyChangeListener)
068     */
069    public static final String RESIZE_PROP = SlippyMapBBoxChooser.class.getName() + ".resize";
070
071    private final SizeButton iSizeButton;
072    private final ButtonModel showDownloadAreaButtonModel;
073    private final SourceButton iSourceButton;
074    private transient Bounds bbox;
075
076    // upper left and lower right corners of the selection rectangle (x/y on ZOOM_MAX)
077    private transient ICoordinate iSelectionRectStart;
078    private transient ICoordinate iSelectionRectEnd;
079
080    /**
081     * Constructs a new {@code SlippyMapBBoxChooser}.
082     */
083    public SlippyMapBBoxChooser() {
084        debug = Logging.isDebugEnabled();
085        SpringLayout springLayout = new SpringLayout();
086        setLayout(springLayout);
087
088        setZoomControlsVisible(Config.getPref().getBoolean("slippy_map_chooser.zoomcontrols", false));
089        setMapMarkerVisible(false);
090        setMinimumSize(new Dimension(350, 350 / 2));
091        // We need to set an initial size - this prevents a wrong zoom selection
092        // for the area before the component has been displayed the first time
093        setBounds(new Rectangle(getMinimumSize()));
094        if (cachedLoader == null) {
095            setFileCacheEnabled(false);
096        } else {
097            setFileCacheEnabled(Config.getPref().getBoolean("slippy_map_chooser.file_cache", true));
098        }
099        setMaxTilesInMemory(Config.getPref().getInt("slippy_map_chooser.max_tiles", 1000));
100
101        List<TileSource> tileSources = new ArrayList<>(getAllTileSources().values());
102
103        this.showDownloadAreaButtonModel = new JToggleButton.ToggleButtonModel();
104        this.showDownloadAreaButtonModel.setSelected(PROP_SHOWDLAREA.get());
105        this.showDownloadAreaButtonModel.addChangeListener(this);
106        iSourceButton = new SourceButton(this, tileSources, this.showDownloadAreaButtonModel);
107        add(iSourceButton);
108        springLayout.putConstraint(SpringLayout.EAST, iSourceButton, -2, SpringLayout.EAST, this);
109        springLayout.putConstraint(SpringLayout.NORTH, iSourceButton, 2, SpringLayout.NORTH, this);
110
111        iSizeButton = new SizeButton(this);
112        add(iSizeButton);
113
114        String mapStyle = PROP_MAPSTYLE.get();
115        boolean foundSource = false;
116        for (TileSource source: tileSources) {
117            if (source.getName().equals(mapStyle)) {
118                this.setTileSource(source);
119                iSourceButton.setCurrentMap(source);
120                foundSource = true;
121                break;
122            }
123        }
124        if (!foundSource) {
125            setTileSource(tileSources.get(0));
126            iSourceButton.setCurrentMap(tileSources.get(0));
127        }
128
129        MainApplication.getLayerManager().addActiveLayerChangeListener(this);
130
131        new SlippyMapControler(this, this);
132    }
133
134    private static LinkedHashMap<String, TileSource> getAllTileSources() {
135        // using a LinkedHashMap of <id, TileSource> to retain ordering but provide deduplication
136        return providers.stream().flatMap(
137            provider -> provider.getTileSources().stream()
138        ).collect(Collectors.toMap(
139            TileSource::getId,
140            ts -> ts,
141            (oldTs, newTs) -> oldTs,
142            LinkedHashMap::new
143        ));
144    }
145
146    /**
147     * Handles a click/move on the attribution
148     * @param p The point in the view
149     * @param click true if it was a click, false for hover
150     * @return if the attribution handled the event
151     */
152    public boolean handleAttribution(Point p, boolean click) {
153        return attribution.handleAttribution(p, click);
154    }
155
156    /**
157     * Draw the map.
158     */
159    @Override
160    public void paintComponent(Graphics g) {
161        super.paintComponent(g);
162        Graphics2D g2d = (Graphics2D) g;
163
164        // draw shaded area for non-downloaded region of current data set, but only if there *is* a current data set,
165        // and it has defined bounds. Routine is analogous to that in OsmDataLayer's paint routine (but just different
166        // enough to make sharing code impractical)
167        final DataSet ds = MainApplication.getLayerManager().getActiveDataSet();
168        if (ds != null && this.showDownloadAreaButtonModel.isSelected() && !ds.getDataSources().isEmpty()) {
169            // initialize area with current viewport
170            Rectangle b = this.getBounds();
171            // ensure we comfortably cover full area
172            b.grow(100, 100);
173            Path2D p = new Path2D.Float();
174
175            // combine successively downloaded areas after converting to screen-space
176            for (Bounds bounds : ds.getDataSourceBounds()) {
177                if (bounds.isCollapsed()) {
178                    continue;
179                }
180                Rectangle r = new Rectangle(this.getMapPosition(bounds.getMinLat(), bounds.getMinLon(), false));
181                r.add(this.getMapPosition(bounds.getMaxLat(), bounds.getMaxLon(), false));
182                p.append(r, false);
183            }
184            // subtract combined areas
185            Area a = new Area(b);
186            a.subtract(new Area(p));
187
188            // paint remainder
189            g2d.setPaint(new Color(0, 0, 0, 32));
190            g2d.fill(a);
191        }
192
193        // draw selection rectangle
194        if (iSelectionRectStart != null && iSelectionRectEnd != null) {
195            Rectangle box = new Rectangle(getMapPosition(iSelectionRectStart, false));
196            box.add(getMapPosition(iSelectionRectEnd, false));
197
198            g.setColor(new Color(0.9f, 0.7f, 0.7f, 0.6f));
199            g.fillRect(box.x, box.y, box.width, box.height);
200
201            g.setColor(Color.BLACK);
202            g.drawRect(box.x, box.y, box.width, box.height);
203        }
204    }
205
206    @Override
207    public void activeOrEditLayerChanged(MainLayerManager.ActiveLayerChangeEvent e) {
208        this.repaint();
209    }
210
211    @Override
212    public void stateChanged(ChangeEvent e) {
213        // fired for the stateChanged event of this.showDownloadAreaButtonModel
214        PROP_SHOWDLAREA.put(this.showDownloadAreaButtonModel.isSelected());
215        this.repaint();
216    }
217
218    /**
219     * Callback for the OsmMapControl. (Re-)Sets the start and end point of the selection rectangle.
220     *
221     * @param aStart selection start
222     * @param aEnd selection end
223     */
224    public void setSelection(Point aStart, Point aEnd) {
225        if (aStart == null || aEnd == null || aStart.x == aEnd.x || aStart.y == aEnd.y)
226            return;
227
228        Point pMax = new Point(Math.max(aEnd.x, aStart.x), Math.max(aEnd.y, aStart.y));
229        Point pMin = new Point(Math.min(aEnd.x, aStart.x), Math.min(aEnd.y, aStart.y));
230
231        iSelectionRectStart = getPosition(pMin);
232        iSelectionRectEnd = getPosition(pMax);
233
234        Bounds b = new Bounds(
235                new LatLon(
236                        Math.min(iSelectionRectStart.getLat(), iSelectionRectEnd.getLat()),
237                        LatLon.toIntervalLon(Math.min(iSelectionRectStart.getLon(), iSelectionRectEnd.getLon()))
238                        ),
239                        new LatLon(
240                                Math.max(iSelectionRectStart.getLat(), iSelectionRectEnd.getLat()),
241                                LatLon.toIntervalLon(Math.max(iSelectionRectStart.getLon(), iSelectionRectEnd.getLon())))
242                );
243        Bounds oldValue = this.bbox;
244        this.bbox = b;
245        repaint();
246        firePropertyChange(BBOX_PROP, oldValue, this.bbox);
247    }
248
249    /**
250     * Performs resizing of the DownloadDialog in order to enlarge or shrink the
251     * map.
252     */
253    public void resizeSlippyMap() {
254        boolean large = iSizeButton.isEnlarged();
255        firePropertyChange(RESIZE_PROP, !large, large);
256    }
257
258    /**
259     * Sets the active tile source
260     * @param tileSource The active tile source
261     */
262    public void toggleMapSource(TileSource tileSource) {
263        this.tileController.setTileCache(new MemoryTileCache());
264        this.setTileSource(tileSource);
265        PROP_MAPSTYLE.put(tileSource.getName()); // TODO Is name really unique?
266
267        // we need to refresh the tile sources in case the deselected source should no longer be present
268        // (and only remained there because its removal was deferred while the source was still the
269        // selected one). this should also have the effect of propagating the new selection to the
270        // iSourceButton & menu: it attempts to re-select the current source when rebuilding its menu.
271        this.refreshTileSources();
272    }
273
274    @Override
275    public Bounds getBoundingBox() {
276        return bbox;
277    }
278
279    /**
280     * Sets the current bounding box in this bbox chooser without
281     * emitting a property change event.
282     *
283     * @param bbox the bounding box. null to reset the bounding box
284     */
285    @Override
286    public void setBoundingBox(Bounds bbox) {
287        if (bbox == null || (bbox.getMinLat() == 0 && bbox.getMinLon() == 0
288                && bbox.getMaxLat() == 0 && bbox.getMaxLon() == 0)) {
289            this.bbox = null;
290            iSelectionRectStart = null;
291            iSelectionRectEnd = null;
292            repaint();
293            return;
294        }
295
296        this.bbox = bbox;
297        iSelectionRectStart = new Coordinate(bbox.getMinLat(), bbox.getMinLon());
298        iSelectionRectEnd = new Coordinate(bbox.getMaxLat(), bbox.getMaxLon());
299
300        // calc the screen coordinates for the new selection rectangle
301        MapMarkerDot min = new MapMarkerDot(bbox.getMinLat(), bbox.getMinLon());
302        MapMarkerDot max = new MapMarkerDot(bbox.getMaxLat(), bbox.getMaxLon());
303
304        List<MapMarker> marker = new ArrayList<>(2);
305        marker.add(min);
306        marker.add(max);
307        setMapMarkerList(marker);
308        setDisplayToFitMapMarkers();
309        zoomOut();
310        repaint();
311    }
312
313    /**
314     * Enables or disables painting of the shrink/enlarge button
315     *
316     * @param visible {@code true} to enable painting of the shrink/enlarge button
317     */
318    public void setSizeButtonVisible(boolean visible) {
319        iSizeButton.setVisible(visible);
320    }
321
322    /**
323     * Refreshes the tile sources
324     * @since 6364
325     */
326    public final void refreshTileSources() {
327        final LinkedHashMap<String, TileSource> newTileSources = getAllTileSources();
328        final TileSource currentTileSource = this.getTileController().getTileSource();
329
330        // re-add the currently active TileSource to prevent inconsistent display of menu
331        newTileSources.putIfAbsent(currentTileSource.getId(), currentTileSource);
332
333        this.iSourceButton.setSources(new ArrayList<>(newTileSources.values()));
334    }
335
336    @Override
337    public void layerAdded(MainLayerManager.LayerAddEvent e) {
338        if (e.getAddedLayer() instanceof ImageryLayer) {
339            this.refreshTileSources();
340        }
341    }
342
343    @Override
344    public void layerRemoving(MainLayerManager.LayerRemoveEvent e) {
345        if (e.getRemovedLayer() instanceof ImageryLayer) {
346            this.refreshTileSources();
347        }
348    }
349
350    @Override
351    public void layerOrderChanged(MainLayerManager.LayerOrderChangeEvent e) {
352        // Do nothing
353    }
354}