source: josm/trunk/src/org/openstreetmap/josm/actions/MergeNodesAction.java @ 13842

Last change on this file since 13842 was 13842, checked in by Don-vip, 5 months ago

see #16319 - scale properly all icons using ButtonSpec

  • Property svn:eol-style set to native
File size: 15.2 KB
Line 
1// License: GPL. For details, see LICENSE file.
2package org.openstreetmap.josm.actions;
3
4import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
5import static org.openstreetmap.josm.tools.I18n.tr;
6import static org.openstreetmap.josm.tools.I18n.trn;
7
8import java.awt.event.ActionEvent;
9import java.awt.event.KeyEvent;
10import java.util.ArrayList;
11import java.util.Collection;
12import java.util.Collections;
13import java.util.HashSet;
14import java.util.LinkedList;
15import java.util.List;
16import java.util.Objects;
17import java.util.Optional;
18import java.util.Set;
19
20import javax.swing.JOptionPane;
21
22import org.openstreetmap.josm.Main;
23import org.openstreetmap.josm.command.ChangeCommand;
24import org.openstreetmap.josm.command.ChangeNodesCommand;
25import org.openstreetmap.josm.command.Command;
26import org.openstreetmap.josm.command.DeleteCommand;
27import org.openstreetmap.josm.command.SequenceCommand;
28import org.openstreetmap.josm.data.coor.EastNorth;
29import org.openstreetmap.josm.data.coor.LatLon;
30import org.openstreetmap.josm.data.osm.DefaultNameFormatter;
31import org.openstreetmap.josm.data.osm.Node;
32import org.openstreetmap.josm.data.osm.OsmPrimitive;
33import org.openstreetmap.josm.data.osm.OsmUtils;
34import org.openstreetmap.josm.data.osm.TagCollection;
35import org.openstreetmap.josm.data.osm.Way;
36import org.openstreetmap.josm.gui.HelpAwareOptionPane;
37import org.openstreetmap.josm.gui.HelpAwareOptionPane.ButtonSpec;
38import org.openstreetmap.josm.gui.MainApplication;
39import org.openstreetmap.josm.gui.MapView;
40import org.openstreetmap.josm.gui.Notification;
41import org.openstreetmap.josm.gui.conflict.tags.CombinePrimitiveResolverDialog;
42import org.openstreetmap.josm.gui.layer.OsmDataLayer;
43import org.openstreetmap.josm.spi.preferences.Config;
44import org.openstreetmap.josm.tools.CheckParameterUtil;
45import org.openstreetmap.josm.tools.ImageProvider;
46import org.openstreetmap.josm.tools.Logging;
47import org.openstreetmap.josm.tools.Shortcut;
48import org.openstreetmap.josm.tools.UserCancelException;
49
50/**
51 * Merges a collection of nodes into one node.
52 *
53 * The "surviving" node will be the one with the lowest positive id.
54 * (I.e. it was uploaded to the server and is the oldest one.)
55 *
56 * However we use the location of the node that was selected *last*.
57 * The "surviving" node will be moved to that location if it is
58 * different from the last selected node.
59 *
60 * @since 422
61 */
62public class MergeNodesAction extends JosmAction {
63
64    /**
65     * Constructs a new {@code MergeNodesAction}.
66     */
67    public MergeNodesAction() {
68        super(tr("Merge Nodes"), "mergenodes", tr("Merge nodes into the oldest one."),
69                Shortcut.registerShortcut("tools:mergenodes", tr("Tool: {0}", tr("Merge Nodes")), KeyEvent.VK_M, Shortcut.DIRECT), true);
70        putValue("help", ht("/Action/MergeNodes"));
71    }
72
73    @Override
74    public void actionPerformed(ActionEvent event) {
75        if (!isEnabled())
76            return;
77        Collection<OsmPrimitive> selection = getLayerManager().getEditDataSet().getAllSelected();
78        List<Node> selectedNodes = OsmPrimitive.getFilteredList(selection, Node.class);
79        selectedNodes.removeIf(n -> n.isDeleted() || n.isIncomplete());
80
81        if (selectedNodes.size() == 1) {
82            MapView mapView = MainApplication.getMap().mapView;
83            List<Node> nearestNodes = mapView.getNearestNodes(
84                    mapView.getPoint(selectedNodes.get(0)), selectedNodes, OsmPrimitive::isUsable);
85            if (nearestNodes.isEmpty()) {
86                new Notification(
87                        tr("Please select at least two nodes to merge or one node that is close to another node."))
88                        .setIcon(JOptionPane.WARNING_MESSAGE)
89                        .show();
90                return;
91            }
92            selectedNodes.addAll(nearestNodes);
93        }
94
95        Node targetNode = selectTargetNode(selectedNodes);
96        if (targetNode != null) {
97            Node targetLocationNode = selectTargetLocationNode(selectedNodes);
98            Command cmd = mergeNodes(selectedNodes, targetNode, targetLocationNode);
99            if (cmd != null) {
100                MainApplication.undoRedo.add(cmd);
101                getLayerManager().getEditLayer().data.setSelected(targetNode);
102            }
103        }
104    }
105
106    /**
107     * Select the location of the target node after merge.
108     *
109     * @param candidates the collection of candidate nodes
110     * @return the coordinates of this node are later used for the target node
111     */
112    public static Node selectTargetLocationNode(List<Node> candidates) {
113        int size = candidates.size();
114        if (size == 0)
115            throw new IllegalArgumentException("empty list");
116        if (size == 1) // to avoid division by 0 in mode 2
117            return candidates.get(0);
118
119        switch (Config.getPref().getInt("merge-nodes.mode", 0)) {
120        case 0:
121            return candidates.get(size - 1);
122        case 1:
123            double east1 = 0;
124            double north1 = 0;
125            for (final Node n : candidates) {
126                EastNorth en = n.getEastNorth();
127                east1 += en.east();
128                north1 += en.north();
129            }
130
131            return new Node(new EastNorth(east1 / size, north1 / size));
132        case 2:
133            final double[] weights = new double[size];
134
135            for (int i = 0; i < size; i++) {
136                final LatLon c1 = candidates.get(i).getCoor();
137                for (int j = i + 1; j < size; j++) {
138                    final LatLon c2 = candidates.get(j).getCoor();
139                    final double d = c1.distance(c2);
140                    weights[i] += d;
141                    weights[j] += d;
142                }
143            }
144
145            double east2 = 0;
146            double north2 = 0;
147            double weight = 0;
148            for (int i = 0; i < size; i++) {
149                final EastNorth en = candidates.get(i).getEastNorth();
150                final double w = weights[i];
151                east2 += en.east() * w;
152                north2 += en.north() * w;
153                weight += w;
154            }
155
156            if (weight == 0) // to avoid division by 0
157                return candidates.get(0);
158
159            return new Node(new EastNorth(east2 / weight, north2 / weight));
160        default:
161            throw new IllegalStateException("unacceptable merge-nodes.mode");
162        }
163    }
164
165    /**
166     * Find which node to merge into (i.e. which one will be left)
167     *
168     * @param candidates the collection of candidate nodes
169     * @return the selected target node
170     */
171    public static Node selectTargetNode(Collection<Node> candidates) {
172        Node oldestNode = null;
173        Node targetNode = null;
174        Node lastNode = null;
175        for (Node n : candidates) {
176            if (!n.isNew()) {
177                // Among existing nodes, try to keep the oldest used one
178                if (!n.getReferrers().isEmpty()) {
179                    if (targetNode == null || n.getId() < targetNode.getId()) {
180                        targetNode = n;
181                    }
182                } else if (oldestNode == null || n.getId() < oldestNode.getId()) {
183                    oldestNode = n;
184                }
185            }
186            lastNode = n;
187        }
188        return Optional.ofNullable(targetNode).orElse(oldestNode != null ? oldestNode : lastNode);
189    }
190
191    /**
192     * Fixes the parent ways referring to one of the nodes.
193     *
194     * Replies null, if the ways could not be fixed, i.e. because a way would have to be deleted
195     * which is referred to by a relation.
196     *
197     * @param nodesToDelete the collection of nodes to be deleted
198     * @param targetNode the target node the other nodes are merged to
199     * @return a list of commands; null, if the ways could not be fixed
200     */
201    protected static List<Command> fixParentWays(Collection<Node> nodesToDelete, Node targetNode) {
202        List<Command> cmds = new ArrayList<>();
203        Set<Way> waysToDelete = new HashSet<>();
204
205        for (Way w: OsmPrimitive.getFilteredList(OsmPrimitive.getReferrer(nodesToDelete), Way.class)) {
206            List<Node> newNodes = new ArrayList<>(w.getNodesCount());
207            for (Node n: w.getNodes()) {
208                if (!nodesToDelete.contains(n) && !n.equals(targetNode)) {
209                    newNodes.add(n);
210                } else if (newNodes.isEmpty() || !newNodes.get(newNodes.size()-1).equals(targetNode)) {
211                    // make sure we collapse a sequence of deleted nodes
212                    // to exactly one occurrence of the merged target node
213                    newNodes.add(targetNode);
214                }
215                // else: drop the node
216            }
217            if (newNodes.size() < 2) {
218                if (w.getReferrers().isEmpty()) {
219                    waysToDelete.add(w);
220                } else {
221                    ButtonSpec[] options = new ButtonSpec[] {
222                            new ButtonSpec(
223                                    tr("Abort Merging"),
224                                    new ImageProvider("cancel"),
225                                    tr("Click to abort merging nodes"),
226                                    null /* no special help topic */
227                            )
228                    };
229                    HelpAwareOptionPane.showOptionDialog(
230                            Main.parent,
231                            tr("Cannot merge nodes: Would have to delete way {0} which is still used by {1}",
232                                DefaultNameFormatter.getInstance().formatAsHtmlUnorderedList(w),
233                                DefaultNameFormatter.getInstance().formatAsHtmlUnorderedList(w.getReferrers(), 20)),
234                            tr("Warning"),
235                            JOptionPane.WARNING_MESSAGE,
236                            null, /* no icon */
237                            options,
238                            options[0],
239                            ht("/Action/MergeNodes#WaysToDeleteStillInUse")
240                    );
241                    return null;
242                }
243            } else if (newNodes.size() < 2 && w.getReferrers().isEmpty()) {
244                waysToDelete.add(w);
245            } else {
246                cmds.add(new ChangeNodesCommand(w, newNodes));
247            }
248        }
249        if (!waysToDelete.isEmpty()) {
250            cmds.add(new DeleteCommand(waysToDelete));
251        }
252        return cmds;
253    }
254
255    /**
256     * Merges the nodes in {@code nodes} at the specified node's location. Uses the dataset
257     * managed by {@code layer} as reference.
258     * @param layer layer the reference data layer. Must not be null
259     * @param nodes the collection of nodes. Ignored if null
260     * @param targetLocationNode this node's location will be used for the target node
261     * @throws IllegalArgumentException if {@code layer} is null
262     */
263    public static void doMergeNodes(OsmDataLayer layer, Collection<Node> nodes, Node targetLocationNode) {
264        if (nodes == null) {
265            return;
266        }
267        Set<Node> allNodes = new HashSet<>(nodes);
268        allNodes.add(targetLocationNode);
269        Node target;
270        if (nodes.contains(targetLocationNode) && !targetLocationNode.isNew()) {
271            target = targetLocationNode; // keep existing targetLocationNode as target to avoid unnecessary changes (see #2447)
272        } else {
273            target = selectTargetNode(allNodes);
274        }
275
276        if (target != null) {
277            Command cmd = mergeNodes(nodes, target, targetLocationNode);
278            if (cmd != null) {
279                MainApplication.undoRedo.add(cmd);
280                layer.data.setSelected(target);
281            }
282        }
283    }
284
285    /**
286     * Merges the nodes in {@code nodes} at the specified node's location.
287     *
288     * @param nodes the collection of nodes. Ignored if null.
289     * @param targetLocationNode this node's location will be used for the targetNode.
290     * @return The command necessary to run in order to perform action, or {@code null} if there is nothing to do
291     * @throws IllegalArgumentException if {@code layer} is null
292     * @since 12689
293     */
294    public static Command mergeNodes(Collection<Node> nodes, Node targetLocationNode) {
295        if (nodes == null) {
296            return null;
297        }
298        Set<Node> allNodes = new HashSet<>(nodes);
299        allNodes.add(targetLocationNode);
300        Node targetNode = selectTargetNode(allNodes);
301        if (targetNode == null) {
302            return null;
303        }
304        return mergeNodes(nodes, targetNode, targetLocationNode);
305    }
306
307    /**
308     * Merges the nodes in <code>nodes</code> onto one of the nodes.
309     *
310     * @param nodes the collection of nodes. Ignored if null.
311     * @param targetNode the target node the collection of nodes is merged to. Must not be null.
312     * @param targetLocationNode this node's location will be used for the targetNode.
313     * @return The command necessary to run in order to perform action, or {@code null} if there is nothing to do
314     * @throws IllegalArgumentException if layer is null
315     */
316    public static Command mergeNodes(Collection<Node> nodes, Node targetNode, Node targetLocationNode) {
317        CheckParameterUtil.ensureParameterNotNull(targetNode, "targetNode");
318        if (nodes == null) {
319            return null;
320        }
321
322        try {
323            TagCollection nodeTags = TagCollection.unionOfAllPrimitives(nodes);
324
325            // the nodes we will have to delete
326            //
327            Collection<Node> nodesToDelete = new HashSet<>(nodes);
328            nodesToDelete.remove(targetNode);
329
330            // fix the ways referring to at least one of the merged nodes
331            //
332            List<Command> wayFixCommands = fixParentWays(nodesToDelete, targetNode);
333            if (wayFixCommands == null) {
334                return null;
335            }
336            List<Command> cmds = new LinkedList<>(wayFixCommands);
337
338            // build the commands
339            //
340            if (!targetNode.equals(targetLocationNode)) {
341                LatLon targetLocationCoor = targetLocationNode.getCoor();
342                if (!Objects.equals(targetNode.getCoor(), targetLocationCoor)) {
343                    Node newTargetNode = new Node(targetNode);
344                    newTargetNode.setCoor(targetLocationCoor);
345                    cmds.add(new ChangeCommand(targetNode, newTargetNode));
346                }
347            }
348            cmds.addAll(CombinePrimitiveResolverDialog.launchIfNecessary(nodeTags, nodes, Collections.singleton(targetNode)));
349            if (!nodesToDelete.isEmpty()) {
350                cmds.add(new DeleteCommand(nodesToDelete));
351            }
352            return new SequenceCommand(/* for correct i18n of plural forms - see #9110 */
353                    trn("Merge {0} node", "Merge {0} nodes", nodes.size(), nodes.size()), cmds);
354        } catch (UserCancelException ex) {
355            Logging.trace(ex);
356            return null;
357        }
358    }
359
360    @Override
361    protected void updateEnabledState() {
362        updateEnabledStateOnCurrentSelection();
363    }
364
365    @Override
366    protected void updateEnabledState(Collection<? extends OsmPrimitive> selection) {
367        boolean ok = OsmUtils.isOsmCollectionEditable(selection);
368        if (ok) {
369            for (OsmPrimitive osm : selection) {
370                if (!(osm instanceof Node)) {
371                    ok = false;
372                    break;
373                }
374            }
375        }
376        setEnabled(ok);
377    }
378}
Note: See TracBrowser for help on using the repository browser.