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