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