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