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 @SuppressWarnings("unchecked") 184 @Override 185 public Marker createMarker(WayPoint wpt, File relativePath, MarkerLayer parentLayer, double time, double offset) { 186 String uri = null; 187 // cheapest way to check whether "link" object exists and is a non-empty 188 // collection of GpxLink objects... 189 Collection<GpxLink> links = (Collection<GpxLink>)wpt.attr.get(GpxConstants.META_LINKS); 190 if (links != null) { 191 for (GpxLink oneLink : links ) { 192 uri = oneLink.uri; 193 break; 194 } 195 } 196 197 URL url = null; 198 if (uri != null) { 199 try { 200 url = new URL(uri); 201 } catch (MalformedURLException e) { 202 // Try a relative file:// url, if the link is not in an URL-compatible form 203 if (relativePath != null) { 204 url = Utils.fileToURL(new File(relativePath.getParentFile(), uri)); 205 } 206 } 207 } 208 209 if (url == null) { 210 String symbolName = wpt.getString("symbol"); 211 if (symbolName == null) { 212 symbolName = wpt.getString("sym"); 213 } 214 return new Marker(wpt.getCoor(), wpt, symbolName, parentLayer, time, offset); 215 } 216 else if (url.toString().endsWith(".wav")) { 217 AudioMarker audioMarker = new AudioMarker(wpt.getCoor(), wpt, url, parentLayer, time, offset); 218 Extensions exts = (Extensions) wpt.get(GpxConstants.META_EXTENSIONS); 219 if (exts != null && exts.containsKey("offset")) { 220 try { 221 double syncOffset = Double.parseDouble(exts.get("sync-offset")); 222 audioMarker.syncOffset = syncOffset; 223 } catch (NumberFormatException nfe) { 224 Main.warn(nfe); 225 } 226 } 227 return audioMarker; 228 } else if (url.toString().endsWith(".png") || url.toString().endsWith(".jpg") || url.toString().endsWith(".jpeg") || url.toString().endsWith(".gif")) { 229 return new ImageMarker(wpt.getCoor(), url, parentLayer, time, offset); 230 } else { 231 return new WebMarker(wpt.getCoor(), url, parentLayer, time, offset); 232 } 233 } 234 }); 235 } 236 237 /** 238 * Returns an object of class Marker or one of its subclasses 239 * created from the parameters given. 240 * 241 * @param wpt waypoint data for marker 242 * @param relativePath An path to use for constructing relative URLs or 243 * <code>null</code> for no relative URLs 244 * @param parentLayer the <code>MarkerLayer</code> that will contain the created <code>Marker</code> 245 * @param time time of the marker in seconds since epoch 246 * @param offset double in seconds as the time offset of this marker from 247 * the GPX file from which it was derived (if any). 248 * @return a new Marker object 249 */ 250 public static Marker createMarker(WayPoint wpt, File relativePath, MarkerLayer parentLayer, double time, double offset) { 251 for (MarkerProducers maker : Marker.markerProducers) { 252 Marker marker = maker.createMarker(wpt, relativePath, parentLayer, time, offset); 253 if (marker != null) 254 return marker; 255 } 256 return null; 257 } 258 259 public static final String MARKER_OFFSET = "waypointOffset"; 260 public static final String MARKER_FORMATTED_OFFSET = "formattedWaypointOffset"; 261 262 public static final String LABEL_PATTERN_AUTO = "?{ '{name} - {desc}' | '{name}' | '{desc}' }"; 263 public static final String LABEL_PATTERN_NAME = "{name}"; 264 public static final String LABEL_PATTERN_DESC = "{desc}"; 265 266 private final DateFormat timeFormatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); 267 private final TemplateEngineDataProvider dataProvider; 268 private final String text; 269 270 protected final ImageIcon symbol; 271 private BufferedImage redSymbol = null; 272 public final MarkerLayer parentLayer; 273 /** Absolute time of marker in seconds since epoch */ 274 public double time; 275 /** 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 */ 276 public double offset; 277 278 private String cachedText; 279 private int textVersion = -1; 280 private CachedLatLon coor; 281 282 private boolean erroneous = false; 283 284 public Marker(LatLon ll, TemplateEngineDataProvider dataProvider, String iconName, MarkerLayer parentLayer, double time, double offset) { 285 this(ll, dataProvider, null, iconName, parentLayer, time, offset); 286 } 287 288 public Marker(LatLon ll, String text, String iconName, MarkerLayer parentLayer, double time, double offset) { 289 this(ll, null, text, iconName, parentLayer, time, offset); 290 } 291 292 private Marker(LatLon ll, TemplateEngineDataProvider dataProvider, String text, String iconName, MarkerLayer parentLayer, double time, double offset) { 293 timeFormatter.setTimeZone(TimeZone.getTimeZone("UTC")); 294 setCoor(ll); 295 296 this.offset = offset; 297 this.time = time; 298 this.symbol = iconName != null ? ImageProvider.getIfAvailable("markers",iconName) : null; 299 this.parentLayer = parentLayer; 300 301 this.dataProvider = dataProvider; 302 this.text = text; 303 } 304 305 /** 306 * Convert Marker to WayPoint so it can be exported to a GPX file. 307 * 308 * Override in subclasses to add all necessary attributes. 309 * 310 * @return the corresponding WayPoint with all relevant attributes 311 */ 312 public WayPoint convertToWayPoint() { 313 WayPoint wpt = new WayPoint(getCoor()); 314 wpt.put("time", timeFormatter.format(new Date(Math.round(time * 1000)))); 315 if (text != null) { 316 wpt.addExtension("text", text); 317 } else if (dataProvider != null) { 318 for (String key : dataProvider.getTemplateKeys()) { 319 Object value = dataProvider.getTemplateValue(key, false); 320 if (value != null && GpxConstants.WPT_KEYS.contains(key)) { 321 wpt.put(key, value); 322 } 323 } 324 } 325 return wpt; 326 } 327 328 /** 329 * Sets the marker's coordinates. 330 * @param coor The marker's coordinates (lat/lon) 331 */ 332 public final void setCoor(LatLon coor) { 333 this.coor = new CachedLatLon(coor); 334 } 335 336 /** 337 * Returns the marker's coordinates. 338 * @return The marker's coordinates (lat/lon) 339 */ 340 public final LatLon getCoor() { 341 return coor; 342 } 343 344 /** 345 * Sets the marker's projected coordinates. 346 * @param eastNorth The marker's projected coordinates (easting/northing) 347 */ 348 public final void setEastNorth(EastNorth eastNorth) { 349 this.coor = new CachedLatLon(eastNorth); 350 } 351 352 /** 353 * Returns the marker's projected coordinates. 354 * @return The marker's projected coordinates (easting/northing) 355 */ 356 public final EastNorth getEastNorth() { 357 return coor.getEastNorth(); 358 } 359 360 /** 361 * Checks whether the marker display area contains the given point. 362 * Markers not interested in mouse clicks may always return false. 363 * 364 * @param p The point to check 365 * @return <code>true</code> if the marker "hotspot" contains the point. 366 */ 367 public boolean containsPoint(Point p) { 368 return false; 369 } 370 371 /** 372 * Called when the mouse is clicked in the marker's hotspot. Never 373 * called for markers which always return false from containsPoint. 374 * 375 * @param ev A dummy ActionEvent 376 */ 377 public void actionPerformed(ActionEvent ev) { 378 } 379 380 /** 381 * Paints the marker. 382 * @param g graphics context 383 * @param mv map view 384 * @param mousePressed true if the left mouse button is pressed 385 * @param showTextOrIcon true if text and icon shall be drawn 386 */ 387 public void paint(Graphics g, MapView mv, boolean mousePressed, boolean showTextOrIcon) { 388 Point screen = mv.getPoint(getEastNorth()); 389 if (symbol != null && showTextOrIcon) { 390 paintIcon(mv, g, screen.x-symbol.getIconWidth()/2, screen.y-symbol.getIconHeight()/2); 391 } else { 392 g.drawLine(screen.x-2, screen.y-2, screen.x+2, screen.y+2); 393 g.drawLine(screen.x+2, screen.y-2, screen.x-2, screen.y+2); 394 } 395 396 String labelText = getText(); 397 if ((labelText != null) && showTextOrIcon) { 398 g.drawString(labelText, screen.x+4, screen.y+2); 399 } 400 } 401 402 protected void paintIcon(MapView mv, Graphics g, int x, int y) { 403 if (!erroneous) { 404 symbol.paintIcon(mv, g, x, y); 405 } else { 406 if (redSymbol == null) { 407 int width = symbol.getIconWidth(); 408 int height = symbol.getIconHeight(); 409 410 redSymbol = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); 411 Graphics2D gbi = redSymbol.createGraphics(); 412 gbi.drawImage(symbol.getImage(), 0, 0, null); 413 gbi.setColor(Color.RED); 414 gbi.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_ATOP, 0.666f)); 415 gbi.fillRect(0, 0, width, height); 416 gbi.dispose(); 417 } 418 g.drawImage(redSymbol, x, y, mv); 419 } 420 } 421 422 protected TemplateEntryProperty getTextTemplate() { 423 return TemplateEntryProperty.forMarker(parentLayer.getName()); 424 } 425 426 /** 427 * Returns the Text which should be displayed, depending on chosen preference 428 * @return Text of the label 429 */ 430 public String getText() { 431 if (text != null) 432 return text; 433 else { 434 TemplateEntryProperty property = getTextTemplate(); 435 if (property.getUpdateCount() != textVersion) { 436 TemplateEntry templateEntry = property.get(); 437 StringBuilder sb = new StringBuilder(); 438 templateEntry.appendText(sb, this); 439 440 cachedText = sb.toString(); 441 textVersion = property.getUpdateCount(); 442 } 443 return cachedText; 444 } 445 } 446 447 @Override 448 public Collection<String> getTemplateKeys() { 449 Collection<String> result; 450 if (dataProvider != null) { 451 result = dataProvider.getTemplateKeys(); 452 } else { 453 result = new ArrayList<>(); 454 } 455 result.add(MARKER_FORMATTED_OFFSET); 456 result.add(MARKER_OFFSET); 457 return result; 458 } 459 460 private String formatOffset() { 461 int wholeSeconds = (int)(offset + 0.5); 462 if (wholeSeconds < 60) 463 return Integer.toString(wholeSeconds); 464 else if (wholeSeconds < 3600) 465 return String.format("%d:%02d", wholeSeconds / 60, wholeSeconds % 60); 466 else 467 return String.format("%d:%02d:%02d", wholeSeconds / 3600, (wholeSeconds % 3600)/60, wholeSeconds % 60); 468 } 469 470 @Override 471 public Object getTemplateValue(String name, boolean special) { 472 if (MARKER_FORMATTED_OFFSET.equals(name)) 473 return formatOffset(); 474 else if (MARKER_OFFSET.equals(name)) 475 return offset; 476 else if (dataProvider != null) 477 return dataProvider.getTemplateValue(name, special); 478 else 479 return null; 480 } 481 482 @Override 483 public boolean evaluateCondition(Match condition) { 484 throw new UnsupportedOperationException(); 485 } 486 487 /** 488 * Determines if this marker is erroneous. 489 * @return {@code true} if this markers has any kind of error, {@code false} otherwise 490 * @since 6299 491 */ 492 public final boolean isErroneous() { 493 return erroneous; 494 } 495 496 /** 497 * Sets this marker erroneous or not. 498 * @param erroneous {@code true} if this markers has any kind of error, {@code false} otherwise 499 * @since 6299 500 */ 501 public final void setErroneous(boolean erroneous) { 502 this.erroneous = erroneous; 503 if (!erroneous) { 504 redSymbol = null; 505 } 506 } 507}