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.Stroke; 010import java.awt.event.ActionEvent; 011import java.awt.image.BufferedImage; 012import java.io.File; 013import java.util.ArrayList; 014import java.util.Collection; 015import java.util.HashMap; 016import java.util.LinkedList; 017import java.util.List; 018import java.util.Map; 019import java.util.Objects; 020 021import javax.swing.ImageIcon; 022 023import org.openstreetmap.josm.data.Preferences; 024import org.openstreetmap.josm.data.coor.CachedLatLon; 025import org.openstreetmap.josm.data.coor.EastNorth; 026import org.openstreetmap.josm.data.coor.ILatLon; 027import org.openstreetmap.josm.data.coor.LatLon; 028import org.openstreetmap.josm.data.gpx.GpxConstants; 029import org.openstreetmap.josm.data.gpx.WayPoint; 030import org.openstreetmap.josm.data.osm.search.SearchCompiler.Match; 031import org.openstreetmap.josm.gui.MapView; 032import org.openstreetmap.josm.gui.layer.GpxLayer; 033import org.openstreetmap.josm.gui.preferences.display.GPXSettingsPanel; 034import org.openstreetmap.josm.spi.preferences.PreferenceChangedListener; 035import org.openstreetmap.josm.tools.Destroyable; 036import org.openstreetmap.josm.tools.ImageProvider; 037import org.openstreetmap.josm.tools.Logging; 038import org.openstreetmap.josm.tools.template_engine.ParseError; 039import org.openstreetmap.josm.tools.template_engine.TemplateEngineDataProvider; 040import org.openstreetmap.josm.tools.template_engine.TemplateEntry; 041import org.openstreetmap.josm.tools.template_engine.TemplateParser; 042 043/** 044 * Basic marker class. Requires a position, and supports 045 * a custom icon and a name. 046 * 047 * This class is also used to create appropriate Marker-type objects 048 * when waypoints are imported. 049 * 050 * It hosts a public list object, named makers, containing implementations of 051 * the MarkerMaker interface. Whenever a Marker needs to be created, each 052 * object in makers is called with the waypoint parameters (Lat/Lon and tag 053 * data), and the first one to return a Marker object wins. 054 * 055 * By default, one the list contains one default "Maker" implementation that 056 * will create AudioMarkers for supported audio files, ImageMarkers for supported image 057 * files, and WebMarkers for everything else. (The creation of a WebMarker will 058 * fail if there's no valid URL in the <link> tag, so it might still make sense 059 * to add Makers for such waypoints at the end of the list.) 060 * 061 * The default implementation only looks at the value of the <link> tag inside 062 * the <wpt> tag of the GPX file. 063 * 064 * <h2>HowTo implement a new Marker</h2> 065 * <ul> 066 * <li> Subclass Marker or ButtonMarker and override <code>containsPoint</code> 067 * if you like to respond to user clicks</li> 068 * <li> Override paint, if you want a custom marker look (not "a label and a symbol")</li> 069 * <li> Implement MarkerCreator to return a new instance of your marker class</li> 070 * <li> In you plugin constructor, add an instance of your MarkerCreator 071 * implementation either on top or bottom of Marker.markerProducers. 072 * Add at top, if your marker should overwrite an current marker or at bottom 073 * if you only add a new marker style.</li> 074 * </ul> 075 * 076 * @author Frederik Ramm 077 */ 078public class Marker implements TemplateEngineDataProvider, ILatLon, Destroyable { 079 080 /** 081 * Plugins can add their Marker creation stuff at the bottom or top of this list 082 * (depending on whether they want to override default behaviour or just add new stuff). 083 */ 084 private static final List<MarkerProducers> markerProducers = new LinkedList<>(); 085 086 // Add one Marker specifying the default behaviour. 087 static { 088 Marker.markerProducers.add(new DefaultMarkerProducers()); 089 } 090 091 /** 092 * Add a new marker producers at the end of the JOSM list. 093 * @param mp a new marker producers 094 * @since 11850 095 */ 096 public static void appendMarkerProducer(MarkerProducers mp) { 097 markerProducers.add(mp); 098 } 099 100 /** 101 * Add a new marker producers at the beginning of the JOSM list. 102 * @param mp a new marker producers 103 * @since 11850 104 */ 105 public static void prependMarkerProducer(MarkerProducers mp) { 106 markerProducers.add(0, mp); 107 } 108 109 /** 110 * Returns an object of class Marker or one of its subclasses 111 * created from the parameters given. 112 * 113 * @param wpt waypoint data for marker 114 * @param relativePath An path to use for constructing relative URLs or 115 * <code>null</code> for no relative URLs 116 * @param parentLayer the <code>MarkerLayer</code> that will contain the created <code>Marker</code> 117 * @param time time of the marker in seconds since epoch 118 * @param offset double in seconds as the time offset of this marker from 119 * the GPX file from which it was derived (if any). 120 * @return a new Marker object 121 */ 122 public static Collection<Marker> createMarkers(WayPoint wpt, File relativePath, MarkerLayer parentLayer, double time, double offset) { 123 return Marker.markerProducers.stream() 124 .map(maker -> maker.createMarkers(wpt, relativePath, parentLayer, time, offset)) 125 .filter(Objects::nonNull) 126 .findFirst().orElse(null); 127 } 128 129 public static final String MARKER_OFFSET = "waypointOffset"; 130 public static final String MARKER_FORMATTED_OFFSET = "formattedWaypointOffset"; 131 132 public static final String LABEL_PATTERN_AUTO = "?{ '{name} ({desc})' | '{name} ({cmt})' | '{name}' | '{desc}' | '{cmt}' }"; 133 public static final String LABEL_PATTERN_NAME = "{name}"; 134 public static final String LABEL_PATTERN_DESC = "{desc}"; 135 136 private final TemplateEngineDataProvider dataProvider; 137 private final String text; 138 139 protected final ImageIcon symbol; 140 private BufferedImage redSymbol; 141 public final MarkerLayer parentLayer; 142 /** Absolute time of marker in seconds since epoch */ 143 public double time; 144 /** 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 */ 145 public double offset; 146 147 private String cachedText; 148 private static Map<GpxLayer, String> cachedTemplates = new HashMap<>(); 149 private String cachedDefaultTemplate; 150 151 private CachedLatLon coor; 152 private PreferenceChangedListener listener = l -> updateText(); 153 154 private boolean erroneous; 155 156 public Marker(LatLon ll, TemplateEngineDataProvider dataProvider, String iconName, MarkerLayer parentLayer, 157 double time, double offset) { 158 this(ll, dataProvider, null, iconName, parentLayer, time, offset); 159 } 160 161 public Marker(LatLon ll, String text, String iconName, MarkerLayer parentLayer, double time, double offset) { 162 this(ll, null, text, iconName, parentLayer, time, offset); 163 } 164 165 private Marker(LatLon ll, TemplateEngineDataProvider dataProvider, String text, String iconName, MarkerLayer parentLayer, 166 double time, double offset) { 167 setCoor(ll); 168 169 this.offset = offset; 170 this.time = time; 171 /* tell icon checking that we expect these names to exist */ 172 // /* ICON(markers/) */"Bridge" 173 // /* ICON(markers/) */"Crossing" 174 this.symbol = iconName != null ? ImageProvider.getIfAvailable("markers", iconName) : null; 175 this.parentLayer = parentLayer; 176 177 this.dataProvider = dataProvider; 178 this.text = text; 179 180 Preferences.main().addKeyPreferenceChangeListener(getPreferenceKey(), listener); 181 } 182 183 /** 184 * Convert Marker to WayPoint so it can be exported to a GPX file. 185 * 186 * Override in subclasses to add all necessary attributes. 187 * 188 * @return the corresponding WayPoint with all relevant attributes 189 */ 190 public WayPoint convertToWayPoint() { 191 WayPoint wpt = new WayPoint(getCoor()); 192 if (time > 0d) { 193 wpt.setTimeInMillis((long) (time * 1000)); 194 } 195 if (text != null) { 196 wpt.getExtensions().add("josm", "text", text); 197 } else if (dataProvider != null) { 198 for (String key : dataProvider.getTemplateKeys()) { 199 Object value = dataProvider.getTemplateValue(key, false); 200 if (value != null && GpxConstants.WPT_KEYS.contains(key)) { 201 wpt.put(key, value); 202 } 203 } 204 } 205 return wpt; 206 } 207 208 /** 209 * Sets the marker's coordinates. 210 * @param coor The marker's coordinates (lat/lon) 211 */ 212 public final void setCoor(LatLon coor) { 213 this.coor = new CachedLatLon(coor); 214 } 215 216 /** 217 * Returns the marker's coordinates. 218 * @return The marker's coordinates (lat/lon) 219 */ 220 public final LatLon getCoor() { 221 return coor; 222 } 223 224 /** 225 * Sets the marker's projected coordinates. 226 * @param eastNorth The marker's projected coordinates (easting/northing) 227 */ 228 public final void setEastNorth(EastNorth eastNorth) { 229 this.coor = new CachedLatLon(eastNorth); 230 } 231 232 /** 233 * @since 12725 234 */ 235 @Override 236 public double lon() { 237 return coor == null ? Double.NaN : coor.lon(); 238 } 239 240 /** 241 * @since 12725 242 */ 243 @Override 244 public double lat() { 245 return coor == null ? Double.NaN : coor.lat(); 246 } 247 248 /** 249 * Checks whether the marker display area contains the given point. 250 * Markers not interested in mouse clicks may always return false. 251 * 252 * @param p The point to check 253 * @return <code>true</code> if the marker "hotspot" contains the point. 254 */ 255 public boolean containsPoint(Point p) { 256 return false; 257 } 258 259 /** 260 * Called when the mouse is clicked in the marker's hotspot. Never 261 * called for markers which always return false from containsPoint. 262 * 263 * @param ev A dummy ActionEvent 264 */ 265 public void actionPerformed(ActionEvent ev) { 266 // Do nothing 267 } 268 269 /** 270 * Paints the marker. 271 * @param g graphics context 272 * @param mv map view 273 * @param mousePressed true if the left mouse button is pressed 274 * @param showTextOrIcon true if text and icon shall be drawn 275 */ 276 public void paint(Graphics2D g, MapView mv, boolean mousePressed, boolean showTextOrIcon) { 277 Point screen = mv.getPoint(this); 278 int size2 = parentLayer.markerSize / 2; 279 280 if (symbol != null && showTextOrIcon) { 281 paintIcon(mv, g, screen.x-symbol.getIconWidth()/2, screen.y-symbol.getIconHeight()/2); 282 } else { 283 Stroke stroke = g.getStroke(); 284 g.setStroke(parentLayer.markerStroke); 285 g.drawLine(screen.x - size2, screen.y - size2, screen.x + size2, screen.y + size2); 286 g.drawLine(screen.x + size2, screen.y - size2, screen.x - size2, screen.y + size2); 287 g.setStroke(stroke); 288 } 289 290 String labelText = getText(); 291 if (!labelText.isEmpty() && showTextOrIcon) { 292 g.drawString(labelText, screen.x + size2 + 2, screen.y + size2); 293 } 294 } 295 296 protected void paintIcon(MapView mv, Graphics g, int x, int y) { 297 if (!erroneous) { 298 symbol.paintIcon(mv, g, x, y); 299 } else { 300 if (redSymbol == null) { 301 int width = symbol.getIconWidth(); 302 int height = symbol.getIconHeight(); 303 304 redSymbol = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); 305 Graphics2D gbi = redSymbol.createGraphics(); 306 gbi.drawImage(symbol.getImage(), 0, 0, null); 307 gbi.setColor(Color.RED); 308 gbi.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_ATOP, 0.666f)); 309 gbi.fillRect(0, 0, width, height); 310 gbi.dispose(); 311 } 312 g.drawImage(redSymbol, x, y, mv); 313 } 314 } 315 316 protected String getTextTemplateKey() { 317 return "markers.pattern"; 318 } 319 320 private String getTextTemplate() { 321 String tmpl; 322 if (cachedTemplates.containsKey(parentLayer.fromLayer)) { 323 tmpl = cachedTemplates.get(parentLayer.fromLayer); 324 } else { 325 tmpl = GPXSettingsPanel.getLayerPref(parentLayer.fromLayer, getTextTemplateKey()); 326 cachedTemplates.put(parentLayer.fromLayer, tmpl); 327 } 328 return tmpl; 329 } 330 331 private String getDefaultTextTemplate() { 332 if (cachedDefaultTemplate == null) { 333 cachedDefaultTemplate = GPXSettingsPanel.getLayerPref(null, getTextTemplateKey()); 334 } 335 return cachedDefaultTemplate; 336 } 337 338 /** 339 * Returns the Text which should be displayed, depending on chosen preference 340 * @return Text of the label 341 */ 342 public String getText() { 343 if (text != null) { 344 return text; 345 } else if (cachedText == null) { 346 TemplateEntry template; 347 String templateString = getTextTemplate(); 348 try { 349 template = new TemplateParser(templateString).parse(); 350 } catch (ParseError e) { 351 Logging.debug(e); 352 String def = getDefaultTextTemplate(); 353 Logging.warn("Unable to parse template engine pattern ''{0}'' for property {1}. Using default (''{2}'') instead", 354 templateString, getTextTemplateKey(), def); 355 try { 356 template = new TemplateParser(def).parse(); 357 } catch (ParseError e1) { 358 Logging.error(e1); 359 cachedText = ""; 360 return ""; 361 } 362 } 363 StringBuilder sb = new StringBuilder(); 364 template.appendText(sb, this); 365 cachedText = sb.toString(); 366 367 } 368 return cachedText; 369 } 370 371 /** 372 * Called when the template changes 373 */ 374 public void updateText() { 375 cachedText = null; 376 cachedDefaultTemplate = null; 377 cachedTemplates.clear(); 378 } 379 380 @Override 381 public Collection<String> getTemplateKeys() { 382 Collection<String> result; 383 if (dataProvider != null) { 384 result = dataProvider.getTemplateKeys(); 385 } else { 386 result = new ArrayList<>(); 387 } 388 result.add(MARKER_FORMATTED_OFFSET); 389 result.add(MARKER_OFFSET); 390 return result; 391 } 392 393 private String formatOffset() { 394 int wholeSeconds = (int) (offset + 0.5); 395 if (wholeSeconds < 60) 396 return Integer.toString(wholeSeconds); 397 else if (wholeSeconds < 3600) 398 return String.format("%d:%02d", wholeSeconds / 60, wholeSeconds % 60); 399 else 400 return String.format("%d:%02d:%02d", wholeSeconds / 3600, (wholeSeconds % 3600)/60, wholeSeconds % 60); 401 } 402 403 @Override 404 public Object getTemplateValue(String name, boolean special) { 405 if (MARKER_FORMATTED_OFFSET.equals(name)) 406 return formatOffset(); 407 else if (MARKER_OFFSET.equals(name)) 408 return offset; 409 else if (dataProvider != null) 410 return dataProvider.getTemplateValue(name, special); 411 else 412 return null; 413 } 414 415 @Override 416 public boolean evaluateCondition(Match condition) { 417 throw new UnsupportedOperationException(); 418 } 419 420 /** 421 * Determines if this marker is erroneous. 422 * @return {@code true} if this markers has any kind of error, {@code false} otherwise 423 * @since 6299 424 */ 425 public final boolean isErroneous() { 426 return erroneous; 427 } 428 429 /** 430 * Sets this marker erroneous or not. 431 * @param erroneous {@code true} if this markers has any kind of error, {@code false} otherwise 432 * @since 6299 433 */ 434 public final void setErroneous(boolean erroneous) { 435 this.erroneous = erroneous; 436 if (!erroneous) { 437 redSymbol = null; 438 } 439 } 440 441 @Override 442 public void destroy() { 443 cachedTemplates.clear(); 444 Preferences.main().removeKeyPreferenceChangeListener(getPreferenceKey(), listener); 445 } 446 447 private String getPreferenceKey() { 448 return "draw.rawgps." + getTextTemplateKey(); 449 } 450}