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        if (mode == Mode.normal) {
396            // Should we ensure that the copyTags modifiers are still valid?
397
398            // Important to use mouse position from the press, since the drag
399            // event can come quite late
400            if (!isModifiersValidForDragMode())
401                return;
402            if (!initParallelWays(mousePressedPos, copyTags))
403                return;
404            setMode(Mode.dragging);
405        }
406
407        // Calculate distance to the reference line
408        Point p = e.getPoint();
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}