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