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