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}