001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.layer; 003 004import static org.openstreetmap.josm.gui.help.HelpUtil.ht; 005import static org.openstreetmap.josm.tools.I18n.marktr; 006import static org.openstreetmap.josm.tools.I18n.tr; 007import static org.openstreetmap.josm.tools.I18n.trn; 008 009import java.awt.AlphaComposite; 010import java.awt.Color; 011import java.awt.Composite; 012import java.awt.Graphics2D; 013import java.awt.GraphicsEnvironment; 014import java.awt.GridBagLayout; 015import java.awt.Rectangle; 016import java.awt.TexturePaint; 017import java.awt.event.ActionEvent; 018import java.awt.geom.Area; 019import java.awt.geom.Path2D; 020import java.awt.geom.Rectangle2D; 021import java.awt.image.BufferedImage; 022import java.io.File; 023import java.util.ArrayList; 024import java.util.Arrays; 025import java.util.Collection; 026import java.util.Collections; 027import java.util.HashMap; 028import java.util.HashSet; 029import java.util.LinkedHashMap; 030import java.util.List; 031import java.util.Map; 032import java.util.Set; 033import java.util.concurrent.CopyOnWriteArrayList; 034import java.util.concurrent.atomic.AtomicBoolean; 035import java.util.concurrent.atomic.AtomicInteger; 036import java.util.regex.Pattern; 037 038import javax.swing.AbstractAction; 039import javax.swing.Action; 040import javax.swing.Icon; 041import javax.swing.JLabel; 042import javax.swing.JOptionPane; 043import javax.swing.JPanel; 044import javax.swing.JScrollPane; 045 046import org.openstreetmap.josm.Main; 047import org.openstreetmap.josm.actions.ExpertToggleAction; 048import org.openstreetmap.josm.actions.RenameLayerAction; 049import org.openstreetmap.josm.actions.ToggleUploadDiscouragedLayerAction; 050import org.openstreetmap.josm.data.APIDataSet; 051import org.openstreetmap.josm.data.Bounds; 052import org.openstreetmap.josm.data.DataSource; 053import org.openstreetmap.josm.data.ProjectionBounds; 054import org.openstreetmap.josm.data.conflict.Conflict; 055import org.openstreetmap.josm.data.conflict.ConflictCollection; 056import org.openstreetmap.josm.data.coor.EastNorth; 057import org.openstreetmap.josm.data.coor.LatLon; 058import org.openstreetmap.josm.data.gpx.GpxConstants; 059import org.openstreetmap.josm.data.gpx.GpxData; 060import org.openstreetmap.josm.data.gpx.GpxLink; 061import org.openstreetmap.josm.data.gpx.ImmutableGpxTrack; 062import org.openstreetmap.josm.data.gpx.WayPoint; 063import org.openstreetmap.josm.data.osm.DataIntegrityProblemException; 064import org.openstreetmap.josm.data.osm.DataSelectionListener; 065import org.openstreetmap.josm.data.osm.DataSet; 066import org.openstreetmap.josm.data.osm.DownloadPolicy; 067import org.openstreetmap.josm.data.osm.UploadPolicy; 068import org.openstreetmap.josm.data.osm.DataSetMerger; 069import org.openstreetmap.josm.data.osm.DatasetConsistencyTest; 070import org.openstreetmap.josm.data.osm.HighlightUpdateListener; 071import org.openstreetmap.josm.data.osm.IPrimitive; 072import org.openstreetmap.josm.data.osm.Node; 073import org.openstreetmap.josm.data.osm.OsmPrimitive; 074import org.openstreetmap.josm.data.osm.OsmPrimitiveComparator; 075import org.openstreetmap.josm.data.osm.Relation; 076import org.openstreetmap.josm.data.osm.Way; 077import org.openstreetmap.josm.data.osm.event.AbstractDatasetChangedEvent; 078import org.openstreetmap.josm.data.osm.event.DataSetListenerAdapter; 079import org.openstreetmap.josm.data.osm.event.DataSetListenerAdapter.Listener; 080import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor; 081import org.openstreetmap.josm.data.osm.visitor.OsmPrimitiveVisitor; 082import org.openstreetmap.josm.data.osm.visitor.paint.MapRendererFactory; 083import org.openstreetmap.josm.data.osm.visitor.paint.Rendering; 084import org.openstreetmap.josm.data.osm.visitor.paint.relations.MultipolygonCache; 085import org.openstreetmap.josm.data.preferences.IntegerProperty; 086import org.openstreetmap.josm.data.preferences.NamedColorProperty; 087import org.openstreetmap.josm.data.preferences.StringProperty; 088import org.openstreetmap.josm.data.projection.Projection; 089import org.openstreetmap.josm.data.validation.TestError; 090import org.openstreetmap.josm.gui.ExtendedDialog; 091import org.openstreetmap.josm.gui.MainApplication; 092import org.openstreetmap.josm.gui.MapFrame; 093import org.openstreetmap.josm.gui.MapView; 094import org.openstreetmap.josm.gui.MapViewState.MapViewPoint; 095import org.openstreetmap.josm.gui.dialogs.LayerListDialog; 096import org.openstreetmap.josm.gui.dialogs.LayerListPopup; 097import org.openstreetmap.josm.gui.io.AbstractIOTask; 098import org.openstreetmap.josm.gui.io.AbstractUploadDialog; 099import org.openstreetmap.josm.gui.io.UploadDialog; 100import org.openstreetmap.josm.gui.io.UploadLayerTask; 101import org.openstreetmap.josm.gui.io.importexport.OsmImporter; 102import org.openstreetmap.josm.gui.layer.markerlayer.MarkerLayer; 103import org.openstreetmap.josm.gui.progress.ProgressMonitor; 104import org.openstreetmap.josm.gui.progress.swing.PleaseWaitProgressMonitor; 105import org.openstreetmap.josm.gui.util.GuiHelper; 106import org.openstreetmap.josm.gui.widgets.FileChooserManager; 107import org.openstreetmap.josm.gui.widgets.JosmTextArea; 108import org.openstreetmap.josm.spi.preferences.Config; 109import org.openstreetmap.josm.tools.AlphanumComparator; 110import org.openstreetmap.josm.tools.CheckParameterUtil; 111import org.openstreetmap.josm.tools.GBC; 112import org.openstreetmap.josm.tools.ImageOverlay; 113import org.openstreetmap.josm.tools.ImageProvider; 114import org.openstreetmap.josm.tools.ImageProvider.ImageSizes; 115import org.openstreetmap.josm.tools.Logging; 116import org.openstreetmap.josm.tools.date.DateUtils; 117 118/** 119 * A layer that holds OSM data from a specific dataset. 120 * The data can be fully edited. 121 * 122 * @author imi 123 * @since 17 124 */ 125public class OsmDataLayer extends AbstractModifiableLayer implements Listener, DataSelectionListener, HighlightUpdateListener { 126 private static final int HATCHED_SIZE = 15; 127 /** Property used to know if this layer has to be saved on disk */ 128 public static final String REQUIRES_SAVE_TO_DISK_PROP = OsmDataLayer.class.getName() + ".requiresSaveToDisk"; 129 /** Property used to know if this layer has to be uploaded */ 130 public static final String REQUIRES_UPLOAD_TO_SERVER_PROP = OsmDataLayer.class.getName() + ".requiresUploadToServer"; 131 132 private boolean requiresSaveToFile; 133 private boolean requiresUploadToServer; 134 /** Flag used to know if the layer is being uploaded */ 135 private final AtomicBoolean isUploadInProgress = new AtomicBoolean(false); 136 137 /** 138 * List of validation errors in this layer. 139 * @since 3669 140 */ 141 public final List<TestError> validationErrors = new ArrayList<>(); 142 143 /** 144 * The default number of relations in the recent relations cache. 145 * @see #getRecentRelations() 146 */ 147 public static final int DEFAULT_RECENT_RELATIONS_NUMBER = 20; 148 /** 149 * The number of relations to use in the recent relations cache. 150 * @see #getRecentRelations() 151 */ 152 public static final IntegerProperty PROPERTY_RECENT_RELATIONS_NUMBER = new IntegerProperty("properties.last-closed-relations-size", 153 DEFAULT_RECENT_RELATIONS_NUMBER); 154 /** 155 * The extension that should be used when saving the OSM file. 156 */ 157 public static final StringProperty PROPERTY_SAVE_EXTENSION = new StringProperty("save.extension.osm", "osm"); 158 159 private static final NamedColorProperty PROPERTY_BACKGROUND_COLOR = new NamedColorProperty(marktr("background"), Color.BLACK); 160 private static final NamedColorProperty PROPERTY_OUTSIDE_COLOR = new NamedColorProperty(marktr("outside downloaded area"), Color.YELLOW); 161 162 /** List of recent relations */ 163 private final Map<Relation, Void> recentRelations = new LruCache(PROPERTY_RECENT_RELATIONS_NUMBER.get()+1); 164 165 /** 166 * Returns list of recently closed relations or null if none. 167 * @return list of recently closed relations or <code>null</code> if none 168 * @since 12291 (signature) 169 * @since 9668 170 */ 171 public List<Relation> getRecentRelations() { 172 ArrayList<Relation> list = new ArrayList<>(recentRelations.keySet()); 173 Collections.reverse(list); 174 return list; 175 } 176 177 /** 178 * Adds recently closed relation. 179 * @param relation new entry for the list of recently closed relations 180 * @see #PROPERTY_RECENT_RELATIONS_NUMBER 181 * @since 9668 182 */ 183 public void setRecentRelation(Relation relation) { 184 recentRelations.put(relation, null); 185 MapFrame map = MainApplication.getMap(); 186 if (map != null && map.relationListDialog != null) { 187 map.relationListDialog.enableRecentRelations(); 188 } 189 } 190 191 /** 192 * Remove relation from list of recent relations. 193 * @param relation relation to remove 194 * @since 9668 195 */ 196 public void removeRecentRelation(Relation relation) { 197 recentRelations.remove(relation); 198 MapFrame map = MainApplication.getMap(); 199 if (map != null && map.relationListDialog != null) { 200 map.relationListDialog.enableRecentRelations(); 201 } 202 } 203 204 protected void setRequiresSaveToFile(boolean newValue) { 205 boolean oldValue = requiresSaveToFile; 206 requiresSaveToFile = newValue; 207 if (oldValue != newValue) { 208 GuiHelper.runInEDT(() -> 209 propertyChangeSupport.firePropertyChange(REQUIRES_SAVE_TO_DISK_PROP, oldValue, newValue) 210 ); 211 } 212 } 213 214 protected void setRequiresUploadToServer(boolean newValue) { 215 boolean oldValue = requiresUploadToServer; 216 requiresUploadToServer = newValue; 217 if (oldValue != newValue) { 218 GuiHelper.runInEDT(() -> 219 propertyChangeSupport.firePropertyChange(REQUIRES_UPLOAD_TO_SERVER_PROP, oldValue, newValue) 220 ); 221 } 222 } 223 224 /** the global counter for created data layers */ 225 private static final AtomicInteger dataLayerCounter = new AtomicInteger(); 226 227 /** 228 * Replies a new unique name for a data layer 229 * 230 * @return a new unique name for a data layer 231 */ 232 public static String createNewName() { 233 return createLayerName(dataLayerCounter.incrementAndGet()); 234 } 235 236 static String createLayerName(Object arg) { 237 return tr("Data Layer {0}", arg); 238 } 239 240 static final class LruCache extends LinkedHashMap<Relation, Void> { 241 LruCache(int initialCapacity) { 242 super(initialCapacity, 1.1f, true); 243 } 244 245 @Override 246 protected boolean removeEldestEntry(Map.Entry<Relation, Void> eldest) { 247 return size() > PROPERTY_RECENT_RELATIONS_NUMBER.get(); 248 } 249 } 250 251 /** 252 * A listener that counts the number of primitives it encounters 253 */ 254 public static final class DataCountVisitor implements OsmPrimitiveVisitor { 255 /** 256 * Nodes that have been visited 257 */ 258 public int nodes; 259 /** 260 * Ways that have been visited 261 */ 262 public int ways; 263 /** 264 * Relations that have been visited 265 */ 266 public int relations; 267 /** 268 * Deleted nodes that have been visited 269 */ 270 public int deletedNodes; 271 /** 272 * Deleted ways that have been visited 273 */ 274 public int deletedWays; 275 /** 276 * Deleted relations that have been visited 277 */ 278 public int deletedRelations; 279 280 @Override 281 public void visit(final Node n) { 282 nodes++; 283 if (n.isDeleted()) { 284 deletedNodes++; 285 } 286 } 287 288 @Override 289 public void visit(final Way w) { 290 ways++; 291 if (w.isDeleted()) { 292 deletedWays++; 293 } 294 } 295 296 @Override 297 public void visit(final Relation r) { 298 relations++; 299 if (r.isDeleted()) { 300 deletedRelations++; 301 } 302 } 303 } 304 305 /** 306 * Listener called when a state of this layer has changed. 307 * @since 10600 (functional interface) 308 */ 309 @FunctionalInterface 310 public interface LayerStateChangeListener { 311 /** 312 * Notifies that the "upload discouraged" (upload=no) state has changed. 313 * @param layer The layer that has been modified 314 * @param newValue The new value of the state 315 */ 316 void uploadDiscouragedChanged(OsmDataLayer layer, boolean newValue); 317 } 318 319 private final CopyOnWriteArrayList<LayerStateChangeListener> layerStateChangeListeners = new CopyOnWriteArrayList<>(); 320 321 /** 322 * Adds a layer state change listener 323 * 324 * @param listener the listener. Ignored if null or already registered. 325 * @since 5519 326 */ 327 public void addLayerStateChangeListener(LayerStateChangeListener listener) { 328 if (listener != null) { 329 layerStateChangeListeners.addIfAbsent(listener); 330 } 331 } 332 333 /** 334 * Removes a layer state change listener 335 * 336 * @param listener the listener. Ignored if null or already registered. 337 * @since 10340 338 */ 339 public void removeLayerStateChangeListener(LayerStateChangeListener listener) { 340 layerStateChangeListeners.remove(listener); 341 } 342 343 /** 344 * The data behind this layer. 345 */ 346 public final DataSet data; 347 348 /** 349 * a texture for non-downloaded area 350 */ 351 private static volatile BufferedImage hatched; 352 353 static { 354 createHatchTexture(); 355 } 356 357 /** 358 * Replies background color for downloaded areas. 359 * @return background color for downloaded areas. Black by default 360 */ 361 public static Color getBackgroundColor() { 362 return PROPERTY_BACKGROUND_COLOR.get(); 363 } 364 365 /** 366 * Replies background color for non-downloaded areas. 367 * @return background color for non-downloaded areas. Yellow by default 368 */ 369 public static Color getOutsideColor() { 370 return PROPERTY_OUTSIDE_COLOR.get(); 371 } 372 373 /** 374 * Initialize the hatch pattern used to paint the non-downloaded area 375 */ 376 public static void createHatchTexture() { 377 BufferedImage bi = new BufferedImage(HATCHED_SIZE, HATCHED_SIZE, BufferedImage.TYPE_INT_ARGB); 378 Graphics2D big = bi.createGraphics(); 379 big.setColor(getBackgroundColor()); 380 Composite comp = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.3f); 381 big.setComposite(comp); 382 big.fillRect(0, 0, HATCHED_SIZE, HATCHED_SIZE); 383 big.setColor(getOutsideColor()); 384 big.drawLine(-1, 6, 6, -1); 385 big.drawLine(4, 16, 16, 4); 386 hatched = bi; 387 } 388 389 /** 390 * Construct a new {@code OsmDataLayer}. 391 * @param data OSM data 392 * @param name Layer name 393 * @param associatedFile Associated .osm file (can be null) 394 */ 395 public OsmDataLayer(final DataSet data, final String name, final File associatedFile) { 396 super(name); 397 CheckParameterUtil.ensureParameterNotNull(data, "data"); 398 this.data = data; 399 this.data.setName(name); 400 this.setAssociatedFile(associatedFile); 401 data.addDataSetListener(new DataSetListenerAdapter(this)); 402 data.addDataSetListener(MultipolygonCache.getInstance()); 403 data.addHighlightUpdateListener(this); 404 data.addSelectionListener(this); 405 if (name != null && name.startsWith(createLayerName("")) && Character.isDigit( 406 (name.substring(createLayerName("").length()) + "XX" /*avoid StringIndexOutOfBoundsException*/).charAt(1))) { 407 while (AlphanumComparator.getInstance().compare(createLayerName(dataLayerCounter), name) < 0) { 408 final int i = dataLayerCounter.incrementAndGet(); 409 if (i > 1_000_000) { 410 break; // to avoid looping in unforeseen case 411 } 412 } 413 } 414 } 415 416 /** 417 * Returns the {@link DataSet} behind this layer. 418 * @return the {@link DataSet} behind this layer. 419 * @since 13558 420 */ 421 public DataSet getDataSet() { 422 return data; 423 } 424 425 /** 426 * Return the image provider to get the base icon 427 * @return image provider class which can be modified 428 * @since 8323 429 */ 430 protected ImageProvider getBaseIconProvider() { 431 return new ImageProvider("layer", "osmdata_small"); 432 } 433 434 @Override 435 public Icon getIcon() { 436 ImageProvider base = getBaseIconProvider().setMaxSize(ImageSizes.LAYER); 437 if (data.getDownloadPolicy() != null && data.getDownloadPolicy() != DownloadPolicy.NORMAL) { 438 base.addOverlay(new ImageOverlay(new ImageProvider("warning-small"), 0.5, 0.0, 1.0, 0.5)); 439 } 440 if (data.getUploadPolicy() != null && data.getUploadPolicy() != UploadPolicy.NORMAL) { 441 base.addOverlay(new ImageOverlay(new ImageProvider("warning-small"), 0.5, 0.5, 1.0, 1.0)); 442 } 443 444 if (isUploadInProgress()) { 445 // If the layer is being uploaded then change the default icon to a clock 446 base = new ImageProvider("clock").setMaxSize(ImageSizes.LAYER); 447 } else if (isLocked()) { 448 // If the layer is read only then change the default icon to a lock 449 base = new ImageProvider("lock").setMaxSize(ImageSizes.LAYER); 450 } 451 return base.get(); 452 } 453 454 /** 455 * Draw all primitives in this layer but do not draw modified ones (they 456 * are drawn by the edit layer). 457 * Draw nodes last to overlap the ways they belong to. 458 */ 459 @Override public void paint(final Graphics2D g, final MapView mv, Bounds box) { 460 boolean active = mv.getLayerManager().getActiveLayer() == this; 461 boolean inactive = !active && Config.getPref().getBoolean("draw.data.inactive_color", true); 462 boolean virtual = !inactive && mv.isVirtualNodesEnabled(); 463 464 // draw the hatched area for non-downloaded region. only draw if we're the active 465 // and bounds are defined; don't draw for inactive layers or loaded GPX files etc 466 if (active && Config.getPref().getBoolean("draw.data.downloaded_area", true) && !data.getDataSources().isEmpty()) { 467 // initialize area with current viewport 468 Rectangle b = mv.getBounds(); 469 // on some platforms viewport bounds seem to be offset from the left, 470 // over-grow it just to be sure 471 b.grow(100, 100); 472 Path2D p = new Path2D.Double(); 473 474 // combine successively downloaded areas 475 for (Bounds bounds : data.getDataSourceBounds()) { 476 if (bounds.isCollapsed()) { 477 continue; 478 } 479 p.append(mv.getState().getArea(bounds), false); 480 } 481 // subtract combined areas 482 Area a = new Area(b); 483 a.subtract(new Area(p)); 484 485 // paint remainder 486 MapViewPoint anchor = mv.getState().getPointFor(new EastNorth(0, 0)); 487 Rectangle2D anchorRect = new Rectangle2D.Double(anchor.getInView().getX() % HATCHED_SIZE, 488 anchor.getInView().getY() % HATCHED_SIZE, HATCHED_SIZE, HATCHED_SIZE); 489 g.setPaint(new TexturePaint(hatched, anchorRect)); 490 g.fill(a); 491 } 492 493 Rendering painter = MapRendererFactory.getInstance().createActiveRenderer(g, mv, inactive); 494 painter.render(data, virtual, box); 495 MainApplication.getMap().conflictDialog.paintConflicts(g, mv); 496 } 497 498 @Override public String getToolTipText() { 499 DataCountVisitor counter = new DataCountVisitor(); 500 for (final OsmPrimitive osm : data.allPrimitives()) { 501 osm.accept(counter); 502 } 503 int nodes = counter.nodes - counter.deletedNodes; 504 int ways = counter.ways - counter.deletedWays; 505 int rels = counter.relations - counter.deletedRelations; 506 507 StringBuilder tooltip = new StringBuilder("<html>") 508 .append(trn("{0} node", "{0} nodes", nodes, nodes)) 509 .append("<br>") 510 .append(trn("{0} way", "{0} ways", ways, ways)) 511 .append("<br>") 512 .append(trn("{0} relation", "{0} relations", rels, rels)); 513 514 File f = getAssociatedFile(); 515 if (f != null) { 516 tooltip.append("<br>").append(f.getPath()); 517 } 518 tooltip.append("</html>"); 519 return tooltip.toString(); 520 } 521 522 @Override public void mergeFrom(final Layer from) { 523 final PleaseWaitProgressMonitor monitor = new PleaseWaitProgressMonitor(tr("Merging layers")); 524 monitor.setCancelable(false); 525 if (from instanceof OsmDataLayer && ((OsmDataLayer) from).isUploadDiscouraged()) { 526 setUploadDiscouraged(true); 527 } 528 mergeFrom(((OsmDataLayer) from).data, monitor); 529 monitor.close(); 530 } 531 532 /** 533 * merges the primitives in dataset <code>from</code> into the dataset of 534 * this layer 535 * 536 * @param from the source data set 537 */ 538 public void mergeFrom(final DataSet from) { 539 mergeFrom(from, null); 540 } 541 542 /** 543 * merges the primitives in dataset <code>from</code> into the dataset of this layer 544 * 545 * @param from the source data set 546 * @param progressMonitor the progress monitor, can be {@code null} 547 */ 548 public void mergeFrom(final DataSet from, ProgressMonitor progressMonitor) { 549 final DataSetMerger visitor = new DataSetMerger(data, from); 550 try { 551 visitor.merge(progressMonitor); 552 } catch (DataIntegrityProblemException e) { 553 Logging.error(e); 554 JOptionPane.showMessageDialog( 555 Main.parent, 556 e.getHtmlMessage() != null ? e.getHtmlMessage() : e.getMessage(), 557 tr("Error"), 558 JOptionPane.ERROR_MESSAGE 559 ); 560 return; 561 } 562 563 int numNewConflicts = 0; 564 for (Conflict<?> c : visitor.getConflicts()) { 565 if (!data.getConflicts().hasConflict(c)) { 566 numNewConflicts++; 567 data.getConflicts().add(c); 568 } 569 } 570 // repaint to make sure new data is displayed properly. 571 invalidate(); 572 // warn about new conflicts 573 MapFrame map = MainApplication.getMap(); 574 if (numNewConflicts > 0 && map != null && map.conflictDialog != null) { 575 map.conflictDialog.warnNumNewConflicts(numNewConflicts); 576 } 577 } 578 579 @Override 580 public boolean isMergable(final Layer other) { 581 // allow merging between normal layers and discouraged layers with a warning (see #7684) 582 return other instanceof OsmDataLayer; 583 } 584 585 @Override 586 public void visitBoundingBox(final BoundingXYVisitor v) { 587 for (final Node n: data.getNodes()) { 588 if (n.isUsable()) { 589 v.visit(n); 590 } 591 } 592 } 593 594 /** 595 * Clean out the data behind the layer. This means clearing the redo/undo lists, 596 * really deleting all deleted objects and reset the modified flags. This should 597 * be done after an upload, even after a partial upload. 598 * 599 * @param processed A list of all objects that were actually uploaded. 600 * May be <code>null</code>, which means nothing has been uploaded 601 */ 602 public void cleanupAfterUpload(final Collection<? extends IPrimitive> processed) { 603 // return immediately if an upload attempt failed 604 if (processed == null || processed.isEmpty()) 605 return; 606 607 MainApplication.undoRedo.clean(data); 608 609 // if uploaded, clean the modified flags as well 610 data.cleanupDeletedPrimitives(); 611 data.beginUpdate(); 612 try { 613 for (OsmPrimitive p: data.allPrimitives()) { 614 if (processed.contains(p)) { 615 p.setModified(false); 616 } 617 } 618 } finally { 619 data.endUpdate(); 620 } 621 } 622 623 @Override 624 public Object getInfoComponent() { 625 final DataCountVisitor counter = new DataCountVisitor(); 626 for (final OsmPrimitive osm : data.allPrimitives()) { 627 osm.accept(counter); 628 } 629 final JPanel p = new JPanel(new GridBagLayout()); 630 631 String nodeText = trn("{0} node", "{0} nodes", counter.nodes, counter.nodes); 632 if (counter.deletedNodes > 0) { 633 nodeText += " ("+trn("{0} deleted", "{0} deleted", counter.deletedNodes, counter.deletedNodes)+')'; 634 } 635 636 String wayText = trn("{0} way", "{0} ways", counter.ways, counter.ways); 637 if (counter.deletedWays > 0) { 638 wayText += " ("+trn("{0} deleted", "{0} deleted", counter.deletedWays, counter.deletedWays)+')'; 639 } 640 641 String relationText = trn("{0} relation", "{0} relations", counter.relations, counter.relations); 642 if (counter.deletedRelations > 0) { 643 relationText += " ("+trn("{0} deleted", "{0} deleted", counter.deletedRelations, counter.deletedRelations)+')'; 644 } 645 646 p.add(new JLabel(tr("{0} consists of:", getName())), GBC.eol()); 647 p.add(new JLabel(nodeText, ImageProvider.get("data", "node"), JLabel.HORIZONTAL), GBC.eop().insets(15, 0, 0, 0)); 648 p.add(new JLabel(wayText, ImageProvider.get("data", "way"), JLabel.HORIZONTAL), GBC.eop().insets(15, 0, 0, 0)); 649 p.add(new JLabel(relationText, ImageProvider.get("data", "relation"), JLabel.HORIZONTAL), GBC.eop().insets(15, 0, 0, 0)); 650 p.add(new JLabel(tr("API version: {0}", (data.getVersion() != null) ? data.getVersion() : tr("unset"))), 651 GBC.eop().insets(15, 0, 0, 0)); 652 if (isUploadDiscouraged()) { 653 p.add(new JLabel(tr("Upload is discouraged")), GBC.eop().insets(15, 0, 0, 0)); 654 } 655 if (data.getUploadPolicy() == UploadPolicy.BLOCKED) { 656 p.add(new JLabel(tr("Upload is blocked")), GBC.eop().insets(15, 0, 0, 0)); 657 } 658 659 return p; 660 } 661 662 @Override public Action[] getMenuEntries() { 663 List<Action> actions = new ArrayList<>(); 664 actions.addAll(Arrays.asList( 665 LayerListDialog.getInstance().createActivateLayerAction(this), 666 LayerListDialog.getInstance().createShowHideLayerAction(), 667 LayerListDialog.getInstance().createDeleteLayerAction(), 668 SeparatorLayerAction.INSTANCE, 669 LayerListDialog.getInstance().createMergeLayerAction(this), 670 LayerListDialog.getInstance().createDuplicateLayerAction(this), 671 new LayerSaveAction(this), 672 new LayerSaveAsAction(this))); 673 if (ExpertToggleAction.isExpert()) { 674 actions.addAll(Arrays.asList( 675 new LayerGpxExportAction(this), 676 new ConvertToGpxLayerAction())); 677 } 678 actions.addAll(Arrays.asList( 679 SeparatorLayerAction.INSTANCE, 680 new RenameLayerAction(getAssociatedFile(), this))); 681 if (ExpertToggleAction.isExpert()) { 682 actions.add(new ToggleUploadDiscouragedLayerAction(this)); 683 } 684 actions.addAll(Arrays.asList( 685 new ConsistencyTestAction(), 686 SeparatorLayerAction.INSTANCE, 687 new LayerListPopup.InfoAction(this))); 688 return actions.toArray(new Action[0]); 689 } 690 691 /** 692 * Converts given OSM dataset to GPX data. 693 * @param data OSM dataset 694 * @param file output .gpx file 695 * @return GPX data 696 */ 697 public static GpxData toGpxData(DataSet data, File file) { 698 GpxData gpxData = new GpxData(); 699 gpxData.storageFile = file; 700 Set<Node> doneNodes = new HashSet<>(); 701 waysToGpxData(data.getWays(), gpxData, doneNodes); 702 nodesToGpxData(data.getNodes(), gpxData, doneNodes); 703 return gpxData; 704 } 705 706 private static void waysToGpxData(Collection<Way> ways, GpxData gpxData, Set<Node> doneNodes) { 707 /* When the dataset has been obtained from a gpx layer and now is being converted back, 708 * the ways have negative ids. The first created way corresponds to the first gpx segment, 709 * and has the highest id (i.e., closest to zero). 710 * Thus, sorting by OsmPrimitive#getUniqueId gives the original order. 711 * (Only works if the data layer has not been saved to and been loaded from an osm file before.) 712 */ 713 ways.stream() 714 .sorted(OsmPrimitiveComparator.comparingUniqueId().reversed()) 715 .forEachOrdered(w -> { 716 if (!w.isUsable()) { 717 return; 718 } 719 Collection<Collection<WayPoint>> trk = new ArrayList<>(); 720 Map<String, Object> trkAttr = new HashMap<>(); 721 722 String name = w.get("name"); 723 if (name != null) { 724 trkAttr.put("name", name); 725 } 726 727 List<WayPoint> trkseg = null; 728 for (Node n : w.getNodes()) { 729 if (!n.isUsable()) { 730 trkseg = null; 731 continue; 732 } 733 if (trkseg == null) { 734 trkseg = new ArrayList<>(); 735 trk.add(trkseg); 736 } 737 if (!n.isTagged()) { 738 doneNodes.add(n); 739 } 740 trkseg.add(nodeToWayPoint(n)); 741 } 742 743 gpxData.addTrack(new ImmutableGpxTrack(trk, trkAttr)); 744 }); 745 } 746 747 /** 748 * @param n the {@code Node} to convert 749 * @return {@code WayPoint} object 750 * @since 13210 751 */ 752 public static WayPoint nodeToWayPoint(Node n) { 753 return nodeToWayPoint(n, 0); 754 } 755 756 /** 757 * @param n the {@code Node} to convert 758 * @param time a time value in milliseconds from the epoch. 759 * @return {@code WayPoint} object 760 * @since 13210 761 */ 762 public static WayPoint nodeToWayPoint(Node n, long time) { 763 WayPoint wpt = new WayPoint(n.getCoor()); 764 765 // Position info 766 767 addDoubleIfPresent(wpt, n, GpxConstants.PT_ELE); 768 769 if (time > 0) { 770 wpt.setTime(time); 771 } else if (!n.isTimestampEmpty()) { 772 wpt.put("time", DateUtils.fromTimestamp(n.getRawTimestamp())); 773 wpt.setTime(); 774 } 775 776 addDoubleIfPresent(wpt, n, GpxConstants.PT_MAGVAR); 777 addDoubleIfPresent(wpt, n, GpxConstants.PT_GEOIDHEIGHT); 778 779 // Description info 780 781 addStringIfPresent(wpt, n, GpxConstants.GPX_NAME); 782 addStringIfPresent(wpt, n, GpxConstants.GPX_DESC, "description"); 783 addStringIfPresent(wpt, n, GpxConstants.GPX_CMT, "comment"); 784 addStringIfPresent(wpt, n, GpxConstants.GPX_SRC, "source", "source:position"); 785 786 Collection<GpxLink> links = new ArrayList<>(); 787 for (String key : new String[]{"link", "url", "website", "contact:website"}) { 788 String value = n.get(key); 789 if (value != null) { 790 links.add(new GpxLink(value)); 791 } 792 } 793 wpt.put(GpxConstants.META_LINKS, links); 794 795 addStringIfPresent(wpt, n, GpxConstants.PT_SYM, "wpt_symbol"); 796 addStringIfPresent(wpt, n, GpxConstants.PT_TYPE); 797 798 // Accuracy info 799 addStringIfPresent(wpt, n, GpxConstants.PT_FIX, "gps:fix"); 800 addIntegerIfPresent(wpt, n, GpxConstants.PT_SAT, "gps:sat"); 801 addDoubleIfPresent(wpt, n, GpxConstants.PT_HDOP, "gps:hdop"); 802 addDoubleIfPresent(wpt, n, GpxConstants.PT_VDOP, "gps:vdop"); 803 addDoubleIfPresent(wpt, n, GpxConstants.PT_PDOP, "gps:pdop"); 804 addDoubleIfPresent(wpt, n, GpxConstants.PT_AGEOFDGPSDATA, "gps:ageofdgpsdata"); 805 addIntegerIfPresent(wpt, n, GpxConstants.PT_DGPSID, "gps:dgpsid"); 806 807 return wpt; 808 } 809 810 private static void nodesToGpxData(Collection<Node> nodes, GpxData gpxData, Set<Node> doneNodes) { 811 List<Node> sortedNodes = new ArrayList<>(nodes); 812 sortedNodes.removeAll(doneNodes); 813 Collections.sort(sortedNodes); 814 for (Node n : sortedNodes) { 815 if (n.isIncomplete() || n.isDeleted()) { 816 continue; 817 } 818 gpxData.waypoints.add(nodeToWayPoint(n)); 819 } 820 } 821 822 private static void addIntegerIfPresent(WayPoint wpt, OsmPrimitive p, String gpxKey, String... osmKeys) { 823 List<String> possibleKeys = new ArrayList<>(Arrays.asList(osmKeys)); 824 possibleKeys.add(0, gpxKey); 825 for (String key : possibleKeys) { 826 String value = p.get(key); 827 if (value != null) { 828 try { 829 int i = Integer.parseInt(value); 830 // Sanity checks 831 if ((!GpxConstants.PT_SAT.equals(gpxKey) || i >= 0) && 832 (!GpxConstants.PT_DGPSID.equals(gpxKey) || (0 <= i && i <= 1023))) { 833 wpt.put(gpxKey, value); 834 break; 835 } 836 } catch (NumberFormatException e) { 837 Logging.trace(e); 838 } 839 } 840 } 841 } 842 843 private static void addDoubleIfPresent(WayPoint wpt, OsmPrimitive p, String gpxKey, String... osmKeys) { 844 List<String> possibleKeys = new ArrayList<>(Arrays.asList(osmKeys)); 845 possibleKeys.add(0, gpxKey); 846 for (String key : possibleKeys) { 847 String value = p.get(key); 848 if (value != null) { 849 try { 850 double d = Double.parseDouble(value); 851 // Sanity checks 852 if (!GpxConstants.PT_MAGVAR.equals(gpxKey) || (0.0 <= d && d < 360.0)) { 853 wpt.put(gpxKey, value); 854 break; 855 } 856 } catch (NumberFormatException e) { 857 Logging.trace(e); 858 } 859 } 860 } 861 } 862 863 private static void addStringIfPresent(WayPoint wpt, OsmPrimitive p, String gpxKey, String... osmKeys) { 864 List<String> possibleKeys = new ArrayList<>(Arrays.asList(osmKeys)); 865 possibleKeys.add(0, gpxKey); 866 for (String key : possibleKeys) { 867 String value = p.get(key); 868 // Sanity checks 869 if (value != null && (!GpxConstants.PT_FIX.equals(gpxKey) || GpxConstants.FIX_VALUES.contains(value))) { 870 wpt.put(gpxKey, value); 871 break; 872 } 873 } 874 } 875 876 /** 877 * Converts OSM data behind this layer to GPX data. 878 * @return GPX data 879 */ 880 public GpxData toGpxData() { 881 return toGpxData(data, getAssociatedFile()); 882 } 883 884 /** 885 * Action that converts this OSM layer to a GPX layer. 886 */ 887 public class ConvertToGpxLayerAction extends AbstractAction { 888 /** 889 * Constructs a new {@code ConvertToGpxLayerAction}. 890 */ 891 public ConvertToGpxLayerAction() { 892 super(tr("Convert to GPX layer")); 893 new ImageProvider("converttogpx").getResource().attachImageIcon(this, true); 894 putValue("help", ht("/Action/ConvertToGpxLayer")); 895 } 896 897 @Override 898 public void actionPerformed(ActionEvent e) { 899 final GpxData gpxData = toGpxData(); 900 final GpxLayer gpxLayer = new GpxLayer(gpxData, tr("Converted from: {0}", getName())); 901 if (getAssociatedFile() != null) { 902 String filename = getAssociatedFile().getName().replaceAll(Pattern.quote(".gpx.osm") + '$', "") + ".gpx"; 903 gpxLayer.setAssociatedFile(new File(getAssociatedFile().getParentFile(), filename)); 904 } 905 MainApplication.getLayerManager().addLayer(gpxLayer); 906 if (Config.getPref().getBoolean("marker.makeautomarkers", true) && !gpxData.waypoints.isEmpty()) { 907 MainApplication.getLayerManager().addLayer(new MarkerLayer(gpxData, tr("Converted from: {0}", getName()), null, gpxLayer)); 908 } 909 MainApplication.getLayerManager().removeLayer(OsmDataLayer.this); 910 } 911 } 912 913 /** 914 * Determines if this layer contains data at the given coordinate. 915 * @param coor the coordinate 916 * @return {@code true} if data sources bounding boxes contain {@code coor} 917 */ 918 public boolean containsPoint(LatLon coor) { 919 // we'll assume that if this has no data sources 920 // that it also has no borders 921 if (this.data.getDataSources().isEmpty()) 922 return true; 923 924 boolean layerBoundsPoint = false; 925 for (DataSource src : this.data.getDataSources()) { 926 if (src.bounds.contains(coor)) { 927 layerBoundsPoint = true; 928 break; 929 } 930 } 931 return layerBoundsPoint; 932 } 933 934 /** 935 * Replies the set of conflicts currently managed in this layer. 936 * 937 * @return the set of conflicts currently managed in this layer 938 */ 939 public ConflictCollection getConflicts() { 940 return data.getConflicts(); 941 } 942 943 @Override 944 public boolean isDownloadable() { 945 return data.getDownloadPolicy() != DownloadPolicy.BLOCKED && !isLocked(); 946 } 947 948 @Override 949 public boolean isUploadable() { 950 return data.getUploadPolicy() != UploadPolicy.BLOCKED && !isLocked(); 951 } 952 953 @Override 954 public boolean requiresUploadToServer() { 955 return isUploadable() && requiresUploadToServer; 956 } 957 958 @Override 959 public boolean requiresSaveToFile() { 960 return getAssociatedFile() != null && requiresSaveToFile; 961 } 962 963 @Override 964 public void onPostLoadFromFile() { 965 setRequiresSaveToFile(false); 966 setRequiresUploadToServer(isModified()); 967 invalidate(); 968 } 969 970 /** 971 * Actions run after data has been downloaded to this layer. 972 */ 973 public void onPostDownloadFromServer() { 974 setRequiresSaveToFile(true); 975 setRequiresUploadToServer(isModified()); 976 invalidate(); 977 } 978 979 @Override 980 public void onPostSaveToFile() { 981 setRequiresSaveToFile(false); 982 setRequiresUploadToServer(isModified()); 983 } 984 985 @Override 986 public void onPostUploadToServer() { 987 setRequiresUploadToServer(isModified()); 988 // keep requiresSaveToDisk unchanged 989 } 990 991 private class ConsistencyTestAction extends AbstractAction { 992 993 ConsistencyTestAction() { 994 super(tr("Dataset consistency test")); 995 } 996 997 @Override 998 public void actionPerformed(ActionEvent e) { 999 String result = DatasetConsistencyTest.runTests(data); 1000 if (result.isEmpty()) { 1001 JOptionPane.showMessageDialog(Main.parent, tr("No problems found")); 1002 } else { 1003 JPanel p = new JPanel(new GridBagLayout()); 1004 p.add(new JLabel(tr("Following problems found:")), GBC.eol()); 1005 JosmTextArea info = new JosmTextArea(result, 20, 60); 1006 info.setCaretPosition(0); 1007 info.setEditable(false); 1008 p.add(new JScrollPane(info), GBC.eop()); 1009 1010 JOptionPane.showMessageDialog(Main.parent, p, tr("Warning"), JOptionPane.WARNING_MESSAGE); 1011 } 1012 } 1013 } 1014 1015 @Override 1016 public synchronized void destroy() { 1017 super.destroy(); 1018 data.removeSelectionListener(this); 1019 data.removeHighlightUpdateListener(this); 1020 } 1021 1022 @Override 1023 public void processDatasetEvent(AbstractDatasetChangedEvent event) { 1024 invalidate(); 1025 setRequiresSaveToFile(true); 1026 setRequiresUploadToServer(event.getDataset().requiresUploadToServer()); 1027 } 1028 1029 @Override 1030 public void selectionChanged(SelectionChangeEvent event) { 1031 invalidate(); 1032 } 1033 1034 @Override 1035 public void projectionChanged(Projection oldValue, Projection newValue) { 1036 // No reprojection required. The dataset itself is registered as projection 1037 // change listener and already got notified. 1038 } 1039 1040 @Override 1041 public final boolean isUploadDiscouraged() { 1042 return data.getUploadPolicy() == UploadPolicy.DISCOURAGED; 1043 } 1044 1045 /** 1046 * Sets the "discouraged upload" flag. 1047 * @param uploadDiscouraged {@code true} if upload of data managed by this layer is discouraged. 1048 * This feature allows to use "private" data layers. 1049 */ 1050 public final void setUploadDiscouraged(boolean uploadDiscouraged) { 1051 if (data.getUploadPolicy() != UploadPolicy.BLOCKED && 1052 (uploadDiscouraged ^ isUploadDiscouraged())) { 1053 data.setUploadPolicy(uploadDiscouraged ? UploadPolicy.DISCOURAGED : UploadPolicy.NORMAL); 1054 for (LayerStateChangeListener l : layerStateChangeListeners) { 1055 l.uploadDiscouragedChanged(this, uploadDiscouraged); 1056 } 1057 } 1058 } 1059 1060 @Override 1061 public final boolean isModified() { 1062 return data.isModified(); 1063 } 1064 1065 @Override 1066 public boolean isSavable() { 1067 return true; // With OsmExporter 1068 } 1069 1070 @Override 1071 public boolean checkSaveConditions() { 1072 if (isDataSetEmpty() && 1 != GuiHelper.runInEDTAndWaitAndReturn(() -> { 1073 if (GraphicsEnvironment.isHeadless()) { 1074 return 2; 1075 } 1076 return new ExtendedDialog( 1077 Main.parent, 1078 tr("Empty document"), 1079 tr("Save anyway"), tr("Cancel")) 1080 .setContent(tr("The document contains no data.")) 1081 .setButtonIcons("save", "cancel") 1082 .showDialog().getValue(); 1083 })) { 1084 return false; 1085 } 1086 1087 ConflictCollection conflictsCol = getConflicts(); 1088 return conflictsCol == null || conflictsCol.isEmpty() || 1 == GuiHelper.runInEDTAndWaitAndReturn(() -> 1089 new ExtendedDialog( 1090 Main.parent, 1091 /* I18N: Display title of the window showing conflicts */ 1092 tr("Conflicts"), 1093 tr("Reject Conflicts and Save"), tr("Cancel")) 1094 .setContent( 1095 tr("There are unresolved conflicts. Conflicts will not be saved and handled as if you rejected all. Continue?")) 1096 .setButtonIcons("save", "cancel") 1097 .showDialog().getValue() 1098 ); 1099 } 1100 1101 /** 1102 * Check the data set if it would be empty on save. It is empty, if it contains 1103 * no objects (after all objects that are created and deleted without being 1104 * transferred to the server have been removed). 1105 * 1106 * @return <code>true</code>, if a save result in an empty data set. 1107 */ 1108 private boolean isDataSetEmpty() { 1109 if (data != null) { 1110 for (OsmPrimitive osm : data.allNonDeletedPrimitives()) { 1111 if (!osm.isDeleted() || !osm.isNewOrUndeleted()) 1112 return false; 1113 } 1114 } 1115 return true; 1116 } 1117 1118 @Override 1119 public File createAndOpenSaveFileChooser() { 1120 String extension = PROPERTY_SAVE_EXTENSION.get(); 1121 File file = getAssociatedFile(); 1122 if (file == null && isRenamed()) { 1123 StringBuilder filename = new StringBuilder(Config.getPref().get("lastDirectory")).append('/').append(getName()); 1124 if (!OsmImporter.FILE_FILTER.acceptName(filename.toString())) { 1125 filename.append('.').append(extension); 1126 } 1127 file = new File(filename.toString()); 1128 } 1129 return new FileChooserManager() 1130 .title(tr("Save OSM file")) 1131 .extension(extension) 1132 .file(file) 1133 .allTypes(true) 1134 .getFileForSave(); 1135 } 1136 1137 @Override 1138 public AbstractIOTask createUploadTask(final ProgressMonitor monitor) { 1139 UploadDialog dialog = UploadDialog.getUploadDialog(); 1140 return new UploadLayerTask( 1141 dialog.getUploadStrategySpecification(), 1142 this, 1143 monitor, 1144 dialog.getChangeset()); 1145 } 1146 1147 @Override 1148 public AbstractUploadDialog getUploadDialog() { 1149 UploadDialog dialog = UploadDialog.getUploadDialog(); 1150 dialog.setUploadedPrimitives(new APIDataSet(data)); 1151 return dialog; 1152 } 1153 1154 @Override 1155 public ProjectionBounds getViewProjectionBounds() { 1156 BoundingXYVisitor v = new BoundingXYVisitor(); 1157 v.visit(data.getDataSourceBoundingBox()); 1158 if (!v.hasExtend()) { 1159 v.computeBoundingBox(data.getNodes()); 1160 } 1161 return v.getBounds(); 1162 } 1163 1164 @Override 1165 public void highlightUpdated(HighlightUpdateEvent e) { 1166 invalidate(); 1167 } 1168 1169 @Override 1170 public void setName(String name) { 1171 if (data != null) { 1172 data.setName(name); 1173 } 1174 super.setName(name); 1175 } 1176 1177 @Override 1178 public void lock() { 1179 data.lock(); 1180 } 1181 1182 @Override 1183 public void unlock() { 1184 data.unlock(); 1185 } 1186 1187 @Override 1188 public boolean isLocked() { 1189 return data.isLocked(); 1190 } 1191 1192 /** 1193 * Sets the "upload in progress" flag, which will result in displaying a new icon and forbid to remove the layer. 1194 * @since 13434 1195 */ 1196 public void setUploadInProgress() { 1197 if (!isUploadInProgress.compareAndSet(false, true)) { 1198 Logging.warn("Trying to set uploadInProgress flag on layer already being uploaded ", getName()); 1199 } 1200 } 1201 1202 /** 1203 * Unsets the "upload in progress" flag, which will result in displaying the standard icon and allow to remove the layer. 1204 * @since 13434 1205 */ 1206 public void unsetUploadInProgress() { 1207 if (!isUploadInProgress.compareAndSet(true, false)) { 1208 Logging.warn("Trying to unset uploadInProgress flag on layer not being uploaded ", getName()); 1209 } 1210 } 1211 1212 @Override 1213 public boolean isUploadInProgress() { 1214 return isUploadInProgress.get(); 1215 } 1216}