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}