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