001 // License: GPL. See LICENSE file for details. 002 package org.openstreetmap.josm.actions.mapmode; 003 004 import static org.openstreetmap.josm.gui.help.HelpUtil.ht; 005 import static org.openstreetmap.josm.tools.I18n.tr; 006 007 import java.awt.AWTEvent; 008 import java.awt.BasicStroke; 009 import java.awt.Color; 010 import java.awt.Cursor; 011 import java.awt.Graphics2D; 012 import java.awt.Point; 013 import java.awt.Stroke; 014 import java.awt.Toolkit; 015 import java.awt.event.AWTEventListener; 016 import java.awt.event.InputEvent; 017 import java.awt.event.KeyEvent; 018 import java.awt.event.MouseEvent; 019 import java.util.Collection; 020 import java.util.LinkedHashSet; 021 022 import javax.swing.JOptionPane; 023 024 import org.openstreetmap.josm.Main; 025 import org.openstreetmap.josm.data.Bounds; 026 import org.openstreetmap.josm.data.coor.EastNorth; 027 import org.openstreetmap.josm.data.osm.Node; 028 import org.openstreetmap.josm.data.osm.OsmPrimitive; 029 import org.openstreetmap.josm.data.osm.Way; 030 import org.openstreetmap.josm.data.osm.WaySegment; 031 import org.openstreetmap.josm.gui.MapFrame; 032 import org.openstreetmap.josm.gui.MapView; 033 import org.openstreetmap.josm.gui.NavigatableComponent; 034 import org.openstreetmap.josm.gui.NavigatableComponent.SystemOfMeasurement; 035 import org.openstreetmap.josm.gui.layer.Layer; 036 import org.openstreetmap.josm.gui.layer.MapViewPaintable; 037 import org.openstreetmap.josm.gui.layer.OsmDataLayer; 038 import org.openstreetmap.josm.tools.Geometry; 039 import org.openstreetmap.josm.tools.ImageProvider; 040 import org.openstreetmap.josm.tools.Shortcut; 041 042 //// TODO: (list below) 043 /* == Functionality == 044 * 045 * 1. Use selected nodes as split points for the selected ways. 046 * 047 * The ways containing the selected nodes will be split and only the "inner" 048 * parts will be copied 049 * 050 * 2. Enter exact offset 051 * 052 * 3. Improve snapping 053 * 054 * 4. Visual cues could be better 055 * 056 * 5. Cursors (Half-done) 057 * 058 * 6. (long term) Parallelize and adjust offsets of existing ways 059 * 060 * == Code quality == 061 * 062 * a) The mode, flags, and modifiers might be updated more than necessary. 063 * 064 * Not a performance problem, but better if they where more centralized 065 * 066 * b) Extract generic MapMode services into a super class and/or utility class 067 * 068 * c) Maybe better to simply draw our own source way highlighting? 069 * 070 * Current code doesn't not take into account that ways might been highlighted 071 * by other than us. Don't think that situation should ever happen though. 072 */ 073 074 /** 075 * MapMode for making parallel ways. 076 * 077 * All calculations are done in projected coordinates. 078 * 079 * @author Ole J??rgen Br??nner (olejorgenb) 080 */ 081 public class ParallelWayAction extends MapMode implements AWTEventListener, MapViewPaintable { 082 083 private static final long serialVersionUID = 1L; 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 103 private ModifiersSpec snapModifierCombo; 104 private ModifiersSpec copyTagsModifierCombo; 105 private ModifiersSpec addToSelectionModifierCombo; 106 private ModifiersSpec toggleSelectedModifierCombo; 107 private ModifiersSpec setSelectedModifierCombo; 108 109 private int initialMoveDelay; 110 111 private final MapView mv; 112 113 // Mouse tracking state 114 private Point mousePressedPos; 115 private boolean mouseIsDown; 116 private long mousePressedTime; 117 private boolean mouseHasBeenDragged; 118 119 private WaySegment referenceSegment; 120 private ParallelWays pWays; 121 LinkedHashSet<Way> sourceWays; 122 private EastNorth helperLineStart; 123 private EastNorth helperLineEnd; 124 125 public ParallelWayAction(MapFrame mapFrame) { 126 super(tr("Parallel"), "parallel", tr("Make parallel copies of ways"), 127 Shortcut.registerShortcut("mapmode:parallel", tr("Mode: {0}", 128 tr("Parallel")), KeyEvent.VK_P, Shortcut.SHIFT), 129 mapFrame, ImageProvider.getCursor("normal", "parallel")); 130 putValue("help", ht("/Action/Parallel")); 131 mv = mapFrame.mapView; 132 updateModeLocalPreferences(); 133 } 134 135 @Override 136 public void enterMode() { 137 // super.enterMode() updates the status line and cursor so we need our state to be set correctly 138 setMode(Mode.normal); 139 pWays = null; 140 updateAllPreferences(); // All default values should've been set now 141 142 super.enterMode(); 143 144 mv.addMouseListener(this); 145 mv.addMouseMotionListener(this); 146 mv.addTemporaryLayer(this); 147 148 //// Needed to update the mouse cursor if modifiers are changed when the mouse is motionless 149 try { 150 Toolkit.getDefaultToolkit().addAWTEventListener(this, AWTEvent.KEY_EVENT_MASK); 151 } catch (SecurityException ex) { 152 } 153 sourceWays = new LinkedHashSet<Way>(getCurrentDataSet().getSelectedWays()); 154 for (Way w : sourceWays) { 155 w.setHighlighted(true); 156 } 157 mv.repaint(); 158 } 159 160 @Override 161 public void exitMode() { 162 super.exitMode(); 163 mv.removeMouseListener(this); 164 mv.removeMouseMotionListener(this); 165 mv.removeTemporaryLayer(this); 166 Main.map.statusLine.setDist(-1); 167 Main.map.statusLine.repaint(); 168 try { 169 Toolkit.getDefaultToolkit().removeAWTEventListener(this); 170 } catch (SecurityException ex) { 171 } 172 removeWayHighlighting(sourceWays); 173 pWays = null; 174 sourceWays = null; 175 referenceSegment = null; 176 mv.repaint(); 177 } 178 179 @Override 180 public String getModeHelpText() { 181 // TODO: add more detailed feedback based on modifier state. 182 // TODO: dynamic messages based on preferences. (Could be problematic translation wise) 183 switch (mode) { 184 case normal: 185 return tr("Select ways as in Select mode. Drag selected ways or a single way to create a parallel copy (Alt toggles tag preservation)"); 186 case dragging: 187 return tr("Hold Ctrl to toggle snapping"); 188 } 189 return ""; // impossible .. 190 } 191 192 // Separated due to "race condition" between default values 193 private void updateAllPreferences() { 194 updateModeLocalPreferences(); 195 // @formatter:off 196 // @formatter:on 197 } 198 199 private void updateModeLocalPreferences() { 200 // @formatter:off 201 //snapThreshold = Main.pref.getDouble (prefKey("snap-threshold"), 0.35); // Old preference was stored in meters, hence the new name (percent) 202 snapThreshold = Main.pref.getDouble (prefKey("snap-threshold-percent"), 0.70); 203 snapDefault = Main.pref.getBoolean(prefKey("snap-default"), true); 204 copyTagsDefault = Main.pref.getBoolean(prefKey("copy-tags-default"), true); 205 initialMoveDelay = Main.pref.getInteger(prefKey("initial-move-delay"), 200); 206 snapDistanceMetric = Main.pref.getDouble(prefKey("snap-distance-metric"), 0.5); 207 snapDistanceImperial = Main.pref.getDouble(prefKey("snap-distance-imperial"), 1); 208 snapDistanceChinese = Main.pref.getDouble(prefKey("snap-distance-chinese"), 1); 209 210 snapModifierCombo = new ModifiersSpec(getStringPref("snap-modifier-combo", "?sC")); 211 copyTagsModifierCombo = new ModifiersSpec(getStringPref("copy-tags-modifier-combo", "As?")); 212 addToSelectionModifierCombo = new ModifiersSpec(getStringPref("add-to-selection-modifier-combo", "aSc")); 213 toggleSelectedModifierCombo = new ModifiersSpec(getStringPref("toggle-selection-modifier-combo", "asC")); 214 setSelectedModifierCombo = new ModifiersSpec(getStringPref("set-selection-modifier-combo", "asc")); 215 // @formatter:on 216 } 217 218 @Override 219 public boolean layerIsSupported(Layer layer) { 220 return layer instanceof OsmDataLayer; 221 } 222 223 @Override 224 public void eventDispatched(AWTEvent e) { 225 if (Main.map == null || mv == null || !mv.isActiveLayerDrawable()) 226 return; 227 228 // Should only get InputEvents due to the mask in enterMode 229 if (updateModifiersState((InputEvent) e)) { 230 updateStatusLine(); 231 updateCursor(); 232 } 233 } 234 235 private boolean updateModifiersState(InputEvent e) { 236 boolean oldAlt = alt, oldShift = shift, oldCtrl = ctrl; 237 updateKeyModifiers(e); 238 boolean changed = (oldAlt != alt || oldShift != shift || oldCtrl != ctrl); 239 return changed; 240 } 241 242 private void updateCursor() { 243 Cursor newCursor = null; 244 switch (mode) { 245 case normal: 246 if (matchesCurrentModifiers(setSelectedModifierCombo)) { 247 newCursor = ImageProvider.getCursor("normal", "parallel"); 248 } else if (matchesCurrentModifiers(addToSelectionModifierCombo)) { 249 newCursor = ImageProvider.getCursor("normal", "parallel"); // FIXME 250 } else if (matchesCurrentModifiers(toggleSelectedModifierCombo)) { 251 newCursor = ImageProvider.getCursor("normal", "parallel"); // FIXME 252 } else { 253 // TODO: set to a cursor indicating an error 254 } 255 break; 256 case dragging: 257 if (snap) { 258 // TODO: snapping cursor? 259 newCursor = Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR); 260 } else { 261 newCursor = Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR); 262 } 263 } 264 if (newCursor != null) { 265 mv.setNewCursor(newCursor, this); 266 } 267 } 268 269 private void setMode(Mode mode) { 270 this.mode = mode; 271 updateCursor(); 272 updateStatusLine(); 273 } 274 275 private boolean isValidModifierCombination() { 276 // TODO: implement to give feedback on invalid modifier combination 277 return true; 278 } 279 280 private boolean sanityCheck() { 281 // @formatter:off 282 boolean areWeSane = 283 mv.isActiveLayerVisible() && 284 mv.isActiveLayerDrawable() && 285 ((Boolean) this.getValue("active")); 286 // @formatter:on 287 assert (areWeSane); // mad == bad 288 return areWeSane; 289 } 290 291 @Override 292 public void mousePressed(MouseEvent e) { 293 updateModifiersState(e); 294 // Other buttons are off limit, but we still get events. 295 if (e.getButton() != MouseEvent.BUTTON1) 296 return; 297 298 if(sanityCheck() == false) 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); 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); 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(NavigatableComponent.CHINESE_SOM)) { 421 snapDistance = snapDistanceChinese * NavigatableComponent.CHINESE_SOM.aValue; 422 } else if (som.equals(NavigatableComponent.IMPERIAL_SOM)) { 423 snapDistance = snapDistanceImperial * NavigatableComponent.IMPERIAL_SOM.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 Stroke refLineStroke = new BasicStroke(1, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL, 10.0f, new float[] { 466 2f, 2f }, 0f); 467 g.setStroke(refLineStroke); 468 g.setColor(Color.RED); 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 Stroke helpLineStroke = new BasicStroke(1, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL); 474 g.setStroke(helpLineStroke); 475 g.setColor(Color.RED); 476 p1 = mv.getPoint(helperLineStart); 477 p2 = mv.getPoint(helperLineEnd); 478 g.drawLine(p1.x, p1.y, p2.x, p2.y); 479 } 480 } 481 482 private boolean isModifiersValidForDragMode() { 483 return (!alt && !shift && !ctrl) || matchesCurrentModifiers(snapModifierCombo) 484 || matchesCurrentModifiers(copyTagsModifierCombo); 485 } 486 487 private void updateFlagsOnlyChangeableOnPress() { 488 copyTags = copyTagsDefault != matchesCurrentModifiers(copyTagsModifierCombo); 489 } 490 491 private void updateFlagsChangeableAlways() { 492 snap = snapDefault != matchesCurrentModifiers(snapModifierCombo); 493 } 494 495 //// We keep the source ways and the selection in sync so the user can see the source way's tags 496 private void addSourceWay(Way w) { 497 assert (sourceWays != null); 498 getCurrentDataSet().addSelected(w); 499 w.setHighlighted(true); 500 sourceWays.add(w); 501 } 502 503 private void removeSourceWay(Way w) { 504 assert (sourceWays != null); 505 getCurrentDataSet().clearSelection(w); 506 w.setHighlighted(false); 507 sourceWays.remove(w); 508 } 509 510 private void clearSourceWays() { 511 assert (sourceWays != null); 512 if (sourceWays == null) 513 return; 514 getCurrentDataSet().clearSelection(sourceWays); 515 for (Way w : sourceWays) { 516 w.setHighlighted(false); 517 } 518 sourceWays.clear(); 519 } 520 521 private void resetMouseTrackingState() { 522 mouseIsDown = false; 523 mousePressedPos = null; 524 mouseHasBeenDragged = false; 525 } 526 527 // TODO: rename 528 private boolean initParallelWays(Point p, boolean copyTags) { 529 referenceSegment = mv.getNearestWaySegment(p, Way.isUsablePredicate, true); 530 if (referenceSegment == null) 531 return false; 532 533 if (!sourceWays.contains(referenceSegment.way)) { 534 clearSourceWays(); 535 addSourceWay(referenceSegment.way); 536 } 537 538 try { 539 int referenceWayIndex = -1; 540 int i = 0; 541 for (Way w : sourceWays) { 542 if (w == referenceSegment.way) { 543 referenceWayIndex = i; 544 break; 545 } 546 i++; 547 } 548 pWays = new ParallelWays(sourceWays, copyTags, referenceWayIndex); 549 pWays.commit(); 550 getCurrentDataSet().setSelected(pWays.ways); 551 return true; 552 } catch (IllegalArgumentException e) { 553 // TODO: Not ideal feedback. Maybe changing the cursor could be a good mechanism? 554 JOptionPane.showMessageDialog( 555 Main.parent, 556 tr("ParallelWayAction\n" + 557 "The ways selected must form a simple branchless path"), 558 tr("Make parallel way error"), 559 JOptionPane.INFORMATION_MESSAGE); 560 // The error dialog prevents us from getting the mouseReleased event 561 resetMouseTrackingState(); 562 pWays = null; 563 return false; 564 } 565 } 566 567 private String prefKey(String subKey) { 568 return "edit.make-parallel-way-action." + subKey; 569 } 570 571 private String getStringPref(String subKey, String def) { 572 return Main.pref.get(prefKey(subKey), def); 573 } 574 575 private String getStringPref(String subKey) { 576 return getStringPref(subKey, null); 577 } 578 }