001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.bbox;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.Color;
007import java.awt.Dimension;
008import java.awt.Graphics;
009import java.awt.Graphics2D;
010import java.awt.Point;
011import java.awt.Rectangle;
012import java.awt.geom.Area;
013import java.awt.geom.Path2D;
014import java.util.ArrayList;
015import java.util.Collections;
016import java.util.HashMap;
017import java.util.LinkedHashMap;
018import java.util.List;
019import java.util.Map;
020import java.util.concurrent.CopyOnWriteArrayList;
021import java.util.concurrent.TimeUnit;
022import java.util.stream.Collectors;
023
024import javax.swing.ButtonModel;
025import javax.swing.JOptionPane;
026import javax.swing.JToggleButton;
027import javax.swing.SpringLayout;
028import javax.swing.event.ChangeEvent;
029import javax.swing.event.ChangeListener;
030
031import org.openstreetmap.gui.jmapviewer.Coordinate;
032import org.openstreetmap.gui.jmapviewer.JMapViewer;
033import org.openstreetmap.gui.jmapviewer.MapMarkerDot;
034import org.openstreetmap.gui.jmapviewer.MemoryTileCache;
035import org.openstreetmap.gui.jmapviewer.OsmTileLoader;
036import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
037import org.openstreetmap.gui.jmapviewer.interfaces.MapMarker;
038import org.openstreetmap.gui.jmapviewer.interfaces.TileLoader;
039import org.openstreetmap.gui.jmapviewer.interfaces.TileSource;
040import org.openstreetmap.gui.jmapviewer.tilesources.OsmTileSource;
041import org.openstreetmap.josm.data.Bounds;
042import org.openstreetmap.josm.data.Version;
043import org.openstreetmap.josm.data.coor.LatLon;
044import org.openstreetmap.josm.data.imagery.ImageryInfo;
045import org.openstreetmap.josm.data.imagery.ImageryLayerInfo;
046import org.openstreetmap.josm.data.imagery.TMSCachedTileLoader;
047import org.openstreetmap.josm.data.imagery.TileLoaderFactory;
048import org.openstreetmap.josm.data.osm.BBox;
049import org.openstreetmap.josm.data.osm.DataSet;
050import org.openstreetmap.josm.data.preferences.BooleanProperty;
051import org.openstreetmap.josm.data.preferences.StringProperty;
052import org.openstreetmap.josm.gui.MainApplication;
053import org.openstreetmap.josm.gui.layer.AbstractCachedTileSourceLayer;
054import org.openstreetmap.josm.gui.layer.ImageryLayer;
055import org.openstreetmap.josm.gui.layer.MainLayerManager;
056import org.openstreetmap.josm.gui.layer.TMSLayer;
057import org.openstreetmap.josm.spi.preferences.Config;
058import org.openstreetmap.josm.tools.Logging;
059
060/**
061 * This panel displays a map and lets the user chose a {@link BBox}.
062 */
063public class SlippyMapBBoxChooser extends JMapViewer implements BBoxChooser, ChangeListener,
064    MainLayerManager.ActiveLayerChangeListener, MainLayerManager.LayerChangeListener {
065    /**
066     * A list of tile sources that can be used for displaying the map.
067     */
068    @FunctionalInterface
069    public interface TileSourceProvider {
070        /**
071         * Gets the tile sources that can be displayed
072         * @return The tile sources
073         */
074        List<TileSource> getTileSources();
075    }
076
077    /**
078     * TileSource provider for the slippymap chooser.
079     * @since 14300
080     */
081    public abstract static class AbstractImageryInfoBasedTileSourceProvider implements TileSourceProvider {
082        /**
083         * Returns the list of imagery infos backing tile sources.
084         * @return the list of imagery infos backing tile sources
085         */
086        public abstract List<ImageryInfo> getImageryInfos();
087
088        @Override
089        public List<TileSource> getTileSources() {
090            if (!TMSLayer.PROP_ADD_TO_SLIPPYMAP_CHOOSER.get()) return Collections.<TileSource>emptyList();
091            return imageryInfosToTileSources(getImageryInfos());
092        }
093    }
094
095    /**
096     * TileSource provider for the slippymap chooser - providing default OSM tile source
097     * @since 14495
098     */
099    public static class DefaultOsmTileSourceProvider implements TileSourceProvider {
100
101        protected static final StringProperty DEFAULT_OSM_TILE_URL = new StringProperty(
102                "default.osm.tile.source.url", "https://{switch:a,b,c}.tile.openstreetmap.org/{zoom}/{x}/{y}.png");
103
104        @Override
105        public List<TileSource> getTileSources() {
106            List<TileSource> result = imageryInfosToTileSources(ImageryLayerInfo.instance.getLayers().stream()
107                   .filter(l -> l.getUrl().equals(DEFAULT_OSM_TILE_URL.get())).collect(Collectors.toList()));
108            if (result.isEmpty()) {
109                result.add(new OsmTileSource.Mapnik());
110            }
111            return result;
112        }
113
114        /**
115         * Returns the default OSM tile source.
116         * @return the default OSM tile source
117         */
118        public static TileSource get() {
119            return new DefaultOsmTileSourceProvider().getTileSources().get(0);
120        }
121    }
122
123    /**
124     * TileSource provider for the slippymap chooser - providing sources from imagery sources menu
125     * @since 14300
126     */
127    public static class TMSTileSourceProvider extends AbstractImageryInfoBasedTileSourceProvider {
128        @Override
129        public List<ImageryInfo> getImageryInfos() {
130            return ImageryLayerInfo.instance.getLayers();
131        }
132    }
133
134    /**
135     * TileSource provider for the slippymap chooser - providing sources from current layers
136     * @since 14300
137     */
138    public static class CurrentLayersTileSourceProvider extends AbstractImageryInfoBasedTileSourceProvider {
139        @Override
140        public List<ImageryInfo> getImageryInfos() {
141            return MainApplication.getLayerManager().getLayers().stream().filter(
142                layer -> layer instanceof ImageryLayer
143            ).map(
144                layer -> ((ImageryLayer) layer).getInfo()
145            ).collect(Collectors.toList());
146        }
147    }
148
149    static List<TileSource> imageryInfosToTileSources(List<ImageryInfo> imageryInfos) {
150        List<TileSource> sources = new ArrayList<>();
151        for (ImageryInfo info : imageryInfos) {
152            try {
153                TileSource source = TMSLayer.getTileSourceStatic(info);
154                if (source != null) {
155                    sources.add(source);
156                }
157            } catch (IllegalArgumentException ex) {
158                Logging.warn(ex);
159                if (ex.getMessage() != null && !ex.getMessage().isEmpty()) {
160                    JOptionPane.showMessageDialog(MainApplication.getMainFrame(),
161                            ex.getMessage(), tr("Warning"),
162                            JOptionPane.WARNING_MESSAGE);
163                }
164            }
165        }
166        return sources;
167    }
168
169    /**
170     * Plugins that wish to add custom tile sources to slippy map choose should call this method
171     * @param tileSourceProvider new tile source provider
172     */
173    public static void addTileSourceProvider(TileSourceProvider tileSourceProvider) {
174        providers.addIfAbsent(tileSourceProvider);
175    }
176
177    private static CopyOnWriteArrayList<TileSourceProvider> providers = new CopyOnWriteArrayList<>();
178    static {
179        addTileSourceProvider(new DefaultOsmTileSourceProvider());
180        addTileSourceProvider(new TMSTileSourceProvider());
181        addTileSourceProvider(new CurrentLayersTileSourceProvider());
182    }
183
184    private static final StringProperty PROP_MAPSTYLE = new StringProperty("slippy_map_chooser.mapstyle", "Mapnik");
185    private static final BooleanProperty PROP_SHOWDLAREA = new BooleanProperty("slippy_map_chooser.show_downloaded_area", true);
186    /**
187     * The property name used for the resize button.
188     * @see #addPropertyChangeListener(java.beans.PropertyChangeListener)
189     */
190    public static final String RESIZE_PROP = SlippyMapBBoxChooser.class.getName() + ".resize";
191
192    private final transient TileLoader cachedLoader;
193    private final transient OsmTileLoader uncachedLoader;
194
195    private final SizeButton iSizeButton;
196    private final ButtonModel showDownloadAreaButtonModel;
197    private final SourceButton iSourceButton;
198    private transient Bounds bbox;
199
200    // upper left and lower right corners of the selection rectangle (x/y on ZOOM_MAX)
201    private transient ICoordinate iSelectionRectStart;
202    private transient ICoordinate iSelectionRectEnd;
203
204    /**
205     * Constructs a new {@code SlippyMapBBoxChooser}.
206     */
207    public SlippyMapBBoxChooser() {
208        debug = Logging.isDebugEnabled();
209        SpringLayout springLayout = new SpringLayout();
210        setLayout(springLayout);
211
212        Map<String, String> headers = new HashMap<>();
213        headers.put("User-Agent", Version.getInstance().getFullAgentString());
214
215        TileLoaderFactory cachedLoaderFactory = AbstractCachedTileSourceLayer.getTileLoaderFactory("TMS", TMSCachedTileLoader.class);
216        if (cachedLoaderFactory != null) {
217            cachedLoader = cachedLoaderFactory.makeTileLoader(this, headers, TimeUnit.HOURS.toSeconds(1));
218        } else {
219            cachedLoader = null;
220        }
221
222        uncachedLoader = new OsmTileLoader(this);
223        uncachedLoader.headers.putAll(headers);
224        setZoomControlsVisible(Config.getPref().getBoolean("slippy_map_chooser.zoomcontrols", false));
225        setMapMarkerVisible(false);
226        setMinimumSize(new Dimension(350, 350 / 2));
227        // We need to set an initial size - this prevents a wrong zoom selection
228        // for the area before the component has been displayed the first time
229        setBounds(new Rectangle(getMinimumSize()));
230        if (cachedLoader == null) {
231            setFileCacheEnabled(false);
232        } else {
233            setFileCacheEnabled(Config.getPref().getBoolean("slippy_map_chooser.file_cache", true));
234        }
235        setMaxTilesInMemory(Config.getPref().getInt("slippy_map_chooser.max_tiles", 1000));
236
237        List<TileSource> tileSources = new ArrayList<>(getAllTileSources().values());
238
239        this.showDownloadAreaButtonModel = new JToggleButton.ToggleButtonModel();
240        this.showDownloadAreaButtonModel.setSelected(PROP_SHOWDLAREA.get());
241        this.showDownloadAreaButtonModel.addChangeListener(this);
242        iSourceButton = new SourceButton(this, tileSources, this.showDownloadAreaButtonModel);
243        add(iSourceButton);
244        springLayout.putConstraint(SpringLayout.EAST, iSourceButton, -2, SpringLayout.EAST, this);
245        springLayout.putConstraint(SpringLayout.NORTH, iSourceButton, 2, SpringLayout.NORTH, this);
246
247        iSizeButton = new SizeButton(this);
248        add(iSizeButton);
249
250        String mapStyle = PROP_MAPSTYLE.get();
251        boolean foundSource = false;
252        for (TileSource source: tileSources) {
253            if (source.getName().equals(mapStyle)) {
254                this.setTileSource(source);
255                iSourceButton.setCurrentMap(source);
256                foundSource = true;
257                break;
258            }
259        }
260        if (!foundSource) {
261            setTileSource(tileSources.get(0));
262            iSourceButton.setCurrentMap(tileSources.get(0));
263        }
264
265        MainApplication.getLayerManager().addActiveLayerChangeListener(this);
266
267        new SlippyMapControler(this, this);
268    }
269
270    private static LinkedHashMap<String, TileSource> getAllTileSources() {
271        // using a LinkedHashMap of <id, TileSource> to retain ordering but provide deduplication
272        return providers.stream().flatMap(
273            provider -> provider.getTileSources().stream()
274        ).collect(Collectors.toMap(
275            TileSource::getId,
276            ts -> ts,
277            (oldTs, newTs) -> oldTs,
278            LinkedHashMap::new
279        ));
280    }
281
282    /**
283     * Handles a click/move on the attribution
284     * @param p The point in the view
285     * @param click true if it was a click, false for hover
286     * @return if the attribution handled the event
287     */
288    public boolean handleAttribution(Point p, boolean click) {
289        return attribution.handleAttribution(p, click);
290    }
291
292    /**
293     * Draw the map.
294     */
295    @Override
296    public void paintComponent(Graphics g) {
297        super.paintComponent(g);
298        Graphics2D g2d = (Graphics2D) g;
299
300        // draw shaded area for non-downloaded region of current data set, but only if there *is* a current data set,
301        // and it has defined bounds. Routine is analogous to that in OsmDataLayer's paint routine (but just different
302        // enough to make sharing code impractical)
303        final DataSet ds = MainApplication.getLayerManager().getActiveDataSet();
304        if (ds != null && this.showDownloadAreaButtonModel.isSelected() && !ds.getDataSources().isEmpty()) {
305            // initialize area with current viewport
306            Rectangle b = this.getBounds();
307            // ensure we comfortably cover full area
308            b.grow(100, 100);
309            Path2D p = new Path2D.Float();
310
311            // combine successively downloaded areas after converting to screen-space
312            for (Bounds bounds : ds.getDataSourceBounds()) {
313                if (bounds.isCollapsed()) {
314                    continue;
315                }
316                Rectangle r = new Rectangle(this.getMapPosition(bounds.getMinLat(), bounds.getMinLon(), false));
317                r.add(this.getMapPosition(bounds.getMaxLat(), bounds.getMaxLon(), false));
318                p.append(r, false);
319            }
320            // subtract combined areas
321            Area a = new Area(b);
322            a.subtract(new Area(p));
323
324            // paint remainder
325            g2d.setPaint(new Color(0, 0, 0, 32));
326            g2d.fill(a);
327        }
328
329        // draw selection rectangle
330        if (iSelectionRectStart != null && iSelectionRectEnd != null) {
331            Rectangle box = new Rectangle(getMapPosition(iSelectionRectStart, false));
332            box.add(getMapPosition(iSelectionRectEnd, false));
333
334            g.setColor(new Color(0.9f, 0.7f, 0.7f, 0.6f));
335            g.fillRect(box.x, box.y, box.width, box.height);
336
337            g.setColor(Color.BLACK);
338            g.drawRect(box.x, box.y, box.width, box.height);
339        }
340    }
341
342    @Override
343    public void activeOrEditLayerChanged(MainLayerManager.ActiveLayerChangeEvent e) {
344        this.repaint();
345    }
346
347    @Override
348    public void stateChanged(ChangeEvent e) {
349        // fired for the stateChanged event of this.showDownloadAreaButtonModel
350        PROP_SHOWDLAREA.put(this.showDownloadAreaButtonModel.isSelected());
351        this.repaint();
352    }
353
354    /**
355     * Enables the disk tile cache.
356     * @param enabled true to enable, false to disable
357     */
358    public final void setFileCacheEnabled(boolean enabled) {
359        if (enabled && cachedLoader != null) {
360            setTileLoader(cachedLoader);
361        } else {
362            setTileLoader(uncachedLoader);
363        }
364    }
365
366    /**
367     * Sets the maximum number of tiles that may be held in memory
368     * @param tiles The maximum number of tiles.
369     */
370    public final void setMaxTilesInMemory(int tiles) {
371        ((MemoryTileCache) getTileCache()).setCacheSize(tiles);
372    }
373
374    /**
375     * Callback for the OsmMapControl. (Re-)Sets the start and end point of the selection rectangle.
376     *
377     * @param aStart selection start
378     * @param aEnd selection end
379     */
380    public void setSelection(Point aStart, Point aEnd) {
381        if (aStart == null || aEnd == null || aStart.x == aEnd.x || aStart.y == aEnd.y)
382            return;
383
384        Point pMax = new Point(Math.max(aEnd.x, aStart.x), Math.max(aEnd.y, aStart.y));
385        Point pMin = new Point(Math.min(aEnd.x, aStart.x), Math.min(aEnd.y, aStart.y));
386
387        iSelectionRectStart = getPosition(pMin);
388        iSelectionRectEnd = getPosition(pMax);
389
390        Bounds b = new Bounds(
391                new LatLon(
392                        Math.min(iSelectionRectStart.getLat(), iSelectionRectEnd.getLat()),
393                        LatLon.toIntervalLon(Math.min(iSelectionRectStart.getLon(), iSelectionRectEnd.getLon()))
394                        ),
395                        new LatLon(
396                                Math.max(iSelectionRectStart.getLat(), iSelectionRectEnd.getLat()),
397                                LatLon.toIntervalLon(Math.max(iSelectionRectStart.getLon(), iSelectionRectEnd.getLon())))
398                );
399        Bounds oldValue = this.bbox;
400        this.bbox = b;
401        repaint();
402        firePropertyChange(BBOX_PROP, oldValue, this.bbox);
403    }
404
405    /**
406     * Performs resizing of the DownloadDialog in order to enlarge or shrink the
407     * map.
408     */
409    public void resizeSlippyMap() {
410        boolean large = iSizeButton.isEnlarged();
411        firePropertyChange(RESIZE_PROP, !large, large);
412    }
413
414    /**
415     * Sets the active tile source
416     * @param tileSource The active tile source
417     */
418    public void toggleMapSource(TileSource tileSource) {
419        this.tileController.setTileCache(new MemoryTileCache());
420        this.setTileSource(tileSource);
421        PROP_MAPSTYLE.put(tileSource.getName()); // TODO Is name really unique?
422
423        // we need to refresh the tile sources in case the deselected source should no longer be present
424        // (and only remained there because its removal was deferred while the source was still the
425        // selected one). this should also have the effect of propagating the new selection to the
426        // iSourceButton & menu: it attempts to re-select the current source when rebuilding its menu.
427        this.refreshTileSources();
428    }
429
430    @Override
431    public Bounds getBoundingBox() {
432        return bbox;
433    }
434
435    /**
436     * Sets the current bounding box in this bbox chooser without
437     * emitting a property change event.
438     *
439     * @param bbox the bounding box. null to reset the bounding box
440     */
441    @Override
442    public void setBoundingBox(Bounds bbox) {
443        if (bbox == null || (bbox.getMinLat() == 0 && bbox.getMinLon() == 0
444                && bbox.getMaxLat() == 0 && bbox.getMaxLon() == 0)) {
445            this.bbox = null;
446            iSelectionRectStart = null;
447            iSelectionRectEnd = null;
448            repaint();
449            return;
450        }
451
452        this.bbox = bbox;
453        iSelectionRectStart = new Coordinate(bbox.getMinLat(), bbox.getMinLon());
454        iSelectionRectEnd = new Coordinate(bbox.getMaxLat(), bbox.getMaxLon());
455
456        // calc the screen coordinates for the new selection rectangle
457        MapMarkerDot min = new MapMarkerDot(bbox.getMinLat(), bbox.getMinLon());
458        MapMarkerDot max = new MapMarkerDot(bbox.getMaxLat(), bbox.getMaxLon());
459
460        List<MapMarker> marker = new ArrayList<>(2);
461        marker.add(min);
462        marker.add(max);
463        setMapMarkerList(marker);
464        setDisplayToFitMapMarkers();
465        zoomOut();
466        repaint();
467    }
468
469    /**
470     * Enables or disables painting of the shrink/enlarge button
471     *
472     * @param visible {@code true} to enable painting of the shrink/enlarge button
473     */
474    public void setSizeButtonVisible(boolean visible) {
475        iSizeButton.setVisible(visible);
476    }
477
478    /**
479     * Refreshes the tile sources
480     * @since 6364
481     */
482    public final void refreshTileSources() {
483        final LinkedHashMap<String, TileSource> newTileSources = getAllTileSources();
484        final TileSource currentTileSource = this.getTileController().getTileSource();
485
486        // re-add the currently active TileSource to prevent inconsistent display of menu
487        newTileSources.putIfAbsent(currentTileSource.getId(), currentTileSource);
488
489        this.iSourceButton.setSources(new ArrayList<>(newTileSources.values()));
490    }
491
492    @Override
493    public void layerAdded(MainLayerManager.LayerAddEvent e) {
494        if (e.getAddedLayer() instanceof ImageryLayer) {
495            this.refreshTileSources();
496        }
497    }
498
499    @Override
500    public void layerRemoving(MainLayerManager.LayerRemoveEvent e) {
501        if (e.getRemovedLayer() instanceof ImageryLayer) {
502            this.refreshTileSources();
503        }
504    }
505
506    @Override
507    public void layerOrderChanged(MainLayerManager.LayerOrderChangeEvent e) {
508        // Do nothing
509    }
510}