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