001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.layer.markerlayer; 003 004import static org.openstreetmap.josm.gui.help.HelpUtil.ht; 005import static org.openstreetmap.josm.tools.I18n.marktr; 006import static org.openstreetmap.josm.tools.I18n.tr; 007import static org.openstreetmap.josm.tools.I18n.trn; 008 009import java.awt.Color; 010import java.awt.Component; 011import java.awt.Graphics2D; 012import java.awt.Point; 013import java.awt.event.ActionEvent; 014import java.awt.event.MouseAdapter; 015import java.awt.event.MouseEvent; 016import java.io.File; 017import java.net.URI; 018import java.net.URISyntaxException; 019import java.util.ArrayList; 020import java.util.Collection; 021import java.util.Comparator; 022import java.util.List; 023 024import javax.swing.AbstractAction; 025import javax.swing.Action; 026import javax.swing.Icon; 027import javax.swing.JCheckBoxMenuItem; 028import javax.swing.JOptionPane; 029 030import org.openstreetmap.josm.Main; 031import org.openstreetmap.josm.actions.RenameLayerAction; 032import org.openstreetmap.josm.data.Bounds; 033import org.openstreetmap.josm.data.coor.LatLon; 034import org.openstreetmap.josm.data.gpx.Extensions; 035import org.openstreetmap.josm.data.gpx.GpxConstants; 036import org.openstreetmap.josm.data.gpx.GpxData; 037import org.openstreetmap.josm.data.gpx.GpxLink; 038import org.openstreetmap.josm.data.gpx.WayPoint; 039import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor; 040import org.openstreetmap.josm.data.preferences.ColorProperty; 041import org.openstreetmap.josm.gui.MapView; 042import org.openstreetmap.josm.gui.dialogs.LayerListDialog; 043import org.openstreetmap.josm.gui.dialogs.LayerListPopup; 044import org.openstreetmap.josm.gui.layer.CustomizeColor; 045import org.openstreetmap.josm.gui.layer.GpxLayer; 046import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToMarkerLayer; 047import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToNextMarker; 048import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToPreviousMarker; 049import org.openstreetmap.josm.gui.layer.Layer; 050import org.openstreetmap.josm.gui.layer.gpx.ConvertToDataLayerAction; 051import org.openstreetmap.josm.tools.AudioPlayer; 052import org.openstreetmap.josm.tools.ImageProvider; 053 054/** 055 * A layer holding markers. 056 * 057 * Markers are GPS points with a name and, optionally, a symbol code attached; 058 * marker layers can be created from waypoints when importing raw GPS data, 059 * but they may also come from other sources. 060 * 061 * The symbol code is for future use. 062 * 063 * The data is read only. 064 */ 065public class MarkerLayer extends Layer implements JumpToMarkerLayer { 066 067 /** 068 * A list of markers. 069 */ 070 public final List<Marker> data; 071 private boolean mousePressed; 072 public GpxLayer fromLayer; 073 private Marker currentMarker; 074 public AudioMarker syncAudioMarker; 075 076 private static final Color DEFAULT_COLOR = Color.magenta; 077 private static final ColorProperty COLOR_PROPERTY = new ColorProperty(marktr("gps marker"), DEFAULT_COLOR); 078 079 /** 080 * Constructs a new {@code MarkerLayer}. 081 * @param indata The GPX data for this layer 082 * @param name The marker layer name 083 * @param associatedFile The associated GPX file 084 * @param fromLayer The associated GPX layer 085 */ 086 public MarkerLayer(GpxData indata, String name, File associatedFile, GpxLayer fromLayer) { 087 super(name); 088 this.setAssociatedFile(associatedFile); 089 this.data = new ArrayList<>(); 090 this.fromLayer = fromLayer; 091 double firstTime = -1.0; 092 String lastLinkedFile = ""; 093 094 for (WayPoint wpt : indata.waypoints) { 095 /* calculate time differences in waypoints */ 096 double time = wpt.time; 097 boolean wptHasLink = wpt.attr.containsKey(GpxConstants.META_LINKS); 098 if (firstTime < 0 && wptHasLink) { 099 firstTime = time; 100 for (GpxLink oneLink : wpt.<GpxLink>getCollection(GpxConstants.META_LINKS)) { 101 lastLinkedFile = oneLink.uri; 102 break; 103 } 104 } 105 if (wptHasLink) { 106 for (GpxLink oneLink : wpt.<GpxLink>getCollection(GpxConstants.META_LINKS)) { 107 String uri = oneLink.uri; 108 if (uri != null) { 109 if (!uri.equals(lastLinkedFile)) { 110 firstTime = time; 111 } 112 lastLinkedFile = uri; 113 break; 114 } 115 } 116 } 117 Double offset = null; 118 // If we have an explicit offset, take it. 119 // Otherwise, for a group of markers with the same Link-URI (e.g. an 120 // audio file) calculate the offset relative to the first marker of 121 // that group. This way the user can jump to the corresponding 122 // playback positions in a long audio track. 123 Extensions exts = (Extensions) wpt.get(GpxConstants.META_EXTENSIONS); 124 if (exts != null && exts.containsKey("offset")) { 125 try { 126 offset = Double.valueOf(exts.get("offset")); 127 } catch (NumberFormatException nfe) { 128 Main.warn(nfe); 129 } 130 } 131 if (offset == null) { 132 offset = time - firstTime; 133 } 134 final Collection<Marker> markers = Marker.createMarkers(wpt, indata.storageFile, this, time, offset); 135 if (markers != null) { 136 data.addAll(markers); 137 } 138 } 139 } 140 141 @Override 142 public LayerPainter attachToMapView(MapViewEvent event) { 143 event.getMapView().addMouseListener(new MouseAdapter() { 144 @Override 145 public void mousePressed(MouseEvent e) { 146 if (e.getButton() != MouseEvent.BUTTON1) 147 return; 148 boolean mousePressedInButton = false; 149 for (Marker mkr : data) { 150 if (mkr.containsPoint(e.getPoint())) { 151 mousePressedInButton = true; 152 break; 153 } 154 } 155 if (!mousePressedInButton) 156 return; 157 mousePressed = true; 158 if (isVisible()) { 159 invalidate(); 160 } 161 } 162 163 @Override 164 public void mouseReleased(MouseEvent ev) { 165 if (ev.getButton() != MouseEvent.BUTTON1 || !mousePressed) 166 return; 167 mousePressed = false; 168 if (!isVisible()) 169 return; 170 for (Marker mkr : data) { 171 if (mkr.containsPoint(ev.getPoint())) { 172 mkr.actionPerformed(new ActionEvent(this, 0, null)); 173 } 174 } 175 invalidate(); 176 } 177 }); 178 179 if (event.getMapView().playHeadMarker == null) { 180 event.getMapView().playHeadMarker = PlayHeadMarker.create(); 181 } 182 183 return super.attachToMapView(event); 184 } 185 186 /** 187 * Return a static icon. 188 */ 189 @Override 190 public Icon getIcon() { 191 return ImageProvider.get("layer", "marker_small"); 192 } 193 194 @Override 195 protected ColorProperty getBaseColorProperty() { 196 return COLOR_PROPERTY; 197 } 198 199 /* for preferences */ 200 public static Color getGenericColor() { 201 return COLOR_PROPERTY.get(); 202 } 203 204 @Override 205 public void paint(Graphics2D g, MapView mv, Bounds box) { 206 boolean showTextOrIcon = isTextOrIconShown(); 207 g.setColor(getColorProperty().get()); 208 209 if (mousePressed) { 210 boolean mousePressedTmp = mousePressed; 211 Point mousePos = mv.getMousePosition(); // Get mouse position only when necessary (it's the slowest part of marker layer painting) 212 for (Marker mkr : data) { 213 if (mousePos != null && mkr.containsPoint(mousePos)) { 214 mkr.paint(g, mv, mousePressedTmp, showTextOrIcon); 215 mousePressedTmp = false; 216 } 217 } 218 } else { 219 for (Marker mkr : data) { 220 mkr.paint(g, mv, false, showTextOrIcon); 221 } 222 } 223 } 224 225 @Override 226 public String getToolTipText() { 227 return Integer.toString(data.size())+' '+trn("marker", "markers", data.size()); 228 } 229 230 @Override 231 public void mergeFrom(Layer from) { 232 if (from instanceof MarkerLayer) { 233 data.addAll(((MarkerLayer) from).data); 234 data.sort(Comparator.comparingDouble(o -> o.time)); 235 } 236 } 237 238 @Override public boolean isMergable(Layer other) { 239 return other instanceof MarkerLayer; 240 } 241 242 @Override public void visitBoundingBox(BoundingXYVisitor v) { 243 for (Marker mkr : data) { 244 v.visit(mkr.getEastNorth()); 245 } 246 } 247 248 @Override public Object getInfoComponent() { 249 return "<html>"+trn("{0} consists of {1} marker", "{0} consists of {1} markers", data.size(), getName(), data.size()) + "</html>"; 250 } 251 252 @Override public Action[] getMenuEntries() { 253 Collection<Action> components = new ArrayList<>(); 254 components.add(LayerListDialog.getInstance().createShowHideLayerAction()); 255 components.add(new ShowHideMarkerText(this)); 256 components.add(LayerListDialog.getInstance().createDeleteLayerAction()); 257 components.add(LayerListDialog.getInstance().createMergeLayerAction(this)); 258 components.add(SeparatorLayerAction.INSTANCE); 259 components.add(new CustomizeColor(this)); 260 components.add(SeparatorLayerAction.INSTANCE); 261 components.add(new SynchronizeAudio()); 262 if (Main.pref.getBoolean("marker.traceaudio", true)) { 263 components.add(new MoveAudio()); 264 } 265 components.add(new JumpToNextMarker(this)); 266 components.add(new JumpToPreviousMarker(this)); 267 components.add(new ConvertToDataLayerAction.FromMarkerLayer(this)); 268 components.add(new RenameLayerAction(getAssociatedFile(), this)); 269 components.add(SeparatorLayerAction.INSTANCE); 270 components.add(new LayerListPopup.InfoAction(this)); 271 return components.toArray(new Action[components.size()]); 272 } 273 274 public boolean synchronizeAudioMarkers(final AudioMarker startMarker) { 275 syncAudioMarker = startMarker; 276 if (syncAudioMarker != null && !data.contains(syncAudioMarker)) { 277 syncAudioMarker = null; 278 } 279 if (syncAudioMarker == null) { 280 // find the first audioMarker in this layer 281 for (Marker m : data) { 282 if (m instanceof AudioMarker) { 283 syncAudioMarker = (AudioMarker) m; 284 break; 285 } 286 } 287 } 288 if (syncAudioMarker == null) 289 return false; 290 291 // apply adjustment to all subsequent audio markers in the layer 292 double adjustment = AudioPlayer.position() - syncAudioMarker.offset; // in seconds 293 boolean seenStart = false; 294 try { 295 URI uri = syncAudioMarker.url().toURI(); 296 for (Marker m : data) { 297 if (m == syncAudioMarker) { 298 seenStart = true; 299 } 300 if (seenStart && m instanceof AudioMarker) { 301 AudioMarker ma = (AudioMarker) m; 302 // Do not ever call URL.equals but use URI.equals instead to avoid Internet connection 303 // See http://michaelscharf.blogspot.fr/2006/11/javaneturlequals-and-hashcode-make.html for details 304 if (ma.url().toURI().equals(uri)) { 305 ma.adjustOffset(adjustment); 306 } 307 } 308 } 309 } catch (URISyntaxException e) { 310 Main.warn(e); 311 } 312 return true; 313 } 314 315 public AudioMarker addAudioMarker(double time, LatLon coor) { 316 // find first audio marker to get absolute start time 317 double offset = 0.0; 318 AudioMarker am = null; 319 for (Marker m : data) { 320 if (m.getClass() == AudioMarker.class) { 321 am = (AudioMarker) m; 322 offset = time - am.time; 323 break; 324 } 325 } 326 if (am == null) { 327 JOptionPane.showMessageDialog( 328 Main.parent, 329 tr("No existing audio markers in this layer to offset from."), 330 tr("Error"), 331 JOptionPane.ERROR_MESSAGE 332 ); 333 return null; 334 } 335 336 // make our new marker 337 AudioMarker newAudioMarker = new AudioMarker(coor, 338 null, AudioPlayer.url(), this, time, offset); 339 340 // insert it at the right place in a copy the collection 341 Collection<Marker> newData = new ArrayList<>(); 342 am = null; 343 AudioMarker ret = newAudioMarker; // save to have return value 344 for (Marker m : data) { 345 if (m.getClass() == AudioMarker.class) { 346 am = (AudioMarker) m; 347 if (newAudioMarker != null && offset < am.offset) { 348 newAudioMarker.adjustOffset(am.syncOffset()); // i.e. same as predecessor 349 newData.add(newAudioMarker); 350 newAudioMarker = null; 351 } 352 } 353 newData.add(m); 354 } 355 356 if (newAudioMarker != null) { 357 if (am != null) { 358 newAudioMarker.adjustOffset(am.syncOffset()); // i.e. same as predecessor 359 } 360 newData.add(newAudioMarker); // insert at end 361 } 362 363 // replace the collection 364 data.clear(); 365 data.addAll(newData); 366 return ret; 367 } 368 369 @Override 370 public void jumpToNextMarker() { 371 if (currentMarker == null) { 372 currentMarker = data.get(0); 373 } else { 374 boolean foundCurrent = false; 375 for (Marker m: data) { 376 if (foundCurrent) { 377 currentMarker = m; 378 break; 379 } else if (currentMarker == m) { 380 foundCurrent = true; 381 } 382 } 383 } 384 Main.map.mapView.zoomTo(currentMarker.getEastNorth()); 385 } 386 387 @Override 388 public void jumpToPreviousMarker() { 389 if (currentMarker == null) { 390 currentMarker = data.get(data.size() - 1); 391 } else { 392 boolean foundCurrent = false; 393 for (int i = data.size() - 1; i >= 0; i--) { 394 Marker m = data.get(i); 395 if (foundCurrent) { 396 currentMarker = m; 397 break; 398 } else if (currentMarker == m) { 399 foundCurrent = true; 400 } 401 } 402 } 403 Main.map.mapView.zoomTo(currentMarker.getEastNorth()); 404 } 405 406 public static void playAudio() { 407 playAdjacentMarker(null, true); 408 } 409 410 public static void playNextMarker() { 411 playAdjacentMarker(AudioMarker.recentlyPlayedMarker(), true); 412 } 413 414 public static void playPreviousMarker() { 415 playAdjacentMarker(AudioMarker.recentlyPlayedMarker(), false); 416 } 417 418 private static Marker getAdjacentMarker(Marker startMarker, boolean next, Layer layer) { 419 Marker previousMarker = null; 420 boolean nextTime = false; 421 if (layer.getClass() == MarkerLayer.class) { 422 MarkerLayer markerLayer = (MarkerLayer) layer; 423 for (Marker marker : markerLayer.data) { 424 if (marker == startMarker) { 425 if (next) { 426 nextTime = true; 427 } else { 428 if (previousMarker == null) { 429 previousMarker = startMarker; // if no previous one, play the first one again 430 } 431 return previousMarker; 432 } 433 } else if (marker.getClass() == AudioMarker.class) { 434 if (nextTime || startMarker == null) 435 return marker; 436 previousMarker = marker; 437 } 438 } 439 if (nextTime) // there was no next marker in that layer, so play the last one again 440 return startMarker; 441 } 442 return null; 443 } 444 445 private static void playAdjacentMarker(Marker startMarker, boolean next) { 446 if (!Main.isDisplayingMapView()) 447 return; 448 Marker m = null; 449 Layer l = Main.getLayerManager().getActiveLayer(); 450 if (l != null) { 451 m = getAdjacentMarker(startMarker, next, l); 452 } 453 if (m == null) { 454 for (Layer layer : Main.getLayerManager().getLayers()) { 455 m = getAdjacentMarker(startMarker, next, layer); 456 if (m != null) { 457 break; 458 } 459 } 460 } 461 if (m != null) { 462 ((AudioMarker) m).play(); 463 } 464 } 465 466 /** 467 * Get state of text display. 468 * @return <code>true</code> if text should be shown, <code>false</code> otherwise. 469 */ 470 private boolean isTextOrIconShown() { 471 String current = Main.pref.get("marker.show "+getName(), "show"); 472 return "show".equalsIgnoreCase(current); 473 } 474 475 public static final class ShowHideMarkerText extends AbstractAction implements LayerAction { 476 private final transient MarkerLayer layer; 477 478 public ShowHideMarkerText(MarkerLayer layer) { 479 super(tr("Show Text/Icons"), ImageProvider.get("dialogs", "showhide")); 480 putValue(SHORT_DESCRIPTION, tr("Toggle visible state of the marker text and icons.")); 481 putValue("help", ht("/Action/ShowHideTextIcons")); 482 this.layer = layer; 483 } 484 485 @Override 486 public void actionPerformed(ActionEvent e) { 487 Main.pref.put("marker.show "+layer.getName(), layer.isTextOrIconShown() ? "hide" : "show"); 488 Main.map.mapView.repaint(); 489 } 490 491 @Override 492 public Component createMenuComponent() { 493 JCheckBoxMenuItem showMarkerTextItem = new JCheckBoxMenuItem(this); 494 showMarkerTextItem.setState(layer.isTextOrIconShown()); 495 return showMarkerTextItem; 496 } 497 498 @Override 499 public boolean supportLayers(List<Layer> layers) { 500 return layers.size() == 1 && layers.get(0) instanceof MarkerLayer; 501 } 502 } 503 504 private class SynchronizeAudio extends AbstractAction { 505 506 /** 507 * Constructs a new {@code SynchronizeAudio} action. 508 */ 509 SynchronizeAudio() { 510 super(tr("Synchronize Audio"), ImageProvider.get("audio-sync")); 511 putValue("help", ht("/Action/SynchronizeAudio")); 512 } 513 514 @Override 515 public void actionPerformed(ActionEvent e) { 516 if (!AudioPlayer.paused()) { 517 JOptionPane.showMessageDialog( 518 Main.parent, 519 tr("You need to pause audio at the moment when you hear your synchronization cue."), 520 tr("Warning"), 521 JOptionPane.WARNING_MESSAGE 522 ); 523 return; 524 } 525 AudioMarker recent = AudioMarker.recentlyPlayedMarker(); 526 if (synchronizeAudioMarkers(recent)) { 527 JOptionPane.showMessageDialog( 528 Main.parent, 529 tr("Audio synchronized at point {0}.", syncAudioMarker.getText()), 530 tr("Information"), 531 JOptionPane.INFORMATION_MESSAGE 532 ); 533 } else { 534 JOptionPane.showMessageDialog( 535 Main.parent, 536 tr("Unable to synchronize in layer being played."), 537 tr("Error"), 538 JOptionPane.ERROR_MESSAGE 539 ); 540 } 541 } 542 } 543 544 private class MoveAudio extends AbstractAction { 545 546 MoveAudio() { 547 super(tr("Make Audio Marker at Play Head"), ImageProvider.get("addmarkers")); 548 putValue("help", ht("/Action/MakeAudioMarkerAtPlayHead")); 549 } 550 551 @Override 552 public void actionPerformed(ActionEvent e) { 553 if (!AudioPlayer.paused()) { 554 JOptionPane.showMessageDialog( 555 Main.parent, 556 tr("You need to have paused audio at the point on the track where you want the marker."), 557 tr("Warning"), 558 JOptionPane.WARNING_MESSAGE 559 ); 560 return; 561 } 562 PlayHeadMarker playHeadMarker = Main.map.mapView.playHeadMarker; 563 if (playHeadMarker == null) 564 return; 565 addAudioMarker(playHeadMarker.time, playHeadMarker.getCoor()); 566 Main.map.mapView.repaint(); 567 } 568 } 569}