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