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}