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