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, 6 years 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.