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