001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.layer.markerlayer;
003
004import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
005import static org.openstreetmap.josm.tools.I18n.marktr;
006import static org.openstreetmap.josm.tools.I18n.tr;
007import static org.openstreetmap.josm.tools.I18n.trn;
008
009import java.awt.BasicStroke;
010import java.awt.Color;
011import java.awt.Component;
012import java.awt.Graphics2D;
013import java.awt.Point;
014import java.awt.event.ActionEvent;
015import java.awt.event.MouseAdapter;
016import java.awt.event.MouseEvent;
017import java.io.File;
018import java.net.URI;
019import java.net.URISyntaxException;
020import java.util.ArrayList;
021import java.util.Collection;
022import java.util.Comparator;
023import java.util.HashMap;
024import java.util.List;
025import java.util.Map;
026import java.util.Optional;
027
028import javax.swing.AbstractAction;
029import javax.swing.Action;
030import javax.swing.Icon;
031import javax.swing.JCheckBoxMenuItem;
032import javax.swing.JOptionPane;
033
034import org.openstreetmap.josm.actions.AutoScaleAction;
035import org.openstreetmap.josm.actions.RenameLayerAction;
036import org.openstreetmap.josm.data.Bounds;
037import org.openstreetmap.josm.data.coor.LatLon;
038import org.openstreetmap.josm.data.gpx.GpxConstants;
039import org.openstreetmap.josm.data.gpx.GpxData;
040import org.openstreetmap.josm.data.gpx.GpxExtension;
041import org.openstreetmap.josm.data.gpx.GpxLink;
042import org.openstreetmap.josm.data.gpx.IGpxLayerPrefs;
043import org.openstreetmap.josm.data.gpx.WayPoint;
044import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor;
045import org.openstreetmap.josm.data.preferences.IntegerProperty;
046import org.openstreetmap.josm.data.preferences.NamedColorProperty;
047import org.openstreetmap.josm.data.preferences.StrokeProperty;
048import org.openstreetmap.josm.gui.MainApplication;
049import org.openstreetmap.josm.gui.MapView;
050import org.openstreetmap.josm.gui.dialogs.LayerListDialog;
051import org.openstreetmap.josm.gui.dialogs.LayerListPopup;
052import org.openstreetmap.josm.gui.layer.CustomizeColor;
053import org.openstreetmap.josm.gui.layer.GpxLayer;
054import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToMarkerLayer;
055import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToNextMarker;
056import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToPreviousMarker;
057import org.openstreetmap.josm.gui.layer.Layer;
058import org.openstreetmap.josm.gui.layer.gpx.ConvertFromMarkerLayerAction;
059import org.openstreetmap.josm.gui.preferences.display.GPXSettingsPanel;
060import org.openstreetmap.josm.io.audio.AudioPlayer;
061import org.openstreetmap.josm.spi.preferences.Config;
062import org.openstreetmap.josm.tools.ColorHelper;
063import org.openstreetmap.josm.tools.ImageProvider;
064import org.openstreetmap.josm.tools.Logging;
065import org.openstreetmap.josm.tools.Utils;
066
067/**
068 * A layer holding markers.
069 *
070 * Markers are GPS points with a name and, optionally, a symbol code attached;
071 * marker layers can be created from waypoints when importing raw GPS data,
072 * but they may also come from other sources.
073 *
074 * The symbol code is for future use.
075 *
076 * The data is read only.
077 */
078public class MarkerLayer extends Layer implements JumpToMarkerLayer {
079
080    /**
081     * A list of markers.
082     */
083    public final MarkerData data;
084    private boolean mousePressed;
085    public GpxLayer fromLayer;
086    private Marker currentMarker;
087    public AudioMarker syncAudioMarker;
088    private Color color, realcolor;
089    final int markerSize = new IntegerProperty("draw.rawgps.markers.size", 4).get();
090    final BasicStroke markerStroke = new StrokeProperty("draw.rawgps.markers.stroke", "1").get();
091
092    /**
093     * The default color that is used for drawing markers.
094     */
095    public static final NamedColorProperty DEFAULT_COLOR_PROPERTY = new NamedColorProperty(marktr("gps marker"), Color.magenta);
096
097    /**
098     * Constructs a new {@code MarkerLayer}.
099     * @param indata The GPX data for this layer
100     * @param name The marker layer name
101     * @param associatedFile The associated GPX file
102     * @param fromLayer The associated GPX layer
103     */
104    public MarkerLayer(GpxData indata, String name, File associatedFile, GpxLayer fromLayer) {
105        super(name);
106        this.setAssociatedFile(associatedFile);
107        this.data = new MarkerData();
108        this.fromLayer = fromLayer;
109        double firstTime = -1.0;
110        String lastLinkedFile = "";
111
112        if (fromLayer == null || fromLayer.data == null) {
113            data.ownLayerPrefs = indata.getLayerPrefs();
114        }
115
116        String cs = GPXSettingsPanel.tryGetDataPrefLocal(data, "markers.color");
117        Color c = null;
118        if (cs != null) {
119            c = ColorHelper.html2color(cs);
120            if (c == null) {
121                Logging.warn("Could not read marker color: " + cs);
122            }
123        }
124        setPrivateColors(c);
125
126        for (WayPoint wpt : indata.waypoints) {
127            /* calculate time differences in waypoints */
128            double time = wpt.getTime();
129            boolean wptHasLink = wpt.attr.containsKey(GpxConstants.META_LINKS);
130            if (firstTime < 0 && wptHasLink) {
131                firstTime = time;
132                for (GpxLink oneLink : wpt.<GpxLink>getCollection(GpxConstants.META_LINKS)) {
133                    lastLinkedFile = oneLink.uri;
134                    break;
135                }
136            }
137            if (wptHasLink) {
138                for (GpxLink oneLink : wpt.<GpxLink>getCollection(GpxConstants.META_LINKS)) {
139                    String uri = oneLink.uri;
140                    if (uri != null) {
141                        if (!uri.equals(lastLinkedFile)) {
142                            firstTime = time;
143                        }
144                        lastLinkedFile = uri;
145                        break;
146                    }
147                }
148            }
149            Double offset = null;
150            // If we have an explicit offset, take it.
151            // Otherwise, for a group of markers with the same Link-URI (e.g. an
152            // audio file) calculate the offset relative to the first marker of
153            // that group. This way the user can jump to the corresponding
154            // playback positions in a long audio track.
155            GpxExtension offsetExt = wpt.getExtensions().get("josm", "offset");
156            if (offsetExt != null && offsetExt.getValue() != null) {
157                try {
158                    offset = Double.valueOf(offsetExt.getValue());
159                } catch (NumberFormatException nfe) {
160                    Logging.warn(nfe);
161                }
162            }
163            if (offset == null) {
164                offset = time - firstTime;
165            }
166            final Collection<Marker> markers = Marker.createMarkers(wpt, indata.storageFile, this, time, offset);
167            if (markers != null) {
168                data.addAll(markers);
169            }
170        }
171    }
172
173    @Override
174    public synchronized void destroy() {
175        if (data.contains(AudioMarker.recentlyPlayedMarker())) {
176            AudioMarker.resetRecentlyPlayedMarker();
177        }
178        syncAudioMarker = null;
179        currentMarker = null;
180        fromLayer = null;
181        data.forEach(Marker::destroy);
182        data.clear();
183        super.destroy();
184    }
185
186    @Override
187    public LayerPainter attachToMapView(MapViewEvent event) {
188        event.getMapView().addMouseListener(new MarkerMouseAdapter());
189
190        if (event.getMapView().playHeadMarker == null) {
191            event.getMapView().playHeadMarker = PlayHeadMarker.create();
192        }
193
194        return super.attachToMapView(event);
195    }
196
197    /**
198     * Return a static icon.
199     */
200    @Override
201    public Icon getIcon() {
202        return ImageProvider.get("layer", "marker_small");
203    }
204
205    @Override
206    public void paint(Graphics2D g, MapView mv, Bounds box) {
207        boolean showTextOrIcon = isTextOrIconShown();
208        g.setColor(realcolor);
209        if (mousePressed) {
210            boolean mousePressedTmp = mousePressed;
211            Point mousePos = mv.getMousePosition(); // Get mouse position only when necessary (it's the slowest part of marker layer painting)
212            for (Marker mkr : data) {
213                if (mousePos != null && mkr.containsPoint(mousePos)) {
214                    mkr.paint(g, mv, mousePressedTmp, showTextOrIcon);
215                    mousePressedTmp = false;
216                }
217            }
218        } else {
219            for (Marker mkr : data) {
220                mkr.paint(g, mv, false, showTextOrIcon);
221            }
222        }
223    }
224
225    @Override
226    public String getToolTipText() {
227        return Integer.toString(data.size())+' '+trn("marker", "markers", data.size());
228    }
229
230    @Override
231    public void mergeFrom(Layer from) {
232        if (from instanceof MarkerLayer) {
233            data.addAll(((MarkerLayer) from).data);
234            data.sort(Comparator.comparingDouble(o -> o.time));
235        }
236    }
237
238    @Override public boolean isMergable(Layer other) {
239        return other instanceof MarkerLayer;
240    }
241
242    @Override public void visitBoundingBox(BoundingXYVisitor v) {
243        for (Marker mkr : data) {
244            v.visit(mkr);
245        }
246    }
247
248    @Override public Object getInfoComponent() {
249        return "<html>"+trn("{0} consists of {1} marker", "{0} consists of {1} markers",
250                data.size(), Utils.escapeReservedCharactersHTML(getName()), data.size()) + "</html>";
251    }
252
253    @Override public Action[] getMenuEntries() {
254        Collection<Action> components = new ArrayList<>();
255        components.add(LayerListDialog.getInstance().createShowHideLayerAction());
256        components.add(new ShowHideMarkerText(this));
257        components.add(LayerListDialog.getInstance().createDeleteLayerAction());
258        components.add(MainApplication.getMenu().autoScaleActions.get(AutoScaleAction.AutoScaleMode.LAYER));
259        components.add(LayerListDialog.getInstance().createMergeLayerAction(this));
260        components.add(SeparatorLayerAction.INSTANCE);
261        components.add(new CustomizeColor(this));
262        components.add(SeparatorLayerAction.INSTANCE);
263        components.add(new SynchronizeAudio());
264        if (Config.getPref().getBoolean("marker.traceaudio", true)) {
265            components.add(new MoveAudio());
266        }
267        components.add(new JumpToNextMarker(this));
268        components.add(new JumpToPreviousMarker(this));
269        components.add(new ConvertFromMarkerLayerAction(this));
270        components.add(new RenameLayerAction(getAssociatedFile(), this));
271        components.add(SeparatorLayerAction.INSTANCE);
272        components.add(new LayerListPopup.InfoAction(this));
273        return components.toArray(new Action[0]);
274    }
275
276    public boolean synchronizeAudioMarkers(final AudioMarker startMarker) {
277        syncAudioMarker = startMarker;
278        if (syncAudioMarker != null && !data.contains(syncAudioMarker)) {
279            syncAudioMarker = null;
280        }
281        if (syncAudioMarker == null) {
282            // find the first audioMarker in this layer
283            syncAudioMarker = Utils.filteredCollection(data, AudioMarker.class).stream()
284                    .findFirst().orElse(syncAudioMarker);
285        }
286        if (syncAudioMarker == null)
287            return false;
288
289        // apply adjustment to all subsequent audio markers in the layer
290        double adjustment = AudioPlayer.position() - syncAudioMarker.offset; // in seconds
291        boolean seenStart = false;
292        try {
293            URI uri = syncAudioMarker.url().toURI();
294            for (Marker m : data) {
295                if (m == syncAudioMarker) {
296                    seenStart = true;
297                }
298                if (seenStart && m instanceof AudioMarker) {
299                    AudioMarker ma = (AudioMarker) m;
300                    // Do not ever call URL.equals but use URI.equals instead to avoid Internet connection
301                    // See http://michaelscharf.blogspot.fr/2006/11/javaneturlequals-and-hashcode-make.html for details
302                    if (ma.url().toURI().equals(uri)) {
303                        ma.adjustOffset(adjustment);
304                    }
305                }
306            }
307        } catch (URISyntaxException e) {
308            Logging.warn(e);
309        }
310        return true;
311    }
312
313    public AudioMarker addAudioMarker(double time, LatLon coor) {
314        // find first audio marker to get absolute start time
315        double offset = 0.0;
316        AudioMarker am = null;
317        for (Marker m : data) {
318            if (m.getClass() == AudioMarker.class) {
319                am = (AudioMarker) m;
320                offset = time - am.time;
321                break;
322            }
323        }
324        if (am == null) {
325            JOptionPane.showMessageDialog(
326                    MainApplication.getMainFrame(),
327                    tr("No existing audio markers in this layer to offset from."),
328                    tr("Error"),
329                    JOptionPane.ERROR_MESSAGE
330                    );
331            return null;
332        }
333
334        // make our new marker
335        AudioMarker newAudioMarker = new AudioMarker(coor,
336                null, AudioPlayer.url(), this, time, offset);
337
338        // insert it at the right place in a copy the collection
339        Collection<Marker> newData = new ArrayList<>();
340        am = null;
341        AudioMarker ret = newAudioMarker; // save to have return value
342        for (Marker m : data) {
343            if (m.getClass() == AudioMarker.class) {
344                am = (AudioMarker) m;
345                if (newAudioMarker != null && offset < am.offset) {
346                    newAudioMarker.adjustOffset(am.syncOffset()); // i.e. same as predecessor
347                    newData.add(newAudioMarker);
348                    newAudioMarker = null;
349                }
350            }
351            newData.add(m);
352        }
353
354        if (newAudioMarker != null) {
355            if (am != null) {
356                newAudioMarker.adjustOffset(am.syncOffset()); // i.e. same as predecessor
357            }
358            newData.add(newAudioMarker); // insert at end
359        }
360
361        // replace the collection
362        data.clear();
363        data.addAll(newData);
364        return ret;
365    }
366
367    @Override
368    public void jumpToNextMarker() {
369        if (currentMarker == null) {
370            currentMarker = data.get(0);
371        } else {
372            boolean foundCurrent = false;
373            for (Marker m: data) {
374                if (foundCurrent) {
375                    currentMarker = m;
376                    break;
377                } else if (currentMarker == m) {
378                    foundCurrent = true;
379                }
380            }
381        }
382        MainApplication.getMap().mapView.zoomTo(currentMarker);
383    }
384
385    @Override
386    public void jumpToPreviousMarker() {
387        if (currentMarker == null) {
388            currentMarker = data.get(data.size() - 1);
389        } else {
390            boolean foundCurrent = false;
391            for (int i = data.size() - 1; i >= 0; i--) {
392                Marker m = data.get(i);
393                if (foundCurrent) {
394                    currentMarker = m;
395                    break;
396                } else if (currentMarker == m) {
397                    foundCurrent = true;
398                }
399            }
400        }
401        MainApplication.getMap().mapView.zoomTo(currentMarker);
402    }
403
404    public static void playAudio() {
405        playAdjacentMarker(null, true);
406    }
407
408    public static void playNextMarker() {
409        playAdjacentMarker(AudioMarker.recentlyPlayedMarker(), true);
410    }
411
412    public static void playPreviousMarker() {
413        playAdjacentMarker(AudioMarker.recentlyPlayedMarker(), false);
414    }
415
416    private static Marker getAdjacentMarker(Marker startMarker, boolean next, Layer layer) {
417        Marker previousMarker = null;
418        boolean nextTime = false;
419        if (layer.getClass() == MarkerLayer.class) {
420            MarkerLayer markerLayer = (MarkerLayer) layer;
421            for (Marker marker : markerLayer.data) {
422                if (marker == startMarker) {
423                    if (next) {
424                        nextTime = true;
425                    } else {
426                        if (previousMarker == null) {
427                            previousMarker = startMarker; // if no previous one, play the first one again
428                        }
429                        return previousMarker;
430                    }
431                } else if (marker.getClass() == AudioMarker.class) {
432                    if (nextTime || startMarker == null)
433                        return marker;
434                    previousMarker = marker;
435                }
436            }
437            if (nextTime) // there was no next marker in that layer, so play the last one again
438                return startMarker;
439        }
440        return null;
441    }
442
443    private static void playAdjacentMarker(Marker startMarker, boolean next) {
444        if (!MainApplication.isDisplayingMapView())
445            return;
446        Marker m = null;
447        Layer l = MainApplication.getLayerManager().getActiveLayer();
448        if (l != null) {
449            m = getAdjacentMarker(startMarker, next, l);
450        }
451        if (m == null) {
452            for (Layer layer : MainApplication.getLayerManager().getLayers()) {
453                m = getAdjacentMarker(startMarker, next, layer);
454                if (m != null) {
455                    break;
456                }
457            }
458        }
459        if (m != null) {
460            ((AudioMarker) m).play();
461        }
462    }
463
464    /**
465     * Get state of text display.
466     * @return <code>true</code> if text should be shown, <code>false</code> otherwise.
467     */
468    private boolean isTextOrIconShown() {
469        return Boolean.parseBoolean(GPXSettingsPanel.getDataPref(data, "markers.show-text"));
470    }
471
472    @Override
473    public boolean hasColor() {
474        return true;
475    }
476
477    @Override
478    public Color getColor() {
479        return color;
480    }
481
482    @Override
483    public void setColor(Color color) {
484        setPrivateColors(color);
485        String cs = null;
486        if (color != null) {
487            cs = ColorHelper.color2html(color);
488        }
489        GPXSettingsPanel.putDataPrefLocal(data, "markers.color", cs);
490        invalidate();
491    }
492
493    private void setPrivateColors(Color color) {
494        this.color = color;
495        this.realcolor = Optional.ofNullable(color).orElse(DEFAULT_COLOR_PROPERTY.get());
496    }
497
498    private final class MarkerMouseAdapter extends MouseAdapter {
499        @Override
500        public void mousePressed(MouseEvent e) {
501            if (e.getButton() != MouseEvent.BUTTON1)
502                return;
503            boolean mousePressedInButton = data.stream().anyMatch(mkr -> mkr.containsPoint(e.getPoint()));
504            if (!mousePressedInButton)
505                return;
506            mousePressed = true;
507            if (isVisible()) {
508                invalidate();
509            }
510        }
511
512        @Override
513        public void mouseReleased(MouseEvent ev) {
514            if (ev.getButton() != MouseEvent.BUTTON1 || !mousePressed)
515                return;
516            mousePressed = false;
517            if (!isVisible())
518                return;
519            for (Marker mkr : data) {
520                if (mkr.containsPoint(ev.getPoint())) {
521                    mkr.actionPerformed(new ActionEvent(this, 0, null));
522                }
523            }
524            invalidate();
525        }
526    }
527
528    public static final class ShowHideMarkerText extends AbstractAction implements LayerAction {
529        private final transient MarkerLayer layer;
530
531        public ShowHideMarkerText(MarkerLayer layer) {
532            super(tr("Show Text/Icons"));
533            new ImageProvider("dialogs", "showhide").getResource().attachImageIcon(this, true);
534            putValue(SHORT_DESCRIPTION, tr("Toggle visible state of the marker text and icons."));
535            putValue("help", ht("/Action/ShowHideTextIcons"));
536            this.layer = layer;
537        }
538
539        @Override
540        public void actionPerformed(ActionEvent e) {
541            GPXSettingsPanel.putDataPrefLocal(layer.data, "markers.show-text", Boolean.toString(!layer.isTextOrIconShown()));
542            layer.invalidate();
543        }
544
545        @Override
546        public Component createMenuComponent() {
547            JCheckBoxMenuItem showMarkerTextItem = new JCheckBoxMenuItem(this);
548            showMarkerTextItem.setState(layer.isTextOrIconShown());
549            return showMarkerTextItem;
550        }
551
552        @Override
553        public boolean supportLayers(List<Layer> layers) {
554            return layers.size() == 1 && layers.get(0) instanceof MarkerLayer;
555        }
556    }
557
558    private class SynchronizeAudio extends AbstractAction {
559
560        /**
561         * Constructs a new {@code SynchronizeAudio} action.
562         */
563        SynchronizeAudio() {
564            super(tr("Synchronize Audio"));
565            new ImageProvider("audio-sync").getResource().attachImageIcon(this, true);
566            putValue("help", ht("/Action/SynchronizeAudio"));
567        }
568
569        @Override
570        public void actionPerformed(ActionEvent e) {
571            if (!AudioPlayer.paused()) {
572                JOptionPane.showMessageDialog(
573                        MainApplication.getMainFrame(),
574                        tr("You need to pause audio at the moment when you hear your synchronization cue."),
575                        tr("Warning"),
576                        JOptionPane.WARNING_MESSAGE
577                        );
578                return;
579            }
580            AudioMarker recent = AudioMarker.recentlyPlayedMarker();
581            if (synchronizeAudioMarkers(recent)) {
582                JOptionPane.showMessageDialog(
583                        MainApplication.getMainFrame(),
584                        tr("Audio synchronized at point {0}.", syncAudioMarker.getText()),
585                        tr("Information"),
586                        JOptionPane.INFORMATION_MESSAGE
587                        );
588            } else {
589                JOptionPane.showMessageDialog(
590                        MainApplication.getMainFrame(),
591                        tr("Unable to synchronize in layer being played."),
592                        tr("Error"),
593                        JOptionPane.ERROR_MESSAGE
594                        );
595            }
596        }
597    }
598
599    private class MoveAudio extends AbstractAction {
600
601        MoveAudio() {
602            super(tr("Make Audio Marker at Play Head"));
603            new ImageProvider("addmarkers").getResource().attachImageIcon(this, true);
604            putValue("help", ht("/Action/MakeAudioMarkerAtPlayHead"));
605        }
606
607        @Override
608        public void actionPerformed(ActionEvent e) {
609            if (!AudioPlayer.paused()) {
610                JOptionPane.showMessageDialog(
611                        MainApplication.getMainFrame(),
612                        tr("You need to have paused audio at the point on the track where you want the marker."),
613                        tr("Warning"),
614                        JOptionPane.WARNING_MESSAGE
615                        );
616                return;
617            }
618            PlayHeadMarker playHeadMarker = MainApplication.getMap().mapView.playHeadMarker;
619            if (playHeadMarker == null)
620                return;
621            addAudioMarker(playHeadMarker.time, playHeadMarker.getCoor());
622            invalidate();
623        }
624    }
625
626    /**
627     * the data of a MarkerLayer
628     * @since 18287
629     */
630    public class MarkerData extends ArrayList<Marker> implements IGpxLayerPrefs {
631
632        private Map<String, String> ownLayerPrefs;
633
634        @Override
635        public Map<String, String> getLayerPrefs() {
636            if (ownLayerPrefs == null && fromLayer != null && fromLayer.data != null) {
637                return fromLayer.data.getLayerPrefs();
638            }
639            // fallback to own layerPrefs if the corresponding gpxLayer has already been deleted
640            // by the user or never existed when loaded from a session file
641            if (ownLayerPrefs == null) {
642                ownLayerPrefs = new HashMap<>();
643            }
644            return ownLayerPrefs;
645        }
646
647        /**
648         * Transfers the layerPrefs from the GpxData to MarkerData (when GpxData is deleted)
649         * @param gpxLayerPrefs the layerPrefs from the GpxData object
650         */
651        public void transferLayerPrefs(Map<String, String> gpxLayerPrefs) {
652            ownLayerPrefs = new HashMap<>(gpxLayerPrefs);
653        }
654
655        @Override
656        public void setModified(boolean value) {
657            if (fromLayer != null && fromLayer.data != null) {
658                fromLayer.data.setModified(value);
659            }
660        }
661    }
662}