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