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(d.getString(GpxConstants.META_NAME));
110        data = d;
111        data.addWeakChangeListener(dataChangeListener);
112        trackVisibility = new boolean[data.getTracks().size()];
113        Arrays.fill(trackVisibility, true);
114        setName(name);
115        isLocalFile = isLocal;
116        ExpertToggleAction.addExpertModeChangeListener(this, true);
117    }
118
119    @Override
120    public Color getColor() {
121        Color[] c = data.getTracks().stream().map(t -> t.getColor()).distinct().toArray(Color[]::new);
122        return c.length == 1 ? c[0] : null; //only return if exactly one distinct color present
123    }
124
125    @Override
126    public void setColor(Color color) {
127        data.beginUpdate();
128        for (IGpxTrack trk : data.getTracks()) {
129            trk.setColor(color);
130        }
131        GPXSettingsPanel.putLayerPrefLocal(this, "colormode", "0");
132        data.endUpdate();
133    }
134
135    @Override
136    public boolean hasColor() {
137        return true;
138    }
139
140    /**
141     * Returns a human readable string that shows the timespan of the given track
142     * @param trk The GPX track for which timespan is displayed
143     * @return The timespan as a string
144     */
145    public static String getTimespanForTrack(IGpxTrack trk) {
146        Date[] bounds = GpxData.getMinMaxTimeForTrack(trk);
147        String ts = "";
148        if (bounds != null) {
149            DateFormat df = DateUtils.getDateFormat(DateFormat.SHORT);
150            String earliestDate = df.format(bounds[0]);
151            String latestDate = df.format(bounds[1]);
152
153            if (earliestDate.equals(latestDate)) {
154                DateFormat tf = DateUtils.getTimeFormat(DateFormat.SHORT);
155                ts += earliestDate + ' ';
156                ts += tf.format(bounds[0]) + " - " + tf.format(bounds[1]);
157            } else {
158                DateFormat dtf = DateUtils.getDateTimeFormat(DateFormat.SHORT, DateFormat.MEDIUM);
159                ts += dtf.format(bounds[0]) + " - " + dtf.format(bounds[1]);
160            }
161
162            int diff = (int) (bounds[1].getTime() - bounds[0].getTime()) / 1000;
163            ts += String.format(" (%d:%02d)", diff / 3600, (diff % 3600) / 60);
164        }
165        return ts;
166    }
167
168    @Override
169    public Icon getIcon() {
170        return ImageProvider.get("layer", "gpx_small");
171    }
172
173    @Override
174    public Object getInfoComponent() {
175        StringBuilder info = new StringBuilder(128)
176                .append("<html><head><style>td { padding: 4px 16px; }</style></head><body>");
177
178        if (data.attr.containsKey("name")) {
179            info.append(tr("Name: {0}", data.get(GpxConstants.META_NAME))).append("<br>");
180        }
181
182        if (data.attr.containsKey("desc")) {
183            info.append(tr("Description: {0}", data.get(GpxConstants.META_DESC))).append("<br>");
184        }
185
186        if (!Utils.isStripEmpty(data.creator)) {
187            info.append(tr("Creator: {0}", data.creator)).append("<br>");
188        }
189
190        if (!data.getTracks().isEmpty()) {
191            info.append("<table><thead align='center'><tr><td colspan='5'>")
192                .append(trn("{0} track, {1} track segments", "{0} tracks, {1} track segments",
193                        data.getTrackCount(), data.getTrackCount(),
194                        data.getTrackSegsCount(), data.getTrackSegsCount()))
195                .append("</td></tr><tr align='center'><td>").append(tr("Name"))
196                .append("</td><td>").append(tr("Description"))
197                .append("</td><td>").append(tr("Timespan"))
198                .append("</td><td>").append(tr("Length"))
199                .append("</td><td>").append(tr("Number of<br/>Segments"))
200                .append("</td><td>").append(tr("URL"))
201                .append("</td></tr></thead>");
202
203            for (IGpxTrack trk : data.getTracks()) {
204                info.append("<tr><td>");
205                if (trk.getAttributes().containsKey(GpxConstants.GPX_NAME)) {
206                    info.append(trk.get(GpxConstants.GPX_NAME));
207                }
208                info.append("</td><td>");
209                if (trk.getAttributes().containsKey(GpxConstants.GPX_DESC)) {
210                    info.append(' ').append(trk.get(GpxConstants.GPX_DESC));
211                }
212                info.append("</td><td>");
213                info.append(getTimespanForTrack(trk));
214                info.append("</td><td>");
215                info.append(SystemOfMeasurement.getSystemOfMeasurement().getDistText(trk.length()));
216                info.append("</td><td>");
217                info.append(trk.getSegments().size());
218                info.append("</td><td>");
219                if (trk.getAttributes().containsKey("url")) {
220                    info.append(trk.get("url"));
221                }
222                info.append("</td></tr>");
223            }
224            info.append("</table><br><br>");
225        }
226
227        info.append(tr("Length: {0}", SystemOfMeasurement.getSystemOfMeasurement().getDistText(data.length()))).append("<br>")
228            .append(trn("{0} route, ", "{0} routes, ", data.getRoutes().size(), data.getRoutes().size()))
229            .append(trn("{0} waypoint", "{0} waypoints", data.getWaypoints().size(), data.getWaypoints().size()))
230            .append("<br></body></html>");
231
232        final JScrollPane sp = new JScrollPane(new HtmlPanel(info.toString()));
233        sp.setPreferredSize(new Dimension(sp.getPreferredSize().width+20, 370));
234        SwingUtilities.invokeLater(() -> sp.getVerticalScrollBar().setValue(0));
235        return sp;
236    }
237
238    @Override
239    public boolean isInfoResizable() {
240        return true;
241    }
242
243    @Override
244    public Action[] getMenuEntries() {
245        List<Action> entries = new ArrayList<>(Arrays.asList(
246                LayerListDialog.getInstance().createShowHideLayerAction(),
247                LayerListDialog.getInstance().createDeleteLayerAction(),
248                LayerListDialog.getInstance().createMergeLayerAction(this),
249                SeparatorLayerAction.INSTANCE,
250                new LayerSaveAction(this),
251                new LayerSaveAsAction(this),
252                new CustomizeColor(this),
253                new CustomizeDrawingAction(this),
254                new ImportImagesAction(this),
255                new ImportAudioAction(this),
256                new MarkersFromNamedPointsAction(this),
257                new ConvertFromGpxLayerAction(this),
258                new DownloadAlongTrackAction(data),
259                new DownloadWmsAlongTrackAction(data),
260                SeparatorLayerAction.INSTANCE,
261                new ChooseTrackVisibilityAction(this),
262                new RenameLayerAction(getAssociatedFile(), this)));
263
264        List<Action> expert = Arrays.asList(
265                new CombineTracksToSegmentedTrackAction(this),
266                new SplitTrackSegementsToTracksAction(this),
267                new SplitTracksToLayersAction(this));
268
269        if (isExpertMode && expert.stream().anyMatch(Action::isEnabled)) {
270            entries.add(SeparatorLayerAction.INSTANCE);
271            expert.stream().filter(Action::isEnabled).forEach(entries::add);
272        }
273
274        entries.add(SeparatorLayerAction.INSTANCE);
275        entries.add(new LayerListPopup.InfoAction(this));
276        return entries.toArray(new Action[0]);
277    }
278
279    /**
280     * Determines if data is attached to a local file.
281     * @return {@code true} if data is attached to a local file, {@code false} otherwise
282     */
283    public boolean isLocalFile() {
284        return isLocalFile;
285    }
286
287    @Override
288    public String getToolTipText() {
289        StringBuilder info = new StringBuilder(48).append("<html>");
290
291        if (data.attr.containsKey(GpxConstants.META_NAME)) {
292            info.append(tr("Name: {0}", data.get(GpxConstants.META_NAME))).append("<br>");
293        }
294
295        if (data.attr.containsKey(GpxConstants.META_DESC)) {
296            info.append(tr("Description: {0}", data.get(GpxConstants.META_DESC))).append("<br>");
297        }
298
299        info.append(trn("{0} track", "{0} tracks", data.getTrackCount(), data.getTrackCount()))
300            .append(trn(" ({0} segment)", " ({0} segments)", data.getTrackSegsCount(), data.getTrackSegsCount()))
301            .append(", ")
302            .append(trn("{0} route, ", "{0} routes, ", data.getRoutes().size(), data.getRoutes().size()))
303            .append(trn("{0} waypoint", "{0} waypoints", data.getWaypoints().size(), data.getWaypoints().size())).append("<br>")
304            .append(tr("Length: {0}", SystemOfMeasurement.getSystemOfMeasurement().getDistText(data.length())));
305
306        if (Logging.isDebugEnabled() && !data.getLayerPrefs().isEmpty()) {
307            info.append("<br><br>")
308                .append(String.join("<br>", data.getLayerPrefs().entrySet().stream()
309                        .map(e -> e.getKey() + "=" + e.getValue()).collect(Collectors.toList())));
310        }
311
312        info.append("<br></html>");
313
314        return info.toString();
315    }
316
317    @Override
318    public boolean isMergable(Layer other) {
319        return other instanceof GpxLayer;
320    }
321
322    /**
323     * Shows/hides all tracks of a given date range by setting them to visible/invisible.
324     * @param fromDate The min date
325     * @param toDate The max date
326     * @param showWithoutDate Include tracks that don't have any date set..
327     */
328    public void filterTracksByDate(Date fromDate, Date toDate, boolean showWithoutDate) {
329        int i = 0;
330        long from = fromDate.getTime();
331        long to = toDate.getTime();
332        for (IGpxTrack trk : data.getTracks()) {
333            Date[] t = GpxData.getMinMaxTimeForTrack(trk);
334
335            if (t == null) continue;
336            long tm = t[1].getTime();
337            trackVisibility[i] = (tm == 0 && showWithoutDate) || (from <= tm && tm <= to);
338            i++;
339        }
340        invalidate();
341    }
342
343    @Override
344    public void mergeFrom(Layer from) {
345        if (!(from instanceof GpxLayer))
346            throw new IllegalArgumentException("not a GpxLayer: " + from);
347        mergeFrom((GpxLayer) from, false, false);
348    }
349
350    /**
351     * Merges the given GpxLayer into this layer and can remove timewise overlapping parts of the given track
352     * @param from The GpxLayer that gets merged into this one
353     * @param cutOverlapping whether overlapping parts of the given track should be removed
354     * @param connect whether the tracks should be connected on cuts
355     * @since 14338
356     */
357    public void mergeFrom(GpxLayer from, boolean cutOverlapping, boolean connect) {
358        data.mergeFrom(from.data, cutOverlapping, connect);
359        invalidate();
360    }
361
362    @Override
363    public void visitBoundingBox(BoundingXYVisitor v) {
364        v.visit(data.recalculateBounds());
365    }
366
367    @Override
368    public File getAssociatedFile() {
369        return data.storageFile;
370    }
371
372    @Override
373    public void setAssociatedFile(File file) {
374        data.storageFile = file;
375    }
376
377    /**
378     * @return the linked MarkerLayer (imported from the same file)
379     * @since 15496
380     */
381    public MarkerLayer getLinkedMarkerLayer() {
382        return linkedMarkerLayer;
383    }
384
385    /**
386     * @param linkedMarkerLayer the linked MarkerLayer
387     * @since 15496
388     */
389    public void setLinkedMarkerLayer(MarkerLayer linkedMarkerLayer) {
390        this.linkedMarkerLayer = linkedMarkerLayer;
391    }
392
393    @Override
394    public void projectionChanged(Projection oldValue, Projection newValue) {
395        if (newValue == null) return;
396        data.resetEastNorthCache();
397    }
398
399    @Override
400    public boolean isSavable() {
401        return true; // With GpxExporter
402    }
403
404    @Override
405    public boolean checkSaveConditions() {
406        return data != null;
407    }
408
409    @Override
410    public File createAndOpenSaveFileChooser() {
411        return SaveActionBase.createAndOpenSaveFileChooser(tr("Save GPX file"), GpxImporter.getFileFilter());
412    }
413
414    @Override
415    public LayerPositionStrategy getDefaultLayerPosition() {
416        return LayerPositionStrategy.AFTER_LAST_DATA_LAYER;
417    }
418
419    @Override
420    public void paint(Graphics2D g, MapView mv, Bounds bbox) {
421        // unused - we use a painter so this is not called.
422    }
423
424    @Override
425    protected LayerPainter createMapViewPainter(MapViewEvent event) {
426        return new GpxDrawHelper(this);
427    }
428
429    /**
430     * Action to merge tracks into a single segmented track
431     *
432     * @since 13210
433     */
434    public static class CombineTracksToSegmentedTrackAction extends AbstractAction {
435        private final transient GpxLayer layer;
436
437        /**
438         * Create a new CombineTracksToSegmentedTrackAction
439         * @param layer The layer with the data to work on.
440         */
441        public CombineTracksToSegmentedTrackAction(GpxLayer layer) {
442            // FIXME: icon missing, create a new icon for this action
443            //new ImageProvider(..."gpx_tracks_to_segmented_track").getResource().attachImageIcon(this, true);
444            putValue(SHORT_DESCRIPTION, tr("Collect segments of all tracks and combine in a single track."));
445            putValue(NAME, tr("Combine tracks of this layer"));
446            this.layer = layer;
447        }
448
449        @Override
450        public void actionPerformed(ActionEvent e) {
451            layer.data.combineTracksToSegmentedTrack();
452            layer.invalidate();
453        }
454
455        @Override
456        public boolean isEnabled() {
457            return layer.data.getTrackCount() > 1;
458        }
459    }
460
461    /**
462     * Action to split track segments into a multiple tracks with one segment each
463     *
464     * @since 13210
465     */
466    public static class SplitTrackSegementsToTracksAction extends AbstractAction {
467        private final transient GpxLayer layer;
468
469        /**
470         * Create a new SplitTrackSegementsToTracksAction
471         * @param layer The layer with the data to work on.
472         */
473        public SplitTrackSegementsToTracksAction(GpxLayer layer) {
474            // FIXME: icon missing, create a new icon for this action
475            //new ImageProvider(..."gpx_segmented_track_to_tracks").getResource().attachImageIcon(this, true);
476            putValue(SHORT_DESCRIPTION, tr("Split multiple track segments of one track into multiple tracks."));
477            putValue(NAME, tr("Split track segments to tracks"));
478            this.layer = layer;
479        }
480
481        @Override
482        public void actionPerformed(ActionEvent e) {
483            layer.data.splitTrackSegmentsToTracks(!layer.getName().isEmpty() ? layer.getName() : "GPX split result");
484            layer.invalidate();
485        }
486
487        @Override
488        public boolean isEnabled() {
489            return layer.data.getTrackSegsCount() > layer.data.getTrackCount();
490        }
491    }
492
493    /**
494     * Action to split tracks of one gpx layer into multiple gpx layers,
495     * the result is one GPX track per gpx layer.
496     *
497     * @since 13210
498     */
499    public static class SplitTracksToLayersAction extends AbstractAction {
500        private final transient GpxLayer layer;
501
502        /**
503         * Create a new SplitTrackSegementsToTracksAction
504         * @param layer The layer with the data to work on.
505         */
506        public SplitTracksToLayersAction(GpxLayer layer) {
507            // FIXME: icon missing, create a new icon for this action
508            //new ImageProvider(..."gpx_split_tracks_to_layers").getResource().attachImageIcon(this, true);
509            putValue(SHORT_DESCRIPTION, tr("Split the tracks of this layer to one new layer each."));
510            putValue(NAME, tr("Split tracks to new layers"));
511            this.layer = layer;
512        }
513
514        @Override
515        public void actionPerformed(ActionEvent e) {
516            layer.data.splitTracksToLayers(!layer.getName().isEmpty() ? layer.getName() : "GPX split result");
517            // layer is not modified by this action
518        }
519
520        @Override
521        public boolean isEnabled() {
522            return layer.data.getTrackCount() > 1;
523        }
524    }
525
526    @Override
527    public void expertChanged(boolean isExpert) {
528        this.isExpertMode = isExpert;
529    }
530
531    @Override
532    public boolean isModified() {
533        return data.isModified();
534    }
535
536    @Override
537    public boolean requiresSaveToFile() {
538        return isModified() && isLocalFile();
539    }
540
541    @Override
542    public void onPostSaveToFile() {
543        isLocalFile = true;
544        data.invalidate();
545        data.setModified(false);
546    }
547
548    @Override
549    public String getChangesetSourceTag() {
550        // no i18n for international values
551        return "survey";
552    }
553}