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