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 if (e.getPoint() != null) { 150 for (Marker mkr : data) { 151 if (mkr.containsPoint(e.getPoint())) { 152 mousePressedInButton = true; 153 break; 154 } 155 } 156 } 157 if (!mousePressedInButton) 158 return; 159 mousePressed = true; 160 if (isVisible()) { 161 invalidate(); 162 } 163 } 164 165 @Override 166 public void mouseReleased(MouseEvent ev) { 167 if (ev.getButton() != MouseEvent.BUTTON1 || !mousePressed) 168 return; 169 mousePressed = false; 170 if (!isVisible()) 171 return; 172 if (ev.getPoint() != null) { 173 for (Marker mkr : data) { 174 if (mkr.containsPoint(ev.getPoint())) { 175 mkr.actionPerformed(new ActionEvent(this, 0, null)); 176 } 177 } 178 } 179 invalidate(); 180 } 181 }); 182 183 if (event.getMapView().playHeadMarker == null) { 184 event.getMapView().playHeadMarker = PlayHeadMarker.create(); 185 } 186 187 return super.attachToMapView(event); 188 } 189 190 /** 191 * Return a static icon. 192 */ 193 @Override 194 public Icon getIcon() { 195 return ImageProvider.get("layer", "marker_small"); 196 } 197 198 @Override 199 protected ColorProperty getBaseColorProperty() { 200 return COLOR_PROPERTY; 201 } 202 203 /* for preferences */ 204 public static Color getGenericColor() { 205 return COLOR_PROPERTY.get(); 206 } 207 208 @Override 209 public void paint(Graphics2D g, MapView mv, Bounds box) { 210 boolean showTextOrIcon = isTextOrIconShown(); 211 g.setColor(getColorProperty().get()); 212 213 if (mousePressed) { 214 boolean mousePressedTmp = mousePressed; 215 Point mousePos = mv.getMousePosition(); // Get mouse position only when necessary (it's the slowest part of marker layer painting) 216 for (Marker mkr : data) { 217 if (mousePos != null && mkr.containsPoint(mousePos)) { 218 mkr.paint(g, mv, mousePressedTmp, showTextOrIcon); 219 mousePressedTmp = false; 220 } 221 } 222 } else { 223 for (Marker mkr : data) { 224 mkr.paint(g, mv, false, showTextOrIcon); 225 } 226 } 227 } 228 229 @Override 230 public String getToolTipText() { 231 return Integer.toString(data.size())+' '+trn("marker", "markers", data.size()); 232 } 233 234 @Override 235 public void mergeFrom(Layer from) { 236 if (from instanceof MarkerLayer) { 237 data.addAll(((MarkerLayer) from).data); 238 data.sort(Comparator.comparingDouble(o -> o.time)); 239 } 240 } 241 242 @Override public boolean isMergable(Layer other) { 243 return other instanceof MarkerLayer; 244 } 245 246 @Override public void visitBoundingBox(BoundingXYVisitor v) { 247 for (Marker mkr : data) { 248 v.visit(mkr.getEastNorth()); 249 } 250 } 251 252 @Override public Object getInfoComponent() { 253 return "<html>"+trn("{0} consists of {1} marker", "{0} consists of {1} markers", data.size(), getName(), data.size()) + "</html>"; 254 } 255 256 @Override public Action[] getMenuEntries() { 257 Collection<Action> components = new ArrayList<>(); 258 components.add(LayerListDialog.getInstance().createShowHideLayerAction()); 259 components.add(new ShowHideMarkerText(this)); 260 components.add(LayerListDialog.getInstance().createDeleteLayerAction()); 261 components.add(LayerListDialog.getInstance().createMergeLayerAction(this)); 262 components.add(SeparatorLayerAction.INSTANCE); 263 components.add(new CustomizeColor(this)); 264 components.add(SeparatorLayerAction.INSTANCE); 265 components.add(new SynchronizeAudio()); 266 if (Main.pref.getBoolean("marker.traceaudio", true)) { 267 components.add(new MoveAudio()); 268 } 269 components.add(new JumpToNextMarker(this)); 270 components.add(new JumpToPreviousMarker(this)); 271 components.add(new ConvertToDataLayerAction.FromMarkerLayer(this)); 272 components.add(new RenameLayerAction(getAssociatedFile(), this)); 273 components.add(SeparatorLayerAction.INSTANCE); 274 components.add(new LayerListPopup.InfoAction(this)); 275 return components.toArray(new Action[components.size()]); 276 } 277 278 public boolean synchronizeAudioMarkers(final AudioMarker startMarker) { 279 syncAudioMarker = startMarker; 280 if (syncAudioMarker != null && !data.contains(syncAudioMarker)) { 281 syncAudioMarker = null; 282 } 283 if (syncAudioMarker == null) { 284 // find the first audioMarker in this layer 285 for (Marker m : data) { 286 if (m instanceof AudioMarker) { 287 syncAudioMarker = (AudioMarker) m; 288 break; 289 } 290 } 291 } 292 if (syncAudioMarker == null) 293 return false; 294 295 // apply adjustment to all subsequent audio markers in the layer 296 double adjustment = AudioPlayer.position() - syncAudioMarker.offset; // in seconds 297 boolean seenStart = false; 298 try { 299 URI uri = syncAudioMarker.url().toURI(); 300 for (Marker m : data) { 301 if (m == syncAudioMarker) { 302 seenStart = true; 303 } 304 if (seenStart && m instanceof AudioMarker) { 305 AudioMarker ma = (AudioMarker) m; 306 // Do not ever call URL.equals but use URI.equals instead to avoid Internet connection 307 // See http://michaelscharf.blogspot.fr/2006/11/javaneturlequals-and-hashcode-make.html for details 308 if (ma.url().toURI().equals(uri)) { 309 ma.adjustOffset(adjustment); 310 } 311 } 312 } 313 } catch (URISyntaxException e) { 314 Main.warn(e); 315 } 316 return true; 317 } 318 319 public AudioMarker addAudioMarker(double time, LatLon coor) { 320 // find first audio marker to get absolute start time 321 double offset = 0.0; 322 AudioMarker am = null; 323 for (Marker m : data) { 324 if (m.getClass() == AudioMarker.class) { 325 am = (AudioMarker) m; 326 offset = time - am.time; 327 break; 328 } 329 } 330 if (am == null) { 331 JOptionPane.showMessageDialog( 332 Main.parent, 333 tr("No existing audio markers in this layer to offset from."), 334 tr("Error"), 335 JOptionPane.ERROR_MESSAGE 336 ); 337 return null; 338 } 339 340 // make our new marker 341 AudioMarker newAudioMarker = new AudioMarker(coor, 342 null, AudioPlayer.url(), this, time, offset); 343 344 // insert it at the right place in a copy the collection 345 Collection<Marker> newData = new ArrayList<>(); 346 am = null; 347 AudioMarker ret = newAudioMarker; // save to have return value 348 for (Marker m : data) { 349 if (m.getClass() == AudioMarker.class) { 350 am = (AudioMarker) m; 351 if (newAudioMarker != null && offset < am.offset) { 352 newAudioMarker.adjustOffset(am.syncOffset()); // i.e. same as predecessor 353 newData.add(newAudioMarker); 354 newAudioMarker = null; 355 } 356 } 357 newData.add(m); 358 } 359 360 if (newAudioMarker != null) { 361 if (am != null) { 362 newAudioMarker.adjustOffset(am.syncOffset()); // i.e. same as predecessor 363 } 364 newData.add(newAudioMarker); // insert at end 365 } 366 367 // replace the collection 368 data.clear(); 369 data.addAll(newData); 370 return ret; 371 } 372 373 @Override 374 public void jumpToNextMarker() { 375 if (currentMarker == null) { 376 currentMarker = data.get(0); 377 } else { 378 boolean foundCurrent = false; 379 for (Marker m: data) { 380 if (foundCurrent) { 381 currentMarker = m; 382 break; 383 } else if (currentMarker == m) { 384 foundCurrent = true; 385 } 386 } 387 } 388 Main.map.mapView.zoomTo(currentMarker.getEastNorth()); 389 } 390 391 @Override 392 public void jumpToPreviousMarker() { 393 if (currentMarker == null) { 394 currentMarker = data.get(data.size() - 1); 395 } else { 396 boolean foundCurrent = false; 397 for (int i = data.size() - 1; i >= 0; i--) { 398 Marker m = data.get(i); 399 if (foundCurrent) { 400 currentMarker = m; 401 break; 402 } else if (currentMarker == m) { 403 foundCurrent = true; 404 } 405 } 406 } 407 Main.map.mapView.zoomTo(currentMarker.getEastNorth()); 408 } 409 410 public static void playAudio() { 411 playAdjacentMarker(null, true); 412 } 413 414 public static void playNextMarker() { 415 playAdjacentMarker(AudioMarker.recentlyPlayedMarker(), true); 416 } 417 418 public static void playPreviousMarker() { 419 playAdjacentMarker(AudioMarker.recentlyPlayedMarker(), false); 420 } 421 422 private static Marker getAdjacentMarker(Marker startMarker, boolean next, Layer layer) { 423 Marker previousMarker = null; 424 boolean nextTime = false; 425 if (layer.getClass() == MarkerLayer.class) { 426 MarkerLayer markerLayer = (MarkerLayer) layer; 427 for (Marker marker : markerLayer.data) { 428 if (marker == startMarker) { 429 if (next) { 430 nextTime = true; 431 } else { 432 if (previousMarker == null) { 433 previousMarker = startMarker; // if no previous one, play the first one again 434 } 435 return previousMarker; 436 } 437 } else if (marker.getClass() == AudioMarker.class) { 438 if (nextTime || startMarker == null) 439 return marker; 440 previousMarker = marker; 441 } 442 } 443 if (nextTime) // there was no next marker in that layer, so play the last one again 444 return startMarker; 445 } 446 return null; 447 } 448 449 private static void playAdjacentMarker(Marker startMarker, boolean next) { 450 if (!Main.isDisplayingMapView()) 451 return; 452 Marker m = null; 453 Layer l = Main.getLayerManager().getActiveLayer(); 454 if (l != null) { 455 m = getAdjacentMarker(startMarker, next, l); 456 } 457 if (m == null) { 458 for (Layer layer : Main.getLayerManager().getLayers()) { 459 m = getAdjacentMarker(startMarker, next, layer); 460 if (m != null) { 461 break; 462 } 463 } 464 } 465 if (m != null) { 466 ((AudioMarker) m).play(); 467 } 468 } 469 470 /** 471 * Get state of text display. 472 * @return <code>true</code> if text should be shown, <code>false</code> otherwise. 473 */ 474 private boolean isTextOrIconShown() { 475 String current = Main.pref.get("marker.show "+getName(), "show"); 476 return "show".equalsIgnoreCase(current); 477 } 478 479 public static final class ShowHideMarkerText extends AbstractAction implements LayerAction { 480 private final transient MarkerLayer layer; 481 482 public ShowHideMarkerText(MarkerLayer layer) { 483 super(tr("Show Text/Icons"), ImageProvider.get("dialogs", "showhide")); 484 putValue(SHORT_DESCRIPTION, tr("Toggle visible state of the marker text and icons.")); 485 putValue("help", ht("/Action/ShowHideTextIcons")); 486 this.layer = layer; 487 } 488 489 @Override 490 public void actionPerformed(ActionEvent e) { 491 Main.pref.put("marker.show "+layer.getName(), layer.isTextOrIconShown() ? "hide" : "show"); 492 Main.map.mapView.repaint(); 493 } 494 495 @Override 496 public Component createMenuComponent() { 497 JCheckBoxMenuItem showMarkerTextItem = new JCheckBoxMenuItem(this); 498 showMarkerTextItem.setState(layer.isTextOrIconShown()); 499 return showMarkerTextItem; 500 } 501 502 @Override 503 public boolean supportLayers(List<Layer> layers) { 504 return layers.size() == 1 && layers.get(0) instanceof MarkerLayer; 505 } 506 } 507 508 private class SynchronizeAudio extends AbstractAction { 509 510 /** 511 * Constructs a new {@code SynchronizeAudio} action. 512 */ 513 SynchronizeAudio() { 514 super(tr("Synchronize Audio"), ImageProvider.get("audio-sync")); 515 putValue("help", ht("/Action/SynchronizeAudio")); 516 } 517 518 @Override 519 public void actionPerformed(ActionEvent e) { 520 if (!AudioPlayer.paused()) { 521 JOptionPane.showMessageDialog( 522 Main.parent, 523 tr("You need to pause audio at the moment when you hear your synchronization cue."), 524 tr("Warning"), 525 JOptionPane.WARNING_MESSAGE 526 ); 527 return; 528 } 529 AudioMarker recent = AudioMarker.recentlyPlayedMarker(); 530 if (synchronizeAudioMarkers(recent)) { 531 JOptionPane.showMessageDialog( 532 Main.parent, 533 tr("Audio synchronized at point {0}.", syncAudioMarker.getText()), 534 tr("Information"), 535 JOptionPane.INFORMATION_MESSAGE 536 ); 537 } else { 538 JOptionPane.showMessageDialog( 539 Main.parent, 540 tr("Unable to synchronize in layer being played."), 541 tr("Error"), 542 JOptionPane.ERROR_MESSAGE 543 ); 544 } 545 } 546 } 547 548 private class MoveAudio extends AbstractAction { 549 550 MoveAudio() { 551 super(tr("Make Audio Marker at Play Head"), ImageProvider.get("addmarkers")); 552 putValue("help", ht("/Action/MakeAudioMarkerAtPlayHead")); 553 } 554 555 @Override 556 public void actionPerformed(ActionEvent e) { 557 if (!AudioPlayer.paused()) { 558 JOptionPane.showMessageDialog( 559 Main.parent, 560 tr("You need to have paused audio at the point on the track where you want the marker."), 561 tr("Warning"), 562 JOptionPane.WARNING_MESSAGE 563 ); 564 return; 565 } 566 PlayHeadMarker playHeadMarker = Main.map.mapView.playHeadMarker; 567 if (playHeadMarker == null) 568 return; 569 addAudioMarker(playHeadMarker.time, playHeadMarker.getCoor()); 570 Main.map.mapView.repaint(); 571 } 572 } 573}