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