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