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