001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.actions.mapmode;
003
004import static org.openstreetmap.josm.tools.I18n.marktr;
005import static org.openstreetmap.josm.tools.I18n.tr;
006import static org.openstreetmap.josm.tools.I18n.trn;
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.ArrayList;
016import java.util.Collection;
017import java.util.Collections;
018import java.util.HashSet;
019import java.util.LinkedList;
020import java.util.List;
021import java.util.Set;
022
023import javax.swing.JOptionPane;
024
025import org.openstreetmap.josm.command.AddCommand;
026import org.openstreetmap.josm.command.ChangeCommand;
027import org.openstreetmap.josm.command.Command;
028import org.openstreetmap.josm.command.DeleteCommand;
029import org.openstreetmap.josm.command.MoveCommand;
030import org.openstreetmap.josm.command.SequenceCommand;
031import org.openstreetmap.josm.data.Bounds;
032import org.openstreetmap.josm.data.UndoRedoHandler;
033import org.openstreetmap.josm.data.coor.EastNorth;
034import org.openstreetmap.josm.data.osm.DataSelectionListener;
035import org.openstreetmap.josm.data.osm.DataSet;
036import org.openstreetmap.josm.data.osm.Node;
037import org.openstreetmap.josm.data.osm.OsmPrimitive;
038import org.openstreetmap.josm.data.osm.Way;
039import org.openstreetmap.josm.data.osm.WaySegment;
040import org.openstreetmap.josm.data.osm.event.SelectionEventManager;
041import org.openstreetmap.josm.data.preferences.CachingProperty;
042import org.openstreetmap.josm.data.preferences.IntegerProperty;
043import org.openstreetmap.josm.data.preferences.NamedColorProperty;
044import org.openstreetmap.josm.data.preferences.StrokeProperty;
045import org.openstreetmap.josm.gui.MainApplication;
046import org.openstreetmap.josm.gui.MapFrame;
047import org.openstreetmap.josm.gui.MapView;
048import org.openstreetmap.josm.gui.draw.MapViewPath;
049import org.openstreetmap.josm.gui.draw.SymbolShape;
050import org.openstreetmap.josm.gui.layer.AbstractMapViewPaintable;
051import org.openstreetmap.josm.gui.layer.Layer;
052import org.openstreetmap.josm.gui.util.ModifierExListener;
053import org.openstreetmap.josm.tools.ImageProvider;
054import org.openstreetmap.josm.tools.Logging;
055import org.openstreetmap.josm.tools.Pair;
056import org.openstreetmap.josm.tools.Shortcut;
057
058/**
059 * A special map mode that is optimized for improving way geometry.
060 * (by efficiently moving, adding and deleting way-nodes)
061 *
062 * @author Alexander Kachkaev <alexander@kachkaev.ru>, 2011
063 */
064public class ImproveWayAccuracyAction extends MapMode implements DataSelectionListener, ModifierExListener {
065
066    private static final String CROSSHAIR = /* ICON(cursor/)*/ "crosshair";
067
068    enum State {
069        SELECTING, IMPROVING
070    }
071
072    private State state;
073
074    private MapView mv;
075
076    private static final long serialVersionUID = 42L;
077
078    private transient Way targetWay;
079    private transient Node candidateNode;
080    private transient WaySegment candidateSegment;
081
082    private Point mousePos;
083    private boolean dragging;
084
085    private final Cursor cursorSelect = ImageProvider.getCursor(/* ICON(cursor/)*/ "normal", /* ICON(cursor/modifier/)*/ "mode");
086    private final Cursor cursorSelectHover = ImageProvider.getCursor(/* ICON(cursor/)*/ "hand", /* ICON(cursor/modifier/)*/ "mode");
087    private final Cursor cursorImprove = ImageProvider.getCursor(CROSSHAIR, null);
088    private final Cursor cursorImproveAdd = ImageProvider.getCursor(CROSSHAIR, /* ICON(cursor/modifier/)*/ "addnode");
089    private final Cursor cursorImproveDelete = ImageProvider.getCursor(CROSSHAIR, /* ICON(cursor/modifier/)*/ "delete_node");
090    private final Cursor cursorImproveAddLock = ImageProvider.getCursor(CROSSHAIR, /* ICON(cursor/modifier/)*/ "add_node_lock");
091    private final Cursor cursorImproveLock = ImageProvider.getCursor(CROSSHAIR, /* ICON(cursor/modifier/)*/ "lock");
092
093    private Color guideColor;
094
095    private static final CachingProperty<BasicStroke> SELECT_TARGET_WAY_STROKE
096            = new StrokeProperty("improvewayaccuracy.stroke.select-target", "2").cached();
097    private static final CachingProperty<BasicStroke> MOVE_NODE_STROKE
098            = new StrokeProperty("improvewayaccuracy.stroke.move-node", "1 6").cached();
099    private static final CachingProperty<BasicStroke> MOVE_NODE_INTERSECTING_STROKE
100            = new StrokeProperty("improvewayaccuracy.stroke.move-node-intersecting", "1 2 6").cached();
101    private static final CachingProperty<BasicStroke> ADD_NODE_STROKE
102            = new StrokeProperty("improvewayaccuracy.stroke.add-node", "1").cached();
103    private static final CachingProperty<BasicStroke> DELETE_NODE_STROKE
104            = new StrokeProperty("improvewayaccuracy.stroke.delete-node", "1").cached();
105    private static final CachingProperty<Integer> DOT_SIZE
106            = new IntegerProperty("improvewayaccuracy.dot-size", 6).cached();
107
108    private boolean selectionChangedBlocked;
109
110    protected String oldModeHelpText;
111
112    private final transient AbstractMapViewPaintable temporaryLayer = new AbstractMapViewPaintable() {
113        @Override
114        public void paint(Graphics2D g, MapView mv, Bounds bbox) {
115            ImproveWayAccuracyAction.this.paint(g, mv, bbox);
116        }
117    };
118
119    /**
120     * Constructs a new {@code ImproveWayAccuracyAction}.
121     * @since 11713
122     */
123    public ImproveWayAccuracyAction() {
124        super(tr("Improve Way Accuracy"), "improvewayaccuracy",
125                tr("Improve Way Accuracy mode"),
126                Shortcut.registerShortcut("mapmode:ImproveWayAccuracy",
127                tr("Mode: {0}", tr("Improve Way Accuracy")),
128                KeyEvent.VK_W, Shortcut.DIRECT), Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR));
129
130        readPreferences();
131    }
132
133    // -------------------------------------------------------------------------
134    // Mode methods
135    // -------------------------------------------------------------------------
136    @Override
137    public void enterMode() {
138        if (!isEnabled()) {
139            return;
140        }
141        super.enterMode();
142        readPreferences();
143
144        MapFrame map = MainApplication.getMap();
145        mv = map.mapView;
146        mousePos = null;
147        oldModeHelpText = "";
148
149        if (getLayerManager().getEditDataSet() == null) {
150            return;
151        }
152
153        updateStateByCurrentSelection();
154
155        map.mapView.addMouseListener(this);
156        map.mapView.addMouseMotionListener(this);
157        map.mapView.addTemporaryLayer(temporaryLayer);
158        SelectionEventManager.getInstance().addSelectionListener(this);
159
160        map.keyDetector.addModifierExListener(this);
161    }
162
163    @Override
164    protected void readPreferences() {
165        guideColor = new NamedColorProperty(marktr("improve way accuracy helper line"), Color.RED).get();
166    }
167
168    @Override
169    public void exitMode() {
170        super.exitMode();
171
172        MapFrame map = MainApplication.getMap();
173        map.mapView.removeMouseListener(this);
174        map.mapView.removeMouseMotionListener(this);
175        map.mapView.removeTemporaryLayer(temporaryLayer);
176        SelectionEventManager.getInstance().removeSelectionListener(this);
177
178        map.keyDetector.removeModifierExListener(this);
179        temporaryLayer.invalidate();
180    }
181
182    @Override
183    protected void updateStatusLine() {
184        String newModeHelpText = getModeHelpText();
185        if (!newModeHelpText.equals(oldModeHelpText)) {
186            oldModeHelpText = newModeHelpText;
187            MapFrame map = MainApplication.getMap();
188            map.statusLine.setHelpText(newModeHelpText);
189            map.statusLine.repaint();
190        }
191    }
192
193    @Override
194    public String getModeHelpText() {
195        if (state == State.SELECTING) {
196            if (targetWay != null) {
197                return tr("Click on the way to start improving its shape.");
198            } else {
199                return tr("Select a way that you want to make more accurate.");
200            }
201        } else {
202            if (ctrl) {
203                return tr("Click to add a new node. Release Ctrl to move existing nodes or hold Alt to delete.");
204            } else if (alt) {
205                return tr("Click to delete the highlighted node. Release Alt to move existing nodes or hold Ctrl to add new nodes.");
206            } else {
207                return tr("Click to move the highlighted node. Hold Ctrl to add new nodes, or Alt to delete.");
208            }
209        }
210    }
211
212    @Override
213    public boolean layerIsSupported(Layer l) {
214        return isEditableDataLayer(l);
215    }
216
217    @Override
218    protected void updateEnabledState() {
219        setEnabled(getLayerManager().getEditLayer() != null);
220    }
221
222    // -------------------------------------------------------------------------
223    // MapViewPaintable methods
224    // -------------------------------------------------------------------------
225    /**
226     * Redraws temporary layer. Highlights targetWay in select mode. Draws
227     * preview lines in improve mode and highlights the candidateNode
228     * @param g The graphics
229     * @param mv The map view
230     * @param bbox The bounding box
231     */
232    public void paint(Graphics2D g, MapView mv, Bounds bbox) {
233        if (mousePos == null) {
234            return;
235        }
236
237        g.setColor(guideColor);
238
239        if (state == State.SELECTING && targetWay != null) {
240            // Highlighting the targetWay in Selecting state
241            // Non-native highlighting is used, because sometimes highlighted
242            // segments are covered with others, which is bad.
243            BasicStroke stroke = SELECT_TARGET_WAY_STROKE.get();
244            g.setStroke(stroke);
245
246            List<Node> nodes = targetWay.getNodes();
247
248            g.draw(new MapViewPath(mv).append(nodes, false).computeClippedLine(stroke));
249
250        } else if (state == State.IMPROVING) {
251            // Drawing preview lines and highlighting the node
252            // that is going to be moved.
253            // Non-native highlighting is used here as well.
254
255            // Finding endpoints
256            Node p1 = null;
257            Node p2 = null;
258            if (ctrl && candidateSegment != null) {
259                g.setStroke(ADD_NODE_STROKE.get());
260                try {
261                    p1 = candidateSegment.getFirstNode();
262                    p2 = candidateSegment.getSecondNode();
263                } catch (ArrayIndexOutOfBoundsException e) {
264                    Logging.error(e);
265                }
266            } else if (!alt && !ctrl && candidateNode != null) {
267                g.setStroke(MOVE_NODE_STROKE.get());
268                List<Pair<Node, Node>> wpps = targetWay.getNodePairs(false);
269                for (Pair<Node, Node> wpp : wpps) {
270                    if (wpp.a == candidateNode) {
271                        p1 = wpp.b;
272                    }
273                    if (wpp.b == candidateNode) {
274                        p2 = wpp.a;
275                    }
276                    if (p1 != null && p2 != null) {
277                        break;
278                    }
279                }
280            } else if (alt && !ctrl && candidateNode != null) {
281                g.setStroke(DELETE_NODE_STROKE.get());
282                List<Node> nodes = targetWay.getNodes();
283                int index = nodes.indexOf(candidateNode);
284
285                // Only draw line if node is not first and/or last
286                if (index > 0 && index < (nodes.size() - 1)) {
287                    p1 = nodes.get(index - 1);
288                    p2 = nodes.get(index + 1);
289                } else if (targetWay.isClosed()) {
290                    p1 = targetWay.getNode(1);
291                    p2 = targetWay.getNode(nodes.size() - 2);
292                }
293                // TODO: indicate what part that will be deleted? (for end nodes)
294            }
295
296
297            // Drawing preview lines
298            MapViewPath b = new MapViewPath(mv);
299            if (alt && !ctrl) {
300                // In delete mode
301                if (p1 != null && p2 != null) {
302                    b.moveTo(p1);
303                    b.lineTo(p2);
304                }
305            } else {
306                // In add or move mode
307                if (p1 != null) {
308                    b.moveTo(mousePos.x, mousePos.y);
309                    b.lineTo(p1);
310                }
311                if (p2 != null) {
312                    b.moveTo(mousePos.x, mousePos.y);
313                    b.lineTo(p2);
314                }
315            }
316            g.draw(b.computeClippedLine(g.getStroke()));
317
318            // Highlighting candidateNode
319            if (candidateNode != null) {
320                p1 = candidateNode;
321                g.fill(new MapViewPath(mv).shapeAround(p1, SymbolShape.SQUARE, DOT_SIZE.get()));
322            }
323
324            if (!alt && !ctrl && candidateNode != null) {
325                b.reset();
326                drawIntersectingWayHelperLines(mv, b);
327                g.setStroke(MOVE_NODE_INTERSECTING_STROKE.get());
328                g.draw(b.computeClippedLine(g.getStroke()));
329            }
330
331        }
332    }
333
334    protected void drawIntersectingWayHelperLines(MapView mv, MapViewPath b) {
335        for (final OsmPrimitive referrer : candidateNode.getReferrers()) {
336            if (!(referrer instanceof Way) || targetWay.equals(referrer)) {
337                continue;
338            }
339            final List<Node> nodes = ((Way) referrer).getNodes();
340            for (int i = 0; i < nodes.size(); i++) {
341                if (!candidateNode.equals(nodes.get(i))) {
342                    continue;
343                }
344                if (i > 0) {
345                    b.moveTo(mousePos.x, mousePos.y);
346                    b.lineTo(nodes.get(i - 1));
347                }
348                if (i < nodes.size() - 1) {
349                    b.moveTo(mousePos.x, mousePos.y);
350                    b.lineTo(nodes.get(i + 1));
351                }
352            }
353        }
354    }
355
356    // -------------------------------------------------------------------------
357    // Event handlers
358    // -------------------------------------------------------------------------
359    @Override
360    public void modifiersExChanged(int modifiers) {
361        if (!MainApplication.isDisplayingMapView() || !MainApplication.getMap().mapView.isActiveLayerDrawable()) {
362            return;
363        }
364        updateKeyModifiersEx(modifiers);
365        updateCursorDependentObjectsIfNeeded();
366        updateCursor();
367        updateStatusLine();
368        temporaryLayer.invalidate();
369    }
370
371    @Override
372    public void selectionChanged(SelectionChangeEvent event) {
373        if (selectionChangedBlocked) {
374            return;
375        }
376        updateStateByCurrentSelection();
377    }
378
379    @Override
380    public void mouseDragged(MouseEvent e) {
381        dragging = true;
382        mouseMoved(e);
383    }
384
385    @Override
386    public void mouseMoved(MouseEvent e) {
387        if (!isEnabled()) {
388            return;
389        }
390
391        mousePos = e.getPoint();
392
393        updateKeyModifiers(e);
394        updateCursorDependentObjectsIfNeeded();
395        updateCursor();
396        updateStatusLine();
397        temporaryLayer.invalidate();
398    }
399
400    @Override
401    public void mouseReleased(MouseEvent e) {
402        dragging = false;
403        if (!isEnabled() || e.getButton() != MouseEvent.BUTTON1) {
404            return;
405        }
406
407        DataSet ds = getLayerManager().getEditDataSet();
408        updateKeyModifiers(e);
409        mousePos = e.getPoint();
410
411        if (state == State.SELECTING) {
412            if (targetWay != null) {
413                ds.setSelected(targetWay.getPrimitiveId());
414                updateStateByCurrentSelection();
415            }
416        } else if (state == State.IMPROVING) {
417            // Checking if the new coordinate is outside of the world
418            if (new Node(mv.getEastNorth(mousePos.x, mousePos.y)).isOutSideWorld()) {
419                JOptionPane.showMessageDialog(MainApplication.getMainFrame(),
420                        tr("Cannot add a node outside of the world."),
421                        tr("Warning"), JOptionPane.WARNING_MESSAGE);
422                return;
423            }
424
425            if (ctrl && !alt && candidateSegment != null) {
426                // Add a new node to the highlighted segment.
427                Collection<WaySegment> virtualSegments = new LinkedList<>();
428
429                // Check if other ways have the same segment.
430                // We have to make sure that we add the new node to all of them.
431                Set<Way> commonParentWays = new HashSet<>(candidateSegment.getFirstNode().getParentWays());
432                commonParentWays.retainAll(candidateSegment.getSecondNode().getParentWays());
433                for (Way w : commonParentWays) {
434                    for (int i = 0; i < w.getNodesCount() - 1; i++) {
435                        WaySegment testWS = new WaySegment(w, i);
436                        if (testWS.isSimilar(candidateSegment)) {
437                            virtualSegments.add(testWS);
438                        }
439                    }
440                }
441
442                Collection<Command> virtualCmds = new LinkedList<>();
443                // Create the new node
444                Node virtualNode = new Node(mv.getEastNorth(mousePos.x, mousePos.y));
445                virtualCmds.add(new AddCommand(ds, virtualNode));
446
447                // Adding the node to all segments found
448                for (WaySegment virtualSegment : virtualSegments) {
449                    Way w = virtualSegment.way;
450                    Way wnew = new Way(w);
451                    wnew.addNode(virtualSegment.lowerIndex + 1, virtualNode);
452                    virtualCmds.add(new ChangeCommand(w, wnew));
453                }
454
455                // Finishing the sequence command
456                String text = trn("Add a new node to way",
457                        "Add a new node to {0} ways",
458                        virtualSegments.size(), virtualSegments.size());
459
460                UndoRedoHandler.getInstance().add(new SequenceCommand(text, virtualCmds));
461
462            } else if (alt && !ctrl && candidateNode != null) {
463                // Deleting the highlighted node
464
465                //check to see if node is in use by more than one object
466                long referrersCount = candidateNode.referrers(OsmPrimitive.class).count();
467                long referrerWayCount = candidateNode.referrers(Way.class).count();
468                if (referrersCount != 1 || referrerWayCount != 1) {
469                    // detach node from way
470                    final Way newWay = new Way(targetWay);
471                    final List<Node> nodes = newWay.getNodes();
472                    nodes.remove(candidateNode);
473                    newWay.setNodes(nodes);
474                    if (nodes.size() < 2) {
475                        final Command deleteCmd = DeleteCommand.delete(Collections.singleton(targetWay), true);
476                        if (deleteCmd != null) {
477                            UndoRedoHandler.getInstance().add(deleteCmd);
478                        }
479                    } else {
480                        UndoRedoHandler.getInstance().add(new ChangeCommand(targetWay, newWay));
481                    }
482                } else if (candidateNode.isTagged()) {
483                    JOptionPane.showMessageDialog(MainApplication.getMainFrame(),
484                            tr("Cannot delete node that has tags"),
485                            tr("Error"), JOptionPane.ERROR_MESSAGE);
486                } else {
487                    final Command deleteCmd = DeleteCommand.delete(Collections.singleton(candidateNode), true);
488                    if (deleteCmd != null) {
489                        UndoRedoHandler.getInstance().add(deleteCmd);
490                    }
491                }
492
493            } else if (candidateNode != null) {
494                // Moving the highlighted node
495                EastNorth nodeEN = candidateNode.getEastNorth();
496                EastNorth cursorEN = mv.getEastNorth(mousePos.x, mousePos.y);
497
498                UndoRedoHandler.getInstance().add(
499                        new MoveCommand(candidateNode, cursorEN.east() - nodeEN.east(), cursorEN.north() - nodeEN.north()));
500            }
501        }
502
503        mousePos = null;
504        updateCursor();
505        updateStatusLine();
506        temporaryLayer.invalidate();
507    }
508
509    @Override
510    public void mouseExited(MouseEvent e) {
511        if (!isEnabled()) {
512            return;
513        }
514
515        if (!dragging) {
516            mousePos = null;
517        }
518        temporaryLayer.invalidate();
519    }
520
521    // -------------------------------------------------------------------------
522    // Custom methods
523    // -------------------------------------------------------------------------
524    /**
525     * Sets new cursor depending on state, mouse position
526     */
527    private void updateCursor() {
528        if (!isEnabled()) {
529            mv.setNewCursor(null, this);
530            return;
531        }
532
533        if (state == State.SELECTING) {
534            mv.setNewCursor(targetWay == null ? cursorSelect
535                    : cursorSelectHover, this);
536        } else if (state == State.IMPROVING) {
537            if (alt && !ctrl) {
538                mv.setNewCursor(cursorImproveDelete, this);
539            } else if (shift || dragging) {
540                if (ctrl) {
541                    mv.setNewCursor(cursorImproveAddLock, this);
542                } else {
543                    mv.setNewCursor(cursorImproveLock, this);
544                }
545            } else if (ctrl && !alt) {
546                mv.setNewCursor(cursorImproveAdd, this);
547            } else {
548                mv.setNewCursor(cursorImprove, this);
549            }
550        }
551    }
552
553    /**
554     * Updates these objects under cursor: targetWay, candidateNode,
555     * candidateSegment
556     */
557    public void updateCursorDependentObjectsIfNeeded() {
558        if (state == State.IMPROVING && (shift || dragging)
559                && !(candidateNode == null && candidateSegment == null)) {
560            return;
561        }
562
563        if (mousePos == null) {
564            candidateNode = null;
565            candidateSegment = null;
566            return;
567        }
568
569        if (state == State.SELECTING) {
570            targetWay = ImproveWayAccuracyHelper.findWay(mv, mousePos);
571        } else if (state == State.IMPROVING) {
572            if (ctrl && !alt) {
573                candidateSegment = ImproveWayAccuracyHelper.findCandidateSegment(mv,
574                        targetWay, mousePos);
575                candidateNode = null;
576            } else {
577                candidateNode = ImproveWayAccuracyHelper.findCandidateNode(mv,
578                        targetWay, mousePos);
579                candidateSegment = null;
580            }
581        }
582    }
583
584    /**
585     * Switches to Selecting state
586     */
587    public void startSelecting() {
588        state = State.SELECTING;
589
590        targetWay = null;
591
592        temporaryLayer.invalidate();
593        updateStatusLine();
594    }
595
596    /**
597     * Switches to Improving state
598     *
599     * @param targetWay Way that is going to be improved
600     */
601    public void startImproving(Way targetWay) {
602        state = State.IMPROVING;
603
604        DataSet ds = getLayerManager().getEditDataSet();
605        Collection<OsmPrimitive> currentSelection = ds.getSelected();
606        if (currentSelection.size() != 1
607                || !currentSelection.iterator().next().equals(targetWay)) {
608            selectionChangedBlocked = true;
609            ds.clearSelection();
610            ds.setSelected(targetWay.getPrimitiveId());
611            selectionChangedBlocked = false;
612        }
613
614        this.targetWay = targetWay;
615        this.candidateNode = null;
616        this.candidateSegment = null;
617
618        temporaryLayer.invalidate();
619        updateStatusLine();
620    }
621
622    /**
623     * Updates the state according to the current selection. Goes to Improve
624     * state if a single way or node is selected. Extracts a way by a node in
625     * the second case.
626     */
627    private void updateStateByCurrentSelection() {
628        final List<Node> nodeList = new ArrayList<>();
629        final List<Way> wayList = new ArrayList<>();
630        final DataSet ds = getLayerManager().getEditDataSet();
631        if (ds != null) {
632            final Collection<OsmPrimitive> sel = ds.getSelected();
633
634            // Collecting nodes and ways from the selection
635            for (OsmPrimitive p : sel) {
636                if (p instanceof Way) {
637                    wayList.add((Way) p);
638                }
639                if (p instanceof Node) {
640                    nodeList.add((Node) p);
641                }
642            }
643
644            if (wayList.size() == 1) {
645                // Starting improving the single selected way
646                startImproving(wayList.get(0));
647                return;
648            } else if (nodeList.size() == 1) {
649                // Starting improving the only way of the single selected node
650                List<OsmPrimitive> r = nodeList.get(0).getReferrers();
651                if (r.size() == 1 && (r.get(0) instanceof Way)) {
652                    startImproving((Way) r.get(0));
653                    return;
654                }
655            }
656        }
657
658        // Starting selecting by default
659        startSelecting();
660    }
661}