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}