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