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.Stroke;
010import java.awt.event.ActionEvent;
011import java.awt.image.BufferedImage;
012import java.io.File;
013import java.util.ArrayList;
014import java.util.Collection;
015import java.util.HashMap;
016import java.util.LinkedList;
017import java.util.List;
018import java.util.Map;
019import java.util.Objects;
020
021import javax.swing.ImageIcon;
022
023import org.openstreetmap.josm.data.Preferences;
024import org.openstreetmap.josm.data.coor.CachedLatLon;
025import org.openstreetmap.josm.data.coor.EastNorth;
026import org.openstreetmap.josm.data.coor.ILatLon;
027import org.openstreetmap.josm.data.coor.LatLon;
028import org.openstreetmap.josm.data.gpx.GpxConstants;
029import org.openstreetmap.josm.data.gpx.WayPoint;
030import org.openstreetmap.josm.data.osm.search.SearchCompiler.Match;
031import org.openstreetmap.josm.gui.MapView;
032import org.openstreetmap.josm.gui.layer.GpxLayer;
033import org.openstreetmap.josm.gui.preferences.display.GPXSettingsPanel;
034import org.openstreetmap.josm.spi.preferences.PreferenceChangedListener;
035import org.openstreetmap.josm.tools.Destroyable;
036import org.openstreetmap.josm.tools.ImageProvider;
037import org.openstreetmap.josm.tools.Logging;
038import org.openstreetmap.josm.tools.template_engine.ParseError;
039import org.openstreetmap.josm.tools.template_engine.TemplateEngineDataProvider;
040import org.openstreetmap.josm.tools.template_engine.TemplateEntry;
041import org.openstreetmap.josm.tools.template_engine.TemplateParser;
042
043/**
044 * Basic marker class. Requires a position, and supports
045 * a custom icon and a name.
046 *
047 * This class is also used to create appropriate Marker-type objects
048 * when waypoints are imported.
049 *
050 * It hosts a public list object, named makers, containing implementations of
051 * the MarkerMaker interface. Whenever a Marker needs to be created, each
052 * object in makers is called with the waypoint parameters (Lat/Lon and tag
053 * data), and the first one to return a Marker object wins.
054 *
055 * By default, one the list contains one default "Maker" implementation that
056 * will create AudioMarkers for supported audio files, ImageMarkers for supported image
057 * files, and WebMarkers for everything else. (The creation of a WebMarker will
058 * fail if there's no valid URL in the <link> tag, so it might still make sense
059 * to add Makers for such waypoints at the end of the list.)
060 *
061 * The default implementation only looks at the value of the <link> tag inside
062 * the <wpt> tag of the GPX file.
063 *
064 * <h2>HowTo implement a new Marker</h2>
065 * <ul>
066 * <li> Subclass Marker or ButtonMarker and override <code>containsPoint</code>
067 *      if you like to respond to user clicks</li>
068 * <li> Override paint, if you want a custom marker look (not "a label and a symbol")</li>
069 * <li> Implement MarkerCreator to return a new instance of your marker class</li>
070 * <li> In you plugin constructor, add an instance of your MarkerCreator
071 *      implementation either on top or bottom of Marker.markerProducers.
072 *      Add at top, if your marker should overwrite an current marker or at bottom
073 *      if you only add a new marker style.</li>
074 * </ul>
075 *
076 * @author Frederik Ramm
077 */
078public class Marker implements TemplateEngineDataProvider, ILatLon, Destroyable {
079
080    /**
081     * Plugins can add their Marker creation stuff at the bottom or top of this list
082     * (depending on whether they want to override default behaviour or just add new stuff).
083     */
084    private static final List<MarkerProducers> markerProducers = new LinkedList<>();
085
086    // Add one Marker specifying the default behaviour.
087    static {
088        Marker.markerProducers.add(new DefaultMarkerProducers());
089    }
090
091    /**
092     * Add a new marker producers at the end of the JOSM list.
093     * @param mp a new marker producers
094     * @since 11850
095     */
096    public static void appendMarkerProducer(MarkerProducers mp) {
097        markerProducers.add(mp);
098    }
099
100    /**
101     * Add a new marker producers at the beginning of the JOSM list.
102     * @param mp a new marker producers
103     * @since 11850
104     */
105    public static void prependMarkerProducer(MarkerProducers mp) {
106        markerProducers.add(0, mp);
107    }
108
109    /**
110     * Returns an object of class Marker or one of its subclasses
111     * created from the parameters given.
112     *
113     * @param wpt waypoint data for marker
114     * @param relativePath An path to use for constructing relative URLs or
115     *        <code>null</code> for no relative URLs
116     * @param parentLayer the <code>MarkerLayer</code> that will contain the created <code>Marker</code>
117     * @param time time of the marker in seconds since epoch
118     * @param offset double in seconds as the time offset of this marker from
119     *        the GPX file from which it was derived (if any).
120     * @return a new Marker object
121     */
122    public static Collection<Marker> createMarkers(WayPoint wpt, File relativePath, MarkerLayer parentLayer, double time, double offset) {
123        return Marker.markerProducers.stream()
124                .map(maker -> maker.createMarkers(wpt, relativePath, parentLayer, time, offset))
125                .filter(Objects::nonNull)
126                .findFirst().orElse(null);
127    }
128
129    public static final String MARKER_OFFSET = "waypointOffset";
130    public static final String MARKER_FORMATTED_OFFSET = "formattedWaypointOffset";
131
132    public static final String LABEL_PATTERN_AUTO = "?{ '{name} ({desc})' | '{name} ({cmt})' | '{name}' | '{desc}' | '{cmt}' }";
133    public static final String LABEL_PATTERN_NAME = "{name}";
134    public static final String LABEL_PATTERN_DESC = "{desc}";
135
136    private final TemplateEngineDataProvider dataProvider;
137    private final String text;
138
139    protected final ImageIcon symbol;
140    private BufferedImage redSymbol;
141    public final MarkerLayer parentLayer;
142    /** Absolute time of marker in seconds since epoch */
143    public double time;
144    /** 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 */
145    public double offset;
146
147    private String cachedText;
148    private static Map<GpxLayer, String> cachedTemplates = new HashMap<>();
149    private String cachedDefaultTemplate;
150
151    private CachedLatLon coor;
152    private PreferenceChangedListener listener = l -> updateText();
153
154    private boolean erroneous;
155
156    public Marker(LatLon ll, TemplateEngineDataProvider dataProvider, String iconName, MarkerLayer parentLayer,
157            double time, double offset) {
158        this(ll, dataProvider, null, iconName, parentLayer, time, offset);
159    }
160
161    public Marker(LatLon ll, String text, String iconName, MarkerLayer parentLayer, double time, double offset) {
162        this(ll, null, text, iconName, parentLayer, time, offset);
163    }
164
165    private Marker(LatLon ll, TemplateEngineDataProvider dataProvider, String text, String iconName, MarkerLayer parentLayer,
166            double time, double offset) {
167        setCoor(ll);
168
169        this.offset = offset;
170        this.time = time;
171        /* tell icon checking that we expect these names to exist */
172        // /* ICON(markers/) */"Bridge"
173        // /* ICON(markers/) */"Crossing"
174        this.symbol = iconName != null ? ImageProvider.getIfAvailable("markers", iconName) : null;
175        this.parentLayer = parentLayer;
176
177        this.dataProvider = dataProvider;
178        this.text = text;
179
180        Preferences.main().addKeyPreferenceChangeListener(getPreferenceKey(), listener);
181    }
182
183    /**
184     * Convert Marker to WayPoint so it can be exported to a GPX file.
185     *
186     * Override in subclasses to add all necessary attributes.
187     *
188     * @return the corresponding WayPoint with all relevant attributes
189     */
190    public WayPoint convertToWayPoint() {
191        WayPoint wpt = new WayPoint(getCoor());
192        if (time > 0d) {
193            wpt.setTimeInMillis((long) (time * 1000));
194        }
195        if (text != null) {
196            wpt.getExtensions().add("josm", "text", text);
197        } else if (dataProvider != null) {
198            for (String key : dataProvider.getTemplateKeys()) {
199                Object value = dataProvider.getTemplateValue(key, false);
200                if (value != null && GpxConstants.WPT_KEYS.contains(key)) {
201                    wpt.put(key, value);
202                }
203            }
204        }
205        return wpt;
206    }
207
208    /**
209     * Sets the marker's coordinates.
210     * @param coor The marker's coordinates (lat/lon)
211     */
212    public final void setCoor(LatLon coor) {
213        this.coor = new CachedLatLon(coor);
214    }
215
216    /**
217     * Returns the marker's coordinates.
218     * @return The marker's coordinates (lat/lon)
219     */
220    public final LatLon getCoor() {
221        return coor;
222    }
223
224    /**
225     * Sets the marker's projected coordinates.
226     * @param eastNorth The marker's projected coordinates (easting/northing)
227     */
228    public final void setEastNorth(EastNorth eastNorth) {
229        this.coor = new CachedLatLon(eastNorth);
230    }
231
232    /**
233     * @since 12725
234     */
235    @Override
236    public double lon() {
237        return coor == null ? Double.NaN : coor.lon();
238    }
239
240    /**
241     * @since 12725
242     */
243    @Override
244    public double lat() {
245        return coor == null ? Double.NaN : coor.lat();
246    }
247
248    /**
249     * Checks whether the marker display area contains the given point.
250     * Markers not interested in mouse clicks may always return false.
251     *
252     * @param p The point to check
253     * @return <code>true</code> if the marker "hotspot" contains the point.
254     */
255    public boolean containsPoint(Point p) {
256        return false;
257    }
258
259    /**
260     * Called when the mouse is clicked in the marker's hotspot. Never
261     * called for markers which always return false from containsPoint.
262     *
263     * @param ev A dummy ActionEvent
264     */
265    public void actionPerformed(ActionEvent ev) {
266        // Do nothing
267    }
268
269    /**
270     * Paints the marker.
271     * @param g graphics context
272     * @param mv map view
273     * @param mousePressed true if the left mouse button is pressed
274     * @param showTextOrIcon true if text and icon shall be drawn
275     */
276    public void paint(Graphics2D g, MapView mv, boolean mousePressed, boolean showTextOrIcon) {
277        Point screen = mv.getPoint(this);
278        int size2 = parentLayer.markerSize / 2;
279
280        if (symbol != null && showTextOrIcon) {
281            paintIcon(mv, g, screen.x-symbol.getIconWidth()/2, screen.y-symbol.getIconHeight()/2);
282        } else {
283            Stroke stroke = g.getStroke();
284            g.setStroke(parentLayer.markerStroke);
285            g.drawLine(screen.x - size2, screen.y - size2, screen.x + size2, screen.y + size2);
286            g.drawLine(screen.x + size2, screen.y - size2, screen.x - size2, screen.y + size2);
287            g.setStroke(stroke);
288        }
289
290        String labelText = getText();
291        if (!labelText.isEmpty() && showTextOrIcon) {
292            g.drawString(labelText, screen.x + size2 + 2, screen.y + size2);
293        }
294    }
295
296    protected void paintIcon(MapView mv, Graphics g, int x, int y) {
297        if (!erroneous) {
298            symbol.paintIcon(mv, g, x, y);
299        } else {
300            if (redSymbol == null) {
301                int width = symbol.getIconWidth();
302                int height = symbol.getIconHeight();
303
304                redSymbol = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
305                Graphics2D gbi = redSymbol.createGraphics();
306                gbi.drawImage(symbol.getImage(), 0, 0, null);
307                gbi.setColor(Color.RED);
308                gbi.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_ATOP, 0.666f));
309                gbi.fillRect(0, 0, width, height);
310                gbi.dispose();
311            }
312            g.drawImage(redSymbol, x, y, mv);
313        }
314    }
315
316    protected String getTextTemplateKey() {
317        return "markers.pattern";
318    }
319
320    private String getTextTemplate() {
321        String tmpl;
322        if (cachedTemplates.containsKey(parentLayer.fromLayer)) {
323            tmpl = cachedTemplates.get(parentLayer.fromLayer);
324        } else {
325            tmpl = GPXSettingsPanel.getLayerPref(parentLayer.fromLayer, getTextTemplateKey());
326            cachedTemplates.put(parentLayer.fromLayer, tmpl);
327        }
328        return tmpl;
329    }
330
331    private String getDefaultTextTemplate() {
332        if (cachedDefaultTemplate == null) {
333            cachedDefaultTemplate = GPXSettingsPanel.getLayerPref(null, getTextTemplateKey());
334        }
335        return cachedDefaultTemplate;
336    }
337
338    /**
339     * Returns the Text which should be displayed, depending on chosen preference
340     * @return Text of the label
341     */
342    public String getText() {
343        if (text != null) {
344            return text;
345        } else if (cachedText == null) {
346            TemplateEntry template;
347            String templateString = getTextTemplate();
348            try {
349                template = new TemplateParser(templateString).parse();
350            } catch (ParseError e) {
351                Logging.debug(e);
352                String def = getDefaultTextTemplate();
353                Logging.warn("Unable to parse template engine pattern ''{0}'' for property {1}. Using default (''{2}'') instead",
354                        templateString, getTextTemplateKey(), def);
355                try {
356                    template = new TemplateParser(def).parse();
357                } catch (ParseError e1) {
358                    Logging.error(e1);
359                    cachedText = "";
360                    return "";
361                }
362            }
363            StringBuilder sb = new StringBuilder();
364            template.appendText(sb, this);
365            cachedText = sb.toString();
366
367        }
368        return cachedText;
369    }
370
371    /**
372     * Called when the template changes
373     */
374    public void updateText() {
375        cachedText = null;
376        cachedDefaultTemplate = null;
377        cachedTemplates.clear();
378    }
379
380    @Override
381    public Collection<String> getTemplateKeys() {
382        Collection<String> result;
383        if (dataProvider != null) {
384            result = dataProvider.getTemplateKeys();
385        } else {
386            result = new ArrayList<>();
387        }
388        result.add(MARKER_FORMATTED_OFFSET);
389        result.add(MARKER_OFFSET);
390        return result;
391    }
392
393    private String formatOffset() {
394        int wholeSeconds = (int) (offset + 0.5);
395        if (wholeSeconds < 60)
396            return Integer.toString(wholeSeconds);
397        else if (wholeSeconds < 3600)
398            return String.format("%d:%02d", wholeSeconds / 60, wholeSeconds % 60);
399        else
400            return String.format("%d:%02d:%02d", wholeSeconds / 3600, (wholeSeconds % 3600)/60, wholeSeconds % 60);
401    }
402
403    @Override
404    public Object getTemplateValue(String name, boolean special) {
405        if (MARKER_FORMATTED_OFFSET.equals(name))
406            return formatOffset();
407        else if (MARKER_OFFSET.equals(name))
408            return offset;
409        else if (dataProvider != null)
410            return dataProvider.getTemplateValue(name, special);
411        else
412            return null;
413    }
414
415    @Override
416    public boolean evaluateCondition(Match condition) {
417        throw new UnsupportedOperationException();
418    }
419
420    /**
421     * Determines if this marker is erroneous.
422     * @return {@code true} if this markers has any kind of error, {@code false} otherwise
423     * @since 6299
424     */
425    public final boolean isErroneous() {
426        return erroneous;
427    }
428
429    /**
430     * Sets this marker erroneous or not.
431     * @param erroneous {@code true} if this markers has any kind of error, {@code false} otherwise
432     * @since 6299
433     */
434    public final void setErroneous(boolean erroneous) {
435        this.erroneous = erroneous;
436        if (!erroneous) {
437            redSymbol = null;
438        }
439    }
440
441    @Override
442    public void destroy() {
443        cachedTemplates.clear();
444        Preferences.main().removeKeyPreferenceChangeListener(getPreferenceKey(), listener);
445    }
446
447    private String getPreferenceKey() {
448        return "draw.rawgps." + getTextTemplateKey();
449    }
450}