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 this.symbol = iconName != null ? ImageProvider.getIfAvailable("markers",iconName) : null; 298 this.parentLayer = parentLayer; 299 300 this.dataProvider = dataProvider; 301 this.text = text; 302 } 303 304 /** 305 * Convert Marker to WayPoint so it can be exported to a GPX file. 306 * 307 * Override in subclasses to add all necessary attributes. 308 * 309 * @return the corresponding WayPoint with all relevant attributes 310 */ 311 public WayPoint convertToWayPoint() { 312 WayPoint wpt = new WayPoint(getCoor()); 313 wpt.put("time", timeFormatter.format(new Date(Math.round(time * 1000)))); 314 if (text != null) { 315 wpt.addExtension("text", text); 316 } else if (dataProvider != null) { 317 for (String key : dataProvider.getTemplateKeys()) { 318 Object value = dataProvider.getTemplateValue(key, false); 319 if (value != null && GpxConstants.WPT_KEYS.contains(key)) { 320 wpt.put(key, value); 321 } 322 } 323 } 324 return wpt; 325 } 326 327 /** 328 * Sets the marker's coordinates. 329 * @param coor The marker's coordinates (lat/lon) 330 */ 331 public final void setCoor(LatLon coor) { 332 this.coor = new CachedLatLon(coor); 333 } 334 335 /** 336 * Returns the marker's coordinates. 337 * @return The marker's coordinates (lat/lon) 338 */ 339 public final LatLon getCoor() { 340 return coor; 341 } 342 343 /** 344 * Sets the marker's projected coordinates. 345 * @param eastNorth The marker's projected coordinates (easting/northing) 346 */ 347 public final void setEastNorth(EastNorth eastNorth) { 348 this.coor = new CachedLatLon(eastNorth); 349 } 350 351 /** 352 * Returns the marker's projected coordinates. 353 * @return The marker's projected coordinates (easting/northing) 354 */ 355 public final EastNorth getEastNorth() { 356 return coor.getEastNorth(); 357 } 358 359 /** 360 * Checks whether the marker display area contains the given point. 361 * Markers not interested in mouse clicks may always return false. 362 * 363 * @param p The point to check 364 * @return <code>true</code> if the marker "hotspot" contains the point. 365 */ 366 public boolean containsPoint(Point p) { 367 return false; 368 } 369 370 /** 371 * Called when the mouse is clicked in the marker's hotspot. Never 372 * called for markers which always return false from containsPoint. 373 * 374 * @param ev A dummy ActionEvent 375 */ 376 public void actionPerformed(ActionEvent ev) { 377 } 378 379 /** 380 * Paints the marker. 381 * @param g graphics context 382 * @param mv map view 383 * @param mousePressed true if the left mouse button is pressed 384 * @param showTextOrIcon true if text and icon shall be drawn 385 */ 386 public void paint(Graphics g, MapView mv, boolean mousePressed, boolean showTextOrIcon) { 387 Point screen = mv.getPoint(getEastNorth()); 388 if (symbol != null && showTextOrIcon) { 389 paintIcon(mv, g, screen.x-symbol.getIconWidth()/2, screen.y-symbol.getIconHeight()/2); 390 } else { 391 g.drawLine(screen.x-2, screen.y-2, screen.x+2, screen.y+2); 392 g.drawLine(screen.x+2, screen.y-2, screen.x-2, screen.y+2); 393 } 394 395 String labelText = getText(); 396 if ((labelText != null) && showTextOrIcon) { 397 g.drawString(labelText, screen.x+4, screen.y+2); 398 } 399 } 400 401 protected void paintIcon(MapView mv, Graphics g, int x, int y) { 402 if (!erroneous) { 403 symbol.paintIcon(mv, g, x, y); 404 } else { 405 if (redSymbol == null) { 406 int width = symbol.getIconWidth(); 407 int height = symbol.getIconHeight(); 408 409 redSymbol = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); 410 Graphics2D gbi = redSymbol.createGraphics(); 411 gbi.drawImage(symbol.getImage(), 0, 0, null); 412 gbi.setColor(Color.RED); 413 gbi.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_ATOP, 0.666f)); 414 gbi.fillRect(0, 0, width, height); 415 gbi.dispose(); 416 } 417 g.drawImage(redSymbol, x, y, mv); 418 } 419 } 420 421 protected TemplateEntryProperty getTextTemplate() { 422 return TemplateEntryProperty.forMarker(parentLayer.getName()); 423 } 424 425 /** 426 * Returns the Text which should be displayed, depending on chosen preference 427 * @return Text of the label 428 */ 429 public String getText() { 430 if (text != null) 431 return text; 432 else { 433 TemplateEntryProperty property = getTextTemplate(); 434 if (property.getUpdateCount() != textVersion) { 435 TemplateEntry templateEntry = property.get(); 436 StringBuilder sb = new StringBuilder(); 437 templateEntry.appendText(sb, this); 438 439 cachedText = sb.toString(); 440 textVersion = property.getUpdateCount(); 441 } 442 return cachedText; 443 } 444 } 445 446 @Override 447 public Collection<String> getTemplateKeys() { 448 Collection<String> result; 449 if (dataProvider != null) { 450 result = dataProvider.getTemplateKeys(); 451 } else { 452 result = new ArrayList<>(); 453 } 454 result.add(MARKER_FORMATTED_OFFSET); 455 result.add(MARKER_OFFSET); 456 return result; 457 } 458 459 private String formatOffset() { 460 int wholeSeconds = (int)(offset + 0.5); 461 if (wholeSeconds < 60) 462 return Integer.toString(wholeSeconds); 463 else if (wholeSeconds < 3600) 464 return String.format("%d:%02d", wholeSeconds / 60, wholeSeconds % 60); 465 else 466 return String.format("%d:%02d:%02d", wholeSeconds / 3600, (wholeSeconds % 3600)/60, wholeSeconds % 60); 467 } 468 469 @Override 470 public Object getTemplateValue(String name, boolean special) { 471 if (MARKER_FORMATTED_OFFSET.equals(name)) 472 return formatOffset(); 473 else if (MARKER_OFFSET.equals(name)) 474 return offset; 475 else if (dataProvider != null) 476 return dataProvider.getTemplateValue(name, special); 477 else 478 return null; 479 } 480 481 @Override 482 public boolean evaluateCondition(Match condition) { 483 throw new UnsupportedOperationException(); 484 } 485 486 /** 487 * Determines if this marker is erroneous. 488 * @return {@code true} if this markers has any kind of error, {@code false} otherwise 489 * @since 6299 490 */ 491 public final boolean isErroneous() { 492 return erroneous; 493 } 494 495 /** 496 * Sets this marker erroneous or not. 497 * @param erroneous {@code true} if this markers has any kind of error, {@code false} otherwise 498 * @since 6299 499 */ 500 public final void setErroneous(boolean erroneous) { 501 this.erroneous = erroneous; 502 if (!erroneous) { 503 redSymbol = null; 504 } 505 } 506}