001    //License: GPL. Copyright 2007 by Immanuel Scholz and others. See LICENSE file for details.
002    package org.openstreetmap.josm.actions;
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.event.ActionEvent;
008    import java.awt.event.KeyEvent;
009    import java.util.ArrayList;
010    import java.util.Collection;
011    import java.util.Collections;
012    import java.util.HashSet;
013    import java.util.LinkedList;
014    import java.util.List;
015    import java.util.Set;
016    
017    import javax.swing.JOptionPane;
018    
019    import org.openstreetmap.josm.Main;
020    import org.openstreetmap.josm.command.ChangeCommand;
021    import org.openstreetmap.josm.command.ChangeNodesCommand;
022    import org.openstreetmap.josm.command.Command;
023    import org.openstreetmap.josm.command.DeleteCommand;
024    import org.openstreetmap.josm.command.SequenceCommand;
025    import org.openstreetmap.josm.corrector.UserCancelException;
026    import org.openstreetmap.josm.data.coor.EastNorth;
027    import org.openstreetmap.josm.data.coor.LatLon;
028    import org.openstreetmap.josm.data.osm.Node;
029    import org.openstreetmap.josm.data.osm.OsmPrimitive;
030    import org.openstreetmap.josm.data.osm.RelationToChildReference;
031    import org.openstreetmap.josm.data.osm.TagCollection;
032    import org.openstreetmap.josm.data.osm.Way;
033    import org.openstreetmap.josm.gui.DefaultNameFormatter;
034    import org.openstreetmap.josm.gui.HelpAwareOptionPane;
035    import org.openstreetmap.josm.gui.HelpAwareOptionPane.ButtonSpec;
036    import org.openstreetmap.josm.gui.conflict.tags.CombinePrimitiveResolverDialog;
037    import org.openstreetmap.josm.gui.layer.OsmDataLayer;
038    import org.openstreetmap.josm.tools.CheckParameterUtil;
039    import org.openstreetmap.josm.tools.ImageProvider;
040    import org.openstreetmap.josm.tools.Shortcut;
041    
042    /**
043     * Merges a collection of nodes into one node.
044     *
045     * The "surviving" node will be the one with the lowest positive id.
046     * (I.e. it was uploaded to the server and is the oldest one.)
047     *
048     * However we use the location of the node that was selected *last*.
049     * The "surviving" node will be moved to that location if it is
050     * different from the last selected node.
051     */
052    public class MergeNodesAction extends JosmAction {
053    
054        public MergeNodesAction() {
055            super(tr("Merge Nodes"), "mergenodes", tr("Merge nodes into the oldest one."),
056                    Shortcut.registerShortcut("tools:mergenodes", tr("Tool: {0}", tr("Merge Nodes")), KeyEvent.VK_M, Shortcut.DIRECT), true);
057            putValue("help", ht("/Action/MergeNodes"));
058        }
059    
060        public void actionPerformed(ActionEvent event) {
061            if (!isEnabled())
062                return;
063            Collection<OsmPrimitive> selection = getCurrentDataSet().getAllSelected();
064            List<Node> selectedNodes = OsmPrimitive.getFilteredList(selection, Node.class);
065    
066            if (selectedNodes.size() == 1) {
067                List<Node> nearestNodes = Main.map.mapView.getNearestNodes(Main.map.mapView.getPoint(selectedNodes.get(0)), selectedNodes, OsmPrimitive.isUsablePredicate);
068                if (nearestNodes.isEmpty()) {
069                    JOptionPane.showMessageDialog(
070                            Main.parent,
071                            tr("Please select at least two nodes to merge or a node that is close to another node."),
072                            tr("Warning"),
073                            JOptionPane.WARNING_MESSAGE
074                    );
075    
076                    return;
077                }
078                selectedNodes.addAll(nearestNodes);
079            }
080    
081            Node targetNode = selectTargetNode(selectedNodes);
082            Node targetLocationNode = selectTargetLocationNode(selectedNodes);
083            Command cmd = mergeNodes(Main.main.getEditLayer(), selectedNodes, targetNode, targetLocationNode);
084            if (cmd != null) {
085                Main.main.undoRedo.add(cmd);
086                Main.main.getEditLayer().data.setSelected(targetNode);
087            }
088        }
089    
090        /**
091         * Select the location of the target node after merge.
092         *
093         * @param candidates the collection of candidate nodes
094         * @return the coordinates of this node are later used for the target node
095         */
096        public static Node selectTargetLocationNode(List<Node> candidates) {
097            int size = candidates.size();
098            if (size == 0)
099                throw new IllegalArgumentException("empty list");
100    
101            switch (Main.pref.getInteger("merge-nodes.mode", 0)) {
102            case 0: {
103                Node targetNode = candidates.get(size - 1);
104                for (final Node n : candidates) { // pick last one
105                    targetNode = n;
106                }
107                return targetNode;
108            }
109            case 1: {
110                double east = 0, north = 0;
111                for (final Node n : candidates) {
112                    east += n.getEastNorth().east();
113                    north += n.getEastNorth().north();
114                }
115    
116                return new Node(new EastNorth(east / size, north / size));
117            }
118            case 2: {
119                final double[] weights = new double[size];
120    
121                for (int i = 0; i < size; i++) {
122                    final LatLon c1 = candidates.get(i).getCoor();
123                    for (int j = i + 1; j < size; j++) {
124                        final LatLon c2 = candidates.get(j).getCoor();
125                        final double d = c1.distance(c2);
126                        weights[i] += d;
127                        weights[j] += d;
128                    }
129                }
130    
131                double east = 0, north = 0, weight = 0;
132                for (int i = 0; i < size; i++) {
133                    final EastNorth en = candidates.get(i).getEastNorth();
134                    final double w = weights[i];
135                    east += en.east() * w;
136                    north += en.north() * w;
137                    weight += w;
138                }
139    
140                return new Node(new EastNorth(east / weight, north / weight));
141            }
142            default:
143                throw new RuntimeException("unacceptable merge-nodes.mode");
144            }
145    
146        }
147    
148        /**
149         * Find which node to merge into (i.e. which one will be left)
150         *
151         * @param candidates the collection of candidate nodes
152         * @return the selected target node
153         */
154        public static Node selectTargetNode(Collection<Node> candidates) {
155            Node targetNode = null;
156            Node lastNode = null;
157            for (Node n : candidates) {
158                if (!n.isNew()) {
159                    if (targetNode == null) {
160                        targetNode = n;
161                    } else if (n.getId() < targetNode.getId()) {
162                        targetNode = n;
163                    }
164                }
165                lastNode = n;
166            }
167            if (targetNode == null) {
168                targetNode = lastNode;
169            }
170            return targetNode;
171        }
172    
173    
174        /**
175         * Fixes the parent ways referring to one of the nodes.
176         *
177         * Replies null, if the ways could not be fixed, i.e. because a way would have to be deleted
178         * which is referred to by a relation.
179         *
180         * @param nodesToDelete the collection of nodes to be deleted
181         * @param targetNode the target node the other nodes are merged to
182         * @return a list of commands; null, if the ways could not be fixed
183         */
184        protected static List<Command> fixParentWays(Collection<Node> nodesToDelete, Node targetNode) {
185            List<Command> cmds = new ArrayList<Command>();
186            Set<Way> waysToDelete = new HashSet<Way>();
187    
188            for (Way w: OsmPrimitive.getFilteredList(OsmPrimitive.getReferrer(nodesToDelete), Way.class)) {
189                ArrayList<Node> newNodes = new ArrayList<Node>(w.getNodesCount());
190                for (Node n: w.getNodes()) {
191                    if (! nodesToDelete.contains(n) && n != targetNode) {
192                        newNodes.add(n);
193                    } else if (newNodes.isEmpty()) {
194                        newNodes.add(targetNode);
195                    } else if (newNodes.get(newNodes.size()-1) != targetNode) {
196                        // make sure we collapse a sequence of deleted nodes
197                        // to exactly one occurrence of the merged target node
198                        //
199                        newNodes.add(targetNode);
200                    } else {
201                        // drop the node
202                    }
203                }
204                if (newNodes.size() < 2) {
205                    if (w.getReferrers().isEmpty()) {
206                        waysToDelete.add(w);
207                    } else {
208                        ButtonSpec[] options = new ButtonSpec[] {
209                                new ButtonSpec(
210                                        tr("Abort Merging"),
211                                        ImageProvider.get("cancel"),
212                                        tr("Click to abort merging nodes"),
213                                        null /* no special help topic */
214                                )
215                        };
216                        HelpAwareOptionPane.showOptionDialog(
217                                Main.parent,
218                                tr("Cannot merge nodes: Would have to delete way {0} which is still used by {1}",
219                                    DefaultNameFormatter.getInstance().formatAsHtmlUnorderedList(w),
220                                    DefaultNameFormatter.getInstance().formatAsHtmlUnorderedList(w.getReferrers())),
221                                tr("Warning"),
222                                JOptionPane.WARNING_MESSAGE,
223                                null, /* no icon */
224                                options,
225                                options[0],
226                                ht("/Action/MergeNodes#WaysToDeleteStillInUse")
227                        );
228                        return null;
229                    }
230                } else if(newNodes.size() < 2 && w.getReferrers().isEmpty()) {
231                    waysToDelete.add(w);
232                } else {
233                    cmds.add(new ChangeNodesCommand(w, newNodes));
234                }
235            }
236            if (!waysToDelete.isEmpty()) {
237                cmds.add(new DeleteCommand(waysToDelete));
238            }
239            return cmds;
240        }
241    
242        public static void doMergeNodes(OsmDataLayer layer, Collection<Node> nodes, Node targetLocationNode) {
243            if (nodes == null) {
244                return;
245            }
246            Set<Node> allNodes = new HashSet<Node>(nodes);
247            allNodes.add(targetLocationNode);
248            Node target = selectTargetNode(allNodes);
249    
250            Command cmd = mergeNodes(layer, nodes, target, targetLocationNode);
251            if (cmd != null) {
252                Main.main.undoRedo.add(cmd);
253                getCurrentDataSet().setSelected(target);
254            }
255        }
256    
257        public static Command mergeNodes(OsmDataLayer layer, Collection<Node> nodes, Node targetLocationNode) {
258            if (nodes == null) {
259                return null;
260            }
261            Set<Node> allNodes = new HashSet<Node>(nodes);
262            allNodes.add(targetLocationNode);
263            return mergeNodes(layer, nodes, selectTargetNode(allNodes), targetLocationNode);
264        }
265    
266        /**
267         * Merges the nodes in <code>nodes</code> onto one of the nodes. Uses the dataset
268         * managed by <code>layer</code> as reference.
269         *
270         * @param layer layer the reference data layer. Must not be null.
271         * @param nodes the collection of nodes. Ignored if null.
272         * @param targetNode the target node the collection of nodes is merged to. Must not be null.
273         * @param targetLocationNode this node's location will be used for the targetNode.
274         * @throw IllegalArgumentException thrown if layer is null
275         */
276        public static Command mergeNodes(OsmDataLayer layer, Collection<Node> nodes, Node targetNode, Node targetLocationNode) {
277            CheckParameterUtil.ensureParameterNotNull(layer, "layer");
278            CheckParameterUtil.ensureParameterNotNull(targetNode, "targetNode");
279            if (nodes == null) {
280                return null;
281            }
282    
283            Set<RelationToChildReference> relationToNodeReferences = RelationToChildReference.getRelationToChildReferences(nodes);
284    
285            try {
286                TagCollection nodeTags = TagCollection.unionOfAllPrimitives(nodes);
287                List<Command> resultion = CombinePrimitiveResolverDialog.launchIfNecessary(nodeTags, nodes, Collections.singleton(targetNode));
288                LinkedList<Command> cmds = new LinkedList<Command>();
289    
290                // the nodes we will have to delete
291                //
292                Collection<Node> nodesToDelete = new HashSet<Node>(nodes);
293                nodesToDelete.remove(targetNode);
294    
295                // fix the ways referring to at least one of the merged nodes
296                //
297                Collection<Way> waysToDelete = new HashSet<Way>();
298                List<Command> wayFixCommands = fixParentWays(
299                        nodesToDelete,
300                        targetNode);
301                if (wayFixCommands == null) {
302                    return null;
303                }
304                cmds.addAll(wayFixCommands);
305    
306                // build the commands
307                //
308                if (targetNode != targetLocationNode) {
309                    Node newTargetNode = new Node(targetNode);
310                    newTargetNode.setCoor(targetLocationNode.getCoor());
311                    cmds.add(new ChangeCommand(targetNode, newTargetNode));
312                }
313                cmds.addAll(resultion);
314                if (!nodesToDelete.isEmpty()) {
315                    cmds.add(new DeleteCommand(nodesToDelete));
316                }
317                if (!waysToDelete.isEmpty()) {
318                    cmds.add(new DeleteCommand(waysToDelete));
319                }
320                Command cmd = new SequenceCommand(tr("Merge {0} nodes", nodes.size()), cmds);
321                return cmd;
322            } catch (UserCancelException ex) {
323                return null;
324            }
325        }
326    
327        @Override
328        protected void updateEnabledState() {
329            if (getCurrentDataSet() == null) {
330                setEnabled(false);
331            } else {
332                updateEnabledState(getCurrentDataSet().getAllSelected());
333            }
334        }
335    
336        @Override
337        protected void updateEnabledState(Collection<? extends OsmPrimitive> selection) {
338            if (selection == null || selection.isEmpty()) {
339                setEnabled(false);
340                return;
341            }
342            boolean ok = true;
343            for (OsmPrimitive osm : selection) {
344                if (!(osm instanceof Node)) {
345                    ok = false;
346                    break;
347                }
348            }
349            setEnabled(ok);
350        }
351    }