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