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