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