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.BasicStroke;
009import java.awt.Color;
010import java.awt.Cursor;
011import java.awt.Graphics2D;
012import java.awt.Point;
013import java.awt.event.KeyEvent;
014import java.awt.event.MouseEvent;
015import java.util.Collection;
016import java.util.Collections;
017import java.util.EnumMap;
018import java.util.EnumSet;
019import java.util.LinkedHashSet;
020import java.util.Map;
021import java.util.Optional;
022import java.util.Set;
023import java.util.stream.Stream;
024
025import javax.swing.JOptionPane;
026
027import org.openstreetmap.josm.data.Bounds;
028import org.openstreetmap.josm.data.SystemOfMeasurement;
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.preferences.AbstractToStringProperty;
035import org.openstreetmap.josm.data.preferences.BooleanProperty;
036import org.openstreetmap.josm.data.preferences.CachingProperty;
037import org.openstreetmap.josm.data.preferences.DoubleProperty;
038import org.openstreetmap.josm.data.preferences.IntegerProperty;
039import org.openstreetmap.josm.data.preferences.NamedColorProperty;
040import org.openstreetmap.josm.data.preferences.StrokeProperty;
041import org.openstreetmap.josm.gui.MainApplication;
042import org.openstreetmap.josm.gui.MapFrame;
043import org.openstreetmap.josm.gui.MapView;
044import org.openstreetmap.josm.gui.Notification;
045import org.openstreetmap.josm.gui.draw.MapViewPath;
046import org.openstreetmap.josm.gui.layer.AbstractMapViewPaintable;
047import org.openstreetmap.josm.gui.layer.Layer;
048import org.openstreetmap.josm.gui.util.ModifierExListener;
049import org.openstreetmap.josm.tools.CheckParameterUtil;
050import org.openstreetmap.josm.tools.Geometry;
051import org.openstreetmap.josm.tools.ImageProvider;
052import org.openstreetmap.josm.tools.Logging;
053import org.openstreetmap.josm.tools.Shortcut;
054
055/**
056 * MapMode for making parallel ways.
057 *
058 * All calculations are done in projected coordinates.
059 *
060 * TODO:
061 * == Functionality ==
062 *
063 * 1. Use selected nodes as split points for the selected ways.
064 *
065 * The ways containing the selected nodes will be split and only the "inner"
066 * parts will be copied
067 *
068 * 2. Enter exact offset
069 *
070 * 3. Improve snapping
071 *
072 * 4. Visual cues could be better
073 *
074 * 5. (long term) Parallelize and adjust offsets of existing ways
075 *
076 * == Code quality ==
077 *
078 * a) The mode, flags, and modifiers might be updated more than necessary.
079 *
080 * Not a performance problem, but better if they where more centralized
081 *
082 * b) Extract generic MapMode services into a super class and/or utility class
083 *
084 * c) Maybe better to simply draw our own source way highlighting?
085 *
086 * Current code doesn't not take into account that ways might been highlighted
087 * by other than us. Don't think that situation should ever happen though.
088 *
089 * @author Ole Jørgen Brønner (olejorgenb)
090 */
091public class ParallelWayAction extends MapMode implements ModifierExListener {
092
093    private static final CachingProperty<BasicStroke> HELPER_LINE_STROKE = new StrokeProperty(prefKey("stroke.hepler-line"), "1").cached();
094    private static final CachingProperty<BasicStroke> REF_LINE_STROKE = new StrokeProperty(prefKey("stroke.ref-line"), "2 2 3").cached();
095
096    // @formatter:off
097    // CHECKSTYLE.OFF: SingleSpaceSeparator
098    private static final CachingProperty<Double> SNAP_THRESHOLD         = new DoubleProperty(prefKey("snap-threshold-percent"), 0.70).cached();
099    private static final CachingProperty<Boolean> SNAP_DEFAULT          = new BooleanProperty(prefKey("snap-default"),      true).cached();
100    private static final CachingProperty<Boolean> COPY_TAGS_DEFAULT     = new BooleanProperty(prefKey("copy-tags-default"), true).cached();
101    private static final CachingProperty<Integer> INITIAL_MOVE_DELAY    = new IntegerProperty(prefKey("initial-move-delay"), 200).cached();
102    private static final CachingProperty<Double> SNAP_DISTANCE_METRIC   = new DoubleProperty(prefKey("snap-distance-metric"), 0.5).cached();
103    private static final CachingProperty<Double> SNAP_DISTANCE_IMPERIAL = new DoubleProperty(prefKey("snap-distance-imperial"), 1).cached();
104    private static final CachingProperty<Double> SNAP_DISTANCE_CHINESE  = new DoubleProperty(prefKey("snap-distance-chinese"), 1).cached();
105    private static final CachingProperty<Double> SNAP_DISTANCE_NAUTICAL = new DoubleProperty(prefKey("snap-distance-nautical"), 0.1).cached();
106    private static final CachingProperty<Color> MAIN_COLOR = new NamedColorProperty(marktr("make parallel helper line"), Color.RED).cached();
107
108    private static final CachingProperty<Map<Modifier, Boolean>> SNAP_MODIFIER_COMBO
109            = new KeyboardModifiersProperty(prefKey("snap-modifier-combo"),             "?sC").cached();
110    private static final CachingProperty<Map<Modifier, Boolean>> COPY_TAGS_MODIFIER_COMBO
111            = new KeyboardModifiersProperty(prefKey("copy-tags-modifier-combo"),        "As?").cached();
112    private static final CachingProperty<Map<Modifier, Boolean>> ADD_TO_SELECTION_MODIFIER_COMBO
113            = new KeyboardModifiersProperty(prefKey("add-to-selection-modifier-combo"), "aSc").cached();
114    private static final CachingProperty<Map<Modifier, Boolean>> TOGGLE_SELECTED_MODIFIER_COMBO
115            = new KeyboardModifiersProperty(prefKey("toggle-selection-modifier-combo"), "asC").cached();
116    private static final CachingProperty<Map<Modifier, Boolean>> SET_SELECTED_MODIFIER_COMBO
117            = new KeyboardModifiersProperty(prefKey("set-selection-modifier-combo"),    "asc").cached();
118    // CHECKSTYLE.ON: SingleSpaceSeparator
119    // @formatter:on
120
121    enum Mode {
122        DRAGGING, NORMAL
123    }
124
125    //// Preferences and flags
126    // See updateModeLocalPreferences for defaults
127    private Mode mode;
128    private boolean copyTags;
129
130    private boolean snap;
131
132    private final MapView mv;
133
134    // Mouse tracking state
135    private Point mousePressedPos;
136    private boolean mouseIsDown;
137    private long mousePressedTime;
138    private boolean mouseHasBeenDragged;
139
140    private transient WaySegment referenceSegment;
141    private transient ParallelWays pWays;
142    private transient Set<Way> sourceWays;
143    private EastNorth helperLineStart;
144    private EastNorth helperLineEnd;
145
146    private final ParallelWayLayer temporaryLayer = new ParallelWayLayer();
147
148    /**
149     * Constructs a new {@code ParallelWayAction}.
150     * @param mapFrame Map frame
151     */
152    public ParallelWayAction(MapFrame mapFrame) {
153        super(tr("Parallel"), "parallel", tr("Make parallel copies of ways"),
154            Shortcut.registerShortcut("mapmode:parallel", tr("Mode: {0}",
155                tr("Parallel")), KeyEvent.VK_P, Shortcut.SHIFT),
156            ImageProvider.getCursor("normal", "parallel"));
157        setHelpId(ht("/Action/Parallel"));
158        mv = mapFrame.mapView;
159    }
160
161    @Override
162    public void enterMode() {
163        // super.enterMode() updates the status line and cursor so we need our state to be set correctly
164        setMode(Mode.NORMAL);
165        pWays = null;
166        super.enterMode();
167
168        // #19887: overwrite default: we want to show the distance to the original way
169        MainApplication.getMap().statusLine.setAutoLength(false);
170
171        mv.addMouseListener(this);
172        mv.addMouseMotionListener(this);
173        mv.addTemporaryLayer(temporaryLayer);
174
175        // Needed to update the mouse cursor if modifiers are changed when the mouse is motionless
176        MainApplication.getMap().keyDetector.addModifierExListener(this);
177        sourceWays = new LinkedHashSet<>(getLayerManager().getEditDataSet().getSelectedWays());
178        for (Way w : sourceWays) {
179            w.setHighlighted(true);
180        }
181    }
182
183    @Override
184    public void exitMode() {
185        super.exitMode();
186        mv.removeMouseListener(this);
187        mv.removeMouseMotionListener(this);
188        mv.removeTemporaryLayer(temporaryLayer);
189        MapFrame map = MainApplication.getMap();
190        map.statusLine.setDist(-1);
191        map.keyDetector.removeModifierExListener(this);
192        removeWayHighlighting(sourceWays);
193        pWays = null;
194        sourceWays = null;
195        referenceSegment = null;
196    }
197
198    @Override
199    public String getModeHelpText() {
200        // TODO: add more detailed feedback based on modifier state.
201        // TODO: dynamic messages based on preferences. (Could be problematic translation wise)
202        switch (mode) {
203        case NORMAL:
204            // CHECKSTYLE.OFF: LineLength
205            return tr("Select ways as in Select mode. Drag selected ways or a single way to create a parallel copy (Alt toggles tag preservation)");
206            // CHECKSTYLE.ON: LineLength
207        case DRAGGING:
208            return tr("Hold Ctrl to toggle snapping");
209        }
210        return ""; // impossible ..
211    }
212
213    @Override
214    public boolean layerIsSupported(Layer l) {
215        return isEditableDataLayer(l);
216    }
217
218    @Override
219    public void modifiersExChanged(int modifiers) {
220        if (MainApplication.getMap() == null || mv == null || !mv.isActiveLayerDrawable())
221            return;
222
223        // Should only get InputEvents due to the mask in enterMode
224        if (updateModifiersState(modifiers)) {
225            updateStatusLine();
226            updateCursor();
227        }
228    }
229
230    private boolean updateModifiersState(int modifiers) {
231        boolean oldAlt = alt, oldShift = shift, oldCtrl = ctrl;
232        updateKeyModifiersEx(modifiers);
233        return oldAlt != alt || oldShift != shift || oldCtrl != ctrl;
234    }
235
236    private void updateCursor() {
237        Cursor newCursor = null;
238        switch (mode) {
239        case NORMAL:
240            if (matchesCurrentModifiers(SET_SELECTED_MODIFIER_COMBO)) {
241                newCursor = ImageProvider.getCursor("normal", "parallel");
242            } else if (matchesCurrentModifiers(ADD_TO_SELECTION_MODIFIER_COMBO)) {
243                newCursor = ImageProvider.getCursor("normal", "parallel_add");
244            } else if (matchesCurrentModifiers(TOGGLE_SELECTED_MODIFIER_COMBO)) {
245                newCursor = ImageProvider.getCursor("normal", "parallel_remove");
246            }
247            break;
248        case DRAGGING:
249            newCursor = Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR);
250            break;
251        default: throw new AssertionError();
252        }
253        if (newCursor != null) {
254            mv.setNewCursor(newCursor, this);
255        }
256    }
257
258    private void setMode(Mode mode) {
259        this.mode = mode;
260        updateCursor();
261        updateStatusLine();
262    }
263
264    private boolean sanityCheck() {
265        // @formatter:off
266        boolean areWeSane =
267            mv.isActiveLayerVisible() &&
268            mv.isActiveLayerDrawable() &&
269            ((Boolean) this.getValue("active"));
270        // @formatter:on
271        assert areWeSane; // mad == bad
272        return areWeSane;
273    }
274
275    @Override
276    public void mousePressed(MouseEvent e) {
277        requestFocusInMapView();
278        updateModifiersState(e.getModifiersEx());
279        // Other buttons are off limit, but we still get events.
280        if (e.getButton() != MouseEvent.BUTTON1)
281            return;
282
283        if (!sanityCheck())
284            return;
285
286        updateFlagsOnlyChangeableOnPress();
287        updateFlagsChangeableAlways();
288
289        // Since the created way is left selected, we need to unselect again here
290        if (pWays != null && pWays.getWays() != null) {
291            getLayerManager().getEditDataSet().clearSelection(pWays.getWays());
292            pWays = null;
293        }
294
295        mouseIsDown = true;
296        mousePressedPos = e.getPoint();
297        mousePressedTime = System.currentTimeMillis();
298
299    }
300
301    @Override
302    public void mouseReleased(MouseEvent e) {
303        updateModifiersState(e.getModifiersEx());
304        // Other buttons are off limit, but we still get events.
305        if (e.getButton() != MouseEvent.BUTTON1)
306            return;
307
308        if (!mouseHasBeenDragged) {
309            // use point from press or click event? (or are these always the same)
310            Way nearestWay = mv.getNearestWay(e.getPoint(), OsmPrimitive::isSelectable);
311            if (nearestWay == null) {
312                if (matchesCurrentModifiers(SET_SELECTED_MODIFIER_COMBO)) {
313                    clearSourceWays();
314                }
315                resetMouseTrackingState();
316                return;
317            }
318            boolean isSelected = nearestWay.isSelected();
319            if (matchesCurrentModifiers(ADD_TO_SELECTION_MODIFIER_COMBO)) {
320                if (!isSelected) {
321                    addSourceWay(nearestWay);
322                }
323            } else if (matchesCurrentModifiers(TOGGLE_SELECTED_MODIFIER_COMBO)) {
324                if (isSelected) {
325                    removeSourceWay(nearestWay);
326                } else {
327                    addSourceWay(nearestWay);
328                }
329            } else if (matchesCurrentModifiers(SET_SELECTED_MODIFIER_COMBO)) {
330                clearSourceWays();
331                addSourceWay(nearestWay);
332            } // else -> invalid modifier combination
333        } else if (mode == Mode.DRAGGING) {
334            clearSourceWays();
335            MainApplication.getMap().statusLine.setDist(pWays.getWays());
336        }
337
338        setMode(Mode.NORMAL);
339        resetMouseTrackingState();
340        temporaryLayer.invalidate();
341    }
342
343    private static void removeWayHighlighting(Collection<Way> ways) {
344        if (ways == null)
345            return;
346        for (Way w : ways) {
347            w.setHighlighted(false);
348        }
349    }
350
351    @Override
352    public void mouseDragged(MouseEvent e) {
353        // WTF.. the event passed here doesn't have button info?
354        // Since we get this event from other buttons too, we must check that
355        // _BUTTON1_ is down.
356        if (!mouseIsDown)
357            return;
358
359        boolean modifiersChanged = updateModifiersState(e.getModifiersEx());
360        updateFlagsChangeableAlways();
361
362        if (modifiersChanged) {
363            // Since this could be remotely slow, do it conditionally
364            updateStatusLine();
365            updateCursor();
366        }
367
368        if ((System.currentTimeMillis() - mousePressedTime) < INITIAL_MOVE_DELAY.get())
369            return;
370        // Assuming this event only is emitted when the mouse has moved
371        // Setting this after the check above means we tolerate clicks with some movement
372        mouseHasBeenDragged = true;
373
374        if (mode == Mode.NORMAL) {
375            // Should we ensure that the copyTags modifiers are still valid?
376
377            // Important to use mouse position from the press, since the drag
378            // event can come quite late
379            if (!isModifiersValidForDragMode())
380                return;
381            if (!initParallelWays(mousePressedPos, copyTags))
382                return;
383            setMode(Mode.DRAGGING);
384        }
385
386        // Calculate distance to the reference line
387        Point p = e.getPoint();
388        EastNorth enp = mv.getEastNorth((int) p.getX(), (int) p.getY());
389        EastNorth nearestPointOnRefLine = Geometry.closestPointToLine(referenceSegment.getFirstNode().getEastNorth(),
390                referenceSegment.getSecondNode().getEastNorth(), enp);
391
392        // Note: d is the distance in _projected units_
393        double d = enp.distance(nearestPointOnRefLine);
394        double realD = mv.getProjection().eastNorth2latlon(enp).greatCircleDistance(mv.getProjection().eastNorth2latlon(nearestPointOnRefLine));
395        double snappedRealD = realD;
396
397        boolean toTheRight = Geometry.angleIsClockwise(
398                referenceSegment.getFirstNode(), referenceSegment.getSecondNode(), new Node(enp));
399
400        if (snap) {
401            // TODO: Very simple snapping
402            // - Snap steps relative to the distance?
403            double snapDistance;
404            SystemOfMeasurement som = SystemOfMeasurement.getSystemOfMeasurement();
405            if (som.equals(SystemOfMeasurement.CHINESE)) {
406                snapDistance = SNAP_DISTANCE_CHINESE.get() * SystemOfMeasurement.CHINESE.aValue;
407            } else if (som.equals(SystemOfMeasurement.IMPERIAL)) {
408                snapDistance = SNAP_DISTANCE_IMPERIAL.get() * SystemOfMeasurement.IMPERIAL.aValue;
409            } else if (som.equals(SystemOfMeasurement.NAUTICAL_MILE)) {
410                snapDistance = SNAP_DISTANCE_NAUTICAL.get() * SystemOfMeasurement.NAUTICAL_MILE.aValue;
411            } else {
412                snapDistance = SNAP_DISTANCE_METRIC.get(); // Metric system by default
413            }
414            double closestWholeUnit;
415            double modulo = realD % snapDistance;
416            if (modulo < snapDistance/2.0) {
417                closestWholeUnit = realD - modulo;
418            } else {
419                closestWholeUnit = realD + (snapDistance-modulo);
420            }
421            if (Math.abs(closestWholeUnit - realD) < (SNAP_THRESHOLD.get() * snapDistance)) {
422                snappedRealD = closestWholeUnit;
423            } else {
424                snappedRealD = closestWholeUnit + Math.signum(realD - closestWholeUnit) * snapDistance;
425            }
426        }
427        d = snappedRealD * (d/realD); // convert back to projected distance. (probably ok on small scales)
428        helperLineStart = nearestPointOnRefLine;
429        helperLineEnd = enp;
430        if (toTheRight) {
431            d = -d;
432        }
433        pWays.changeOffset(d);
434
435        MapFrame map = MainApplication.getMap();
436        map.statusLine.setDist(Math.abs(snappedRealD));
437        map.statusLine.repaint();
438        temporaryLayer.invalidate();
439    }
440
441    private boolean matchesCurrentModifiers(CachingProperty<Map<Modifier, Boolean>> spec) {
442        return matchesCurrentModifiers(spec.get());
443    }
444
445    private boolean matchesCurrentModifiers(Map<Modifier, Boolean> spec) {
446        EnumSet<Modifier> modifiers = EnumSet.noneOf(Modifier.class);
447        if (ctrl) {
448            modifiers.add(Modifier.CTRL);
449        }
450        if (alt) {
451            modifiers.add(Modifier.ALT);
452        }
453        if (shift) {
454            modifiers.add(Modifier.SHIFT);
455        }
456        return spec.entrySet().stream().allMatch(entry -> modifiers.contains(entry.getKey()) == entry.getValue().booleanValue());
457    }
458
459    private boolean isModifiersValidForDragMode() {
460        return (!alt && !shift && !ctrl) || matchesCurrentModifiers(SNAP_MODIFIER_COMBO)
461                || matchesCurrentModifiers(COPY_TAGS_MODIFIER_COMBO);
462    }
463
464    private void updateFlagsOnlyChangeableOnPress() {
465        copyTags = COPY_TAGS_DEFAULT.get().booleanValue() != matchesCurrentModifiers(COPY_TAGS_MODIFIER_COMBO);
466    }
467
468    private void updateFlagsChangeableAlways() {
469        snap = SNAP_DEFAULT.get().booleanValue() != matchesCurrentModifiers(SNAP_MODIFIER_COMBO);
470    }
471
472    // We keep the source ways and the selection in sync so the user can see the source way's tags
473    private void addSourceWay(Way w) {
474        assert sourceWays != null;
475        getLayerManager().getEditDataSet().addSelected(w);
476        w.setHighlighted(true);
477        sourceWays.add(w);
478    }
479
480    private void removeSourceWay(Way w) {
481        assert sourceWays != null;
482        getLayerManager().getEditDataSet().clearSelection(w);
483        w.setHighlighted(false);
484        sourceWays.remove(w);
485    }
486
487    private void clearSourceWays() {
488        assert sourceWays != null;
489        getLayerManager().getEditDataSet().clearSelection(sourceWays);
490        for (Way w : sourceWays) {
491            w.setHighlighted(false);
492        }
493        sourceWays.clear();
494    }
495
496    private void resetMouseTrackingState() {
497        mouseIsDown = false;
498        mousePressedPos = null;
499        mouseHasBeenDragged = false;
500    }
501
502    // TODO: rename
503    private boolean initParallelWays(Point p, boolean copyTags) {
504        referenceSegment = mv.getNearestWaySegment(p, OsmPrimitive::isUsable, true);
505        if (referenceSegment == null)
506            return false;
507
508        sourceWays.removeIf(w -> w.isIncomplete() || w.isEmpty());
509
510        if (!sourceWays.contains(referenceSegment.getWay())) {
511            clearSourceWays();
512            addSourceWay(referenceSegment.getWay());
513        }
514
515        try {
516            int referenceWayIndex = -1;
517            int i = 0;
518            for (Way w : sourceWays) {
519                if (w == referenceSegment.getWay()) {
520                    referenceWayIndex = i;
521                    break;
522                }
523                i++;
524            }
525            pWays = new ParallelWays(sourceWays, copyTags, referenceWayIndex);
526            pWays.commit();
527            getLayerManager().getEditDataSet().setSelected(pWays.getWays());
528            return true;
529        } catch (IllegalArgumentException e) {
530            Logging.debug(e);
531            new Notification(tr("ParallelWayAction\n" +
532                    "The ways selected must form a simple branchless path"))
533                    .setIcon(JOptionPane.INFORMATION_MESSAGE)
534                    .show();
535            // The error dialog prevents us from getting the mouseReleased event
536            resetMouseTrackingState();
537            pWays = null;
538            return false;
539        }
540    }
541
542    private static String prefKey(String subKey) {
543        return "edit.make-parallel-way-action." + subKey;
544    }
545
546    /**
547     * A property that holds the keyboard modifiers.
548     * @author Michael Zangl
549     * @since 10869
550     */
551    private static class KeyboardModifiersProperty extends AbstractToStringProperty<Map<Modifier, Boolean>> {
552
553        KeyboardModifiersProperty(String key, String defaultValue) {
554            super(key, createFromString(defaultValue));
555        }
556
557        KeyboardModifiersProperty(String key, Map<Modifier, Boolean> defaultValue) {
558            super(key, defaultValue);
559        }
560
561        @Override
562        protected String toString(Map<Modifier, Boolean> t) {
563            StringBuilder sb = new StringBuilder();
564            for (Modifier mod : Modifier.values()) {
565                Boolean val = t.get(mod);
566                if (val == null) {
567                    sb.append('?');
568                } else if (val) {
569                    sb.append(Character.toUpperCase(mod.shortChar));
570                } else {
571                    sb.append(mod.shortChar);
572                }
573            }
574            return sb.toString();
575        }
576
577        @Override
578        protected Map<Modifier, Boolean> fromString(String string) {
579            return createFromString(string);
580        }
581
582        private static Map<Modifier, Boolean> createFromString(String string) {
583            Map<Modifier, Boolean> ret = new EnumMap<>(Modifier.class);
584            for (int i = 0; i < string.length(); i++) {
585                char c = string.charAt(i);
586                if (c == '?') {
587                    continue;
588                }
589                Optional<Modifier> mod = Modifier.findWithShortCode(c);
590                if (mod.isPresent()) {
591                    ret.put(mod.get(), Character.isUpperCase(c));
592                } else {
593                    Logging.debug("Ignoring unknown modifier {0}", c);
594                }
595            }
596            return Collections.unmodifiableMap(ret);
597        }
598    }
599
600    enum Modifier {
601        CTRL('c'),
602        ALT('a'),
603        SHIFT('s');
604
605        private final char shortChar;
606
607        Modifier(char shortChar) {
608            this.shortChar = Character.toLowerCase(shortChar);
609        }
610
611        /**
612         * Find the modifier with the given short code
613         * @param charCode The short code
614         * @return The modifier
615         */
616        public static Optional<Modifier> findWithShortCode(int charCode) {
617            return Stream.of(values()).filter(m -> m.shortChar == Character.toLowerCase(charCode)).findAny();
618        }
619    }
620
621    private class ParallelWayLayer extends AbstractMapViewPaintable {
622        @Override
623        public void paint(Graphics2D g, MapView mv, Bounds bbox) {
624            if (mode == Mode.DRAGGING) {
625                CheckParameterUtil.ensureParameterNotNull(mv, "mv");
626
627                Color mainColor = MAIN_COLOR.get();
628                g.setStroke(REF_LINE_STROKE.get());
629                g.setColor(mainColor);
630                MapViewPath line = new MapViewPath(mv);
631                line.moveTo(referenceSegment.getFirstNode());
632                line.lineTo(referenceSegment.getSecondNode());
633                g.draw(line.computeClippedLine(g.getStroke()));
634
635                g.setStroke(HELPER_LINE_STROKE.get());
636                g.setColor(mainColor);
637                line = new MapViewPath(mv);
638                line.moveTo(helperLineStart);
639                line.lineTo(helperLineEnd);
640                g.draw(line.computeClippedLine(g.getStroke()));
641            }
642        }
643    }
644}