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