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}