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