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