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