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