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.Arrays;
016import java.util.Collections;
017import java.util.HashMap;
018import java.util.HashSet;
019import java.util.List;
020import java.util.Map;
021import java.util.Set;
022import java.util.concurrent.CopyOnWriteArrayList;
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.Main;
042import org.openstreetmap.josm.data.Bounds;
043import org.openstreetmap.josm.data.Version;
044import org.openstreetmap.josm.data.coor.LatLon;
045import org.openstreetmap.josm.data.imagery.ImageryInfo;
046import org.openstreetmap.josm.data.imagery.ImageryLayerInfo;
047import org.openstreetmap.josm.data.imagery.TMSCachedTileLoader;
048import org.openstreetmap.josm.data.imagery.TileLoaderFactory;
049import org.openstreetmap.josm.data.osm.BBox;
050import org.openstreetmap.josm.data.osm.DataSet;
051import org.openstreetmap.josm.data.preferences.BooleanProperty;
052import org.openstreetmap.josm.data.preferences.StringProperty;
053import org.openstreetmap.josm.gui.MainApplication;
054import org.openstreetmap.josm.gui.layer.AbstractCachedTileSourceLayer;
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, MainLayerManager.ActiveLayerChangeListener {
064
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     * TMS TileSource provider for the slippymap chooser
079     */
080    public static class TMSTileSourceProvider implements TileSourceProvider {
081        private static final Set<String> existingSlippyMapUrls = new HashSet<>();
082        static {
083            // Urls that already exist in the slippymap chooser and shouldn't be copied from TMS layer list
084            existingSlippyMapUrls.add("https://{switch:a,b,c}.tile.openstreetmap.org/{zoom}/{x}/{y}.png");      // Mapnik
085        }
086
087        @Override
088        public List<TileSource> getTileSources() {
089            if (!TMSLayer.PROP_ADD_TO_SLIPPYMAP_CHOOSER.get()) return Collections.<TileSource>emptyList();
090            List<TileSource> sources = new ArrayList<>();
091            for (ImageryInfo info : ImageryLayerInfo.instance.getLayers()) {
092                if (existingSlippyMapUrls.contains(info.getUrl())) {
093                    continue;
094                }
095                try {
096                    TileSource source = TMSLayer.getTileSourceStatic(info);
097                    if (source != null) {
098                        sources.add(source);
099                    }
100                } catch (IllegalArgumentException ex) {
101                    Logging.warn(ex);
102                    if (ex.getMessage() != null && !ex.getMessage().isEmpty()) {
103                        JOptionPane.showMessageDialog(Main.parent,
104                                ex.getMessage(), tr("Warning"),
105                                JOptionPane.WARNING_MESSAGE);
106                    }
107                }
108            }
109            return sources;
110        }
111    }
112
113    /**
114     * Plugins that wish to add custom tile sources to slippy map choose should call this method
115     * @param tileSourceProvider new tile source provider
116     */
117    public static void addTileSourceProvider(TileSourceProvider tileSourceProvider) {
118        providers.addIfAbsent(tileSourceProvider);
119    }
120
121    private static CopyOnWriteArrayList<TileSourceProvider> providers = new CopyOnWriteArrayList<>();
122    static {
123        addTileSourceProvider(() -> Arrays.<TileSource>asList(new OsmTileSource.Mapnik()));
124        addTileSourceProvider(new TMSTileSourceProvider());
125    }
126
127    private static final StringProperty PROP_MAPSTYLE = new StringProperty("slippy_map_chooser.mapstyle", "Mapnik");
128    private static final BooleanProperty PROP_SHOWDLAREA = new BooleanProperty("slippy_map_chooser.show_downloaded_area", true);
129    /**
130     * The property name used for the resize button.
131     * @see #addPropertyChangeListener(java.beans.PropertyChangeListener)
132     */
133    public static final String RESIZE_PROP = SlippyMapBBoxChooser.class.getName() + ".resize";
134
135    private final transient TileLoader cachedLoader;
136    private final transient OsmTileLoader uncachedLoader;
137
138    private final SizeButton iSizeButton;
139    private final ButtonModel showDownloadAreaButtonModel;
140    private final SourceButton iSourceButton;
141    private transient Bounds bbox;
142
143    // upper left and lower right corners of the selection rectangle (x/y on ZOOM_MAX)
144    private transient ICoordinate iSelectionRectStart;
145    private transient ICoordinate iSelectionRectEnd;
146
147    /**
148     * Constructs a new {@code SlippyMapBBoxChooser}.
149     */
150    public SlippyMapBBoxChooser() {
151        debug = Logging.isDebugEnabled();
152        SpringLayout springLayout = new SpringLayout();
153        setLayout(springLayout);
154
155        Map<String, String> headers = new HashMap<>();
156        headers.put("User-Agent", Version.getInstance().getFullAgentString());
157
158        TileLoaderFactory cachedLoaderFactory = AbstractCachedTileSourceLayer.getTileLoaderFactory("TMS", TMSCachedTileLoader.class);
159        if (cachedLoaderFactory != null) {
160            cachedLoader = cachedLoaderFactory.makeTileLoader(this, headers);
161        } else {
162            cachedLoader = null;
163        }
164
165        uncachedLoader = new OsmTileLoader(this);
166        uncachedLoader.headers.putAll(headers);
167        setZoomControlsVisible(Config.getPref().getBoolean("slippy_map_chooser.zoomcontrols", false));
168        setMapMarkerVisible(false);
169        setMinimumSize(new Dimension(350, 350 / 2));
170        // We need to set an initial size - this prevents a wrong zoom selection
171        // for the area before the component has been displayed the first time
172        setBounds(new Rectangle(getMinimumSize()));
173        if (cachedLoader == null) {
174            setFileCacheEnabled(false);
175        } else {
176            setFileCacheEnabled(Config.getPref().getBoolean("slippy_map_chooser.file_cache", true));
177        }
178        setMaxTilesInMemory(Config.getPref().getInt("slippy_map_chooser.max_tiles", 1000));
179
180        List<TileSource> tileSources = getAllTileSources();
181
182        this.showDownloadAreaButtonModel = new JToggleButton.ToggleButtonModel();
183        this.showDownloadAreaButtonModel.setSelected(PROP_SHOWDLAREA.get());
184        this.showDownloadAreaButtonModel.addChangeListener(this);
185        iSourceButton = new SourceButton(this, tileSources, this.showDownloadAreaButtonModel);
186        add(iSourceButton);
187        springLayout.putConstraint(SpringLayout.EAST, iSourceButton, -2, SpringLayout.EAST, this);
188        springLayout.putConstraint(SpringLayout.NORTH, iSourceButton, 2, SpringLayout.NORTH, this);
189
190        iSizeButton = new SizeButton(this);
191        add(iSizeButton);
192
193        String mapStyle = PROP_MAPSTYLE.get();
194        boolean foundSource = false;
195        for (TileSource source: tileSources) {
196            if (source.getName().equals(mapStyle)) {
197                this.setTileSource(source);
198                iSourceButton.setCurrentMap(source);
199                foundSource = true;
200                break;
201            }
202        }
203        if (!foundSource) {
204            setTileSource(tileSources.get(0));
205            iSourceButton.setCurrentMap(tileSources.get(0));
206        }
207
208        MainApplication.getLayerManager().addActiveLayerChangeListener(this);
209
210        new SlippyMapControler(this, this);
211    }
212
213    private static List<TileSource> getAllTileSources() {
214        List<TileSource> tileSources = new ArrayList<>();
215        for (TileSourceProvider provider: providers) {
216            tileSources.addAll(provider.getTileSources());
217        }
218        return tileSources;
219    }
220
221    /**
222     * Handles a click/move on the attribution
223     * @param p The point in the view
224     * @param click true if it was a click, false for hover
225     * @return if the attribution handled the event
226     */
227    public boolean handleAttribution(Point p, boolean click) {
228        return attribution.handleAttribution(p, click);
229    }
230
231    /**
232     * Draw the map.
233     */
234    @Override
235    public void paintComponent(Graphics g) {
236        super.paintComponent(g);
237        Graphics2D g2d = (Graphics2D) g;
238
239        // draw shaded area for non-downloaded region of current data set, but only if there *is* a current data set,
240        // and it has defined bounds. Routine is analogous to that in OsmDataLayer's paint routine (but just different
241        // enough to make sharing code impractical)
242        final DataSet ds = MainApplication.getLayerManager().getActiveDataSet();
243        if (ds != null && this.showDownloadAreaButtonModel.isSelected() && !ds.getDataSources().isEmpty()) {
244            // initialize area with current viewport
245            Rectangle b = this.getBounds();
246            // ensure we comfortably cover full area
247            b.grow(100, 100);
248            Path2D p = new Path2D.Float();
249
250            // combine successively downloaded areas after converting to screen-space
251            for (Bounds bounds : ds.getDataSourceBounds()) {
252                if (bounds.isCollapsed()) {
253                    continue;
254                }
255                Rectangle r = new Rectangle(this.getMapPosition(bounds.getMinLat(), bounds.getMinLon(), false));
256                r.add(this.getMapPosition(bounds.getMaxLat(), bounds.getMaxLon(), false));
257                p.append(r, false);
258            }
259            // subtract combined areas
260            Area a = new Area(b);
261            a.subtract(new Area(p));
262
263            // paint remainder
264            g2d.setPaint(new Color(0, 0, 0, 32));
265            g2d.fill(a);
266        }
267
268        // draw selection rectangle
269        if (iSelectionRectStart != null && iSelectionRectEnd != null) {
270            Rectangle box = new Rectangle(getMapPosition(iSelectionRectStart, false));
271            box.add(getMapPosition(iSelectionRectEnd, false));
272
273            g.setColor(new Color(0.9f, 0.7f, 0.7f, 0.6f));
274            g.fillRect(box.x, box.y, box.width, box.height);
275
276            g.setColor(Color.BLACK);
277            g.drawRect(box.x, box.y, box.width, box.height);
278        }
279    }
280
281    @Override
282    public void activeOrEditLayerChanged(MainLayerManager.ActiveLayerChangeEvent e) {
283        this.repaint();
284    }
285
286    @Override
287    public void stateChanged(ChangeEvent e) {
288        // fired for the stateChanged event of this.showDownloadAreaButtonModel
289        PROP_SHOWDLAREA.put(this.showDownloadAreaButtonModel.isSelected());
290        this.repaint();
291    }
292
293    /**
294     * Enables the disk tile cache.
295     * @param enabled true to enable, false to disable
296     */
297    public final void setFileCacheEnabled(boolean enabled) {
298        if (enabled && cachedLoader != null) {
299            setTileLoader(cachedLoader);
300        } else {
301            setTileLoader(uncachedLoader);
302        }
303    }
304
305    /**
306     * Sets the maximum number of tiles that may be held in memory
307     * @param tiles The maximum number of tiles.
308     */
309    public final void setMaxTilesInMemory(int tiles) {
310        ((MemoryTileCache) getTileCache()).setCacheSize(tiles);
311    }
312
313    /**
314     * Callback for the OsmMapControl. (Re-)Sets the start and end point of the selection rectangle.
315     *
316     * @param aStart selection start
317     * @param aEnd selection end
318     */
319    public void setSelection(Point aStart, Point aEnd) {
320        if (aStart == null || aEnd == null || aStart.x == aEnd.x || aStart.y == aEnd.y)
321            return;
322
323        Point pMax = new Point(Math.max(aEnd.x, aStart.x), Math.max(aEnd.y, aStart.y));
324        Point pMin = new Point(Math.min(aEnd.x, aStart.x), Math.min(aEnd.y, aStart.y));
325
326        iSelectionRectStart = getPosition(pMin);
327        iSelectionRectEnd = getPosition(pMax);
328
329        Bounds b = new Bounds(
330                new LatLon(
331                        Math.min(iSelectionRectStart.getLat(), iSelectionRectEnd.getLat()),
332                        LatLon.toIntervalLon(Math.min(iSelectionRectStart.getLon(), iSelectionRectEnd.getLon()))
333                        ),
334                        new LatLon(
335                                Math.max(iSelectionRectStart.getLat(), iSelectionRectEnd.getLat()),
336                                LatLon.toIntervalLon(Math.max(iSelectionRectStart.getLon(), iSelectionRectEnd.getLon())))
337                );
338        Bounds oldValue = this.bbox;
339        this.bbox = b;
340        repaint();
341        firePropertyChange(BBOX_PROP, oldValue, this.bbox);
342    }
343
344    /**
345     * Performs resizing of the DownloadDialog in order to enlarge or shrink the
346     * map.
347     */
348    public void resizeSlippyMap() {
349        boolean large = iSizeButton.isEnlarged();
350        firePropertyChange(RESIZE_PROP, !large, large);
351    }
352
353    /**
354     * Sets the active tile source
355     * @param tileSource The active tile source
356     */
357    public void toggleMapSource(TileSource tileSource) {
358        this.tileController.setTileCache(new MemoryTileCache());
359        this.setTileSource(tileSource);
360        PROP_MAPSTYLE.put(tileSource.getName()); // TODO Is name really unique?
361        if (this.iSourceButton.getCurrentSource() != tileSource) { // prevent infinite recursion
362            this.iSourceButton.setCurrentMap(tileSource);
363        }
364    }
365
366    @Override
367    public Bounds getBoundingBox() {
368        return bbox;
369    }
370
371    /**
372     * Sets the current bounding box in this bbox chooser without
373     * emiting a property change event.
374     *
375     * @param bbox the bounding box. null to reset the bounding box
376     */
377    @Override
378    public void setBoundingBox(Bounds bbox) {
379        if (bbox == null || (bbox.getMinLat() == 0 && bbox.getMinLon() == 0
380                && bbox.getMaxLat() == 0 && bbox.getMaxLon() == 0)) {
381            this.bbox = null;
382            iSelectionRectStart = null;
383            iSelectionRectEnd = null;
384            repaint();
385            return;
386        }
387
388        this.bbox = bbox;
389        iSelectionRectStart = new Coordinate(bbox.getMinLat(), bbox.getMinLon());
390        iSelectionRectEnd = new Coordinate(bbox.getMaxLat(), bbox.getMaxLon());
391
392        // calc the screen coordinates for the new selection rectangle
393        MapMarkerDot min = new MapMarkerDot(bbox.getMinLat(), bbox.getMinLon());
394        MapMarkerDot max = new MapMarkerDot(bbox.getMaxLat(), bbox.getMaxLon());
395
396        List<MapMarker> marker = new ArrayList<>(2);
397        marker.add(min);
398        marker.add(max);
399        setMapMarkerList(marker);
400        setDisplayToFitMapMarkers();
401        zoomOut();
402        repaint();
403    }
404
405    /**
406     * Enables or disables painting of the shrink/enlarge button
407     *
408     * @param visible {@code true} to enable painting of the shrink/enlarge button
409     */
410    public void setSizeButtonVisible(boolean visible) {
411        iSizeButton.setVisible(visible);
412    }
413
414    /**
415     * Refreshes the tile sources
416     * @since 6364
417     */
418    public final void refreshTileSources() {
419        iSourceButton.setSources(getAllTileSources());
420    }
421}