001// License: GPL. See LICENSE file for details. 002package org.openstreetmap.josm.actions.mapmode; 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; 007 008import java.awt.Color; 009import java.awt.Cursor; 010import java.awt.Graphics2D; 011import java.awt.Point; 012import java.awt.Stroke; 013import java.awt.event.KeyEvent; 014import java.awt.event.MouseEvent; 015import java.util.Collection; 016import java.util.LinkedHashSet; 017import java.util.Set; 018 019import javax.swing.JOptionPane; 020 021import org.openstreetmap.josm.Main; 022import org.openstreetmap.josm.data.Bounds; 023import org.openstreetmap.josm.data.SystemOfMeasurement; 024import org.openstreetmap.josm.data.Preferences.PreferenceChangeEvent; 025import org.openstreetmap.josm.data.Preferences.PreferenceChangedListener; 026import org.openstreetmap.josm.data.coor.EastNorth; 027import org.openstreetmap.josm.data.osm.Node; 028import org.openstreetmap.josm.data.osm.OsmPrimitive; 029import org.openstreetmap.josm.data.osm.Way; 030import org.openstreetmap.josm.data.osm.WaySegment; 031import org.openstreetmap.josm.data.osm.visitor.paint.PaintColors; 032import org.openstreetmap.josm.gui.MapFrame; 033import org.openstreetmap.josm.gui.MapView; 034import org.openstreetmap.josm.gui.NavigatableComponent; 035import org.openstreetmap.josm.gui.layer.Layer; 036import org.openstreetmap.josm.gui.layer.MapViewPaintable; 037import org.openstreetmap.josm.gui.layer.OsmDataLayer; 038import org.openstreetmap.josm.gui.util.GuiHelper; 039import org.openstreetmap.josm.gui.util.ModifierListener; 040import org.openstreetmap.josm.tools.Geometry; 041import org.openstreetmap.josm.tools.ImageProvider; 042import org.openstreetmap.josm.tools.Shortcut; 043 044//// TODO: (list below) 045/* == Functionality == 046 * 047 * 1. Use selected nodes as split points for the selected ways. 048 * 049 * The ways containing the selected nodes will be split and only the "inner" 050 * parts will be copied 051 * 052 * 2. Enter exact offset 053 * 054 * 3. Improve snapping 055 * 056 * 4. Visual cues could be better 057 * 058 * 5. Cursors (Half-done) 059 * 060 * 6. (long term) Parallelize and adjust offsets of existing ways 061 * 062 * == Code quality == 063 * 064 * a) The mode, flags, and modifiers might be updated more than necessary. 065 * 066 * Not a performance problem, but better if they where more centralized 067 * 068 * b) Extract generic MapMode services into a super class and/or utility class 069 * 070 * c) Maybe better to simply draw our own source way highlighting? 071 * 072 * Current code doesn't not take into account that ways might been highlighted 073 * by other than us. Don't think that situation should ever happen though. 074 */ 075 076/** 077 * MapMode for making parallel ways. 078 * 079 * All calculations are done in projected coordinates. 080 * 081 * @author Ole Jørgen Brønner (olejorgenb) 082 */ 083public class ParallelWayAction extends MapMode implements ModifierListener, MapViewPaintable, PreferenceChangedListener { 084 085 private enum Mode { 086 dragging, normal 087 } 088 089 //// Preferences and flags 090 // See updateModeLocalPreferences for defaults 091 private Mode mode; 092 private boolean copyTags; 093 private boolean copyTagsDefault; 094 095 private boolean snap; 096 private boolean snapDefault; 097 098 private double snapThreshold; 099 private double snapDistanceMetric; 100 private double snapDistanceImperial; 101 private double snapDistanceChinese; 102 private double snapDistanceNautical; 103 104 private ModifiersSpec snapModifierCombo; 105 private ModifiersSpec copyTagsModifierCombo; 106 private ModifiersSpec addToSelectionModifierCombo; 107 private ModifiersSpec toggleSelectedModifierCombo; 108 private ModifiersSpec setSelectedModifierCombo; 109 110 private int initialMoveDelay; 111 112 private final MapView mv; 113 114 // Mouse tracking state 115 private Point mousePressedPos; 116 private boolean mouseIsDown; 117 private long mousePressedTime; 118 private boolean mouseHasBeenDragged; 119 120 private WaySegment referenceSegment; 121 private ParallelWays pWays; 122 private Set<Way> sourceWays; 123 private EastNorth helperLineStart; 124 private EastNorth helperLineEnd; 125 126 Stroke helpLineStroke; 127 Stroke refLineStroke; 128 Color mainColor; 129 130 public ParallelWayAction(MapFrame mapFrame) { 131 super(tr("Parallel"), "parallel", tr("Make parallel copies of ways"), 132 Shortcut.registerShortcut("mapmode:parallel", tr("Mode: {0}", 133 tr("Parallel")), KeyEvent.VK_P, Shortcut.SHIFT), 134 mapFrame, ImageProvider.getCursor("normal", "parallel")); 135 putValue("help", ht("/Action/Parallel")); 136 mv = mapFrame.mapView; 137 updateModeLocalPreferences(); 138 Main.pref.addPreferenceChangeListener(this); 139 } 140 141 @Override 142 public void enterMode() { 143 // super.enterMode() updates the status line and cursor so we need our state to be set correctly 144 setMode(Mode.normal); 145 pWays = null; 146 updateAllPreferences(); // All default values should've been set now 147 148 super.enterMode(); 149 150 mv.addMouseListener(this); 151 mv.addMouseMotionListener(this); 152 mv.addTemporaryLayer(this); 153 154 helpLineStroke = GuiHelper.getCustomizedStroke(getStringPref("stroke.hepler-line", "1" )); 155 refLineStroke = GuiHelper.getCustomizedStroke(getStringPref("stroke.ref-line", "1 2 2")); 156 mainColor = Main.pref.getColor(marktr("make parallel helper line"), null); 157 if (mainColor == null) mainColor = PaintColors.SELECTED.get(); 158 159 //// Needed to update the mouse cursor if modifiers are changed when the mouse is motionless 160 Main.map.keyDetector.addModifierListener(this); 161 sourceWays = new LinkedHashSet<>(getCurrentDataSet().getSelectedWays()); 162 for (Way w : sourceWays) { 163 w.setHighlighted(true); 164 } 165 mv.repaint(); 166 } 167 168 @Override 169 public void exitMode() { 170 super.exitMode(); 171 mv.removeMouseListener(this); 172 mv.removeMouseMotionListener(this); 173 mv.removeTemporaryLayer(this); 174 Main.map.statusLine.setDist(-1); 175 Main.map.statusLine.repaint(); 176 Main.map.keyDetector.removeModifierListener(this); 177 removeWayHighlighting(sourceWays); 178 pWays = null; 179 sourceWays = null; 180 referenceSegment = null; 181 mv.repaint(); 182 } 183 184 @Override 185 public String getModeHelpText() { 186 // TODO: add more detailed feedback based on modifier state. 187 // TODO: dynamic messages based on preferences. (Could be problematic translation wise) 188 switch (mode) { 189 case normal: 190 return tr("Select ways as in Select mode. Drag selected ways or a single way to create a parallel copy (Alt toggles tag preservation)"); 191 case dragging: 192 return tr("Hold Ctrl to toggle snapping"); 193 } 194 return ""; // impossible .. 195 } 196 197 // Separated due to "race condition" between default values 198 private void updateAllPreferences() { 199 updateModeLocalPreferences(); 200 // @formatter:off 201 // @formatter:on 202 } 203 204 private void updateModeLocalPreferences() { 205 // @formatter:off 206 snapThreshold = Main.pref.getDouble (prefKey("snap-threshold-percent"), 0.70); 207 snapDefault = Main.pref.getBoolean(prefKey("snap-default"), true); 208 copyTagsDefault = Main.pref.getBoolean(prefKey("copy-tags-default"), true); 209 initialMoveDelay = Main.pref.getInteger(prefKey("initial-move-delay"), 200); 210 snapDistanceMetric = Main.pref.getDouble(prefKey("snap-distance-metric"), 0.5); 211 snapDistanceImperial = Main.pref.getDouble(prefKey("snap-distance-imperial"), 1); 212 snapDistanceChinese = Main.pref.getDouble(prefKey("snap-distance-chinese"), 1); 213 snapDistanceNautical = Main.pref.getDouble(prefKey("snap-distance-nautical"), 0.1); 214 215 snapModifierCombo = new ModifiersSpec(getStringPref("snap-modifier-combo", "?sC")); 216 copyTagsModifierCombo = new ModifiersSpec(getStringPref("copy-tags-modifier-combo", "As?")); 217 addToSelectionModifierCombo = new ModifiersSpec(getStringPref("add-to-selection-modifier-combo", "aSc")); 218 toggleSelectedModifierCombo = new ModifiersSpec(getStringPref("toggle-selection-modifier-combo", "asC")); 219 setSelectedModifierCombo = new ModifiersSpec(getStringPref("set-selection-modifier-combo", "asc")); 220 // @formatter:on 221 } 222 223 @Override 224 public boolean layerIsSupported(Layer layer) { 225 return layer instanceof OsmDataLayer; 226 } 227 228 @Override 229 public void modifiersChanged(int modifiers) { 230 if (Main.map == null || mv == null || !mv.isActiveLayerDrawable()) 231 return; 232 233 // Should only get InputEvents due to the mask in enterMode 234 if (updateModifiersState(modifiers)) { 235 updateStatusLine(); 236 updateCursor(); 237 } 238 } 239 240 private boolean updateModifiersState(int modifiers) { 241 boolean oldAlt = alt, oldShift = shift, oldCtrl = ctrl; 242 updateKeyModifiers(modifiers); 243 return (oldAlt != alt || oldShift != shift || oldCtrl != ctrl); 244 } 245 246 private void updateCursor() { 247 Cursor newCursor = null; 248 switch (mode) { 249 case normal: 250 if (matchesCurrentModifiers(setSelectedModifierCombo)) { 251 newCursor = ImageProvider.getCursor("normal", "parallel"); 252 } else if (matchesCurrentModifiers(addToSelectionModifierCombo)) { 253 newCursor = ImageProvider.getCursor("normal", "parallel"); // FIXME 254 } else if (matchesCurrentModifiers(toggleSelectedModifierCombo)) { 255 newCursor = ImageProvider.getCursor("normal", "parallel"); // FIXME 256 } else { 257 // TODO: set to a cursor indicating an error 258 } 259 break; 260 case dragging: 261 if (snap) { 262 // TODO: snapping cursor? 263 newCursor = Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR); 264 } else { 265 newCursor = Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR); 266 } 267 } 268 if (newCursor != null) { 269 mv.setNewCursor(newCursor, this); 270 } 271 } 272 273 private void setMode(Mode mode) { 274 this.mode = mode; 275 updateCursor(); 276 updateStatusLine(); 277 } 278 279 private boolean sanityCheck() { 280 // @formatter:off 281 boolean areWeSane = 282 mv.isActiveLayerVisible() && 283 mv.isActiveLayerDrawable() && 284 ((Boolean) this.getValue("active")); 285 // @formatter:on 286 assert (areWeSane); // mad == bad 287 return areWeSane; 288 } 289 290 @Override 291 public void mousePressed(MouseEvent e) { 292 requestFocusInMapView(); 293 updateModifiersState(e.getModifiers()); 294 // Other buttons are off limit, but we still get events. 295 if (e.getButton() != MouseEvent.BUTTON1) 296 return; 297 298 if (!sanityCheck()) 299 return; 300 301 updateFlagsOnlyChangeableOnPress(); 302 updateFlagsChangeableAlways(); 303 304 // Since the created way is left selected, we need to unselect again here 305 if (pWays != null && pWays.ways != null) { 306 getCurrentDataSet().clearSelection(pWays.ways); 307 pWays = null; 308 } 309 310 mouseIsDown = true; 311 mousePressedPos = e.getPoint(); 312 mousePressedTime = System.currentTimeMillis(); 313 314 } 315 316 @Override 317 public void mouseReleased(MouseEvent e) { 318 updateModifiersState(e.getModifiers()); 319 // Other buttons are off limit, but we still get events. 320 if (e.getButton() != MouseEvent.BUTTON1) 321 return; 322 323 if (!mouseHasBeenDragged) { 324 // use point from press or click event? (or are these always the same) 325 Way nearestWay = mv.getNearestWay(e.getPoint(), OsmPrimitive.isSelectablePredicate); 326 if (nearestWay == null) { 327 if (matchesCurrentModifiers(setSelectedModifierCombo)) { 328 clearSourceWays(); 329 } 330 resetMouseTrackingState(); 331 return; 332 } 333 boolean isSelected = nearestWay.isSelected(); 334 if (matchesCurrentModifiers(addToSelectionModifierCombo)) { 335 if (!isSelected) { 336 addSourceWay(nearestWay); 337 } 338 } else if (matchesCurrentModifiers(toggleSelectedModifierCombo)) { 339 if (isSelected) { 340 removeSourceWay(nearestWay); 341 } else { 342 addSourceWay(nearestWay); 343 } 344 } else if (matchesCurrentModifiers(setSelectedModifierCombo)) { 345 clearSourceWays(); 346 addSourceWay(nearestWay); 347 } // else -> invalid modifier combination 348 } else if (mode == Mode.dragging) { 349 clearSourceWays(); 350 } 351 352 setMode(Mode.normal); 353 resetMouseTrackingState(); 354 mv.repaint(); 355 } 356 357 private void removeWayHighlighting(Collection<Way> ways) { 358 if (ways == null) 359 return; 360 for (Way w : ways) { 361 w.setHighlighted(false); 362 } 363 } 364 365 @Override 366 public void mouseDragged(MouseEvent e) { 367 // WTF.. the event passed here doesn't have button info? 368 // Since we get this event from other buttons too, we must check that 369 // _BUTTON1_ is down. 370 if (!mouseIsDown) 371 return; 372 373 boolean modifiersChanged = updateModifiersState(e.getModifiers()); 374 updateFlagsChangeableAlways(); 375 376 if (modifiersChanged) { 377 // Since this could be remotely slow, do it conditionally 378 updateStatusLine(); 379 updateCursor(); 380 } 381 382 if ((System.currentTimeMillis() - mousePressedTime) < initialMoveDelay) 383 return; 384 // Assuming this event only is emitted when the mouse has moved 385 // Setting this after the check above means we tolerate clicks with some movement 386 mouseHasBeenDragged = true; 387 388 Point p = e.getPoint(); 389 if (mode == Mode.normal) { 390 // Should we ensure that the copyTags modifiers are still valid? 391 392 // Important to use mouse position from the press, since the drag 393 // event can come quite late 394 if (!isModifiersValidForDragMode()) 395 return; 396 if (!initParallelWays(mousePressedPos, copyTags)) 397 return; 398 setMode(Mode.dragging); 399 } 400 401 // Calculate distance to the reference line 402 EastNorth enp = mv.getEastNorth((int) p.getX(), (int) p.getY()); 403 EastNorth nearestPointOnRefLine = Geometry.closestPointToLine(referenceSegment.getFirstNode().getEastNorth(), 404 referenceSegment.getSecondNode().getEastNorth(), enp); 405 406 // Note: d is the distance in _projected units_ 407 double d = enp.distance(nearestPointOnRefLine); 408 double realD = mv.getProjection().eastNorth2latlon(enp).greatCircleDistance(mv.getProjection().eastNorth2latlon(nearestPointOnRefLine)); 409 double snappedRealD = realD; 410 411 // TODO: abuse of isToTheRightSideOfLine function. 412 boolean toTheRight = Geometry.isToTheRightSideOfLine(referenceSegment.getFirstNode(), 413 referenceSegment.getFirstNode(), referenceSegment.getSecondNode(), new Node(enp)); 414 415 if (snap) { 416 // TODO: Very simple snapping 417 // - Snap steps relative to the distance? 418 double snapDistance; 419 SystemOfMeasurement som = NavigatableComponent.getSystemOfMeasurement(); 420 if (som.equals(SystemOfMeasurement.CHINESE)) { 421 snapDistance = snapDistanceChinese * SystemOfMeasurement.CHINESE.aValue; 422 } else if (som.equals(SystemOfMeasurement.IMPERIAL)) { 423 snapDistance = snapDistanceImperial * SystemOfMeasurement.IMPERIAL.aValue; 424 } else if (som.equals(SystemOfMeasurement.NAUTICAL_MILE)) { 425 snapDistance = snapDistanceNautical * SystemOfMeasurement.NAUTICAL_MILE.aValue; 426 } else { 427 snapDistance = snapDistanceMetric; // Metric system by default 428 } 429 double closestWholeUnit; 430 double modulo = realD % snapDistance; 431 if (modulo < snapDistance/2.0) { 432 closestWholeUnit = realD - modulo; 433 } else { 434 closestWholeUnit = realD + (snapDistance-modulo); 435 } 436 if (Math.abs(closestWholeUnit - realD) < (snapThreshold * snapDistance)) { 437 snappedRealD = closestWholeUnit; 438 } else { 439 snappedRealD = closestWholeUnit + Math.signum(realD - closestWholeUnit) * snapDistance; 440 } 441 } 442 d = snappedRealD * (d/realD); // convert back to projected distance. (probably ok on small scales) 443 helperLineStart = nearestPointOnRefLine; 444 helperLineEnd = enp; 445 if (toTheRight) { 446 d = -d; 447 } 448 pWays.changeOffset(d); 449 450 Main.map.statusLine.setDist(Math.abs(snappedRealD)); 451 Main.map.statusLine.repaint(); 452 mv.repaint(); 453 } 454 455 private boolean matchesCurrentModifiers(ModifiersSpec spec) { 456 return spec.matchWithKnown(alt, shift, ctrl); 457 } 458 459 @Override 460 public void paint(Graphics2D g, MapView mv, Bounds bbox) { 461 if (mode == Mode.dragging) { 462 // sanity checks 463 if (mv == null) 464 return; 465 466 // FIXME: should clip the line (gets insanely slow when zoomed in on a very long line 467 g.setStroke(refLineStroke); 468 g.setColor(mainColor); 469 Point p1 = mv.getPoint(referenceSegment.getFirstNode().getEastNorth()); 470 Point p2 = mv.getPoint(referenceSegment.getSecondNode().getEastNorth()); 471 g.drawLine(p1.x, p1.y, p2.x, p2.y); 472 473 g.setStroke(helpLineStroke); 474 g.setColor(mainColor); 475 p1 = mv.getPoint(helperLineStart); 476 p2 = mv.getPoint(helperLineEnd); 477 g.drawLine(p1.x, p1.y, p2.x, p2.y); 478 } 479 } 480 481 private boolean isModifiersValidForDragMode() { 482 return (!alt && !shift && !ctrl) || matchesCurrentModifiers(snapModifierCombo) 483 || matchesCurrentModifiers(copyTagsModifierCombo); 484 } 485 486 private void updateFlagsOnlyChangeableOnPress() { 487 copyTags = copyTagsDefault != matchesCurrentModifiers(copyTagsModifierCombo); 488 } 489 490 private void updateFlagsChangeableAlways() { 491 snap = snapDefault != matchesCurrentModifiers(snapModifierCombo); 492 } 493 494 //// We keep the source ways and the selection in sync so the user can see the source way's tags 495 private void addSourceWay(Way w) { 496 assert (sourceWays != null); 497 getCurrentDataSet().addSelected(w); 498 w.setHighlighted(true); 499 sourceWays.add(w); 500 } 501 502 private void removeSourceWay(Way w) { 503 assert (sourceWays != null); 504 getCurrentDataSet().clearSelection(w); 505 w.setHighlighted(false); 506 sourceWays.remove(w); 507 } 508 509 private void clearSourceWays() { 510 assert (sourceWays != null); 511 getCurrentDataSet().clearSelection(sourceWays); 512 for (Way w : sourceWays) { 513 w.setHighlighted(false); 514 } 515 sourceWays.clear(); 516 } 517 518 private void resetMouseTrackingState() { 519 mouseIsDown = false; 520 mousePressedPos = null; 521 mouseHasBeenDragged = false; 522 } 523 524 // TODO: rename 525 private boolean initParallelWays(Point p, boolean copyTags) { 526 referenceSegment = mv.getNearestWaySegment(p, Way.isUsablePredicate, true); 527 if (referenceSegment == null) 528 return false; 529 530 if (!sourceWays.contains(referenceSegment.way)) { 531 clearSourceWays(); 532 addSourceWay(referenceSegment.way); 533 } 534 535 try { 536 int referenceWayIndex = -1; 537 int i = 0; 538 for (Way w : sourceWays) { 539 if (w == referenceSegment.way) { 540 referenceWayIndex = i; 541 break; 542 } 543 i++; 544 } 545 pWays = new ParallelWays(sourceWays, copyTags, referenceWayIndex); 546 pWays.commit(); 547 getCurrentDataSet().setSelected(pWays.ways); 548 return true; 549 } catch (IllegalArgumentException e) { 550 // TODO: Not ideal feedback. Maybe changing the cursor could be a good mechanism? 551 JOptionPane.showMessageDialog( 552 Main.parent, 553 tr("ParallelWayAction\n" + 554 "The ways selected must form a simple branchless path"), 555 tr("Make parallel way error"), 556 JOptionPane.INFORMATION_MESSAGE); 557 // The error dialog prevents us from getting the mouseReleased event 558 resetMouseTrackingState(); 559 pWays = null; 560 return false; 561 } 562 } 563 564 private String prefKey(String subKey) { 565 return "edit.make-parallel-way-action." + subKey; 566 } 567 568 private String getStringPref(String subKey, String def) { 569 return Main.pref.get(prefKey(subKey), def); 570 } 571 572 @Override 573 public void preferenceChanged(PreferenceChangeEvent e) { 574 if (e.getKey().startsWith(prefKey(""))) { 575 updateAllPreferences(); 576 } 577 } 578 579 @Override 580 public void destroy() { 581 super.destroy(); 582 Main.pref.removePreferenceChangeListener(this); 583 } 584}