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