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