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