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>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 ConvertToDataLayerAction.FromGpxLayer(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 data.mergeFrom(((GpxLayer) from).data); 310 invalidate(); 311 } 312 313 @Override 314 public void visitBoundingBox(BoundingXYVisitor v) { 315 v.visit(data.recalculateBounds()); 316 } 317 318 @Override 319 public File getAssociatedFile() { 320 return data.storageFile; 321 } 322 323 @Override 324 public void setAssociatedFile(File file) { 325 data.storageFile = file; 326 } 327 328 @Override 329 public void projectionChanged(Projection oldValue, Projection newValue) { 330 if (newValue == null) return; 331 data.resetEastNorthCache(); 332 } 333 334 @Override 335 public boolean isSavable() { 336 return true; // With GpxExporter 337 } 338 339 @Override 340 public boolean checkSaveConditions() { 341 return data != null; 342 } 343 344 @Override 345 public File createAndOpenSaveFileChooser() { 346 return SaveActionBase.createAndOpenSaveFileChooser(tr("Save GPX file"), GpxImporter.getFileFilter()); 347 } 348 349 @Override 350 public LayerPositionStrategy getDefaultLayerPosition() { 351 return LayerPositionStrategy.AFTER_LAST_DATA_LAYER; 352 } 353 354 @Override 355 public void paint(Graphics2D g, MapView mv, Bounds bbox) { 356 // unused - we use a painter so this is not called. 357 } 358 359 @Override 360 protected LayerPainter createMapViewPainter(MapViewEvent event) { 361 return new GpxDrawHelper(this); 362 } 363 364 /** 365 * Action to merge tracks into a single segmented track 366 * 367 * @since 13210 368 */ 369 public static class CombineTracksToSegmentedTrackAction extends AbstractAction { 370 private final transient GpxLayer layer; 371 372 /** 373 * Create a new CombineTracksToSegmentedTrackAction 374 * @param layer The layer with the data to work on. 375 */ 376 public CombineTracksToSegmentedTrackAction(GpxLayer layer) { 377 // FIXME: icon missing, create a new icon for this action 378 //new ImageProvider(..."gpx_tracks_to_segmented_track").getResource().attachImageIcon(this, true); 379 putValue(SHORT_DESCRIPTION, tr("Collect segments of all tracks and combine in a single track.")); 380 putValue(NAME, tr("Combine tracks of this layer")); 381 this.layer = layer; 382 } 383 384 @Override 385 public void actionPerformed(ActionEvent e) { 386 layer.data.combineTracksToSegmentedTrack(); 387 layer.invalidate(); 388 } 389 390 @Override 391 public boolean isEnabled() { 392 return layer.data.getTrackCount() > 1; 393 } 394 } 395 396 /** 397 * Action to split track segments into a multiple tracks with one segment each 398 * 399 * @since 13210 400 */ 401 public static class SplitTrackSegementsToTracksAction extends AbstractAction { 402 private final transient GpxLayer layer; 403 404 /** 405 * Create a new SplitTrackSegementsToTracksAction 406 * @param layer The layer with the data to work on. 407 */ 408 public SplitTrackSegementsToTracksAction(GpxLayer layer) { 409 // FIXME: icon missing, create a new icon for this action 410 //new ImageProvider(..."gpx_segmented_track_to_tracks").getResource().attachImageIcon(this, true); 411 putValue(SHORT_DESCRIPTION, tr("Split multiple track segments of one track into multiple tracks.")); 412 putValue(NAME, tr("Split track segments to tracks")); 413 this.layer = layer; 414 } 415 416 @Override 417 public void actionPerformed(ActionEvent e) { 418 layer.data.splitTrackSegmentsToTracks(); 419 layer.invalidate(); 420 } 421 422 @Override 423 public boolean isEnabled() { 424 return layer.data.getTrackSegsCount() > layer.data.getTrackCount(); 425 } 426 } 427 428 /** 429 * Action to split tracks of one gpx layer into multiple gpx layers, 430 * the result is one GPX track per gpx layer. 431 * 432 * @since 13210 433 */ 434 public static class SplitTracksToLayersAction extends AbstractAction { 435 private final transient GpxLayer layer; 436 437 /** 438 * Create a new SplitTrackSegementsToTracksAction 439 * @param layer The layer with the data to work on. 440 */ 441 public SplitTracksToLayersAction(GpxLayer layer) { 442 // FIXME: icon missing, create a new icon for this action 443 //new ImageProvider(..."gpx_split_tracks_to_layers").getResource().attachImageIcon(this, true); 444 putValue(SHORT_DESCRIPTION, tr("Split the tracks of this layer to one new layer each.")); 445 putValue(NAME, tr("Split tracks to new layers")); 446 this.layer = layer; 447 } 448 449 @Override 450 public void actionPerformed(ActionEvent e) { 451 layer.data.splitTracksToLayers(); 452 // layer is not modified by this action 453 } 454 455 @Override 456 public boolean isEnabled() { 457 return layer.data.getTrackCount() > 1; 458 } 459 } 460 461 @Override 462 public void expertChanged(boolean isExpert) { 463 this.isExpertMode = isExpert; 464 } 465}