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.ConvertToDataLayerAction; 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>") 151 .append("td { padding: 4px 16px; }") 152 .append("</style></head><body>"); 153 154 if (data.attr.containsKey("name")) { 155 info.append(tr("Name: {0}", data.get(GpxConstants.META_NAME))).append("<br>"); 156 } 157 158 if (data.attr.containsKey("desc")) { 159 info.append(tr("Description: {0}", data.get(GpxConstants.META_DESC))).append("<br>"); 160 } 161 162 if (!data.getTracks().isEmpty()) { 163 info.append("<table><thead align='center'><tr><td colspan='5'>") 164 .append(trn("{0} track, {1} track segments", "{0} tracks, {1} track segments", 165 data.getTrackCount(), data.getTrackCount(), 166 data.getTrackSegsCount(), data.getTrackSegsCount())) 167 .append("</td></tr><tr align='center'><td>").append(tr("Name")) 168 .append("</td><td>").append(tr("Description")) 169 .append("</td><td>").append(tr("Timespan")) 170 .append("</td><td>").append(tr("Length")) 171 .append("</td><td>").append(tr("Number of<br/>Segments")) 172 .append("</td><td>").append(tr("URL")) 173 .append("</td></tr></thead>"); 174 175 for (GpxTrack trk : data.getTracks()) { 176 info.append("<tr><td>"); 177 if (trk.getAttributes().containsKey(GpxConstants.GPX_NAME)) { 178 info.append(trk.get(GpxConstants.GPX_NAME)); 179 } 180 info.append("</td><td>"); 181 if (trk.getAttributes().containsKey(GpxConstants.GPX_DESC)) { 182 info.append(' ').append(trk.get(GpxConstants.GPX_DESC)); 183 } 184 info.append("</td><td>"); 185 info.append(getTimespanForTrack(trk)); 186 info.append("</td><td>"); 187 info.append(SystemOfMeasurement.getSystemOfMeasurement().getDistText(trk.length())); 188 info.append("</td><td>"); 189 info.append(trk.getSegments().size()); 190 info.append("</td><td>"); 191 if (trk.getAttributes().containsKey("url")) { 192 info.append(trk.get("url")); 193 } 194 info.append("</td></tr>"); 195 } 196 info.append("</table><br><br>"); 197 } 198 199 info.append(tr("Length: {0}", SystemOfMeasurement.getSystemOfMeasurement().getDistText(data.length()))).append("<br>") 200 .append(trn("{0} route, ", "{0} routes, ", data.getRoutes().size(), data.getRoutes().size())) 201 .append(trn("{0} waypoint", "{0} waypoints", data.getWaypoints().size(), data.getWaypoints().size())) 202 .append("<br></body></html>"); 203 204 final JScrollPane sp = new JScrollPane(new HtmlPanel(info.toString())); 205 sp.setPreferredSize(new Dimension(sp.getPreferredSize().width+20, 370)); 206 SwingUtilities.invokeLater(() -> sp.getVerticalScrollBar().setValue(0)); 207 return sp; 208 } 209 210 @Override 211 public boolean isInfoResizable() { 212 return true; 213 } 214 215 @Override 216 public Action[] getMenuEntries() { 217 List<Action> entries = new ArrayList<>(Arrays.asList( 218 LayerListDialog.getInstance().createShowHideLayerAction(), 219 LayerListDialog.getInstance().createDeleteLayerAction(), 220 LayerListDialog.getInstance().createMergeLayerAction(this), 221 SeparatorLayerAction.INSTANCE, 222 new LayerSaveAction(this), 223 new LayerSaveAsAction(this), 224 new CustomizeColor(this), 225 new CustomizeDrawingAction(this), 226 new ImportImagesAction(this), 227 new ImportAudioAction(this), 228 new MarkersFromNamedPointsAction(this), 229 new ConvertToDataLayerAction.FromGpxLayer(this), 230 new DownloadAlongTrackAction(data), 231 new DownloadWmsAlongTrackAction(data), 232 SeparatorLayerAction.INSTANCE, 233 new ChooseTrackVisibilityAction(this), 234 new RenameLayerAction(getAssociatedFile(), this))); 235 236 List<Action> expert = Arrays.asList( 237 new CombineTracksToSegmentedTrackAction(this), 238 new SplitTrackSegementsToTracksAction(this), 239 new SplitTracksToLayersAction(this)); 240 241 if (isExpertMode && expert.stream().anyMatch(Action::isEnabled)) { 242 entries.add(SeparatorLayerAction.INSTANCE); 243 expert.stream().filter(Action::isEnabled).forEach(entries::add); 244 } 245 246 entries.add(SeparatorLayerAction.INSTANCE); 247 entries.add(new LayerListPopup.InfoAction(this)); 248 return entries.toArray(new Action[0]); 249 } 250 251 /** 252 * Determines if data is attached to a local file. 253 * @return {@code true} if data is attached to a local file, {@code false} otherwise 254 */ 255 public boolean isLocalFile() { 256 return isLocalFile; 257 } 258 259 @Override 260 public String getToolTipText() { 261 StringBuilder info = new StringBuilder(48).append("<html>"); 262 263 if (data.attr.containsKey(GpxConstants.META_NAME)) { 264 info.append(tr("Name: {0}", data.get(GpxConstants.META_NAME))).append("<br>"); 265 } 266 267 if (data.attr.containsKey(GpxConstants.META_DESC)) { 268 info.append(tr("Description: {0}", data.get(GpxConstants.META_DESC))).append("<br>"); 269 } 270 271 info.append(trn("{0} track", "{0} tracks", data.getTrackCount(), data.getTrackCount())) 272 .append(trn(" ({0} segment)", " ({0} segments)", data.getTrackSegsCount(), data.getTrackSegsCount())) 273 .append(", ") 274 .append(trn("{0} route, ", "{0} routes, ", data.getRoutes().size(), data.getRoutes().size())) 275 .append(trn("{0} waypoint", "{0} waypoints", data.getWaypoints().size(), data.getWaypoints().size())).append("<br>") 276 .append(tr("Length: {0}", SystemOfMeasurement.getSystemOfMeasurement().getDistText(data.length()))) 277 .append("<br></html>"); 278 return info.toString(); 279 } 280 281 @Override 282 public boolean isMergable(Layer other) { 283 return other instanceof GpxLayer; 284 } 285 286 /** 287 * Shows/hides all tracks of a given date range by setting them to visible/invisible. 288 * @param fromDate The min date 289 * @param toDate The max date 290 * @param showWithoutDate Include tracks that don't have any date set.. 291 */ 292 public void filterTracksByDate(Date fromDate, Date toDate, boolean showWithoutDate) { 293 int i = 0; 294 long from = fromDate.getTime(); 295 long to = toDate.getTime(); 296 for (GpxTrack trk : data.getTracks()) { 297 Date[] t = GpxData.getMinMaxTimeForTrack(trk); 298 299 if (t == null) continue; 300 long tm = t[1].getTime(); 301 trackVisibility[i] = (tm == 0 && showWithoutDate) || (from <= tm && tm <= to); 302 i++; 303 } 304 invalidate(); 305 } 306 307 @Override 308 public void mergeFrom(Layer from) { 309 if (!(from instanceof GpxLayer)) 310 throw new IllegalArgumentException("not a GpxLayer: " + from); 311 data.mergeFrom(((GpxLayer) from).data); 312 invalidate(); 313 } 314 315 @Override 316 public void visitBoundingBox(BoundingXYVisitor v) { 317 v.visit(data.recalculateBounds()); 318 } 319 320 @Override 321 public File getAssociatedFile() { 322 return data.storageFile; 323 } 324 325 @Override 326 public void setAssociatedFile(File file) { 327 data.storageFile = file; 328 } 329 330 @Override 331 public void projectionChanged(Projection oldValue, Projection newValue) { 332 if (newValue == null) return; 333 data.resetEastNorthCache(); 334 } 335 336 @Override 337 public boolean isSavable() { 338 return true; // With GpxExporter 339 } 340 341 @Override 342 public boolean checkSaveConditions() { 343 return data != null; 344 } 345 346 @Override 347 public File createAndOpenSaveFileChooser() { 348 return SaveActionBase.createAndOpenSaveFileChooser(tr("Save GPX file"), GpxImporter.getFileFilter()); 349 } 350 351 @Override 352 public LayerPositionStrategy getDefaultLayerPosition() { 353 return LayerPositionStrategy.AFTER_LAST_DATA_LAYER; 354 } 355 356 @Override 357 public void paint(Graphics2D g, MapView mv, Bounds bbox) { 358 // unused - we use a painter so this is not called. 359 } 360 361 @Override 362 protected LayerPainter createMapViewPainter(MapViewEvent event) { 363 return new GpxDrawHelper(this); 364 } 365 366 /** 367 * Action to merge tracks into a single segmented track 368 * 369 * @since 13210 370 */ 371 public static class CombineTracksToSegmentedTrackAction extends AbstractAction { 372 private final transient GpxLayer layer; 373 374 /** 375 * Create a new CombineTracksToSegmentedTrackAction 376 * @param layer The layer with the data to work on. 377 */ 378 public CombineTracksToSegmentedTrackAction(GpxLayer layer) { 379 // FIXME: icon missing, create a new icon for this action 380 //new ImageProvider(..."gpx_tracks_to_segmented_track").getResource().attachImageIcon(this, true); 381 putValue(SHORT_DESCRIPTION, tr("Collect segments of all tracks and combine in a single track.")); 382 putValue(NAME, tr("Combine tracks of this layer")); 383 this.layer = layer; 384 } 385 386 @Override 387 public void actionPerformed(ActionEvent e) { 388 layer.data.combineTracksToSegmentedTrack(); 389 layer.invalidate(); 390 } 391 392 @Override 393 public boolean isEnabled() { 394 return layer.data.getTrackCount() > 1; 395 } 396 } 397 398 /** 399 * Action to split track segments into a multiple tracks with one segment each 400 * 401 * @since 13210 402 */ 403 public static class SplitTrackSegementsToTracksAction extends AbstractAction { 404 private final transient GpxLayer layer; 405 406 /** 407 * Create a new SplitTrackSegementsToTracksAction 408 * @param layer The layer with the data to work on. 409 */ 410 public SplitTrackSegementsToTracksAction(GpxLayer layer) { 411 // FIXME: icon missing, create a new icon for this action 412 //new ImageProvider(..."gpx_segmented_track_to_tracks").getResource().attachImageIcon(this, true); 413 putValue(SHORT_DESCRIPTION, tr("Split multiple track segments of one track into multiple tracks.")); 414 putValue(NAME, tr("Split track segments to tracks")); 415 this.layer = layer; 416 } 417 418 @Override 419 public void actionPerformed(ActionEvent e) { 420 layer.data.splitTrackSegmentsToTracks(); 421 layer.invalidate(); 422 } 423 424 @Override 425 public boolean isEnabled() { 426 return layer.data.getTrackSegsCount() > layer.data.getTrackCount(); 427 } 428 } 429 430 /** 431 * Action to split tracks of one gpx layer into multiple gpx layers, 432 * the result is one GPX track per gpx layer. 433 * 434 * @since 13210 435 */ 436 public static class SplitTracksToLayersAction extends AbstractAction { 437 private final transient GpxLayer layer; 438 439 /** 440 * Create a new SplitTrackSegementsToTracksAction 441 * @param layer The layer with the data to work on. 442 */ 443 public SplitTracksToLayersAction(GpxLayer layer) { 444 // FIXME: icon missing, create a new icon for this action 445 //new ImageProvider(..."gpx_split_tracks_to_layers").getResource().attachImageIcon(this, true); 446 putValue(SHORT_DESCRIPTION, tr("Split the tracks of this layer to one new layer each.")); 447 putValue(NAME, tr("Split tracks to new layers")); 448 this.layer = layer; 449 } 450 451 @Override 452 public void actionPerformed(ActionEvent e) { 453 layer.data.splitTracksToLayers(); 454 // layer is not modified by this action 455 } 456 457 @Override 458 public boolean isEnabled() { 459 return layer.data.getTrackCount() > 1; 460 } 461 } 462 463 @Override 464 public void expertChanged(boolean isExpert) { 465 this.isExpertMode = isExpert; 466 } 467}