// License: GPL. For details, see LICENSE file. package org.openstreetmap.josm.actions.mapmode; import static org.openstreetmap.josm.gui.help.HelpUtil.ht; import static org.openstreetmap.josm.tools.I18n.marktr; import static org.openstreetmap.josm.tools.I18n.tr; import java.awt.BasicStroke; import java.awt.Color; import java.awt.Cursor; import java.awt.Graphics2D; import java.awt.Point; import java.awt.event.KeyEvent; import java.awt.event.MouseEvent; import java.util.Collection; import java.util.Collections; import java.util.EnumMap; import java.util.EnumSet; import java.util.LinkedHashSet; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.stream.Stream; import javax.swing.JOptionPane; import org.openstreetmap.josm.Main; import org.openstreetmap.josm.data.Bounds; import org.openstreetmap.josm.data.SystemOfMeasurement; import org.openstreetmap.josm.data.coor.EastNorth; import org.openstreetmap.josm.data.osm.Node; import org.openstreetmap.josm.data.osm.OsmPrimitive; import org.openstreetmap.josm.data.osm.Way; import org.openstreetmap.josm.data.osm.WaySegment; import org.openstreetmap.josm.data.preferences.AbstractToStringProperty; import org.openstreetmap.josm.data.preferences.BooleanProperty; import org.openstreetmap.josm.data.preferences.CachingProperty; import org.openstreetmap.josm.data.preferences.ColorProperty; import org.openstreetmap.josm.data.preferences.DoubleProperty; import org.openstreetmap.josm.data.preferences.IntegerProperty; import org.openstreetmap.josm.data.preferences.StrokeProperty; import org.openstreetmap.josm.gui.MapFrame; import org.openstreetmap.josm.gui.MapView; import org.openstreetmap.josm.gui.Notification; import org.openstreetmap.josm.gui.draw.MapViewPath; import org.openstreetmap.josm.gui.layer.Layer; import org.openstreetmap.josm.gui.layer.MapViewPaintable; import org.openstreetmap.josm.gui.layer.OsmDataLayer; import org.openstreetmap.josm.gui.util.ModifierListener; import org.openstreetmap.josm.tools.CheckParameterUtil; import org.openstreetmap.josm.tools.Geometry; import org.openstreetmap.josm.tools.ImageProvider; import org.openstreetmap.josm.tools.Shortcut; //// TODO: (list below) /* == Functionality == * * 1. Use selected nodes as split points for the selected ways. * * The ways containing the selected nodes will be split and only the "inner" * parts will be copied * * 2. Enter exact offset * * 3. Improve snapping * * 4. Visual cues could be better * * 5. (long term) Parallelize and adjust offsets of existing ways * * == Code quality == * * a) The mode, flags, and modifiers might be updated more than necessary. * * Not a performance problem, but better if they where more centralized * * b) Extract generic MapMode services into a super class and/or utility class * * c) Maybe better to simply draw our own source way highlighting? * * Current code doesn't not take into account that ways might been highlighted * by other than us. Don't think that situation should ever happen though. */ /** * MapMode for making parallel ways. * * All calculations are done in projected coordinates. * * @author Ole Jørgen Brønner (olejorgenb) */ public class ParallelWayAction extends MapMode implements ModifierListener, MapViewPaintable { private static final CachingProperty<BasicStroke> HELPER_LINE_STROKE = new StrokeProperty(prefKey("stroke.hepler-line"), "1").cached(); private static final CachingProperty<BasicStroke> REF_LINE_STROKE = new StrokeProperty(prefKey("stroke.ref-line"), "2 2 3").cached(); // @formatter:off // CHECKSTYLE.OFF: SingleSpaceSeparator private static final CachingProperty<Double> SNAP_THRESHOLD = new DoubleProperty(prefKey("snap-threshold-percent"), 0.70).cached(); private static final CachingProperty<Boolean> SNAP_DEFAULT = new BooleanProperty(prefKey("snap-default"), true).cached(); private static final CachingProperty<Boolean> COPY_TAGS_DEFAULT = new BooleanProperty(prefKey("copy-tags-default"), true).cached(); private static final CachingProperty<Integer> INITIAL_MOVE_DELAY = new IntegerProperty(prefKey("initial-move-delay"), 200).cached(); private static final CachingProperty<Double> SNAP_DISTANCE_METRIC = new DoubleProperty(prefKey("snap-distance-metric"), 0.5).cached(); private static final CachingProperty<Double> SNAP_DISTANCE_IMPERIAL = new DoubleProperty(prefKey("snap-distance-imperial"), 1).cached(); private static final CachingProperty<Double> SNAP_DISTANCE_CHINESE = new DoubleProperty(prefKey("snap-distance-chinese"), 1).cached(); private static final CachingProperty<Double> SNAP_DISTANCE_NAUTICAL = new DoubleProperty(prefKey("snap-distance-nautical"), 0.1).cached(); private static final CachingProperty<Color> MAIN_COLOR = new ColorProperty(marktr("make parallel helper line"), Color.RED).cached(); private static final CachingProperty<Map<Modifier, Boolean>> SNAP_MODIFIER_COMBO = new KeyboardModifiersProperty(prefKey("snap-modifier-combo"), "?sC").cached(); private static final CachingProperty<Map<Modifier, Boolean>> COPY_TAGS_MODIFIER_COMBO = new KeyboardModifiersProperty(prefKey("copy-tags-modifier-combo"), "As?").cached(); private static final CachingProperty<Map<Modifier, Boolean>> ADD_TO_SELECTION_MODIFIER_COMBO = new KeyboardModifiersProperty(prefKey("add-to-selection-modifier-combo"), "aSc").cached(); private static final CachingProperty<Map<Modifier, Boolean>> TOGGLE_SELECTED_MODIFIER_COMBO = new KeyboardModifiersProperty(prefKey("toggle-selection-modifier-combo"), "asC").cached(); private static final CachingProperty<Map<Modifier, Boolean>> SET_SELECTED_MODIFIER_COMBO = new KeyboardModifiersProperty(prefKey("set-selection-modifier-combo"), "asc").cached(); // CHECKSTYLE.ON: SingleSpaceSeparator // @formatter:on private enum Mode { DRAGGING, NORMAL } //// Preferences and flags // See updateModeLocalPreferences for defaults private Mode mode; private boolean copyTags; private boolean snap; private final MapView mv; // Mouse tracking state private Point mousePressedPos; private boolean mouseIsDown; private long mousePressedTime; private boolean mouseHasBeenDragged; private transient WaySegment referenceSegment; private transient ParallelWays pWays; private transient Set<Way> sourceWays; private EastNorth helperLineStart; private EastNorth helperLineEnd; /** * Constructs a new {@code ParallelWayAction}. * @param mapFrame Map frame */ public ParallelWayAction(MapFrame mapFrame) { super(tr("Parallel"), "parallel", tr("Make parallel copies of ways"), Shortcut.registerShortcut("mapmode:parallel", tr("Mode: {0}", tr("Parallel")), KeyEvent.VK_P, Shortcut.SHIFT), ImageProvider.getCursor("normal", "parallel")); putValue("help", ht("/Action/Parallel")); mv = mapFrame.mapView; } @Override public void enterMode() { // super.enterMode() updates the status line and cursor so we need our state to be set correctly setMode(Mode.NORMAL); pWays = null; super.enterMode(); mv.addMouseListener(this); mv.addMouseMotionListener(this); mv.addTemporaryLayer(this); //// Needed to update the mouse cursor if modifiers are changed when the mouse is motionless Main.map.keyDetector.addModifierListener(this); sourceWays = new LinkedHashSet<>(getLayerManager().getEditDataSet().getSelectedWays()); for (Way w : sourceWays) { w.setHighlighted(true); } mv.repaint(); } @Override public void exitMode() { super.exitMode(); mv.removeMouseListener(this); mv.removeMouseMotionListener(this); mv.removeTemporaryLayer(this); Main.map.statusLine.setDist(-1); Main.map.statusLine.repaint(); Main.map.keyDetector.removeModifierListener(this); removeWayHighlighting(sourceWays); pWays = null; sourceWays = null; referenceSegment = null; mv.repaint(); } @Override public String getModeHelpText() { // TODO: add more detailed feedback based on modifier state. // TODO: dynamic messages based on preferences. (Could be problematic translation wise) switch (mode) { case NORMAL: // CHECKSTYLE.OFF: LineLength return tr("Select ways as in Select mode. Drag selected ways or a single way to create a parallel copy (Alt toggles tag preservation)"); // CHECKSTYLE.ON: LineLength case DRAGGING: return tr("Hold Ctrl to toggle snapping"); } return ""; // impossible .. } @Override public boolean layerIsSupported(Layer layer) { return layer instanceof OsmDataLayer; } @Override public void modifiersChanged(int modifiers) { if (Main.map == null || mv == null || !mv.isActiveLayerDrawable()) return; // Should only get InputEvents due to the mask in enterMode if (updateModifiersState(modifiers)) { updateStatusLine(); updateCursor(); } } private boolean updateModifiersState(int modifiers) { boolean oldAlt = alt, oldShift = shift, oldCtrl = ctrl; updateKeyModifiers(modifiers); return oldAlt != alt || oldShift != shift || oldCtrl != ctrl; } private void updateCursor() { Cursor newCursor = null; switch (mode) { case NORMAL: if (matchesCurrentModifiers(SET_SELECTED_MODIFIER_COMBO)) { newCursor = ImageProvider.getCursor("normal", "parallel"); } else if (matchesCurrentModifiers(ADD_TO_SELECTION_MODIFIER_COMBO)) { newCursor = ImageProvider.getCursor("normal", "parallel_add"); } else if (matchesCurrentModifiers(TOGGLE_SELECTED_MODIFIER_COMBO)) { newCursor = ImageProvider.getCursor("normal", "parallel_remove"); } break; case DRAGGING: newCursor = Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR); break; default: throw new AssertionError(); } if (newCursor != null) { mv.setNewCursor(newCursor, this); } } private void setMode(Mode mode) { this.mode = mode; updateCursor(); updateStatusLine(); } private boolean sanityCheck() { // @formatter:off boolean areWeSane = mv.isActiveLayerVisible() && mv.isActiveLayerDrawable() && ((Boolean) this.getValue("active")); // @formatter:on assert areWeSane; // mad == bad return areWeSane; } @Override public void mousePressed(MouseEvent e) { requestFocusInMapView(); updateModifiersState(e.getModifiers()); // Other buttons are off limit, but we still get events. if (e.getButton() != MouseEvent.BUTTON1) return; if (!sanityCheck()) return; updateFlagsOnlyChangeableOnPress(); updateFlagsChangeableAlways(); // Since the created way is left selected, we need to unselect again here if (pWays != null && pWays.getWays() != null) { getLayerManager().getEditDataSet().clearSelection(pWays.getWays()); pWays = null; } mouseIsDown = true; mousePressedPos = e.getPoint(); mousePressedTime = System.currentTimeMillis(); } @Override public void mouseReleased(MouseEvent e) { updateModifiersState(e.getModifiers()); // Other buttons are off limit, but we still get events. if (e.getButton() != MouseEvent.BUTTON1) return; if (!mouseHasBeenDragged) { // use point from press or click event? (or are these always the same) Way nearestWay = mv.getNearestWay(e.getPoint(), OsmPrimitive::isSelectable); if (nearestWay == null) { if (matchesCurrentModifiers(SET_SELECTED_MODIFIER_COMBO)) { clearSourceWays(); } resetMouseTrackingState(); return; } boolean isSelected = nearestWay.isSelected(); if (matchesCurrentModifiers(ADD_TO_SELECTION_MODIFIER_COMBO)) { if (!isSelected) { addSourceWay(nearestWay); } } else if (matchesCurrentModifiers(TOGGLE_SELECTED_MODIFIER_COMBO)) { if (isSelected) { removeSourceWay(nearestWay); } else { addSourceWay(nearestWay); } } else if (matchesCurrentModifiers(SET_SELECTED_MODIFIER_COMBO)) { clearSourceWays(); addSourceWay(nearestWay); } // else -> invalid modifier combination } else if (mode == Mode.DRAGGING) { clearSourceWays(); } setMode(Mode.NORMAL); resetMouseTrackingState(); mv.repaint(); } private static void removeWayHighlighting(Collection<Way> ways) { if (ways == null) return; for (Way w : ways) { w.setHighlighted(false); } } @Override public void mouseDragged(MouseEvent e) { // WTF.. the event passed here doesn't have button info? // Since we get this event from other buttons too, we must check that // _BUTTON1_ is down. if (!mouseIsDown) return; boolean modifiersChanged = updateModifiersState(e.getModifiers()); updateFlagsChangeableAlways(); if (modifiersChanged) { // Since this could be remotely slow, do it conditionally updateStatusLine(); updateCursor(); } if ((System.currentTimeMillis() - mousePressedTime) < INITIAL_MOVE_DELAY.get()) return; // Assuming this event only is emitted when the mouse has moved // Setting this after the check above means we tolerate clicks with some movement mouseHasBeenDragged = true; if (mode == Mode.NORMAL) { // Should we ensure that the copyTags modifiers are still valid? // Important to use mouse position from the press, since the drag // event can come quite late if (!isModifiersValidForDragMode()) return; if (!initParallelWays(mousePressedPos, copyTags)) return; setMode(Mode.DRAGGING); } // Calculate distance to the reference line Point p = e.getPoint(); EastNorth enp = mv.getEastNorth((int) p.getX(), (int) p.getY()); EastNorth nearestPointOnRefLine = Geometry.closestPointToLine(referenceSegment.getFirstNode().getEastNorth(), referenceSegment.getSecondNode().getEastNorth(), enp); // Note: d is the distance in _projected units_ double d = enp.distance(nearestPointOnRefLine); double realD = mv.getProjection().eastNorth2latlon(enp).greatCircleDistance(mv.getProjection().eastNorth2latlon(nearestPointOnRefLine)); double snappedRealD = realD; boolean toTheRight = Geometry.angleIsClockwise( referenceSegment.getFirstNode(), referenceSegment.getSecondNode(), new Node(enp)); if (snap) { // TODO: Very simple snapping // - Snap steps relative to the distance? double snapDistance; SystemOfMeasurement som = SystemOfMeasurement.getSystemOfMeasurement(); if (som.equals(SystemOfMeasurement.CHINESE)) { snapDistance = SNAP_DISTANCE_CHINESE.get() * SystemOfMeasurement.CHINESE.aValue; } else if (som.equals(SystemOfMeasurement.IMPERIAL)) { snapDistance = SNAP_DISTANCE_IMPERIAL.get() * SystemOfMeasurement.IMPERIAL.aValue; } else if (som.equals(SystemOfMeasurement.NAUTICAL_MILE)) { snapDistance = SNAP_DISTANCE_NAUTICAL.get() * SystemOfMeasurement.NAUTICAL_MILE.aValue; } else { snapDistance = SNAP_DISTANCE_METRIC.get(); // Metric system by default } double closestWholeUnit; double modulo = realD % snapDistance; if (modulo < snapDistance/2.0) { closestWholeUnit = realD - modulo; } else { closestWholeUnit = realD + (snapDistance-modulo); } if (Math.abs(closestWholeUnit - realD) < (SNAP_THRESHOLD.get() * snapDistance)) { snappedRealD = closestWholeUnit; } else { snappedRealD = closestWholeUnit + Math.signum(realD - closestWholeUnit) * snapDistance; } } d = snappedRealD * (d/realD); // convert back to projected distance. (probably ok on small scales) helperLineStart = nearestPointOnRefLine; helperLineEnd = enp; if (toTheRight) { d = -d; } pWays.changeOffset(d); Main.map.statusLine.setDist(Math.abs(snappedRealD)); Main.map.statusLine.repaint(); mv.repaint(); } private boolean matchesCurrentModifiers(CachingProperty<Map<Modifier, Boolean>> spec) { return matchesCurrentModifiers(spec.get()); } private boolean matchesCurrentModifiers(Map<Modifier, Boolean> spec) { EnumSet<Modifier> modifiers = EnumSet.noneOf(Modifier.class); if (ctrl) { modifiers.add(Modifier.CTRL); } if (alt) { modifiers.add(Modifier.ALT); } if (shift) { modifiers.add(Modifier.SHIFT); } return spec.entrySet().stream().allMatch(entry -> modifiers.contains(entry.getKey()) == entry.getValue().booleanValue()); } @Override public void paint(Graphics2D g, MapView mv, Bounds bbox) { if (mode == Mode.DRAGGING) { CheckParameterUtil.ensureParameterNotNull(mv, "mv"); Color mainColor = MAIN_COLOR.get(); // FIXME: should clip the line (gets insanely slow when zoomed in on a very long line g.setStroke(REF_LINE_STROKE.get()); g.setColor(mainColor); MapViewPath line = new MapViewPath(mv); line.moveTo(referenceSegment.getFirstNode()); line.lineTo(referenceSegment.getSecondNode()); g.draw(line.computeClippedLine(g.getStroke())); g.setStroke(HELPER_LINE_STROKE.get()); g.setColor(mainColor); line = new MapViewPath(mv); line.moveTo(helperLineStart); line.lineTo(helperLineEnd); g.draw(line.computeClippedLine(g.getStroke())); } } private boolean isModifiersValidForDragMode() { return (!alt && !shift && !ctrl) || matchesCurrentModifiers(SNAP_MODIFIER_COMBO) || matchesCurrentModifiers(COPY_TAGS_MODIFIER_COMBO); } private void updateFlagsOnlyChangeableOnPress() { copyTags = COPY_TAGS_DEFAULT.get().booleanValue() != matchesCurrentModifiers(COPY_TAGS_MODIFIER_COMBO); } private void updateFlagsChangeableAlways() { snap = SNAP_DEFAULT.get().booleanValue() != matchesCurrentModifiers(SNAP_MODIFIER_COMBO); } // We keep the source ways and the selection in sync so the user can see the source way's tags private void addSourceWay(Way w) { assert sourceWays != null; getLayerManager().getEditDataSet().addSelected(w); w.setHighlighted(true); sourceWays.add(w); } private void removeSourceWay(Way w) { assert sourceWays != null; getLayerManager().getEditDataSet().clearSelection(w); w.setHighlighted(false); sourceWays.remove(w); } private void clearSourceWays() { assert sourceWays != null; getLayerManager().getEditDataSet().clearSelection(sourceWays); for (Way w : sourceWays) { w.setHighlighted(false); } sourceWays.clear(); } private void resetMouseTrackingState() { mouseIsDown = false; mousePressedPos = null; mouseHasBeenDragged = false; } // TODO: rename private boolean initParallelWays(Point p, boolean copyTags) { referenceSegment = mv.getNearestWaySegment(p, OsmPrimitive::isUsable, true); if (referenceSegment == null) return false; if (!sourceWays.contains(referenceSegment.way)) { clearSourceWays(); addSourceWay(referenceSegment.way); } try { int referenceWayIndex = -1; int i = 0; for (Way w : sourceWays) { if (w == referenceSegment.way) { referenceWayIndex = i; break; } i++; } pWays = new ParallelWays(sourceWays, copyTags, referenceWayIndex); pWays.commit(); getLayerManager().getEditDataSet().setSelected(pWays.getWays()); return true; } catch (IllegalArgumentException e) { Main.debug(e); new Notification(tr("ParallelWayAction\n" + "The ways selected must form a simple branchless path")) .setIcon(JOptionPane.INFORMATION_MESSAGE) .show(); // The error dialog prevents us from getting the mouseReleased event resetMouseTrackingState(); pWays = null; return false; } } private static String prefKey(String subKey) { return "edit.make-parallel-way-action." + subKey; } /** * A property that holds the keyboard modifiers. * @author Michael Zangl * @since 10869 */ private static class KeyboardModifiersProperty extends AbstractToStringProperty<Map<Modifier, Boolean>> { KeyboardModifiersProperty(String key, String defaultValue) { super(key, createFromString(defaultValue)); } KeyboardModifiersProperty(String key, Map<Modifier, Boolean> defaultValue) { super(key, defaultValue); } @Override protected String toString(Map<Modifier, Boolean> t) { StringBuilder sb = new StringBuilder(); for (Modifier mod : Modifier.values()) { Boolean val = t.get(mod); if (val == null) { sb.append('?'); } else if (val) { sb.append(Character.toUpperCase(mod.shortChar)); } else { sb.append(mod.shortChar); } } return sb.toString(); } @Override protected Map<Modifier, Boolean> fromString(String string) { return createFromString(string); } private static Map<Modifier, Boolean> createFromString(String string) { Map<Modifier, Boolean> ret = new EnumMap<>(Modifier.class); for (char c : string.toCharArray()) { if (c == '?') { continue; } Optional<Modifier> mod = Modifier.findWithShortCode(c); if (mod.isPresent()) { ret.put(mod.get(), Character.isUpperCase(c)); } else { Main.debug("Ignoring unknown modifier {0}", c); } } return Collections.unmodifiableMap(ret); } } private enum Modifier { CTRL('c'), ALT('a'), SHIFT('s'); private final char shortChar; Modifier(char shortChar) { this.shortChar = Character.toLowerCase(shortChar); } /** * Find the modifier with the given short code * @param charCode The short code * @return The modifier */ public static Optional<Modifier> findWithShortCode(int charCode) { return Stream.of(values()).filter(m -> m.shortChar == Character.toLowerCase(charCode)).findAny(); } } }