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