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