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