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.Arrays; 018import java.util.Collection; 019import java.util.Collections; 020import java.util.Date; 021import java.util.HashMap; 022import java.util.LinkedList; 023import java.util.List; 024import java.util.Map; 025import java.util.TimeZone; 026 027import javax.swing.ImageIcon; 028 029import org.openstreetmap.josm.Main; 030import org.openstreetmap.josm.actions.search.SearchCompiler.Match; 031import org.openstreetmap.josm.data.Preferences.PreferenceChangeEvent; 032import org.openstreetmap.josm.data.coor.CachedLatLon; 033import org.openstreetmap.josm.data.coor.EastNorth; 034import org.openstreetmap.josm.data.coor.LatLon; 035import org.openstreetmap.josm.data.gpx.Extensions; 036import org.openstreetmap.josm.data.gpx.GpxConstants; 037import org.openstreetmap.josm.data.gpx.GpxLink; 038import org.openstreetmap.josm.data.gpx.WayPoint; 039import org.openstreetmap.josm.data.preferences.CachedProperty; 040import org.openstreetmap.josm.data.preferences.IntegerProperty; 041import org.openstreetmap.josm.gui.MapView; 042import org.openstreetmap.josm.tools.ImageProvider; 043import org.openstreetmap.josm.tools.Utils; 044import org.openstreetmap.josm.tools.template_engine.ParseError; 045import org.openstreetmap.josm.tools.template_engine.TemplateEngineDataProvider; 046import org.openstreetmap.josm.tools.template_engine.TemplateEntry; 047import org.openstreetmap.josm.tools.template_engine.TemplateParser; 048 049/** 050 * Basic marker class. Requires a position, and supports 051 * a custom icon and a name. 052 * 053 * This class is also used to create appropriate Marker-type objects 054 * when waypoints are imported. 055 * 056 * It hosts a public list object, named makers, containing implementations of 057 * the MarkerMaker interface. Whenever a Marker needs to be created, each 058 * object in makers is called with the waypoint parameters (Lat/Lon and tag 059 * data), and the first one to return a Marker object wins. 060 * 061 * By default, one the list contains one default "Maker" implementation that 062 * will create AudioMarkers for .wav files, ImageMarkers for .png/.jpg/.jpeg 063 * files, and WebMarkers for everything else. (The creation of a WebMarker will 064 * fail if there's no valid URL in the <link> tag, so it might still make sense 065 * to add Makers for such waypoints at the end of the list.) 066 * 067 * The default implementation only looks at the value of the <link> tag inside 068 * the <wpt> tag of the GPX file. 069 * 070 * <h2>HowTo implement a new Marker</h2> 071 * <ul> 072 * <li> Subclass Marker or ButtonMarker and override <code>containsPoint</code> 073 * if you like to respond to user clicks</li> 074 * <li> Override paint, if you want a custom marker look (not "a label and a symbol")</li> 075 * <li> Implement MarkerCreator to return a new instance of your marker class</li> 076 * <li> In you plugin constructor, add an instance of your MarkerCreator 077 * implementation either on top or bottom of Marker.markerProducers. 078 * Add at top, if your marker should overwrite an current marker or at bottom 079 * if you only add a new marker style.</li> 080 * </ul> 081 * 082 * @author Frederik Ramm 083 */ 084public class Marker implements TemplateEngineDataProvider { 085 086 public static final class TemplateEntryProperty extends CachedProperty<TemplateEntry> { 087 // This class is a bit complicated because it supports both global and per layer settings. I've added per layer settings because 088 // GPXSettingsPanel had possibility to set waypoint label but then I've realized that markers use different layer then gpx data 089 // so per layer settings is useless. Anyway it's possible to specify marker layer pattern in Einstein preferences and maybe somebody 090 // will make gui for it so I'm keeping it here 091 092 private static final Map<String, TemplateEntryProperty> CACHE = new HashMap<>(); 093 094 // Legacy code - convert label from int to template engine expression 095 private static final IntegerProperty PROP_LABEL = new IntegerProperty("draw.rawgps.layer.wpt", 0); 096 097 private static String getDefaultLabelPattern() { 098 switch (PROP_LABEL.get()) { 099 case 1: 100 return LABEL_PATTERN_NAME; 101 case 2: 102 return LABEL_PATTERN_DESC; 103 case 0: 104 case 3: 105 return LABEL_PATTERN_AUTO; 106 default: 107 return ""; 108 } 109 } 110 111 public static TemplateEntryProperty forMarker(String layerName) { 112 String key = "draw.rawgps.layer.wpt.pattern"; 113 if (layerName != null) { 114 key += '.' + layerName; 115 } 116 TemplateEntryProperty result = CACHE.get(key); 117 if (result == null) { 118 String defaultValue = layerName == null ? getDefaultLabelPattern() : ""; 119 TemplateEntryProperty parent = layerName == null ? null : forMarker(null); 120 result = new TemplateEntryProperty(key, defaultValue, parent); 121 CACHE.put(key, result); 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 result = new TemplateEntryProperty(key, defaultValue, parent); 136 CACHE.put(key, result); 137 } 138 return result; 139 } 140 141 private final TemplateEntryProperty parent; 142 143 private TemplateEntryProperty(String key, String defaultValue, TemplateEntryProperty parent) { 144 super(key, defaultValue); 145 this.parent = parent; 146 updateValue(); // Needs to be called because parent wasn't know in super constructor 147 } 148 149 @Override 150 protected TemplateEntry fromString(String s) { 151 try { 152 return new TemplateParser(s).parse(); 153 } catch (ParseError e) { 154 Main.debug(e); 155 Main.warn("Unable to parse template engine pattern ''{0}'' for property {1}. Using default (''{2}'') instead", 156 s, getKey(), super.getDefaultValueAsString()); 157 return getDefaultValue(); 158 } 159 } 160 161 @Override 162 public String getDefaultValueAsString() { 163 if (parent == null) 164 return super.getDefaultValueAsString(); 165 else 166 return parent.getAsString(); 167 } 168 169 @Override 170 public void preferenceChanged(PreferenceChangeEvent e) { 171 if (e.getKey().equals(key) || (parent != null && e.getKey().equals(parent.getKey()))) { 172 updateValue(); 173 } 174 } 175 } 176 177 /** 178 * Plugins can add their Marker creation stuff at the bottom or top of this list 179 * (depending on whether they want to override default behaviour or just add new 180 * stuff). 181 */ 182 public static final List<MarkerProducers> markerProducers = new LinkedList<>(); 183 184 // Add one Marker specifying the default behaviour. 185 static { 186 Marker.markerProducers.add((wpt, relativePath, parentLayer, time, offset) -> { 187 String uri = null; 188 // cheapest way to check whether "link" object exists and is a non-empty collection of GpxLink objects... 189 Collection<GpxLink> links = wpt.<GpxLink>getCollection(GpxConstants.META_LINKS); 190 if (links != null) { 191 for (GpxLink oneLink : links) { 192 uri = oneLink.uri; 193 break; 194 } 195 } 196 197 URL url = uriToUrl(uri, relativePath); 198 199 String urlStr = url == null ? "" : url.toString(); 200 String symbolName = wpt.getString("symbol"); 201 if (symbolName == null) { 202 symbolName = wpt.getString(GpxConstants.PT_SYM); 203 } 204 // text marker is returned in every case, see #10208 205 final Marker marker = new Marker(wpt.getCoor(), wpt, symbolName, parentLayer, time, offset); 206 if (url == null) { 207 return Collections.singleton(marker); 208 } else if (urlStr.endsWith(".wav")) { 209 final AudioMarker audioMarker = new AudioMarker(wpt.getCoor(), wpt, url, parentLayer, time, offset); 210 Extensions exts = (Extensions) wpt.get(GpxConstants.META_EXTENSIONS); 211 if (exts != null && exts.containsKey("offset")) { 212 try { 213 audioMarker.syncOffset = Double.parseDouble(exts.get("sync-offset")); 214 } catch (NumberFormatException nfe) { 215 Main.warn(nfe); 216 } 217 } 218 return Arrays.asList(marker, audioMarker); 219 } else if (urlStr.endsWith(".png") || urlStr.endsWith(".jpg") || urlStr.endsWith(".jpeg") || urlStr.endsWith(".gif")) { 220 return Arrays.asList(marker, new ImageMarker(wpt.getCoor(), url, parentLayer, time, offset)); 221 } else { 222 return Arrays.asList(marker, new WebMarker(wpt.getCoor(), url, parentLayer, time, offset)); 223 } 224 }); 225 } 226 227 private static URL uriToUrl(String uri, File relativePath) { 228 URL url = null; 229 if (uri != null) { 230 try { 231 url = new URL(uri); 232 } catch (MalformedURLException e) { 233 // Try a relative file:// url, if the link is not in an URL-compatible form 234 if (relativePath != null) { 235 url = Utils.fileToURL(new File(relativePath.getParentFile(), uri)); 236 } 237 } 238 } 239 return url; 240 } 241 242 /** 243 * Returns an object of class Marker or one of its subclasses 244 * created from the parameters given. 245 * 246 * @param wpt waypoint data for marker 247 * @param relativePath An path to use for constructing relative URLs or 248 * <code>null</code> for no relative URLs 249 * @param parentLayer the <code>MarkerLayer</code> that will contain the created <code>Marker</code> 250 * @param time time of the marker in seconds since epoch 251 * @param offset double in seconds as the time offset of this marker from 252 * the GPX file from which it was derived (if any). 253 * @return a new Marker object 254 */ 255 public static Collection<Marker> createMarkers(WayPoint wpt, File relativePath, MarkerLayer parentLayer, double time, double offset) { 256 for (MarkerProducers maker : Marker.markerProducers) { 257 final Collection<Marker> markers = maker.createMarkers(wpt, relativePath, parentLayer, time, offset); 258 if (markers != null) 259 return markers; 260 } 261 return null; 262 } 263 264 public static final String MARKER_OFFSET = "waypointOffset"; 265 public static final String MARKER_FORMATTED_OFFSET = "formattedWaypointOffset"; 266 267 public static final String LABEL_PATTERN_AUTO = "?{ '{name} ({desc})' | '{name} ({cmt})' | '{name}' | '{desc}' | '{cmt}' }"; 268 public static final String LABEL_PATTERN_NAME = "{name}"; 269 public static final String LABEL_PATTERN_DESC = "{desc}"; 270 271 private final DateFormat timeFormatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); 272 private final TemplateEngineDataProvider dataProvider; 273 private final String text; 274 275 protected final ImageIcon symbol; 276 private BufferedImage redSymbol; 277 public final MarkerLayer parentLayer; 278 /** Absolute time of marker in seconds since epoch */ 279 public double time; 280 /** 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 */ 281 public double offset; 282 283 private String cachedText; 284 private int textVersion = -1; 285 private CachedLatLon coor; 286 287 private boolean erroneous; 288 289 public Marker(LatLon ll, TemplateEngineDataProvider dataProvider, String iconName, MarkerLayer parentLayer, 290 double time, double offset) { 291 this(ll, dataProvider, null, iconName, parentLayer, time, offset); 292 } 293 294 public Marker(LatLon ll, String text, String iconName, MarkerLayer parentLayer, double time, double offset) { 295 this(ll, null, text, iconName, parentLayer, time, offset); 296 } 297 298 private Marker(LatLon ll, TemplateEngineDataProvider dataProvider, String text, String iconName, MarkerLayer parentLayer, 299 double time, double offset) { 300 timeFormatter.setTimeZone(TimeZone.getTimeZone("UTC")); 301 setCoor(ll); 302 303 this.offset = offset; 304 this.time = time; 305 /* tell icon checking that we expect these names to exist */ 306 // /* ICON(markers/) */"Bridge" 307 // /* ICON(markers/) */"Crossing" 308 this.symbol = iconName != null ? ImageProvider.getIfAvailable("markers", iconName) : null; 309 this.parentLayer = parentLayer; 310 311 this.dataProvider = dataProvider; 312 this.text = text; 313 } 314 315 /** 316 * Convert Marker to WayPoint so it can be exported to a GPX file. 317 * 318 * Override in subclasses to add all necessary attributes. 319 * 320 * @return the corresponding WayPoint with all relevant attributes 321 */ 322 public WayPoint convertToWayPoint() { 323 WayPoint wpt = new WayPoint(getCoor()); 324 wpt.put("time", timeFormatter.format(new Date(Math.round(time * 1000)))); 325 if (text != null) { 326 wpt.addExtension("text", text); 327 } else if (dataProvider != null) { 328 for (String key : dataProvider.getTemplateKeys()) { 329 Object value = dataProvider.getTemplateValue(key, false); 330 if (value != null && GpxConstants.WPT_KEYS.contains(key)) { 331 wpt.put(key, value); 332 } 333 } 334 } 335 return wpt; 336 } 337 338 /** 339 * Sets the marker's coordinates. 340 * @param coor The marker's coordinates (lat/lon) 341 */ 342 public final void setCoor(LatLon coor) { 343 this.coor = new CachedLatLon(coor); 344 } 345 346 /** 347 * Returns the marker's coordinates. 348 * @return The marker's coordinates (lat/lon) 349 */ 350 public final LatLon getCoor() { 351 return coor; 352 } 353 354 /** 355 * Sets the marker's projected coordinates. 356 * @param eastNorth The marker's projected coordinates (easting/northing) 357 */ 358 public final void setEastNorth(EastNorth eastNorth) { 359 this.coor = new CachedLatLon(eastNorth); 360 } 361 362 /** 363 * Returns the marker's projected coordinates. 364 * @return The marker's projected coordinates (easting/northing) 365 */ 366 public final EastNorth getEastNorth() { 367 return coor.getEastNorth(); 368 } 369 370 /** 371 * Checks whether the marker display area contains the given point. 372 * Markers not interested in mouse clicks may always return false. 373 * 374 * @param p The point to check 375 * @return <code>true</code> if the marker "hotspot" contains the point. 376 */ 377 public boolean containsPoint(Point p) { 378 return false; 379 } 380 381 /** 382 * Called when the mouse is clicked in the marker's hotspot. Never 383 * called for markers which always return false from containsPoint. 384 * 385 * @param ev A dummy ActionEvent 386 */ 387 public void actionPerformed(ActionEvent ev) { 388 // Do nothing 389 } 390 391 /** 392 * Paints the marker. 393 * @param g graphics context 394 * @param mv map view 395 * @param mousePressed true if the left mouse button is pressed 396 * @param showTextOrIcon true if text and icon shall be drawn 397 */ 398 public void paint(Graphics g, MapView mv, boolean mousePressed, boolean showTextOrIcon) { 399 Point screen = mv.getPoint(getEastNorth()); 400 if (symbol != null && showTextOrIcon) { 401 paintIcon(mv, g, screen.x-symbol.getIconWidth()/2, screen.y-symbol.getIconHeight()/2); 402 } else { 403 g.drawLine(screen.x-2, screen.y-2, screen.x+2, screen.y+2); 404 g.drawLine(screen.x+2, screen.y-2, screen.x-2, screen.y+2); 405 } 406 407 String labelText = getText(); 408 if ((labelText != null) && showTextOrIcon) { 409 g.drawString(labelText, screen.x+4, screen.y+2); 410 } 411 } 412 413 protected void paintIcon(MapView mv, Graphics g, int x, int y) { 414 if (!erroneous) { 415 symbol.paintIcon(mv, g, x, y); 416 } else { 417 if (redSymbol == null) { 418 int width = symbol.getIconWidth(); 419 int height = symbol.getIconHeight(); 420 421 redSymbol = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); 422 Graphics2D gbi = redSymbol.createGraphics(); 423 gbi.drawImage(symbol.getImage(), 0, 0, null); 424 gbi.setColor(Color.RED); 425 gbi.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_ATOP, 0.666f)); 426 gbi.fillRect(0, 0, width, height); 427 gbi.dispose(); 428 } 429 g.drawImage(redSymbol, x, y, mv); 430 } 431 } 432 433 protected TemplateEntryProperty getTextTemplate() { 434 return TemplateEntryProperty.forMarker(parentLayer.getName()); 435 } 436 437 /** 438 * Returns the Text which should be displayed, depending on chosen preference 439 * @return Text of the label 440 */ 441 public String getText() { 442 if (text != null) 443 return text; 444 else { 445 TemplateEntryProperty property = getTextTemplate(); 446 if (property.getUpdateCount() != textVersion) { 447 TemplateEntry templateEntry = property.get(); 448 StringBuilder sb = new StringBuilder(); 449 templateEntry.appendText(sb, this); 450 451 cachedText = sb.toString(); 452 textVersion = property.getUpdateCount(); 453 } 454 return cachedText; 455 } 456 } 457 458 @Override 459 public Collection<String> getTemplateKeys() { 460 Collection<String> result; 461 if (dataProvider != null) { 462 result = dataProvider.getTemplateKeys(); 463 } else { 464 result = new ArrayList<>(); 465 } 466 result.add(MARKER_FORMATTED_OFFSET); 467 result.add(MARKER_OFFSET); 468 return result; 469 } 470 471 private String formatOffset() { 472 int wholeSeconds = (int) (offset + 0.5); 473 if (wholeSeconds < 60) 474 return Integer.toString(wholeSeconds); 475 else if (wholeSeconds < 3600) 476 return String.format("%d:%02d", wholeSeconds / 60, wholeSeconds % 60); 477 else 478 return String.format("%d:%02d:%02d", wholeSeconds / 3600, (wholeSeconds % 3600)/60, wholeSeconds % 60); 479 } 480 481 @Override 482 public Object getTemplateValue(String name, boolean special) { 483 if (MARKER_FORMATTED_OFFSET.equals(name)) 484 return formatOffset(); 485 else if (MARKER_OFFSET.equals(name)) 486 return offset; 487 else if (dataProvider != null) 488 return dataProvider.getTemplateValue(name, special); 489 else 490 return null; 491 } 492 493 @Override 494 public boolean evaluateCondition(Match condition) { 495 throw new UnsupportedOperationException(); 496 } 497 498 /** 499 * Determines if this marker is erroneous. 500 * @return {@code true} if this markers has any kind of error, {@code false} otherwise 501 * @since 6299 502 */ 503 public final boolean isErroneous() { 504 return erroneous; 505 } 506 507 /** 508 * Sets this marker erroneous or not. 509 * @param erroneous {@code true} if this markers has any kind of error, {@code false} otherwise 510 * @since 6299 511 */ 512 public final void setErroneous(boolean erroneous) { 513 this.erroneous = erroneous; 514 if (!erroneous) { 515 redSymbol = null; 516 } 517 } 518}