001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.layer.markerlayer;
003
004import java.awt.AlphaComposite;
005import java.awt.Color;
006import java.awt.Graphics;
007import java.awt.Graphics2D;
008import java.awt.Point;
009import java.awt.event.ActionEvent;
010import java.awt.image.BufferedImage;
011import java.io.File;
012import java.net.MalformedURLException;
013import java.net.URL;
014import java.text.DateFormat;
015import java.text.SimpleDateFormat;
016import java.util.ArrayList;
017import java.util.Arrays;
018import java.util.Collection;
019import java.util.Collections;
020import java.util.Date;
021import java.util.HashMap;
022import java.util.LinkedList;
023import java.util.List;
024import java.util.Map;
025import java.util.TimeZone;
026
027import javax.swing.ImageIcon;
028
029import org.openstreetmap.josm.Main;
030import org.openstreetmap.josm.actions.search.SearchCompiler.Match;
031import org.openstreetmap.josm.data.Preferences.PreferenceChangeEvent;
032import org.openstreetmap.josm.data.coor.CachedLatLon;
033import org.openstreetmap.josm.data.coor.EastNorth;
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.GpxLink;
038import org.openstreetmap.josm.data.gpx.WayPoint;
039import org.openstreetmap.josm.data.preferences.CachedProperty;
040import org.openstreetmap.josm.data.preferences.IntegerProperty;
041import org.openstreetmap.josm.gui.MapView;
042import org.openstreetmap.josm.tools.ImageProvider;
043import org.openstreetmap.josm.tools.Utils;
044import org.openstreetmap.josm.tools.template_engine.ParseError;
045import org.openstreetmap.josm.tools.template_engine.TemplateEngineDataProvider;
046import org.openstreetmap.josm.tools.template_engine.TemplateEntry;
047import org.openstreetmap.josm.tools.template_engine.TemplateParser;
048
049/**
050 * Basic marker class. Requires a position, and supports
051 * a custom icon and a name.
052 *
053 * This class is also used to create appropriate Marker-type objects
054 * when waypoints are imported.
055 *
056 * It hosts a public list object, named makers, containing implementations of
057 * the MarkerMaker interface. Whenever a Marker needs to be created, each
058 * object in makers is called with the waypoint parameters (Lat/Lon and tag
059 * data), and the first one to return a Marker object wins.
060 *
061 * By default, one the list contains one default "Maker" implementation that
062 * will create AudioMarkers for .wav files, ImageMarkers for .png/.jpg/.jpeg
063 * files, and WebMarkers for everything else. (The creation of a WebMarker will
064 * fail if there's no valid URL in the <link> tag, so it might still make sense
065 * to add Makers for such waypoints at the end of the list.)
066 *
067 * The default implementation only looks at the value of the <link> tag inside
068 * the <wpt> tag of the GPX file.
069 *
070 * <h2>HowTo implement a new Marker</h2>
071 * <ul>
072 * <li> Subclass Marker or ButtonMarker and override <code>containsPoint</code>
073 *      if you like to respond to user clicks</li>
074 * <li> Override paint, if you want a custom marker look (not "a label and a symbol")</li>
075 * <li> Implement MarkerCreator to return a new instance of your marker class</li>
076 * <li> In you plugin constructor, add an instance of your MarkerCreator
077 *      implementation either on top or bottom of Marker.markerProducers.
078 *      Add at top, if your marker should overwrite an current marker or at bottom
079 *      if you only add a new marker style.</li>
080 * </ul>
081 *
082 * @author Frederik Ramm
083 */
084public class Marker implements TemplateEngineDataProvider {
085
086    public static final class TemplateEntryProperty extends CachedProperty<TemplateEntry> {
087        // This class is a bit complicated because it supports both global and per layer settings. I've added per layer settings because
088        // GPXSettingsPanel had possibility to set waypoint label but then I've realized that markers use different layer then gpx data
089        // so per layer settings is useless. Anyway it's possible to specify marker layer pattern in Einstein preferences and maybe somebody
090        // will make gui for it so I'm keeping it here
091
092        private static final Map<String, TemplateEntryProperty> CACHE = new HashMap<>();
093
094        // Legacy code - convert label from int to template engine expression
095        private static final IntegerProperty PROP_LABEL = new IntegerProperty("draw.rawgps.layer.wpt", 0);
096
097        private static String getDefaultLabelPattern() {
098            switch (PROP_LABEL.get()) {
099            case 1:
100                return LABEL_PATTERN_NAME;
101            case 2:
102                return LABEL_PATTERN_DESC;
103            case 0:
104            case 3:
105                return LABEL_PATTERN_AUTO;
106            default:
107                return "";
108            }
109        }
110
111        public static TemplateEntryProperty forMarker(String layerName) {
112            String key = "draw.rawgps.layer.wpt.pattern";
113            if (layerName != null) {
114                key += '.' + layerName;
115            }
116            TemplateEntryProperty result = CACHE.get(key);
117            if (result == null) {
118                String defaultValue = layerName == null ? getDefaultLabelPattern() : "";
119                TemplateEntryProperty parent = layerName == null ? null : forMarker(null);
120                result = new TemplateEntryProperty(key, defaultValue, parent);
121                CACHE.put(key, result);
122            }
123            return result;
124        }
125
126        public static TemplateEntryProperty forAudioMarker(String layerName) {
127            String key = "draw.rawgps.layer.audiowpt.pattern";
128            if (layerName != null) {
129                key += '.' + layerName;
130            }
131            TemplateEntryProperty result = CACHE.get(key);
132            if (result == null) {
133                String defaultValue = layerName == null ? "?{ '{name}' | '{desc}' | '{" + Marker.MARKER_FORMATTED_OFFSET + "}' }" : "";
134                TemplateEntryProperty parent = layerName == null ? null : forAudioMarker(null);
135                result = new TemplateEntryProperty(key, defaultValue, parent);
136                CACHE.put(key, result);
137            }
138            return result;
139        }
140
141        private final TemplateEntryProperty parent;
142
143        private TemplateEntryProperty(String key, String defaultValue, TemplateEntryProperty parent) {
144            super(key, defaultValue);
145            this.parent = parent;
146            updateValue(); // Needs to be called because parent wasn't know in super constructor
147        }
148
149        @Override
150        protected TemplateEntry fromString(String s) {
151            try {
152                return new TemplateParser(s).parse();
153            } catch (ParseError e) {
154                Main.warn("Unable to parse template engine pattern ''{0}'' for property {1}. Using default (''{2}'') instead",
155                        s, getKey(), super.getDefaultValueAsString());
156                return getDefaultValue();
157            }
158        }
159
160        @Override
161        public String getDefaultValueAsString() {
162            if (parent == null)
163                return super.getDefaultValueAsString();
164            else
165                return parent.getAsString();
166        }
167
168        @Override
169        public void preferenceChanged(PreferenceChangeEvent e) {
170            if (e.getKey().equals(key) || (parent != null && e.getKey().equals(parent.getKey()))) {
171                updateValue();
172            }
173        }
174    }
175
176    /**
177     * Plugins can add their Marker creation stuff at the bottom or top of this list
178     * (depending on whether they want to override default behaviour or just add new
179     * stuff).
180     */
181    public static final List<MarkerProducers> markerProducers = new LinkedList<>();
182
183    // Add one Marker specifying the default behaviour.
184    static {
185        Marker.markerProducers.add(new MarkerProducers() {
186            @Override
187            public Collection<Marker> createMarkers(WayPoint wpt, File relativePath, MarkerLayer parentLayer, double time, double offset) {
188                String uri = null;
189                // cheapest way to check whether "link" object exists and is a non-empty collection of GpxLink objects...
190                Collection<GpxLink> links = wpt.<GpxLink>getCollection(GpxConstants.META_LINKS);
191                if (links != null) {
192                    for (GpxLink oneLink : links) {
193                        uri = oneLink.uri;
194                        break;
195                    }
196                }
197
198                URL url = uriToUrl(uri, relativePath);
199
200                String urlStr = url == null ? "" : url.toString();
201                String symbolName = wpt.getString("symbol");
202                if (symbolName == null) {
203                    symbolName = wpt.getString(GpxConstants.PT_SYM);
204                }
205                // text marker is returned in every case, see #10208
206                final Marker marker = new Marker(wpt.getCoor(), wpt, symbolName, parentLayer, time, offset);
207                if (url == null) {
208                    return Collections.singleton(marker);
209                } else if (urlStr.endsWith(".wav")) {
210                    final AudioMarker audioMarker = new AudioMarker(wpt.getCoor(), wpt, url, parentLayer, time, offset);
211                    Extensions exts = (Extensions) wpt.get(GpxConstants.META_EXTENSIONS);
212                    if (exts != null && exts.containsKey("offset")) {
213                        try {
214                            audioMarker.syncOffset = Double.parseDouble(exts.get("sync-offset"));
215                        } catch (NumberFormatException nfe) {
216                            Main.warn(nfe);
217                        }
218                    }
219                    return Arrays.asList(marker, audioMarker);
220                } else if (urlStr.endsWith(".png") || urlStr.endsWith(".jpg") || urlStr.endsWith(".jpeg") || urlStr.endsWith(".gif")) {
221                    return Arrays.asList(marker, new ImageMarker(wpt.getCoor(), url, parentLayer, time, offset));
222                } else {
223                    return Arrays.asList(marker, new WebMarker(wpt.getCoor(), url, parentLayer, time, offset));
224                }
225            }
226        });
227    }
228
229    private static URL uriToUrl(String uri, File relativePath) {
230        URL url = null;
231        if (uri != null) {
232            try {
233                url = new URL(uri);
234            } catch (MalformedURLException e) {
235                // Try a relative file:// url, if the link is not in an URL-compatible form
236                if (relativePath != null) {
237                    url = Utils.fileToURL(new File(relativePath.getParentFile(), uri));
238                }
239            }
240        }
241        return url;
242    }
243
244    /**
245     * Returns an object of class Marker or one of its subclasses
246     * created from the parameters given.
247     *
248     * @param wpt waypoint data for marker
249     * @param relativePath An path to use for constructing relative URLs or
250     *        <code>null</code> for no relative URLs
251     * @param parentLayer the <code>MarkerLayer</code> that will contain the created <code>Marker</code>
252     * @param time time of the marker in seconds since epoch
253     * @param offset double in seconds as the time offset of this marker from
254     *        the GPX file from which it was derived (if any).
255     * @return a new Marker object
256     */
257    public static Collection<Marker> createMarkers(WayPoint wpt, File relativePath, MarkerLayer parentLayer, double time, double offset) {
258        for (MarkerProducers maker : Marker.markerProducers) {
259            final Collection<Marker> markers = maker.createMarkers(wpt, relativePath, parentLayer, time, offset);
260            if (markers != null)
261                return markers;
262        }
263        return null;
264    }
265
266    public static final String MARKER_OFFSET = "waypointOffset";
267    public static final String MARKER_FORMATTED_OFFSET = "formattedWaypointOffset";
268
269    public static final String LABEL_PATTERN_AUTO = "?{ '{name} ({desc})' | '{name} ({cmt})' | '{name}' | '{desc}' | '{cmt}' }";
270    public static final String LABEL_PATTERN_NAME = "{name}";
271    public static final String LABEL_PATTERN_DESC = "{desc}";
272
273    private final DateFormat timeFormatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
274    private final TemplateEngineDataProvider dataProvider;
275    private final String text;
276
277    protected final ImageIcon symbol;
278    private BufferedImage redSymbol;
279    public final MarkerLayer parentLayer;
280    /** Absolute time of marker in seconds since epoch */
281    public double time;
282    /** Time offset in seconds from the gpx point from which it was derived, may be adjusted later to sync with other data, so not final */
283    public double offset;
284
285    private String cachedText;
286    private int textVersion = -1;
287    private CachedLatLon coor;
288
289    private boolean erroneous;
290
291    public Marker(LatLon ll, TemplateEngineDataProvider dataProvider, String iconName, MarkerLayer parentLayer,
292            double time, double offset) {
293        this(ll, dataProvider, null, iconName, parentLayer, time, offset);
294    }
295
296    public Marker(LatLon ll, String text, String iconName, MarkerLayer parentLayer, double time, double offset) {
297        this(ll, null, text, iconName, parentLayer, time, offset);
298    }
299
300    private Marker(LatLon ll, TemplateEngineDataProvider dataProvider, String text, String iconName, MarkerLayer parentLayer,
301            double time, double offset) {
302        timeFormatter.setTimeZone(TimeZone.getTimeZone("UTC"));
303        setCoor(ll);
304
305        this.offset = offset;
306        this.time = time;
307        /* tell icon checking that we expect these names to exist */
308        // /* ICON(markers/) */"Bridge"
309        // /* ICON(markers/) */"Crossing"
310        this.symbol = iconName != null ? ImageProvider.getIfAvailable("markers", iconName) : null;
311        this.parentLayer = parentLayer;
312
313        this.dataProvider = dataProvider;
314        this.text = text;
315    }
316
317    /**
318     * Convert Marker to WayPoint so it can be exported to a GPX file.
319     *
320     * Override in subclasses to add all necessary attributes.
321     *
322     * @return the corresponding WayPoint with all relevant attributes
323     */
324    public WayPoint convertToWayPoint() {
325        WayPoint wpt = new WayPoint(getCoor());
326        wpt.put("time", timeFormatter.format(new Date(Math.round(time * 1000))));
327        if (text != null) {
328            wpt.addExtension("text", text);
329        } else if (dataProvider != null) {
330            for (String key : dataProvider.getTemplateKeys()) {
331                Object value = dataProvider.getTemplateValue(key, false);
332                if (value != null && GpxConstants.WPT_KEYS.contains(key)) {
333                    wpt.put(key, value);
334                }
335            }
336        }
337        return wpt;
338    }
339
340    /**
341     * Sets the marker's coordinates.
342     * @param coor The marker's coordinates (lat/lon)
343     */
344    public final void setCoor(LatLon coor) {
345        this.coor = new CachedLatLon(coor);
346    }
347
348    /**
349     * Returns the marker's coordinates.
350     * @return The marker's coordinates (lat/lon)
351     */
352    public final LatLon getCoor() {
353        return coor;
354    }
355
356    /**
357     * Sets the marker's projected coordinates.
358     * @param eastNorth The marker's projected coordinates (easting/northing)
359     */
360    public final void setEastNorth(EastNorth eastNorth) {
361        this.coor = new CachedLatLon(eastNorth);
362    }
363
364    /**
365     * Returns the marker's projected coordinates.
366     * @return The marker's projected coordinates (easting/northing)
367     */
368    public final EastNorth getEastNorth() {
369        return coor.getEastNorth();
370    }
371
372    /**
373     * Checks whether the marker display area contains the given point.
374     * Markers not interested in mouse clicks may always return false.
375     *
376     * @param p The point to check
377     * @return <code>true</code> if the marker "hotspot" contains the point.
378     */
379    public boolean containsPoint(Point p) {
380        return false;
381    }
382
383    /**
384     * Called when the mouse is clicked in the marker's hotspot. Never
385     * called for markers which always return false from containsPoint.
386     *
387     * @param ev A dummy ActionEvent
388     */
389    public void actionPerformed(ActionEvent ev) {
390        // Do nothing
391    }
392
393    /**
394     * Paints the marker.
395     * @param g graphics context
396     * @param mv map view
397     * @param mousePressed true if the left mouse button is pressed
398     * @param showTextOrIcon true if text and icon shall be drawn
399     */
400    public void paint(Graphics g, MapView mv, boolean mousePressed, boolean showTextOrIcon) {
401        Point screen = mv.getPoint(getEastNorth());
402        if (symbol != null && showTextOrIcon) {
403            paintIcon(mv, g, screen.x-symbol.getIconWidth()/2, screen.y-symbol.getIconHeight()/2);
404        } else {
405            g.drawLine(screen.x-2, screen.y-2, screen.x+2, screen.y+2);
406            g.drawLine(screen.x+2, screen.y-2, screen.x-2, screen.y+2);
407        }
408
409        String labelText = getText();
410        if ((labelText != null) && showTextOrIcon) {
411            g.drawString(labelText, screen.x+4, screen.y+2);
412        }
413    }
414
415    protected void paintIcon(MapView mv, Graphics g, int x, int y) {
416        if (!erroneous) {
417            symbol.paintIcon(mv, g, x, y);
418        } else {
419            if (redSymbol == null) {
420                int width = symbol.getIconWidth();
421                int height = symbol.getIconHeight();
422
423                redSymbol = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
424                Graphics2D gbi = redSymbol.createGraphics();
425                gbi.drawImage(symbol.getImage(), 0, 0, null);
426                gbi.setColor(Color.RED);
427                gbi.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_ATOP, 0.666f));
428                gbi.fillRect(0, 0, width, height);
429                gbi.dispose();
430            }
431            g.drawImage(redSymbol, x, y, mv);
432        }
433    }
434
435    protected TemplateEntryProperty getTextTemplate() {
436        return TemplateEntryProperty.forMarker(parentLayer.getName());
437    }
438
439    /**
440     * Returns the Text which should be displayed, depending on chosen preference
441     * @return Text of the label
442     */
443    public String getText() {
444        if (text != null)
445            return text;
446        else {
447            TemplateEntryProperty property = getTextTemplate();
448            if (property.getUpdateCount() != textVersion) {
449                TemplateEntry templateEntry = property.get();
450                StringBuilder sb = new StringBuilder();
451                templateEntry.appendText(sb, this);
452
453                cachedText = sb.toString();
454                textVersion = property.getUpdateCount();
455            }
456            return cachedText;
457        }
458    }
459
460    @Override
461    public Collection<String> getTemplateKeys() {
462        Collection<String> result;
463        if (dataProvider != null) {
464            result = dataProvider.getTemplateKeys();
465        } else {
466            result = new ArrayList<>();
467        }
468        result.add(MARKER_FORMATTED_OFFSET);
469        result.add(MARKER_OFFSET);
470        return result;
471    }
472
473    private String formatOffset() {
474        int wholeSeconds = (int) (offset + 0.5);
475        if (wholeSeconds < 60)
476            return Integer.toString(wholeSeconds);
477        else if (wholeSeconds < 3600)
478            return String.format("%d:%02d", wholeSeconds / 60, wholeSeconds % 60);
479        else
480            return String.format("%d:%02d:%02d", wholeSeconds / 3600, (wholeSeconds % 3600)/60, wholeSeconds % 60);
481    }
482
483    @Override
484    public Object getTemplateValue(String name, boolean special) {
485        if (MARKER_FORMATTED_OFFSET.equals(name))
486            return formatOffset();
487        else if (MARKER_OFFSET.equals(name))
488            return offset;
489        else if (dataProvider != null)
490            return dataProvider.getTemplateValue(name, special);
491        else
492            return null;
493    }
494
495    @Override
496    public boolean evaluateCondition(Match condition) {
497        throw new UnsupportedOperationException();
498    }
499
500    /**
501     * Determines if this marker is erroneous.
502     * @return {@code true} if this markers has any kind of error, {@code false} otherwise
503     * @since 6299
504     */
505    public final boolean isErroneous() {
506        return erroneous;
507    }
508
509    /**
510     * Sets this marker erroneous or not.
511     * @param erroneous {@code true} if this markers has any kind of error, {@code false} otherwise
512     * @since 6299
513     */
514    public final void setErroneous(boolean erroneous) {
515        this.erroneous = erroneous;
516        if (!erroneous) {
517            redSymbol = null;
518        }
519    }
520}