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