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<>(getCurrentDataSet().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        snapThreshold        = Main.pref.getDouble(prefKey("snap-threshold-percent"), 0.70);
208        snapDefault          = Main.pref.getBoolean(prefKey("snap-default"),      true);
209        copyTagsDefault      = Main.pref.getBoolean(prefKey("copy-tags-default"), true);
210        initialMoveDelay     = Main.pref.getInteger(prefKey("initial-move-delay"), 200);
211        snapDistanceMetric   = Main.pref.getDouble(prefKey("snap-distance-metric"), 0.5);
212        snapDistanceImperial = Main.pref.getDouble(prefKey("snap-distance-imperial"), 1);
213        snapDistanceChinese  = Main.pref.getDouble(prefKey("snap-distance-chinese"), 1);
214        snapDistanceNautical = Main.pref.getDouble(prefKey("snap-distance-nautical"), 0.1);
215
216        snapModifierCombo           = new ModifiersSpec(getStringPref("snap-modifier-combo",             "?sC"));
217        copyTagsModifierCombo       = new ModifiersSpec(getStringPref("copy-tags-modifier-combo",        "As?"));
218        addToSelectionModifierCombo = new ModifiersSpec(getStringPref("add-to-selection-modifier-combo", "aSc"));
219        toggleSelectedModifierCombo = new ModifiersSpec(getStringPref("toggle-selection-modifier-combo", "asC"));
220        setSelectedModifierCombo    = new ModifiersSpec(getStringPref("set-selection-modifier-combo",    "asc"));
221        // @formatter:on
222    }
223
224    @Override
225    public boolean layerIsSupported(Layer layer) {
226        return layer instanceof OsmDataLayer;
227    }
228
229    @Override
230    public void modifiersChanged(int modifiers) {
231        if (Main.map == null || mv == null || !mv.isActiveLayerDrawable())
232            return;
233
234        // Should only get InputEvents due to the mask in enterMode
235        if (updateModifiersState(modifiers)) {
236            updateStatusLine();
237            updateCursor();
238        }
239    }
240
241    private boolean updateModifiersState(int modifiers) {
242        boolean oldAlt = alt, oldShift = shift, oldCtrl = ctrl;
243        updateKeyModifiers(modifiers);
244        return oldAlt != alt || oldShift != shift || oldCtrl != ctrl;
245    }
246
247    private void updateCursor() {
248        Cursor newCursor = null;
249        switch (mode) {
250        case NORMAL:
251            if (matchesCurrentModifiers(setSelectedModifierCombo)) {
252                newCursor = ImageProvider.getCursor("normal", "parallel");
253            } else if (matchesCurrentModifiers(addToSelectionModifierCombo)) {
254                newCursor = ImageProvider.getCursor("normal", "parallel_add");
255            } else if (matchesCurrentModifiers(toggleSelectedModifierCombo)) {
256                newCursor = ImageProvider.getCursor("normal", "parallel_remove");
257            }
258            break;
259        case DRAGGING:
260            newCursor = Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR);
261            break;
262        default: throw new AssertionError();
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 sanityCheck() {
276        // @formatter:off
277        boolean areWeSane =
278            mv.isActiveLayerVisible() &&
279            mv.isActiveLayerDrawable() &&
280            ((Boolean) this.getValue("active"));
281        // @formatter:on
282        assert areWeSane; // mad == bad
283        return areWeSane;
284    }
285
286    @Override
287    public void mousePressed(MouseEvent e) {
288        requestFocusInMapView();
289        updateModifiersState(e.getModifiers());
290        // Other buttons are off limit, but we still get events.
291        if (e.getButton() != MouseEvent.BUTTON1)
292            return;
293
294        if (!sanityCheck())
295            return;
296
297        updateFlagsOnlyChangeableOnPress();
298        updateFlagsChangeableAlways();
299
300        // Since the created way is left selected, we need to unselect again here
301        if (pWays != null && pWays.getWays() != null) {
302            getCurrentDataSet().clearSelection(pWays.getWays());
303            pWays = null;
304        }
305
306        mouseIsDown = true;
307        mousePressedPos = e.getPoint();
308        mousePressedTime = System.currentTimeMillis();
309
310    }
311
312    @Override
313    public void mouseReleased(MouseEvent e) {
314        updateModifiersState(e.getModifiers());
315        // Other buttons are off limit, but we still get events.
316        if (e.getButton() != MouseEvent.BUTTON1)
317            return;
318
319        if (!mouseHasBeenDragged) {
320            // use point from press or click event? (or are these always the same)
321            Way nearestWay = mv.getNearestWay(e.getPoint(), OsmPrimitive.isSelectablePredicate);
322            if (nearestWay == null) {
323                if (matchesCurrentModifiers(setSelectedModifierCombo)) {
324                    clearSourceWays();
325                }
326                resetMouseTrackingState();
327                return;
328            }
329            boolean isSelected = nearestWay.isSelected();
330            if (matchesCurrentModifiers(addToSelectionModifierCombo)) {
331                if (!isSelected) {
332                    addSourceWay(nearestWay);
333                }
334            } else if (matchesCurrentModifiers(toggleSelectedModifierCombo)) {
335                if (isSelected) {
336                    removeSourceWay(nearestWay);
337                } else {
338                    addSourceWay(nearestWay);
339                }
340            } else if (matchesCurrentModifiers(setSelectedModifierCombo)) {
341                clearSourceWays();
342                addSourceWay(nearestWay);
343            } // else -> invalid modifier combination
344        } else if (mode == Mode.DRAGGING) {
345            clearSourceWays();
346        }
347
348        setMode(Mode.NORMAL);
349        resetMouseTrackingState();
350        mv.repaint();
351    }
352
353    private static void removeWayHighlighting(Collection<Way> ways) {
354        if (ways == null)
355            return;
356        for (Way w : ways) {
357            w.setHighlighted(false);
358        }
359    }
360
361    @Override
362    public void mouseDragged(MouseEvent e) {
363        // WTF.. the event passed here doesn't have button info?
364        // Since we get this event from other buttons too, we must check that
365        // _BUTTON1_ is down.
366        if (!mouseIsDown)
367            return;
368
369        boolean modifiersChanged = updateModifiersState(e.getModifiers());
370        updateFlagsChangeableAlways();
371
372        if (modifiersChanged) {
373            // Since this could be remotely slow, do it conditionally
374            updateStatusLine();
375            updateCursor();
376        }
377
378        if ((System.currentTimeMillis() - mousePressedTime) < initialMoveDelay)
379            return;
380        // Assuming this event only is emitted when the mouse has moved
381        // Setting this after the check above means we tolerate clicks with some movement
382        mouseHasBeenDragged = true;
383
384        if (mode == Mode.NORMAL) {
385            // Should we ensure that the copyTags modifiers are still valid?
386
387            // Important to use mouse position from the press, since the drag
388            // event can come quite late
389            if (!isModifiersValidForDragMode())
390                return;
391            if (!initParallelWays(mousePressedPos, copyTags))
392                return;
393            setMode(Mode.DRAGGING);
394        }
395
396        // Calculate distance to the reference line
397        Point p = e.getPoint();
398        EastNorth enp = mv.getEastNorth((int) p.getX(), (int) p.getY());
399        EastNorth nearestPointOnRefLine = Geometry.closestPointToLine(referenceSegment.getFirstNode().getEastNorth(),
400                referenceSegment.getSecondNode().getEastNorth(), enp);
401
402        // Note: d is the distance in _projected units_
403        double d = enp.distance(nearestPointOnRefLine);
404        double realD = mv.getProjection().eastNorth2latlon(enp).greatCircleDistance(mv.getProjection().eastNorth2latlon(nearestPointOnRefLine));
405        double snappedRealD = realD;
406
407        // TODO: abuse of isToTheRightSideOfLine function.
408        boolean toTheRight = Geometry.isToTheRightSideOfLine(referenceSegment.getFirstNode(),
409                referenceSegment.getFirstNode(), referenceSegment.getSecondNode(), new Node(enp));
410
411        if (snap) {
412            // TODO: Very simple snapping
413            // - Snap steps relative to the distance?
414            double snapDistance;
415            SystemOfMeasurement som = SystemOfMeasurement.getSystemOfMeasurement();
416            if (som.equals(SystemOfMeasurement.CHINESE)) {
417                snapDistance = snapDistanceChinese * SystemOfMeasurement.CHINESE.aValue;
418            } else if (som.equals(SystemOfMeasurement.IMPERIAL)) {
419                snapDistance = snapDistanceImperial * SystemOfMeasurement.IMPERIAL.aValue;
420            } else if (som.equals(SystemOfMeasurement.NAUTICAL_MILE)) {
421                snapDistance = snapDistanceNautical * SystemOfMeasurement.NAUTICAL_MILE.aValue;
422            } else {
423                snapDistance = snapDistanceMetric; // Metric system by default
424            }
425            double closestWholeUnit;
426            double modulo = realD % snapDistance;
427            if (modulo < snapDistance/2.0) {
428                closestWholeUnit = realD - modulo;
429            } else {
430                closestWholeUnit = realD + (snapDistance-modulo);
431            }
432            if (Math.abs(closestWholeUnit - realD) < (snapThreshold * snapDistance)) {
433                snappedRealD = closestWholeUnit;
434            } else {
435                snappedRealD = closestWholeUnit + Math.signum(realD - closestWholeUnit) * snapDistance;
436            }
437        }
438        d = snappedRealD * (d/realD); // convert back to projected distance. (probably ok on small scales)
439        helperLineStart = nearestPointOnRefLine;
440        helperLineEnd = enp;
441        if (toTheRight) {
442            d = -d;
443        }
444        pWays.changeOffset(d);
445
446        Main.map.statusLine.setDist(Math.abs(snappedRealD));
447        Main.map.statusLine.repaint();
448        mv.repaint();
449    }
450
451    private boolean matchesCurrentModifiers(ModifiersSpec spec) {
452        return spec.matchWithKnown(alt, shift, ctrl);
453    }
454
455    @Override
456    public void paint(Graphics2D g, MapView mv, Bounds bbox) {
457        if (mode == Mode.DRAGGING) {
458            // sanity checks
459            if (mv == null)
460                return;
461
462            // FIXME: should clip the line (gets insanely slow when zoomed in on a very long line
463            g.setStroke(refLineStroke);
464            g.setColor(mainColor);
465            Point p1 = mv.getPoint(referenceSegment.getFirstNode().getEastNorth());
466            Point p2 = mv.getPoint(referenceSegment.getSecondNode().getEastNorth());
467            g.drawLine(p1.x, p1.y, p2.x, p2.y);
468
469            g.setStroke(helpLineStroke);
470            g.setColor(mainColor);
471            p1 = mv.getPoint(helperLineStart);
472            p2 = mv.getPoint(helperLineEnd);
473            g.drawLine(p1.x, p1.y, p2.x, p2.y);
474        }
475    }
476
477    private boolean isModifiersValidForDragMode() {
478        return (!alt && !shift && !ctrl) || matchesCurrentModifiers(snapModifierCombo)
479                || matchesCurrentModifiers(copyTagsModifierCombo);
480    }
481
482    private void updateFlagsOnlyChangeableOnPress() {
483        copyTags = copyTagsDefault != matchesCurrentModifiers(copyTagsModifierCombo);
484    }
485
486    private void updateFlagsChangeableAlways() {
487        snap = snapDefault != matchesCurrentModifiers(snapModifierCombo);
488    }
489
490    //// We keep the source ways and the selection in sync so the user can see the source way's tags
491    private void addSourceWay(Way w) {
492        assert sourceWays != null;
493        getCurrentDataSet().addSelected(w);
494        w.setHighlighted(true);
495        sourceWays.add(w);
496    }
497
498    private void removeSourceWay(Way w) {
499        assert sourceWays != null;
500        getCurrentDataSet().clearSelection(w);
501        w.setHighlighted(false);
502        sourceWays.remove(w);
503    }
504
505    private void clearSourceWays() {
506        assert sourceWays != null;
507        getCurrentDataSet().clearSelection(sourceWays);
508        for (Way w : sourceWays) {
509            w.setHighlighted(false);
510        }
511        sourceWays.clear();
512    }
513
514    private void resetMouseTrackingState() {
515        mouseIsDown = false;
516        mousePressedPos = null;
517        mouseHasBeenDragged = false;
518    }
519
520    // TODO: rename
521    private boolean initParallelWays(Point p, boolean copyTags) {
522        referenceSegment = mv.getNearestWaySegment(p, Way.isUsablePredicate, true);
523        if (referenceSegment == null)
524            return false;
525
526        if (!sourceWays.contains(referenceSegment.way)) {
527            clearSourceWays();
528            addSourceWay(referenceSegment.way);
529        }
530
531        try {
532            int referenceWayIndex = -1;
533            int i = 0;
534            for (Way w : sourceWays) {
535                if (w == referenceSegment.way) {
536                    referenceWayIndex = i;
537                    break;
538                }
539                i++;
540            }
541            pWays = new ParallelWays(sourceWays, copyTags, referenceWayIndex);
542            pWays.commit();
543            getCurrentDataSet().setSelected(pWays.getWays());
544            return true;
545        } catch (IllegalArgumentException e) {
546            new Notification(tr("ParallelWayAction\n" +
547                    "The ways selected must form a simple branchless path"))
548                    .setIcon(JOptionPane.INFORMATION_MESSAGE)
549                    .show();
550            // The error dialog prevents us from getting the mouseReleased event
551            resetMouseTrackingState();
552            pWays = null;
553            return false;
554        }
555    }
556
557    private static String prefKey(String subKey) {
558        return "edit.make-parallel-way-action." + subKey;
559    }
560
561    private static String getStringPref(String subKey, String def) {
562        return Main.pref.get(prefKey(subKey), def);
563    }
564
565    @Override
566    public void preferenceChanged(PreferenceChangeEvent e) {
567        if (e.getKey().startsWith(prefKey(""))) {
568            updateAllPreferences();
569        }
570    }
571
572    @Override
573    public void destroy() {
574        super.destroy();
575        Main.pref.removePreferenceChangeListener(this);
576    }
577}