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}