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