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}