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