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