001    // License: GPL. See LICENSE file for details.
002    package org.openstreetmap.josm.actions.mapmode;
003    
004    import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
005    import static org.openstreetmap.josm.tools.I18n.tr;
006    
007    import java.awt.AWTEvent;
008    import java.awt.BasicStroke;
009    import java.awt.Color;
010    import java.awt.Cursor;
011    import java.awt.Graphics2D;
012    import java.awt.Point;
013    import java.awt.Stroke;
014    import java.awt.Toolkit;
015    import java.awt.event.AWTEventListener;
016    import java.awt.event.InputEvent;
017    import java.awt.event.KeyEvent;
018    import java.awt.event.MouseEvent;
019    import java.util.Collection;
020    import java.util.LinkedHashSet;
021    
022    import javax.swing.JOptionPane;
023    
024    import org.openstreetmap.josm.Main;
025    import org.openstreetmap.josm.data.Bounds;
026    import org.openstreetmap.josm.data.coor.EastNorth;
027    import org.openstreetmap.josm.data.osm.Node;
028    import org.openstreetmap.josm.data.osm.OsmPrimitive;
029    import org.openstreetmap.josm.data.osm.Way;
030    import org.openstreetmap.josm.data.osm.WaySegment;
031    import org.openstreetmap.josm.gui.MapFrame;
032    import org.openstreetmap.josm.gui.MapView;
033    import org.openstreetmap.josm.gui.NavigatableComponent;
034    import org.openstreetmap.josm.gui.NavigatableComponent.SystemOfMeasurement;
035    import org.openstreetmap.josm.gui.layer.Layer;
036    import org.openstreetmap.josm.gui.layer.MapViewPaintable;
037    import org.openstreetmap.josm.gui.layer.OsmDataLayer;
038    import org.openstreetmap.josm.tools.Geometry;
039    import org.openstreetmap.josm.tools.ImageProvider;
040    import org.openstreetmap.josm.tools.Shortcut;
041    
042    //// TODO: (list below)
043    /* == Functionality ==
044     *
045     * 1. Use selected nodes as split points for the selected ways.
046     *
047     * The ways containing the selected nodes will be split and only the "inner"
048     * parts will be copied
049     *
050     * 2. Enter exact offset
051     *
052     * 3. Improve snapping
053     *
054     * 4. Visual cues could be better
055     *
056     * 5. Cursors (Half-done)
057     *
058     * 6. (long term) Parallelize and adjust offsets of existing ways
059     *
060     * == Code quality ==
061     *
062     * a) The mode, flags, and modifiers might be updated more than necessary.
063     *
064     * Not a performance problem, but better if they where more centralized
065     *
066     * b) Extract generic MapMode services into a super class and/or utility class
067     *
068     * c) Maybe better to simply draw our own source way highlighting?
069     *
070     * Current code doesn't not take into account that ways might been highlighted
071     * by other than us. Don't think that situation should ever happen though.
072     */
073    
074    /**
075     * MapMode for making parallel ways.
076     *
077     * All calculations are done in projected coordinates.
078     *
079     * @author Ole J??rgen Br??nner (olejorgenb)
080     */
081    public class ParallelWayAction extends MapMode implements AWTEventListener, MapViewPaintable {
082    
083        private static final long serialVersionUID = 1L;
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    
103        private ModifiersSpec snapModifierCombo;
104        private ModifiersSpec copyTagsModifierCombo;
105        private ModifiersSpec addToSelectionModifierCombo;
106        private ModifiersSpec toggleSelectedModifierCombo;
107        private ModifiersSpec setSelectedModifierCombo;
108    
109        private int initialMoveDelay;
110    
111        private final MapView mv;
112    
113        // Mouse tracking state
114        private Point mousePressedPos;
115        private boolean mouseIsDown;
116        private long mousePressedTime;
117        private boolean mouseHasBeenDragged;
118    
119        private WaySegment referenceSegment;
120        private ParallelWays pWays;
121        LinkedHashSet<Way> sourceWays;
122        private EastNorth helperLineStart;
123        private EastNorth helperLineEnd;
124    
125        public ParallelWayAction(MapFrame mapFrame) {
126            super(tr("Parallel"), "parallel", tr("Make parallel copies of ways"),
127                Shortcut.registerShortcut("mapmode:parallel", tr("Mode: {0}",
128                    tr("Parallel")), KeyEvent.VK_P, Shortcut.SHIFT),
129                mapFrame, ImageProvider.getCursor("normal", "parallel"));
130            putValue("help", ht("/Action/Parallel"));
131            mv = mapFrame.mapView;
132            updateModeLocalPreferences();
133        }
134    
135        @Override
136        public void enterMode() {
137            // super.enterMode() updates the status line and cursor so we need our state to be set correctly
138            setMode(Mode.normal);
139            pWays = null;
140            updateAllPreferences(); // All default values should've been set now
141    
142            super.enterMode();
143    
144            mv.addMouseListener(this);
145            mv.addMouseMotionListener(this);
146            mv.addTemporaryLayer(this);
147    
148            //// Needed to update the mouse cursor if modifiers are changed when the mouse is motionless
149            try {
150                Toolkit.getDefaultToolkit().addAWTEventListener(this, AWTEvent.KEY_EVENT_MASK);
151            } catch (SecurityException ex) {
152            }
153            sourceWays = new LinkedHashSet<Way>(getCurrentDataSet().getSelectedWays());
154            for (Way w : sourceWays) {
155                w.setHighlighted(true);
156            }
157            mv.repaint();
158        }
159    
160        @Override
161        public void exitMode() {
162            super.exitMode();
163            mv.removeMouseListener(this);
164            mv.removeMouseMotionListener(this);
165            mv.removeTemporaryLayer(this);
166            Main.map.statusLine.setDist(-1);
167            Main.map.statusLine.repaint();
168            try {
169                Toolkit.getDefaultToolkit().removeAWTEventListener(this);
170            } catch (SecurityException ex) {
171            }
172            removeWayHighlighting(sourceWays);
173            pWays = null;
174            sourceWays = null;
175            referenceSegment = null;
176            mv.repaint();
177        }
178    
179        @Override
180        public String getModeHelpText() {
181            // TODO: add more detailed feedback based on modifier state.
182            // TODO: dynamic messages based on preferences. (Could be problematic translation wise)
183            switch (mode) {
184            case normal:
185                return tr("Select ways as in Select mode. Drag selected ways or a single way to create a parallel copy (Alt toggles tag preservation)");
186            case dragging:
187                return tr("Hold Ctrl to toggle snapping");
188            }
189            return ""; // impossible ..
190        }
191    
192        // Separated due to "race condition" between default values
193        private void updateAllPreferences() {
194            updateModeLocalPreferences();
195            // @formatter:off
196            // @formatter:on
197        }
198    
199        private void updateModeLocalPreferences() {
200            // @formatter:off
201            //snapThreshold        = Main.pref.getDouble (prefKey("snap-threshold"), 0.35); // Old preference was stored in meters, hence the new name (percent)
202            snapThreshold        = Main.pref.getDouble (prefKey("snap-threshold-percent"), 0.70);
203            snapDefault          = Main.pref.getBoolean(prefKey("snap-default"),      true);
204            copyTagsDefault      = Main.pref.getBoolean(prefKey("copy-tags-default"), true);
205            initialMoveDelay     = Main.pref.getInteger(prefKey("initial-move-delay"), 200);
206            snapDistanceMetric   = Main.pref.getDouble(prefKey("snap-distance-metric"), 0.5);
207            snapDistanceImperial = Main.pref.getDouble(prefKey("snap-distance-imperial"), 1);
208            snapDistanceChinese  = Main.pref.getDouble(prefKey("snap-distance-chinese"), 1);
209    
210            snapModifierCombo           = new ModifiersSpec(getStringPref("snap-modifier-combo",             "?sC"));
211            copyTagsModifierCombo       = new ModifiersSpec(getStringPref("copy-tags-modifier-combo",        "As?"));
212            addToSelectionModifierCombo = new ModifiersSpec(getStringPref("add-to-selection-modifier-combo", "aSc"));
213            toggleSelectedModifierCombo = new ModifiersSpec(getStringPref("toggle-selection-modifier-combo", "asC"));
214            setSelectedModifierCombo    = new ModifiersSpec(getStringPref("set-selection-modifier-combo",    "asc"));
215            // @formatter:on
216        }
217    
218        @Override
219        public boolean layerIsSupported(Layer layer) {
220            return layer instanceof OsmDataLayer;
221        }
222    
223        @Override
224        public void eventDispatched(AWTEvent e) {
225            if (Main.map == null || mv == null || !mv.isActiveLayerDrawable())
226                return;
227    
228            // Should only get InputEvents due to the mask in enterMode
229            if (updateModifiersState((InputEvent) e)) {
230                updateStatusLine();
231                updateCursor();
232            }
233        }
234    
235        private boolean updateModifiersState(InputEvent e) {
236            boolean oldAlt = alt, oldShift = shift, oldCtrl = ctrl;
237            updateKeyModifiers(e);
238            boolean changed = (oldAlt != alt || oldShift != shift || oldCtrl != ctrl);
239            return changed;
240        }
241    
242        private void updateCursor() {
243            Cursor newCursor = null;
244            switch (mode) {
245            case normal:
246                if (matchesCurrentModifiers(setSelectedModifierCombo)) {
247                    newCursor = ImageProvider.getCursor("normal", "parallel");
248                } else if (matchesCurrentModifiers(addToSelectionModifierCombo)) {
249                    newCursor = ImageProvider.getCursor("normal", "parallel"); // FIXME
250                } else if (matchesCurrentModifiers(toggleSelectedModifierCombo)) {
251                    newCursor = ImageProvider.getCursor("normal", "parallel"); // FIXME
252                } else {
253                    // TODO: set to a cursor indicating an error
254                }
255                break;
256            case dragging:
257                if (snap) {
258                    // TODO: snapping cursor?
259                    newCursor = Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR);
260                } else {
261                    newCursor = Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR);
262                }
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 isValidModifierCombination() {
276            // TODO: implement to give feedback on invalid modifier combination
277            return true;
278        }
279    
280        private boolean sanityCheck() {
281            // @formatter:off
282            boolean areWeSane =
283                mv.isActiveLayerVisible() &&
284                mv.isActiveLayerDrawable() &&
285                ((Boolean) this.getValue("active"));
286            // @formatter:on
287            assert (areWeSane); // mad == bad
288            return areWeSane;
289        }
290    
291        @Override
292        public void mousePressed(MouseEvent e) {
293            updateModifiersState(e);
294            // Other buttons are off limit, but we still get events.
295            if (e.getButton() != MouseEvent.BUTTON1)
296                return;
297    
298            if(sanityCheck() == false)
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);
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);
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(NavigatableComponent.CHINESE_SOM)) {
421                    snapDistance = snapDistanceChinese * NavigatableComponent.CHINESE_SOM.aValue;
422                } else if (som.equals(NavigatableComponent.IMPERIAL_SOM)) {
423                    snapDistance = snapDistanceImperial * NavigatableComponent.IMPERIAL_SOM.aValue;
424                } else {
425                    snapDistance = snapDistanceMetric; // Metric system by default
426                }
427                double closestWholeUnit;
428                double modulo = realD % snapDistance;
429                if (modulo < snapDistance/2.0) {
430                    closestWholeUnit = realD - modulo;
431                } else {
432                    closestWholeUnit = realD + (snapDistance-modulo);
433                }
434                if (Math.abs(closestWholeUnit - realD) < (snapThreshold * snapDistance)) {
435                    snappedRealD = closestWholeUnit;
436                } else {
437                    snappedRealD = closestWholeUnit + Math.signum(realD - closestWholeUnit) * snapDistance;
438                }
439            }
440            d = snappedRealD * (d/realD); // convert back to projected distance. (probably ok on small scales)
441            helperLineStart = nearestPointOnRefLine;
442            helperLineEnd = enp;
443            if (toTheRight) {
444                d = -d;
445            }
446            pWays.changeOffset(d);
447    
448            Main.map.statusLine.setDist(Math.abs(snappedRealD));
449            Main.map.statusLine.repaint();
450            mv.repaint();
451        }
452    
453        private boolean matchesCurrentModifiers(ModifiersSpec spec) {
454            return spec.matchWithKnown(alt, shift, ctrl);
455        }
456    
457        @Override
458        public void paint(Graphics2D g, MapView mv, Bounds bbox) {
459            if (mode == Mode.dragging) {
460                // sanity checks
461                if (mv == null)
462                    return;
463    
464                // FIXME: should clip the line (gets insanely slow when zoomed in on a very long line
465                Stroke refLineStroke = new BasicStroke(1, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL, 10.0f, new float[] {
466                        2f, 2f }, 0f);
467                g.setStroke(refLineStroke);
468                g.setColor(Color.RED);
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                Stroke helpLineStroke = new BasicStroke(1, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL);
474                g.setStroke(helpLineStroke);
475                g.setColor(Color.RED);
476                p1 = mv.getPoint(helperLineStart);
477                p2 = mv.getPoint(helperLineEnd);
478                g.drawLine(p1.x, p1.y, p2.x, p2.y);
479            }
480        }
481    
482        private boolean isModifiersValidForDragMode() {
483            return (!alt && !shift && !ctrl) || matchesCurrentModifiers(snapModifierCombo)
484                    || matchesCurrentModifiers(copyTagsModifierCombo);
485        }
486    
487        private void updateFlagsOnlyChangeableOnPress() {
488            copyTags = copyTagsDefault != matchesCurrentModifiers(copyTagsModifierCombo);
489        }
490    
491        private void updateFlagsChangeableAlways() {
492            snap = snapDefault != matchesCurrentModifiers(snapModifierCombo);
493        }
494    
495        //// We keep the source ways and the selection in sync so the user can see the source way's tags
496        private void addSourceWay(Way w) {
497            assert (sourceWays != null);
498            getCurrentDataSet().addSelected(w);
499            w.setHighlighted(true);
500            sourceWays.add(w);
501        }
502    
503        private void removeSourceWay(Way w) {
504            assert (sourceWays != null);
505            getCurrentDataSet().clearSelection(w);
506            w.setHighlighted(false);
507            sourceWays.remove(w);
508        }
509    
510        private void clearSourceWays() {
511            assert (sourceWays != null);
512            if (sourceWays == null)
513                return;
514            getCurrentDataSet().clearSelection(sourceWays);
515            for (Way w : sourceWays) {
516                w.setHighlighted(false);
517            }
518            sourceWays.clear();
519        }
520    
521        private void resetMouseTrackingState() {
522            mouseIsDown = false;
523            mousePressedPos = null;
524            mouseHasBeenDragged = false;
525        }
526    
527        // TODO: rename
528        private boolean initParallelWays(Point p, boolean copyTags) {
529            referenceSegment = mv.getNearestWaySegment(p, Way.isUsablePredicate, true);
530            if (referenceSegment == null)
531                return false;
532    
533            if (!sourceWays.contains(referenceSegment.way)) {
534                clearSourceWays();
535                addSourceWay(referenceSegment.way);
536            }
537    
538            try {
539                int referenceWayIndex = -1;
540                int i = 0;
541                for (Way w : sourceWays) {
542                    if (w == referenceSegment.way) {
543                        referenceWayIndex = i;
544                        break;
545                    }
546                    i++;
547                }
548                pWays = new ParallelWays(sourceWays, copyTags, referenceWayIndex);
549                pWays.commit();
550                getCurrentDataSet().setSelected(pWays.ways);
551                return true;
552            } catch (IllegalArgumentException e) {
553                // TODO: Not ideal feedback. Maybe changing the cursor could be a good mechanism?
554                JOptionPane.showMessageDialog(
555                        Main.parent,
556                        tr("ParallelWayAction\n" +
557                                "The ways selected must form a simple branchless path"),
558                        tr("Make parallel way error"),
559                        JOptionPane.INFORMATION_MESSAGE);
560                // The error dialog prevents us from getting the mouseReleased event
561                resetMouseTrackingState();
562                pWays = null;
563                return false;
564            }
565        }
566    
567        private String prefKey(String subKey) {
568            return "edit.make-parallel-way-action." + subKey;
569        }
570    
571        private String getStringPref(String subKey, String def) {
572            return Main.pref.get(prefKey(subKey), def);
573        }
574    
575        private String getStringPref(String subKey) {
576            return getStringPref(subKey, null);
577        }
578    }