001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.actions;
003
004import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
005import static org.openstreetmap.josm.tools.I18n.tr;
006
007import java.awt.event.ActionEvent;
008import java.awt.event.KeyEvent;
009import java.io.Serializable;
010import java.util.Collection;
011import java.util.Collections;
012import java.util.Comparator;
013import java.util.HashMap;
014import java.util.HashSet;
015import java.util.LinkedList;
016import java.util.List;
017import java.util.Map;
018import java.util.Set;
019
020import javax.swing.JOptionPane;
021
022import org.openstreetmap.josm.command.ChangeCommand;
023import org.openstreetmap.josm.command.Command;
024import org.openstreetmap.josm.command.MoveCommand;
025import org.openstreetmap.josm.command.SequenceCommand;
026import org.openstreetmap.josm.data.UndoRedoHandler;
027import org.openstreetmap.josm.data.coor.EastNorth;
028import org.openstreetmap.josm.data.osm.DataSet;
029import org.openstreetmap.josm.data.osm.Node;
030import org.openstreetmap.josm.data.osm.OsmPrimitive;
031import org.openstreetmap.josm.data.osm.Way;
032import org.openstreetmap.josm.data.osm.WaySegment;
033import org.openstreetmap.josm.data.projection.ProjectionRegistry;
034import org.openstreetmap.josm.gui.MainApplication;
035import org.openstreetmap.josm.gui.MapView;
036import org.openstreetmap.josm.gui.Notification;
037import org.openstreetmap.josm.tools.Geometry;
038import org.openstreetmap.josm.tools.MultiMap;
039import org.openstreetmap.josm.tools.Shortcut;
040
041/**
042 * Action allowing to join a node to a nearby way, operating on two modes:<ul>
043 * <li><b>Join Node to Way</b>: Include a node into the nearest way segments. The node does not move</li>
044 * <li><b>Move Node onto Way</b>: Move the node onto the nearest way segments and include it</li>
045 * </ul>
046 * @since 466
047 */
048public class JoinNodeWayAction extends JosmAction {
049
050    protected final boolean joinWayToNode;
051
052    protected JoinNodeWayAction(boolean joinWayToNode, String name, String iconName, String tooltip,
053            Shortcut shortcut, boolean registerInToolbar) {
054        super(name, iconName, tooltip, shortcut, registerInToolbar);
055        this.joinWayToNode = joinWayToNode;
056    }
057
058    /**
059     * Constructs a Join Node to Way action.
060     * @return the Join Node to Way action
061     */
062    public static JoinNodeWayAction createJoinNodeToWayAction() {
063        JoinNodeWayAction action = new JoinNodeWayAction(false,
064                tr("Join Node to Way"), /* ICON */ "joinnodeway",
065                tr("Include a node into the nearest way segments"),
066                Shortcut.registerShortcut("tools:joinnodeway", tr("Tool: {0}", tr("Join Node to Way")),
067                        KeyEvent.VK_J, Shortcut.DIRECT), true);
068        action.setHelpId(ht("/Action/JoinNodeWay"));
069        return action;
070    }
071
072    /**
073     * Constructs a Move Node onto Way action.
074     * @return the Move Node onto Way action
075     */
076    public static JoinNodeWayAction createMoveNodeOntoWayAction() {
077        JoinNodeWayAction action = new JoinNodeWayAction(true,
078                tr("Move Node onto Way"), /* ICON*/ "movenodeontoway",
079                tr("Move the node onto the nearest way segments and include it"),
080                Shortcut.registerShortcut("tools:movenodeontoway", tr("Tool: {0}", tr("Move Node onto Way")),
081                        KeyEvent.VK_N, Shortcut.DIRECT), true);
082        action.setHelpId(ht("/Action/MoveNodeWay"));
083        return action;
084    }
085
086    @Override
087    public void actionPerformed(ActionEvent e) {
088        if (!isEnabled())
089            return;
090        DataSet ds = getLayerManager().getEditDataSet();
091        Collection<Node> selectedNodes = ds.getSelectedNodes();
092        Collection<Command> cmds = new LinkedList<>();
093        Map<Way, MultiMap<Integer, Node>> data = new HashMap<>();
094
095        // If the user has selected some ways, only join the node to these.
096        boolean restrictToSelectedWays = !ds.getSelectedWays().isEmpty();
097
098        // Planning phase: decide where we'll insert the nodes and put it all in "data"
099        MapView mapView = MainApplication.getMap().mapView;
100        for (Node node : selectedNodes) {
101            List<WaySegment> wss = mapView.getNearestWaySegments(mapView.getPoint(node), OsmPrimitive::isSelectable);
102            Set<Way> seenWays = new HashSet<>();
103            for (WaySegment ws : wss) {
104                // Maybe cleaner to pass a "isSelected" predicate to getNearestWaySegments, but this is less invasive.
105                if (restrictToSelectedWays && !ws.way.isSelected()) {
106                    continue;
107                }
108                // only use the closest WaySegment of each way and ignore those that already contain the node
109                if (!ws.getFirstNode().equals(node) && !ws.getSecondNode().equals(node)
110                        && !seenWays.contains(ws.way)) {
111                    MultiMap<Integer, Node> innerMap = data.get(ws.way);
112                    if (innerMap == null) {
113                        innerMap = new MultiMap<>();
114                        data.put(ws.way, innerMap);
115                    }
116                    innerMap.put(ws.lowerIndex, node);
117                    seenWays.add(ws.way);
118                }
119            }
120        }
121
122        // Execute phase: traverse the structure "data" and finally put the nodes into place
123        Map<Node, EastNorth> movedNodes = new HashMap<>();
124        for (Map.Entry<Way, MultiMap<Integer, Node>> entry : data.entrySet()) {
125            final Way w = entry.getKey();
126            final MultiMap<Integer, Node> innerEntry = entry.getValue();
127
128            List<Integer> segmentIndexes = new LinkedList<>();
129            segmentIndexes.addAll(innerEntry.keySet());
130            segmentIndexes.sort(Collections.reverseOrder());
131
132            List<Node> wayNodes = w.getNodes();
133            for (Integer segmentIndex : segmentIndexes) {
134                final Set<Node> nodesInSegment = innerEntry.get(segmentIndex);
135                if (joinWayToNode) {
136                    for (Node node : nodesInSegment) {
137                        EastNorth newPosition = Geometry.closestPointToSegment(
138                                w.getNode(segmentIndex).getEastNorth(),
139                                w.getNode(segmentIndex+1).getEastNorth(),
140                                node.getEastNorth());
141                        EastNorth prevMove = movedNodes.get(node);
142                        if (prevMove != null) {
143                            if (!prevMove.equalsEpsilon(newPosition, 1e-4)) {
144                                new Notification(tr("Multiple target ways, no common point found. Nothing was changed."))
145                                        .setIcon(JOptionPane.INFORMATION_MESSAGE)
146                                        .show();
147                                return;
148                            }
149                            continue;
150                        }
151                        MoveCommand c = new MoveCommand(node,
152                                ProjectionRegistry.getProjection().eastNorth2latlon(newPosition));
153                        cmds.add(c);
154                        movedNodes.put(node, newPosition);
155                    }
156                }
157                List<Node> nodesToAdd = new LinkedList<>();
158                nodesToAdd.addAll(nodesInSegment);
159                nodesToAdd.sort(new NodeDistanceToRefNodeComparator(
160                        w.getNode(segmentIndex), w.getNode(segmentIndex+1), !joinWayToNode));
161                wayNodes.addAll(segmentIndex + 1, nodesToAdd);
162            }
163            Way wnew = new Way(w);
164            wnew.setNodes(wayNodes);
165            cmds.add(new ChangeCommand(ds, w, wnew));
166        }
167
168        if (cmds.isEmpty()) return;
169        UndoRedoHandler.getInstance().add(new SequenceCommand(getValue(NAME).toString(), cmds));
170    }
171
172    /**
173     * Sorts collinear nodes by their distance to a common reference node.
174     */
175    private static class NodeDistanceToRefNodeComparator implements Comparator<Node>, Serializable {
176
177        private static final long serialVersionUID = 1L;
178
179        private final EastNorth refPoint;
180        private final EastNorth refPoint2;
181        private final boolean projectToSegment;
182
183        NodeDistanceToRefNodeComparator(Node referenceNode, Node referenceNode2, boolean projectFirst) {
184            refPoint = referenceNode.getEastNorth();
185            refPoint2 = referenceNode2.getEastNorth();
186            projectToSegment = projectFirst;
187        }
188
189        @Override
190        public int compare(Node first, Node second) {
191            EastNorth firstPosition = first.getEastNorth();
192            EastNorth secondPosition = second.getEastNorth();
193
194            if (projectToSegment) {
195                firstPosition = Geometry.closestPointToSegment(refPoint, refPoint2, firstPosition);
196                secondPosition = Geometry.closestPointToSegment(refPoint, refPoint2, secondPosition);
197            }
198
199            double distanceFirst = firstPosition.distance(refPoint);
200            double distanceSecond = secondPosition.distance(refPoint);
201            return Double.compare(distanceFirst, distanceSecond);
202        }
203    }
204
205    @Override
206    protected void updateEnabledState() {
207        updateEnabledStateOnCurrentSelection();
208    }
209
210    @Override
211    protected void updateEnabledState(Collection<? extends OsmPrimitive> selection) {
212        updateEnabledStateOnModifiableSelection(selection);
213    }
214}