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