001// License: GPL. See LICENSE file for details.
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.AWTEvent;
009import java.awt.Color;
010import java.awt.Cursor;
011import java.awt.Graphics2D;
012import java.awt.Point;
013import java.awt.Stroke;
014import java.awt.Toolkit;
015import java.awt.event.AWTEventListener;
016import java.awt.event.InputEvent;
017import java.awt.event.KeyEvent;
018import java.awt.event.MouseEvent;
019import java.util.Collection;
020import java.util.LinkedHashSet;
021import java.util.Set;
022
023import javax.swing.JOptionPane;
024
025import org.openstreetmap.josm.Main;
026import org.openstreetmap.josm.data.Bounds;
027import org.openstreetmap.josm.data.Preferences.PreferenceChangeEvent;
028import org.openstreetmap.josm.data.Preferences.PreferenceChangedListener;
029import org.openstreetmap.josm.data.coor.EastNorth;
030import org.openstreetmap.josm.data.osm.Node;
031import org.openstreetmap.josm.data.osm.OsmPrimitive;
032import org.openstreetmap.josm.data.osm.Way;
033import org.openstreetmap.josm.data.osm.WaySegment;
034import org.openstreetmap.josm.data.osm.visitor.paint.PaintColors;
035import org.openstreetmap.josm.gui.MapFrame;
036import org.openstreetmap.josm.gui.MapView;
037import org.openstreetmap.josm.gui.NavigatableComponent;
038import org.openstreetmap.josm.gui.NavigatableComponent.SystemOfMeasurement;
039import org.openstreetmap.josm.gui.layer.Layer;
040import org.openstreetmap.josm.gui.layer.MapViewPaintable;
041import org.openstreetmap.josm.gui.layer.OsmDataLayer;
042import org.openstreetmap.josm.gui.util.GuiHelper;
043import org.openstreetmap.josm.tools.Geometry;
044import org.openstreetmap.josm.tools.ImageProvider;
045import org.openstreetmap.josm.tools.Shortcut;
046
047//// TODO: (list below)
048/* == Functionality ==
049 *
050 * 1. Use selected nodes as split points for the selected ways.
051 *
052 * The ways containing the selected nodes will be split and only the "inner"
053 * parts will be copied
054 *
055 * 2. Enter exact offset
056 *
057 * 3. Improve snapping
058 *
059 * 4. Visual cues could be better
060 *
061 * 5. Cursors (Half-done)
062 *
063 * 6. (long term) Parallelize and adjust offsets of existing ways
064 *
065 * == Code quality ==
066 *
067 * a) The mode, flags, and modifiers might be updated more than necessary.
068 *
069 * Not a performance problem, but better if they where more centralized
070 *
071 * b) Extract generic MapMode services into a super class and/or utility class
072 *
073 * c) Maybe better to simply draw our own source way highlighting?
074 *
075 * Current code doesn't not take into account that ways might been highlighted
076 * by other than us. Don't think that situation should ever happen though.
077 */
078
079/**
080 * MapMode for making parallel ways.
081 *
082 * All calculations are done in projected coordinates.
083 *
084 * @author Ole Jørgen Brønner (olejorgenb)
085 */
086public class ParallelWayAction extends MapMode implements AWTEventListener, MapViewPaintable, PreferenceChangedListener {
087
088    private enum Mode {
089        dragging, normal
090    }
091
092    //// Preferences and flags
093    // See updateModeLocalPreferences for defaults
094    private Mode mode;
095    private boolean copyTags;
096    private boolean copyTagsDefault;
097
098    private boolean snap;
099    private boolean snapDefault;
100
101    private double snapThreshold;
102    private double snapDistanceMetric;
103    private double snapDistanceImperial;
104    private double snapDistanceChinese;
105    private double snapDistanceNautical;
106
107    private ModifiersSpec snapModifierCombo;
108    private ModifiersSpec copyTagsModifierCombo;
109    private ModifiersSpec addToSelectionModifierCombo;
110    private ModifiersSpec toggleSelectedModifierCombo;
111    private ModifiersSpec setSelectedModifierCombo;
112
113    private int initialMoveDelay;
114
115    private final MapView mv;
116
117    // Mouse tracking state
118    private Point mousePressedPos;
119    private boolean mouseIsDown;
120    private long mousePressedTime;
121    private boolean mouseHasBeenDragged;
122
123    private WaySegment referenceSegment;
124    private ParallelWays pWays;
125    private Set<Way> sourceWays;
126    private EastNorth helperLineStart;
127    private EastNorth helperLineEnd;
128
129    Stroke helpLineStroke;
130    Stroke refLineStroke;
131    Color mainColor;
132
133    public ParallelWayAction(MapFrame mapFrame) {
134        super(tr("Parallel"), "parallel", tr("Make parallel copies of ways"),
135            Shortcut.registerShortcut("mapmode:parallel", tr("Mode: {0}",
136                tr("Parallel")), KeyEvent.VK_P, Shortcut.SHIFT),
137            mapFrame, ImageProvider.getCursor("normal", "parallel"));
138        putValue("help", ht("/Action/Parallel"));
139        mv = mapFrame.mapView;
140        updateModeLocalPreferences();
141        Main.pref.addPreferenceChangeListener(this);
142    }
143
144    @Override
145    public void enterMode() {
146        // super.enterMode() updates the status line and cursor so we need our state to be set correctly
147        setMode(Mode.normal);
148        pWays = null;
149        updateAllPreferences(); // All default values should've been set now
150
151        super.enterMode();
152
153        mv.addMouseListener(this);
154        mv.addMouseMotionListener(this);
155        mv.addTemporaryLayer(this);
156
157        helpLineStroke = GuiHelper.getCustomizedStroke(getStringPref("stroke.hepler-line", "1" ));
158        refLineStroke = GuiHelper.getCustomizedStroke(getStringPref("stroke.ref-line", "1 2 2"));
159        mainColor = Main.pref.getColor(marktr("make parallel helper line"), null);
160        if (mainColor == null) mainColor = PaintColors.SELECTED.get();
161
162        //// Needed to update the mouse cursor if modifiers are changed when the mouse is motionless
163        try {
164            Toolkit.getDefaultToolkit().addAWTEventListener(this, AWTEvent.KEY_EVENT_MASK);
165        } catch (SecurityException ex) {
166            Main.warn(ex);
167        }
168        sourceWays = new LinkedHashSet<Way>(getCurrentDataSet().getSelectedWays());
169        for (Way w : sourceWays) {
170            w.setHighlighted(true);
171        }
172        mv.repaint();
173    }
174
175    @Override
176    public void exitMode() {
177        super.exitMode();
178        mv.removeMouseListener(this);
179        mv.removeMouseMotionListener(this);
180        mv.removeTemporaryLayer(this);
181        Main.map.statusLine.setDist(-1);
182        Main.map.statusLine.repaint();
183        try {
184            Toolkit.getDefaultToolkit().removeAWTEventListener(this);
185        } catch (SecurityException ex) {
186            Main.warn(ex);
187        }
188        removeWayHighlighting(sourceWays);
189        pWays = null;
190        sourceWays = null;
191        referenceSegment = null;
192        mv.repaint();
193    }
194
195    @Override
196    public String getModeHelpText() {
197        // TODO: add more detailed feedback based on modifier state.
198        // TODO: dynamic messages based on preferences. (Could be problematic translation wise)
199        switch (mode) {
200        case normal:
201            return tr("Select ways as in Select mode. Drag selected ways or a single way to create a parallel copy (Alt toggles tag preservation)");
202        case dragging:
203            return tr("Hold Ctrl to toggle snapping");
204        }
205        return ""; // impossible ..
206    }
207
208    // Separated due to "race condition" between default values
209    private void updateAllPreferences() {
210        updateModeLocalPreferences();
211        // @formatter:off
212        // @formatter:on
213    }
214
215    private void updateModeLocalPreferences() {
216        // @formatter:off
217        snapThreshold        = Main.pref.getDouble (prefKey("snap-threshold-percent"), 0.70);
218        snapDefault          = Main.pref.getBoolean(prefKey("snap-default"),      true);
219        copyTagsDefault      = Main.pref.getBoolean(prefKey("copy-tags-default"), true);
220        initialMoveDelay     = Main.pref.getInteger(prefKey("initial-move-delay"), 200);
221        snapDistanceMetric   = Main.pref.getDouble(prefKey("snap-distance-metric"), 0.5);
222        snapDistanceImperial = Main.pref.getDouble(prefKey("snap-distance-imperial"), 1);
223        snapDistanceChinese  = Main.pref.getDouble(prefKey("snap-distance-chinese"), 1);
224        snapDistanceNautical = Main.pref.getDouble(prefKey("snap-distance-nautical"), 0.1);
225
226        snapModifierCombo           = new ModifiersSpec(getStringPref("snap-modifier-combo",             "?sC"));
227        copyTagsModifierCombo       = new ModifiersSpec(getStringPref("copy-tags-modifier-combo",        "As?"));
228        addToSelectionModifierCombo = new ModifiersSpec(getStringPref("add-to-selection-modifier-combo", "aSc"));
229        toggleSelectedModifierCombo = new ModifiersSpec(getStringPref("toggle-selection-modifier-combo", "asC"));
230        setSelectedModifierCombo    = new ModifiersSpec(getStringPref("set-selection-modifier-combo",    "asc"));
231        // @formatter:on
232    }
233
234    @Override
235    public boolean layerIsSupported(Layer layer) {
236        return layer instanceof OsmDataLayer;
237    }
238
239    @Override
240    public void eventDispatched(AWTEvent e) {
241        if (Main.map == null || mv == null || !mv.isActiveLayerDrawable())
242            return;
243
244        // Should only get InputEvents due to the mask in enterMode
245        if (updateModifiersState((InputEvent) e)) {
246            updateStatusLine();
247            updateCursor();
248        }
249    }
250
251    private boolean updateModifiersState(InputEvent e) {
252        boolean oldAlt = alt, oldShift = shift, oldCtrl = ctrl;
253        updateKeyModifiers(e);
254        boolean changed = (oldAlt != alt || oldShift != shift || oldCtrl != ctrl);
255        return changed;
256    }
257
258    private void updateCursor() {
259        Cursor newCursor = null;
260        switch (mode) {
261        case normal:
262            if (matchesCurrentModifiers(setSelectedModifierCombo)) {
263                newCursor = ImageProvider.getCursor("normal", "parallel");
264            } else if (matchesCurrentModifiers(addToSelectionModifierCombo)) {
265                newCursor = ImageProvider.getCursor("normal", "parallel"); // FIXME
266            } else if (matchesCurrentModifiers(toggleSelectedModifierCombo)) {
267                newCursor = ImageProvider.getCursor("normal", "parallel"); // FIXME
268            } else {
269                // TODO: set to a cursor indicating an error
270            }
271            break;
272        case dragging:
273            if (snap) {
274                // TODO: snapping cursor?
275                newCursor = Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR);
276            } else {
277                newCursor = Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR);
278            }
279        }
280        if (newCursor != null) {
281            mv.setNewCursor(newCursor, this);
282        }
283    }
284
285    private void setMode(Mode mode) {
286        this.mode = mode;
287        updateCursor();
288        updateStatusLine();
289    }
290
291    private boolean sanityCheck() {
292        // @formatter:off
293        boolean areWeSane =
294            mv.isActiveLayerVisible() &&
295            mv.isActiveLayerDrawable() &&
296            ((Boolean) this.getValue("active"));
297        // @formatter:on
298        assert (areWeSane); // mad == bad
299        return areWeSane;
300    }
301
302    @Override
303    public void mousePressed(MouseEvent e) {
304        requestFocusInMapView();
305        updateModifiersState(e);
306        // Other buttons are off limit, but we still get events.
307        if (e.getButton() != MouseEvent.BUTTON1)
308            return;
309
310        if (!sanityCheck())
311            return;
312
313        updateFlagsOnlyChangeableOnPress();
314        updateFlagsChangeableAlways();
315
316        // Since the created way is left selected, we need to unselect again here
317        if (pWays != null && pWays.ways != null) {
318            getCurrentDataSet().clearSelection(pWays.ways);
319            pWays = null;
320        }
321
322        mouseIsDown = true;
323        mousePressedPos = e.getPoint();
324        mousePressedTime = System.currentTimeMillis();
325
326    }
327
328    @Override
329    public void mouseReleased(MouseEvent e) {
330        updateModifiersState(e);
331        // Other buttons are off limit, but we still get events.
332        if (e.getButton() != MouseEvent.BUTTON1)
333            return;
334
335        if (!mouseHasBeenDragged) {
336            // use point from press or click event? (or are these always the same)
337            Way nearestWay = mv.getNearestWay(e.getPoint(), OsmPrimitive.isSelectablePredicate);
338            if (nearestWay == null) {
339                if (matchesCurrentModifiers(setSelectedModifierCombo)) {
340                    clearSourceWays();
341                }
342                resetMouseTrackingState();
343                return;
344            }
345            boolean isSelected = nearestWay.isSelected();
346            if (matchesCurrentModifiers(addToSelectionModifierCombo)) {
347                if (!isSelected) {
348                    addSourceWay(nearestWay);
349                }
350            } else if (matchesCurrentModifiers(toggleSelectedModifierCombo)) {
351                if (isSelected) {
352                    removeSourceWay(nearestWay);
353                } else {
354                    addSourceWay(nearestWay);
355                }
356            } else if (matchesCurrentModifiers(setSelectedModifierCombo)) {
357                clearSourceWays();
358                addSourceWay(nearestWay);
359            } // else -> invalid modifier combination
360        } else if (mode == Mode.dragging) {
361            clearSourceWays();
362        }
363
364        setMode(Mode.normal);
365        resetMouseTrackingState();
366        mv.repaint();
367    }
368
369    private void removeWayHighlighting(Collection<Way> ways) {
370        if (ways == null)
371            return;
372        for (Way w : ways) {
373            w.setHighlighted(false);
374        }
375    }
376
377    @Override
378    public void mouseDragged(MouseEvent e) {
379        // WTF.. the event passed here doesn't have button info?
380        // Since we get this event from other buttons too, we must check that
381        // _BUTTON1_ is down.
382        if (!mouseIsDown)
383            return;
384
385        boolean modifiersChanged = updateModifiersState(e);
386        updateFlagsChangeableAlways();
387
388        if (modifiersChanged) {
389            // Since this could be remotely slow, do it conditionally
390            updateStatusLine();
391            updateCursor();
392        }
393
394        if ((System.currentTimeMillis() - mousePressedTime) < initialMoveDelay)
395            return;
396        // Assuming this event only is emitted when the mouse has moved
397        // Setting this after the check above means we tolerate clicks with some movement
398        mouseHasBeenDragged = true;
399
400        Point p = e.getPoint();
401        if (mode == Mode.normal) {
402            // Should we ensure that the copyTags modifiers are still valid?
403
404            // Important to use mouse position from the press, since the drag
405            // event can come quite late
406            if (!isModifiersValidForDragMode())
407                return;
408            if (!initParallelWays(mousePressedPos, copyTags))
409                return;
410            setMode(Mode.dragging);
411        }
412
413        // Calculate distance to the reference line
414        EastNorth enp = mv.getEastNorth((int) p.getX(), (int) p.getY());
415        EastNorth nearestPointOnRefLine = Geometry.closestPointToLine(referenceSegment.getFirstNode().getEastNorth(),
416                referenceSegment.getSecondNode().getEastNorth(), enp);
417
418        // Note: d is the distance in _projected units_
419        double d = enp.distance(nearestPointOnRefLine);
420        double realD = mv.getProjection().eastNorth2latlon(enp).greatCircleDistance(mv.getProjection().eastNorth2latlon(nearestPointOnRefLine));
421        double snappedRealD = realD;
422
423        // TODO: abuse of isToTheRightSideOfLine function.
424        boolean toTheRight = Geometry.isToTheRightSideOfLine(referenceSegment.getFirstNode(),
425                referenceSegment.getFirstNode(), referenceSegment.getSecondNode(), new Node(enp));
426
427        if (snap) {
428            // TODO: Very simple snapping
429            // - Snap steps relative to the distance?
430            double snapDistance;
431            SystemOfMeasurement som = NavigatableComponent.getSystemOfMeasurement();
432            if (som.equals(NavigatableComponent.CHINESE_SOM)) {
433                snapDistance = snapDistanceChinese * NavigatableComponent.CHINESE_SOM.aValue;
434            } else if (som.equals(NavigatableComponent.IMPERIAL_SOM)) {
435                snapDistance = snapDistanceImperial * NavigatableComponent.IMPERIAL_SOM.aValue;
436            } else if (som.equals(NavigatableComponent.NAUTICAL_MILE_SOM)) {
437                snapDistance = snapDistanceNautical * NavigatableComponent.NAUTICAL_MILE_SOM.aValue;
438            } else {
439                snapDistance = snapDistanceMetric; // Metric system by default
440            }
441            double closestWholeUnit;
442            double modulo = realD % snapDistance;
443            if (modulo < snapDistance/2.0) {
444                closestWholeUnit = realD - modulo;
445            } else {
446                closestWholeUnit = realD + (snapDistance-modulo);
447            }
448            if (Math.abs(closestWholeUnit - realD) < (snapThreshold * snapDistance)) {
449                snappedRealD = closestWholeUnit;
450            } else {
451                snappedRealD = closestWholeUnit + Math.signum(realD - closestWholeUnit) * snapDistance;
452            }
453        }
454        d = snappedRealD * (d/realD); // convert back to projected distance. (probably ok on small scales)
455        helperLineStart = nearestPointOnRefLine;
456        helperLineEnd = enp;
457        if (toTheRight) {
458            d = -d;
459        }
460        pWays.changeOffset(d);
461
462        Main.map.statusLine.setDist(Math.abs(snappedRealD));
463        Main.map.statusLine.repaint();
464        mv.repaint();
465    }
466
467    private boolean matchesCurrentModifiers(ModifiersSpec spec) {
468        return spec.matchWithKnown(alt, shift, ctrl);
469    }
470
471    @Override
472    public void paint(Graphics2D g, MapView mv, Bounds bbox) {
473        if (mode == Mode.dragging) {
474            // sanity checks
475            if (mv == null)
476                return;
477
478            // FIXME: should clip the line (gets insanely slow when zoomed in on a very long line
479            g.setStroke(refLineStroke);
480            g.setColor(mainColor);
481            Point p1 = mv.getPoint(referenceSegment.getFirstNode().getEastNorth());
482            Point p2 = mv.getPoint(referenceSegment.getSecondNode().getEastNorth());
483            g.drawLine(p1.x, p1.y, p2.x, p2.y);
484
485            g.setStroke(helpLineStroke);
486            g.setColor(mainColor);
487            p1 = mv.getPoint(helperLineStart);
488            p2 = mv.getPoint(helperLineEnd);
489            g.drawLine(p1.x, p1.y, p2.x, p2.y);
490        }
491    }
492
493    private boolean isModifiersValidForDragMode() {
494        return (!alt && !shift && !ctrl) || matchesCurrentModifiers(snapModifierCombo)
495                || matchesCurrentModifiers(copyTagsModifierCombo);
496    }
497
498    private void updateFlagsOnlyChangeableOnPress() {
499        copyTags = copyTagsDefault != matchesCurrentModifiers(copyTagsModifierCombo);
500    }
501
502    private void updateFlagsChangeableAlways() {
503        snap = snapDefault != matchesCurrentModifiers(snapModifierCombo);
504    }
505
506    //// We keep the source ways and the selection in sync so the user can see the source way's tags
507    private void addSourceWay(Way w) {
508        assert (sourceWays != null);
509        getCurrentDataSet().addSelected(w);
510        w.setHighlighted(true);
511        sourceWays.add(w);
512    }
513
514    private void removeSourceWay(Way w) {
515        assert (sourceWays != null);
516        getCurrentDataSet().clearSelection(w);
517        w.setHighlighted(false);
518        sourceWays.remove(w);
519    }
520
521    private void clearSourceWays() {
522        assert (sourceWays != null);
523        getCurrentDataSet().clearSelection(sourceWays);
524        for (Way w : sourceWays) {
525            w.setHighlighted(false);
526        }
527        sourceWays.clear();
528    }
529
530    private void resetMouseTrackingState() {
531        mouseIsDown = false;
532        mousePressedPos = null;
533        mouseHasBeenDragged = false;
534    }
535
536    // TODO: rename
537    private boolean initParallelWays(Point p, boolean copyTags) {
538        referenceSegment = mv.getNearestWaySegment(p, Way.isUsablePredicate, true);
539        if (referenceSegment == null)
540            return false;
541
542        if (!sourceWays.contains(referenceSegment.way)) {
543            clearSourceWays();
544            addSourceWay(referenceSegment.way);
545        }
546
547        try {
548            int referenceWayIndex = -1;
549            int i = 0;
550            for (Way w : sourceWays) {
551                if (w == referenceSegment.way) {
552                    referenceWayIndex = i;
553                    break;
554                }
555                i++;
556            }
557            pWays = new ParallelWays(sourceWays, copyTags, referenceWayIndex);
558            pWays.commit();
559            getCurrentDataSet().setSelected(pWays.ways);
560            return true;
561        } catch (IllegalArgumentException e) {
562            // TODO: Not ideal feedback. Maybe changing the cursor could be a good mechanism?
563            JOptionPane.showMessageDialog(
564                    Main.parent,
565                    tr("ParallelWayAction\n" +
566                            "The ways selected must form a simple branchless path"),
567                    tr("Make parallel way error"),
568                    JOptionPane.INFORMATION_MESSAGE);
569            // The error dialog prevents us from getting the mouseReleased event
570            resetMouseTrackingState();
571            pWays = null;
572            return false;
573        }
574    }
575
576    private String prefKey(String subKey) {
577        return "edit.make-parallel-way-action." + subKey;
578    }
579
580    private String getStringPref(String subKey, String def) {
581        return Main.pref.get(prefKey(subKey), def);
582    }
583
584    @Override
585    public void preferenceChanged(PreferenceChangeEvent e) {
586        if (e.getKey().startsWith(prefKey(""))) {
587            updateAllPreferences();
588        }
589    }
590
591    @Override
592    public void destroy() {
593        super.destroy();
594        Main.pref.removePreferenceChangeListener(this);
595    }
596}