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>")
151                .append("td { padding: 4px 16px; }")
152                .append("</style></head><body>");
153
154        if (data.attr.containsKey("name")) {
155            info.append(tr("Name: {0}", data.get(GpxConstants.META_NAME))).append("<br>");
156        }
157
158        if (data.attr.containsKey("desc")) {
159            info.append(tr("Description: {0}", data.get(GpxConstants.META_DESC))).append("<br>");
160        }
161
162        if (!data.getTracks().isEmpty()) {
163            info.append("<table><thead align='center'><tr><td colspan='5'>")
164                .append(trn("{0} track, {1} track segments", "{0} tracks, {1} track segments",
165                        data.getTrackCount(), data.getTrackCount(),
166                        data.getTrackSegsCount(), data.getTrackSegsCount()))
167                .append("</td></tr><tr align='center'><td>").append(tr("Name"))
168                .append("</td><td>").append(tr("Description"))
169                .append("</td><td>").append(tr("Timespan"))
170                .append("</td><td>").append(tr("Length"))
171                .append("</td><td>").append(tr("Number of<br/>Segments"))
172                .append("</td><td>").append(tr("URL"))
173                .append("</td></tr></thead>");
174
175            for (GpxTrack trk : data.getTracks()) {
176                info.append("<tr><td>");
177                if (trk.getAttributes().containsKey(GpxConstants.GPX_NAME)) {
178                    info.append(trk.get(GpxConstants.GPX_NAME));
179                }
180                info.append("</td><td>");
181                if (trk.getAttributes().containsKey(GpxConstants.GPX_DESC)) {
182                    info.append(' ').append(trk.get(GpxConstants.GPX_DESC));
183                }
184                info.append("</td><td>");
185                info.append(getTimespanForTrack(trk));
186                info.append("</td><td>");
187                info.append(SystemOfMeasurement.getSystemOfMeasurement().getDistText(trk.length()));
188                info.append("</td><td>");
189                info.append(trk.getSegments().size());
190                info.append("</td><td>");
191                if (trk.getAttributes().containsKey("url")) {
192                    info.append(trk.get("url"));
193                }
194                info.append("</td></tr>");
195            }
196            info.append("</table><br><br>");
197        }
198
199        info.append(tr("Length: {0}", SystemOfMeasurement.getSystemOfMeasurement().getDistText(data.length()))).append("<br>")
200            .append(trn("{0} route, ", "{0} routes, ", data.getRoutes().size(), data.getRoutes().size()))
201            .append(trn("{0} waypoint", "{0} waypoints", data.getWaypoints().size(), data.getWaypoints().size()))
202            .append("<br></body></html>");
203
204        final JScrollPane sp = new JScrollPane(new HtmlPanel(info.toString()));
205        sp.setPreferredSize(new Dimension(sp.getPreferredSize().width+20, 370));
206        SwingUtilities.invokeLater(() -> sp.getVerticalScrollBar().setValue(0));
207        return sp;
208    }
209
210    @Override
211    public boolean isInfoResizable() {
212        return true;
213    }
214
215    @Override
216    public Action[] getMenuEntries() {
217        List<Action> entries = new ArrayList<>(Arrays.asList(
218                LayerListDialog.getInstance().createShowHideLayerAction(),
219                LayerListDialog.getInstance().createDeleteLayerAction(),
220                LayerListDialog.getInstance().createMergeLayerAction(this),
221                SeparatorLayerAction.INSTANCE,
222                new LayerSaveAction(this),
223                new LayerSaveAsAction(this),
224                new CustomizeColor(this),
225                new CustomizeDrawingAction(this),
226                new ImportImagesAction(this),
227                new ImportAudioAction(this),
228                new MarkersFromNamedPointsAction(this),
229                new ConvertToDataLayerAction.FromGpxLayer(this),
230                new DownloadAlongTrackAction(data),
231                new DownloadWmsAlongTrackAction(data),
232                SeparatorLayerAction.INSTANCE,
233                new ChooseTrackVisibilityAction(this),
234                new RenameLayerAction(getAssociatedFile(), this)));
235
236        List<Action> expert = Arrays.asList(
237                new CombineTracksToSegmentedTrackAction(this),
238                new SplitTrackSegementsToTracksAction(this),
239                new SplitTracksToLayersAction(this));
240
241        if (isExpertMode && expert.stream().anyMatch(Action::isEnabled)) {
242            entries.add(SeparatorLayerAction.INSTANCE);
243            expert.stream().filter(Action::isEnabled).forEach(entries::add);
244        }
245
246        entries.add(SeparatorLayerAction.INSTANCE);
247        entries.add(new LayerListPopup.InfoAction(this));
248        return entries.toArray(new Action[0]);
249    }
250
251    /**
252     * Determines if data is attached to a local file.
253     * @return {@code true} if data is attached to a local file, {@code false} otherwise
254     */
255    public boolean isLocalFile() {
256        return isLocalFile;
257    }
258
259    @Override
260    public String getToolTipText() {
261        StringBuilder info = new StringBuilder(48).append("<html>");
262
263        if (data.attr.containsKey(GpxConstants.META_NAME)) {
264            info.append(tr("Name: {0}", data.get(GpxConstants.META_NAME))).append("<br>");
265        }
266
267        if (data.attr.containsKey(GpxConstants.META_DESC)) {
268            info.append(tr("Description: {0}", data.get(GpxConstants.META_DESC))).append("<br>");
269        }
270
271        info.append(trn("{0} track", "{0} tracks", data.getTrackCount(), data.getTrackCount()))
272            .append(trn(" ({0} segment)", " ({0} segments)", data.getTrackSegsCount(), data.getTrackSegsCount()))
273            .append(", ")
274            .append(trn("{0} route, ", "{0} routes, ", data.getRoutes().size(), data.getRoutes().size()))
275            .append(trn("{0} waypoint", "{0} waypoints", data.getWaypoints().size(), data.getWaypoints().size())).append("<br>")
276            .append(tr("Length: {0}", SystemOfMeasurement.getSystemOfMeasurement().getDistText(data.length())))
277            .append("<br></html>");
278        return info.toString();
279    }
280
281    @Override
282    public boolean isMergable(Layer other) {
283        return other instanceof GpxLayer;
284    }
285
286    /**
287     * Shows/hides all tracks of a given date range by setting them to visible/invisible.
288     * @param fromDate The min date
289     * @param toDate The max date
290     * @param showWithoutDate Include tracks that don't have any date set..
291     */
292    public void filterTracksByDate(Date fromDate, Date toDate, boolean showWithoutDate) {
293        int i = 0;
294        long from = fromDate.getTime();
295        long to = toDate.getTime();
296        for (GpxTrack trk : data.getTracks()) {
297            Date[] t = GpxData.getMinMaxTimeForTrack(trk);
298
299            if (t == null) continue;
300            long tm = t[1].getTime();
301            trackVisibility[i] = (tm == 0 && showWithoutDate) || (from <= tm && tm <= to);
302            i++;
303        }
304        invalidate();
305    }
306
307    @Override
308    public void mergeFrom(Layer from) {
309        if (!(from instanceof GpxLayer))
310            throw new IllegalArgumentException("not a GpxLayer: " + from);
311        data.mergeFrom(((GpxLayer) from).data);
312        invalidate();
313    }
314
315    @Override
316    public void visitBoundingBox(BoundingXYVisitor v) {
317        v.visit(data.recalculateBounds());
318    }
319
320    @Override
321    public File getAssociatedFile() {
322        return data.storageFile;
323    }
324
325    @Override
326    public void setAssociatedFile(File file) {
327        data.storageFile = file;
328    }
329
330    @Override
331    public void projectionChanged(Projection oldValue, Projection newValue) {
332        if (newValue == null) return;
333        data.resetEastNorthCache();
334    }
335
336    @Override
337    public boolean isSavable() {
338        return true; // With GpxExporter
339    }
340
341    @Override
342    public boolean checkSaveConditions() {
343        return data != null;
344    }
345
346    @Override
347    public File createAndOpenSaveFileChooser() {
348        return SaveActionBase.createAndOpenSaveFileChooser(tr("Save GPX file"), GpxImporter.getFileFilter());
349    }
350
351    @Override
352    public LayerPositionStrategy getDefaultLayerPosition() {
353        return LayerPositionStrategy.AFTER_LAST_DATA_LAYER;
354    }
355
356    @Override
357    public void paint(Graphics2D g, MapView mv, Bounds bbox) {
358        // unused - we use a painter so this is not called.
359    }
360
361    @Override
362    protected LayerPainter createMapViewPainter(MapViewEvent event) {
363        return new GpxDrawHelper(this);
364    }
365
366    /**
367     * Action to merge tracks into a single segmented track
368     *
369     * @since 13210
370     */
371    public static class CombineTracksToSegmentedTrackAction extends AbstractAction {
372        private final transient GpxLayer layer;
373
374        /**
375         * Create a new CombineTracksToSegmentedTrackAction
376         * @param layer The layer with the data to work on.
377         */
378        public CombineTracksToSegmentedTrackAction(GpxLayer layer) {
379            // FIXME: icon missing, create a new icon for this action
380            //new ImageProvider(..."gpx_tracks_to_segmented_track").getResource().attachImageIcon(this, true);
381            putValue(SHORT_DESCRIPTION, tr("Collect segments of all tracks and combine in a single track."));
382            putValue(NAME, tr("Combine tracks of this layer"));
383            this.layer = layer;
384        }
385
386        @Override
387        public void actionPerformed(ActionEvent e) {
388            layer.data.combineTracksToSegmentedTrack();
389            layer.invalidate();
390        }
391
392        @Override
393        public boolean isEnabled() {
394            return layer.data.getTrackCount() > 1;
395        }
396    }
397
398    /**
399     * Action to split track segments into a multiple tracks with one segment each
400     *
401     * @since 13210
402     */
403    public static class SplitTrackSegementsToTracksAction extends AbstractAction {
404        private final transient GpxLayer layer;
405
406        /**
407         * Create a new SplitTrackSegementsToTracksAction
408         * @param layer The layer with the data to work on.
409         */
410        public SplitTrackSegementsToTracksAction(GpxLayer layer) {
411            // FIXME: icon missing, create a new icon for this action
412            //new ImageProvider(..."gpx_segmented_track_to_tracks").getResource().attachImageIcon(this, true);
413            putValue(SHORT_DESCRIPTION, tr("Split multiple track segments of one track into multiple tracks."));
414            putValue(NAME, tr("Split track segments to tracks"));
415            this.layer = layer;
416        }
417
418        @Override
419        public void actionPerformed(ActionEvent e) {
420            layer.data.splitTrackSegmentsToTracks();
421            layer.invalidate();
422        }
423
424        @Override
425        public boolean isEnabled() {
426            return layer.data.getTrackSegsCount() > layer.data.getTrackCount();
427        }
428    }
429
430    /**
431     * Action to split tracks of one gpx layer into multiple gpx layers,
432     * the result is one GPX track per gpx layer.
433     *
434     * @since 13210
435     */
436    public static class SplitTracksToLayersAction extends AbstractAction {
437        private final transient GpxLayer layer;
438
439        /**
440         * Create a new SplitTrackSegementsToTracksAction
441         * @param layer The layer with the data to work on.
442         */
443        public SplitTracksToLayersAction(GpxLayer layer) {
444            // FIXME: icon missing, create a new icon for this action
445            //new ImageProvider(..."gpx_split_tracks_to_layers").getResource().attachImageIcon(this, true);
446            putValue(SHORT_DESCRIPTION, tr("Split the tracks of this layer to one new layer each."));
447            putValue(NAME, tr("Split tracks to new layers"));
448            this.layer = layer;
449        }
450
451        @Override
452        public void actionPerformed(ActionEvent e) {
453            layer.data.splitTracksToLayers();
454            // layer is not modified by this action
455        }
456
457        @Override
458        public boolean isEnabled() {
459            return layer.data.getTrackCount() > 1;
460        }
461    }
462
463    @Override
464    public void expertChanged(boolean isExpert) {
465        this.isExpertMode = isExpert;
466    }
467}