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