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