001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.layer; 003 004import static org.openstreetmap.josm.tools.I18n.tr; 005import static org.openstreetmap.josm.tools.I18n.trn; 006 007import java.awt.Color; 008import java.awt.Dimension; 009import java.awt.Graphics2D; 010import java.awt.event.ActionEvent; 011import java.io.File; 012import java.time.Instant; 013import java.util.ArrayList; 014import java.util.Arrays; 015import java.util.Collections; 016import java.util.List; 017import java.util.NoSuchElementException; 018import java.util.stream.Collectors; 019 020import javax.swing.AbstractAction; 021import javax.swing.Action; 022import javax.swing.Icon; 023import javax.swing.JScrollPane; 024import javax.swing.SwingUtilities; 025 026import org.openstreetmap.josm.actions.AutoScaleAction; 027import org.openstreetmap.josm.actions.ExpertToggleAction; 028import org.openstreetmap.josm.actions.ExpertToggleAction.ExpertModeChangeListener; 029import org.openstreetmap.josm.actions.RenameLayerAction; 030import org.openstreetmap.josm.actions.SaveActionBase; 031import org.openstreetmap.josm.data.Bounds; 032import org.openstreetmap.josm.data.Data; 033import org.openstreetmap.josm.data.SystemOfMeasurement; 034import org.openstreetmap.josm.data.gpx.GpxConstants; 035import org.openstreetmap.josm.data.gpx.GpxData; 036import org.openstreetmap.josm.data.gpx.GpxData.GpxDataChangeEvent; 037import org.openstreetmap.josm.data.gpx.GpxData.GpxDataChangeListener; 038import org.openstreetmap.josm.data.gpx.GpxDataContainer; 039import org.openstreetmap.josm.data.gpx.IGpxTrack; 040import org.openstreetmap.josm.data.gpx.IGpxTrackSegment; 041import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor; 042import org.openstreetmap.josm.data.projection.Projection; 043import org.openstreetmap.josm.gui.MainApplication; 044import org.openstreetmap.josm.gui.MapView; 045import org.openstreetmap.josm.gui.dialogs.LayerListDialog; 046import org.openstreetmap.josm.gui.dialogs.LayerListPopup; 047import org.openstreetmap.josm.gui.io.importexport.GpxImporter; 048import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToMarkerLayer; 049import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToNextMarker; 050import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToPreviousMarker; 051import org.openstreetmap.josm.gui.layer.gpx.ChooseTrackVisibilityAction; 052import org.openstreetmap.josm.gui.layer.gpx.ConvertFromGpxLayerAction; 053import org.openstreetmap.josm.gui.layer.gpx.CustomizeDrawingAction; 054import org.openstreetmap.josm.gui.layer.gpx.DownloadAlongTrackAction; 055import org.openstreetmap.josm.gui.layer.gpx.DownloadWmsAlongTrackAction; 056import org.openstreetmap.josm.gui.layer.gpx.GpxDrawHelper; 057import org.openstreetmap.josm.gui.layer.gpx.ImportAudioAction; 058import org.openstreetmap.josm.gui.layer.gpx.ImportImagesAction; 059import org.openstreetmap.josm.gui.layer.gpx.MarkersFromNamedPointsAction; 060import org.openstreetmap.josm.gui.layer.markerlayer.MarkerLayer; 061import org.openstreetmap.josm.gui.preferences.display.GPXSettingsPanel; 062import org.openstreetmap.josm.gui.util.GuiHelper; 063import org.openstreetmap.josm.gui.widgets.HtmlPanel; 064import org.openstreetmap.josm.tools.ImageProvider; 065import org.openstreetmap.josm.tools.Logging; 066import org.openstreetmap.josm.tools.Utils; 067import org.openstreetmap.josm.tools.date.Interval; 068 069/** 070 * A layer that displays data from a Gpx file / the OSM gpx downloads. 071 */ 072public class GpxLayer extends AbstractModifiableLayer implements GpxDataContainer, ExpertModeChangeListener, JumpToMarkerLayer { 073 074 /** GPX data */ 075 public GpxData data; 076 private boolean isLocalFile; 077 private boolean isExpertMode; 078 079 /** 080 * used by {@link ChooseTrackVisibilityAction} to determine which tracks to show/hide 081 * 082 * Call {@link #invalidate()} after each change! 083 * 084 * TODO: Make it private, make it respond to track changes. 085 */ 086 public boolean[] trackVisibility = new boolean[0]; 087 /** 088 * Added as field to be kept as reference. 089 */ 090 private final GpxDataChangeListener dataChangeListener = new GpxDataChangeListener() { 091 @Override 092 public void gpxDataChanged(GpxDataChangeEvent e) { 093 invalidate(); 094 } 095 096 @Override 097 public void modifiedStateChanged(boolean modified) { 098 GuiHelper.runInEDT(() -> propertyChangeSupport.firePropertyChange(REQUIRES_SAVE_TO_DISK_PROP, !modified, modified)); 099 } 100 }; 101 /** 102 * The MarkerLayer imported from the same file. 103 */ 104 private MarkerLayer linkedMarkerLayer; 105 106 /** 107 * Current segment for {@link JumpToMarkerLayer}. 108 */ 109 private IGpxTrackSegment currentSegment; 110 111 /** 112 * Constructs a new {@code GpxLayer} without name. 113 * @param d GPX data 114 */ 115 public GpxLayer(GpxData d) { 116 this(d, null, false); 117 } 118 119 /** 120 * Constructs a new {@code GpxLayer} with a given name. 121 * @param d GPX data 122 * @param name layer name 123 */ 124 public GpxLayer(GpxData d, String name) { 125 this(d, name, false); 126 } 127 128 /** 129 * Constructs a new {@code GpxLayer} with a given name, that can be attached to a local file. 130 * @param d GPX data 131 * @param name layer name 132 * @param isLocal whether data is attached to a local file 133 */ 134 public GpxLayer(GpxData d, String name, boolean isLocal) { 135 super(name); 136 data = d; 137 data.addWeakChangeListener(dataChangeListener); 138 trackVisibility = new boolean[data.getTracks().size()]; 139 Arrays.fill(trackVisibility, true); 140 isLocalFile = isLocal; 141 ExpertToggleAction.addExpertModeChangeListener(this, true); 142 } 143 144 @Override 145 public Color getColor() { 146 if (data == null) 147 return null; 148 Color[] c = data.getTracks().stream().map(t -> t.getColor()).distinct().toArray(Color[]::new); 149 return c.length == 1 ? c[0] : null; //only return if exactly one distinct color present 150 } 151 152 @Override 153 public void setColor(Color color) { 154 data.beginUpdate(); 155 for (IGpxTrack trk : data.getTracks()) { 156 trk.setColor(color); 157 } 158 GPXSettingsPanel.putLayerPrefLocal(this, "colormode", "0"); 159 data.endUpdate(); 160 } 161 162 @Override 163 public boolean hasColor() { 164 return data != null; 165 } 166 167 /** 168 * Returns a human readable string that shows the timespan of the given track 169 * @param trk The GPX track for which timespan is displayed 170 * @return The timespan as a string 171 */ 172 public static String getTimespanForTrack(IGpxTrack trk) { 173 return GpxData.getMinMaxTimeForTrack(trk).map(Interval::format).orElse(""); 174 } 175 176 @Override 177 public Icon getIcon() { 178 return ImageProvider.get("layer", "gpx_small"); 179 } 180 181 @Override 182 public Object getInfoComponent() { 183 StringBuilder info = new StringBuilder(128) 184 .append("<html><head><style>td { padding: 4px 16px; }</style></head><body>"); 185 186 if (data != null) { 187 fillDataInfoComponent(info); 188 } 189 190 info.append("<br></body></html>"); 191 192 final JScrollPane sp = new JScrollPane(new HtmlPanel(info.toString())); 193 sp.setPreferredSize(new Dimension(sp.getPreferredSize().width+20, 370)); 194 SwingUtilities.invokeLater(() -> sp.getVerticalScrollBar().setValue(0)); 195 return sp; 196 } 197 198 private void fillDataInfoComponent(StringBuilder info) { 199 if (data.attr.containsKey("name")) { 200 info.append(tr("Name: {0}", data.get(GpxConstants.META_NAME))).append("<br>"); 201 } 202 203 if (data.attr.containsKey("desc")) { 204 info.append(tr("Description: {0}", data.get(GpxConstants.META_DESC))).append("<br>"); 205 } 206 207 if (!Utils.isStripEmpty(data.creator)) { 208 info.append(tr("Creator: {0}", data.creator)).append("<br>"); 209 } 210 211 if (!data.getTracks().isEmpty()) { 212 info.append("<table><thead align='center'><tr><td colspan='5'>") 213 .append(trn("{0} track, {1} track segments", "{0} tracks, {1} track segments", 214 data.getTrackCount(), data.getTrackCount(), 215 data.getTrackSegsCount(), data.getTrackSegsCount())) 216 .append("</td></tr><tr align='center'><td>").append(tr("Name")) 217 .append("</td><td>").append(tr("Description")) 218 .append("</td><td>").append(tr("Timespan")) 219 .append("</td><td>").append(tr("Length")) 220 .append("</td><td>").append(tr("Number of<br/>Segments")) 221 .append("</td><td>").append(tr("URL")) 222 .append("</td></tr></thead>"); 223 224 for (IGpxTrack trk : data.getTracks()) { 225 info.append("<tr><td>"); 226 info.append(trk.getAttributes().getOrDefault(GpxConstants.GPX_NAME, "")); 227 info.append("</td><td>"); 228 info.append(trk.getAttributes().getOrDefault(GpxConstants.GPX_DESC, "")); 229 info.append("</td><td>"); 230 info.append(getTimespanForTrack(trk)); 231 info.append("</td><td>"); 232 info.append(SystemOfMeasurement.getSystemOfMeasurement().getDistText(trk.length())); 233 info.append("</td><td>"); 234 info.append(trk.getSegments().size()); 235 info.append("</td><td>"); 236 if (trk.getAttributes().containsKey("url")) { 237 info.append(trk.get("url")); 238 } 239 info.append("</td></tr>"); 240 } 241 info.append("</table><br><br>"); 242 } 243 244 info.append(tr("Length: {0}", SystemOfMeasurement.getSystemOfMeasurement().getDistText(data.length()))).append("<br>") 245 .append(trn("{0} route, ", "{0} routes, ", data.getRoutes().size(), data.getRoutes().size())) 246 .append(trn("{0} waypoint", "{0} waypoints", data.getWaypoints().size(), data.getWaypoints().size())); 247 } 248 249 @Override 250 public boolean isInfoResizable() { 251 return true; 252 } 253 254 @Override 255 public Action[] getMenuEntries() { 256 JumpToNextMarker jumpToNext = new JumpToNextMarker(this); 257 jumpToNext.putValue(Action.NAME, tr("Jump to next segment")); 258 JumpToPreviousMarker jumpToPrevious = new JumpToPreviousMarker(this); 259 jumpToPrevious.putValue(Action.NAME, tr("Jump to previous segment")); 260 List<Action> entries = new ArrayList<>(Arrays.asList( 261 LayerListDialog.getInstance().createShowHideLayerAction(), 262 LayerListDialog.getInstance().createDeleteLayerAction(), 263 MainApplication.getMenu().autoScaleActions.get(AutoScaleAction.AutoScaleMode.LAYER), 264 LayerListDialog.getInstance().createMergeLayerAction(this), 265 SeparatorLayerAction.INSTANCE, 266 new LayerSaveAction(this), 267 new LayerSaveAsAction(this), 268 new CustomizeColor(this), 269 new CustomizeDrawingAction(this), 270 new ImportImagesAction(this), 271 new ImportAudioAction(this), 272 new MarkersFromNamedPointsAction(this), 273 jumpToNext, 274 jumpToPrevious, 275 new ConvertFromGpxLayerAction(this), 276 new DownloadAlongTrackAction(Collections.singleton(data)), 277 new DownloadWmsAlongTrackAction(data), 278 SeparatorLayerAction.INSTANCE, 279 new ChooseTrackVisibilityAction(this), 280 new RenameLayerAction(getAssociatedFile(), this))); 281 282 List<Action> expert = Arrays.asList( 283 new CombineTracksToSegmentedTrackAction(this), 284 new SplitTrackSegmentsToTracksAction(this), 285 new SplitTracksToLayersAction(this)); 286 287 if (isExpertMode && expert.stream().anyMatch(Action::isEnabled)) { 288 entries.add(SeparatorLayerAction.INSTANCE); 289 expert.stream().filter(Action::isEnabled).forEach(entries::add); 290 } 291 292 entries.add(SeparatorLayerAction.INSTANCE); 293 entries.add(new LayerListPopup.InfoAction(this)); 294 return entries.toArray(new Action[0]); 295 } 296 297 /** 298 * Determines if data is attached to a local file. 299 * @return {@code true} if data is attached to a local file, {@code false} otherwise 300 */ 301 public boolean isLocalFile() { 302 return isLocalFile; 303 } 304 305 @Override 306 public String getToolTipText() { 307 StringBuilder info = new StringBuilder(48).append("<html>"); 308 309 if (data != null) { 310 fillDataToolTipText(info); 311 } 312 313 info.append("<br></html>"); 314 315 return info.toString(); 316 } 317 318 private void fillDataToolTipText(StringBuilder info) { 319 if (data.attr.containsKey(GpxConstants.META_NAME)) { 320 info.append(tr("Name: {0}", data.get(GpxConstants.META_NAME))).append("<br>"); 321 } 322 323 if (data.attr.containsKey(GpxConstants.META_DESC)) { 324 info.append(tr("Description: {0}", data.get(GpxConstants.META_DESC))).append("<br>"); 325 } 326 327 info.append(trn("{0} track", "{0} tracks", data.getTrackCount(), data.getTrackCount())) 328 .append(trn(" ({0} segment)", " ({0} segments)", data.getTrackSegsCount(), data.getTrackSegsCount())) 329 .append(", ") 330 .append(trn("{0} route, ", "{0} routes, ", data.getRoutes().size(), data.getRoutes().size())) 331 .append(trn("{0} waypoint", "{0} waypoints", data.getWaypoints().size(), data.getWaypoints().size())).append("<br>") 332 .append(tr("Length: {0}", SystemOfMeasurement.getSystemOfMeasurement().getDistText(data.length()))); 333 334 if (Logging.isDebugEnabled() && !data.getLayerPrefs().isEmpty()) { 335 info.append("<br><br>") 336 .append(data.getLayerPrefs().entrySet().stream() 337 .map(e -> e.getKey() + "=" + e.getValue()) 338 .collect(Collectors.joining("<br>"))); 339 } 340 } 341 342 @Override 343 public boolean isMergable(Layer other) { 344 return data != null && other instanceof GpxLayer; 345 } 346 347 /** 348 * Shows/hides all tracks of a given date range by setting them to visible/invisible. 349 * @param fromDate The min date 350 * @param toDate The max date 351 * @param showWithoutDate Include tracks that don't have any date set.. 352 */ 353 public void filterTracksByDate(Instant fromDate, Instant toDate, boolean showWithoutDate) { 354 if (data == null) 355 return; 356 int i = 0; 357 long from = fromDate.toEpochMilli(); 358 long to = toDate.toEpochMilli(); 359 for (IGpxTrack trk : data.getTracks()) { 360 Interval t = GpxData.getMinMaxTimeForTrack(trk).orElse(null); 361 362 if (t == null) continue; 363 long tm = t.getEnd().toEpochMilli(); 364 trackVisibility[i] = (tm == 0 && showWithoutDate) || (from <= tm && tm <= to); 365 i++; 366 } 367 invalidate(); 368 } 369 370 @Override 371 public void mergeFrom(Layer from) { 372 if (!(from instanceof GpxLayer)) 373 throw new IllegalArgumentException("not a GpxLayer: " + from); 374 mergeFrom((GpxLayer) from, false, false); 375 } 376 377 /** 378 * Merges the given GpxLayer into this layer and can remove timewise overlapping parts of the given track 379 * @param from The GpxLayer that gets merged into this one 380 * @param cutOverlapping whether overlapping parts of the given track should be removed 381 * @param connect whether the tracks should be connected on cuts 382 * @since 14338 383 */ 384 public void mergeFrom(GpxLayer from, boolean cutOverlapping, boolean connect) { 385 data.mergeFrom(from.data, cutOverlapping, connect); 386 invalidate(); 387 } 388 389 @Override 390 public String getLabel() { 391 return isDirty() ? super.getLabel() + ' ' + IS_DIRTY_SYMBOL : super.getLabel(); 392 } 393 394 @Override 395 public void visitBoundingBox(BoundingXYVisitor v) { 396 if (data != null) { 397 v.visit(data.recalculateBounds()); 398 } 399 } 400 401 @Override 402 public File getAssociatedFile() { 403 return data != null ? data.storageFile : null; 404 } 405 406 @Override 407 public void setAssociatedFile(File file) { 408 data.storageFile = file; 409 } 410 411 /** 412 * Returns the linked MarkerLayer. 413 * @return the linked MarkerLayer (imported from the same file) 414 * @since 15496 415 */ 416 public MarkerLayer getLinkedMarkerLayer() { 417 return linkedMarkerLayer; 418 } 419 420 /** 421 * Sets the linked MarkerLayer. 422 * @param linkedMarkerLayer the linked MarkerLayer 423 * @since 15496 424 */ 425 public void setLinkedMarkerLayer(MarkerLayer linkedMarkerLayer) { 426 this.linkedMarkerLayer = linkedMarkerLayer; 427 } 428 429 @Override 430 public void projectionChanged(Projection oldValue, Projection newValue) { 431 if (newValue == null || data == null) return; 432 data.resetEastNorthCache(); 433 } 434 435 @Override 436 public boolean isSavable() { 437 return data != null; // With GpxExporter 438 } 439 440 @Override 441 public boolean checkSaveConditions() { 442 return data != null; 443 } 444 445 @Override 446 public File createAndOpenSaveFileChooser() { 447 return SaveActionBase.createAndOpenSaveFileChooser(tr("Save GPX file"), GpxImporter.getFileFilter()); 448 } 449 450 @Override 451 public LayerPositionStrategy getDefaultLayerPosition() { 452 return LayerPositionStrategy.AFTER_LAST_DATA_LAYER; 453 } 454 455 @Override 456 public void paint(Graphics2D g, MapView mv, Bounds bbox) { 457 // unused - we use a painter so this is not called. 458 } 459 460 @Override 461 protected LayerPainter createMapViewPainter(MapViewEvent event) { 462 return new GpxDrawHelper(this); 463 } 464 465 /** 466 * Action to merge tracks into a single segmented track 467 * 468 * @since 13210 469 */ 470 public static class CombineTracksToSegmentedTrackAction extends AbstractAction { 471 private final transient GpxLayer layer; 472 473 /** 474 * Create a new CombineTracksToSegmentedTrackAction 475 * @param layer The layer with the data to work on. 476 */ 477 public CombineTracksToSegmentedTrackAction(GpxLayer layer) { 478 // FIXME: icon missing, create a new icon for this action 479 //new ImageProvider(..."gpx_tracks_to_segmented_track").getResource().attachImageIcon(this, true); 480 putValue(SHORT_DESCRIPTION, tr("Collect segments of all tracks and combine in a single track.")); 481 putValue(NAME, tr("Combine tracks of this layer")); 482 this.layer = layer; 483 } 484 485 @Override 486 public void actionPerformed(ActionEvent e) { 487 layer.data.combineTracksToSegmentedTrack(); 488 layer.invalidate(); 489 } 490 491 @Override 492 public boolean isEnabled() { 493 return layer.data.getTrackCount() > 1; 494 } 495 } 496 497 /** 498 * Action to split track segments into a multiple tracks with one segment each 499 * 500 * @since 13210 501 */ 502 public static class SplitTrackSegmentsToTracksAction extends AbstractAction { 503 private final transient GpxLayer layer; 504 505 /** 506 * Create a new SplitTrackSegmentsToTracksAction 507 * @param layer The layer with the data to work on. 508 */ 509 public SplitTrackSegmentsToTracksAction(GpxLayer layer) { 510 // FIXME: icon missing, create a new icon for this action 511 //new ImageProvider(..."gpx_segmented_track_to_tracks").getResource().attachImageIcon(this, true); 512 putValue(SHORT_DESCRIPTION, tr("Split multiple track segments of one track into multiple tracks.")); 513 putValue(NAME, tr("Split track segments to tracks")); 514 this.layer = layer; 515 } 516 517 @Override 518 public void actionPerformed(ActionEvent e) { 519 layer.data.splitTrackSegmentsToTracks(!layer.getName().isEmpty() ? layer.getName() : "GPX split result"); 520 layer.invalidate(); 521 } 522 523 @Override 524 public boolean isEnabled() { 525 return layer.data.getTrackSegsCount() > layer.data.getTrackCount(); 526 } 527 } 528 529 /** 530 * Action to split tracks of one gpx layer into multiple gpx layers, 531 * the result is one GPX track per gpx layer. 532 * 533 * @since 13210 534 */ 535 public static class SplitTracksToLayersAction extends AbstractAction { 536 private final transient GpxLayer layer; 537 538 /** 539 * Create a new SplitTrackSegmentsToTracksAction 540 * @param layer The layer with the data to work on. 541 */ 542 public SplitTracksToLayersAction(GpxLayer layer) { 543 // FIXME: icon missing, create a new icon for this action 544 //new ImageProvider(..."gpx_split_tracks_to_layers").getResource().attachImageIcon(this, true); 545 putValue(SHORT_DESCRIPTION, tr("Split the tracks of this layer to one new layer each.")); 546 putValue(NAME, tr("Split tracks to new layers")); 547 this.layer = layer; 548 } 549 550 @Override 551 public void actionPerformed(ActionEvent e) { 552 layer.data.splitTracksToLayers(!layer.getName().isEmpty() ? layer.getName() : "GPX split result"); 553 // layer is not modified by this action 554 } 555 556 @Override 557 public boolean isEnabled() { 558 return layer.data.getTrackCount() > 1; 559 } 560 } 561 562 @Override 563 public void expertChanged(boolean isExpert) { 564 this.isExpertMode = isExpert; 565 } 566 567 @Override 568 public boolean isModified() { 569 return data != null && data.isModified(); 570 } 571 572 @Override 573 public boolean requiresSaveToFile() { 574 return data != null && isModified() && (isLocalFile() || data.fromSession); 575 } 576 577 @Override 578 public void onPostSaveToFile() { 579 isLocalFile = true; 580 data.invalidate(); 581 data.setModified(false); 582 } 583 584 @Override 585 public String getChangesetSourceTag() { 586 // no i18n for international values 587 return isLocalFile ? "survey" : null; 588 } 589 590 @Override 591 public Data getData() { 592 return data; 593 } 594 595 @Override 596 public GpxData getGpxData() { 597 return data; 598 } 599 600 /** 601 * Jump (move the viewport) to the next track segment. 602 */ 603 @Override 604 public void jumpToNextMarker() { 605 if (data != null) { 606 jumpToNext(data.getTrackSegmentsStream().collect(Collectors.toList())); 607 } 608 } 609 610 /** 611 * Jump (move the viewport) to the previous track segment. 612 */ 613 @Override 614 public void jumpToPreviousMarker() { 615 if (data != null) { 616 List<IGpxTrackSegment> segments = data.getTrackSegmentsStream().collect(Collectors.toList()); 617 Collections.reverse(segments); 618 jumpToNext(segments); 619 } 620 } 621 622 private void jumpToNext(List<IGpxTrackSegment> segments) { 623 if (segments.isEmpty()) { 624 return; 625 } else if (currentSegment == null) { 626 currentSegment = segments.get(0); 627 MainApplication.getMap().mapView.zoomTo(currentSegment.getBounds()); 628 } else { 629 try { 630 int index = segments.indexOf(currentSegment); 631 currentSegment = segments.listIterator(index + 1).next(); 632 MainApplication.getMap().mapView.zoomTo(currentSegment.getBounds()); 633 } catch (IndexOutOfBoundsException | NoSuchElementException ignore) { 634 Logging.trace(ignore); 635 } 636 } 637 } 638 639 @Override 640 public synchronized void destroy() { 641 if (linkedMarkerLayer != null && MainApplication.getLayerManager().containsLayer(linkedMarkerLayer)) { 642 linkedMarkerLayer.data.transferLayerPrefs(data.getLayerPrefs()); 643 } 644 data.clear(); 645 data = null; 646 super.destroy(); 647 } 648}