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