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