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