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.Objects; 017import java.util.Optional; 018import java.util.Set; 019 020import javax.swing.JOptionPane; 021 022import org.openstreetmap.josm.Main; 023import org.openstreetmap.josm.command.ChangeCommand; 024import org.openstreetmap.josm.command.ChangeNodesCommand; 025import org.openstreetmap.josm.command.Command; 026import org.openstreetmap.josm.command.DeleteCommand; 027import org.openstreetmap.josm.command.SequenceCommand; 028import org.openstreetmap.josm.data.coor.EastNorth; 029import org.openstreetmap.josm.data.coor.LatLon; 030import org.openstreetmap.josm.data.osm.DataSet; 031import org.openstreetmap.josm.data.osm.DefaultNameFormatter; 032import org.openstreetmap.josm.data.osm.Node; 033import org.openstreetmap.josm.data.osm.OsmPrimitive; 034import org.openstreetmap.josm.data.osm.TagCollection; 035import org.openstreetmap.josm.data.osm.Way; 036import org.openstreetmap.josm.gui.HelpAwareOptionPane; 037import org.openstreetmap.josm.gui.HelpAwareOptionPane.ButtonSpec; 038import org.openstreetmap.josm.gui.MainApplication; 039import org.openstreetmap.josm.gui.MapView; 040import org.openstreetmap.josm.gui.Notification; 041import org.openstreetmap.josm.gui.conflict.tags.CombinePrimitiveResolverDialog; 042import org.openstreetmap.josm.gui.layer.OsmDataLayer; 043import org.openstreetmap.josm.spi.preferences.Config; 044import org.openstreetmap.josm.tools.CheckParameterUtil; 045import org.openstreetmap.josm.tools.ImageProvider; 046import org.openstreetmap.josm.tools.Logging; 047import org.openstreetmap.josm.tools.Shortcut; 048import org.openstreetmap.josm.tools.UserCancelException; 049 050/** 051 * Merges a collection of nodes into one node. 052 * 053 * The "surviving" node will be the one with the lowest positive id. 054 * (I.e. it was uploaded to the server and is the oldest one.) 055 * 056 * However we use the location of the node that was selected *last*. 057 * The "surviving" node will be moved to that location if it is 058 * different from the last selected node. 059 * 060 * @since 422 061 */ 062public class MergeNodesAction extends JosmAction { 063 064 /** 065 * Constructs a new {@code MergeNodesAction}. 066 */ 067 public MergeNodesAction() { 068 super(tr("Merge Nodes"), "mergenodes", tr("Merge nodes into the oldest one."), 069 Shortcut.registerShortcut("tools:mergenodes", tr("Tool: {0}", tr("Merge Nodes")), KeyEvent.VK_M, Shortcut.DIRECT), true); 070 putValue("help", ht("/Action/MergeNodes")); 071 } 072 073 @Override 074 public void actionPerformed(ActionEvent event) { 075 if (!isEnabled()) 076 return; 077 Collection<OsmPrimitive> selection = getLayerManager().getEditDataSet().getAllSelected(); 078 List<Node> selectedNodes = OsmPrimitive.getFilteredList(selection, Node.class); 079 selectedNodes.removeIf(n -> n.isDeleted() || n.isIncomplete()); 080 081 if (selectedNodes.size() == 1) { 082 MapView mapView = MainApplication.getMap().mapView; 083 List<Node> nearestNodes = mapView.getNearestNodes( 084 mapView.getPoint(selectedNodes.get(0)), selectedNodes, OsmPrimitive::isUsable); 085 if (nearestNodes.isEmpty()) { 086 new Notification( 087 tr("Please select at least two nodes to merge or one node that is close to another node.")) 088 .setIcon(JOptionPane.WARNING_MESSAGE) 089 .show(); 090 return; 091 } 092 selectedNodes.addAll(nearestNodes); 093 } 094 095 Node targetNode = selectTargetNode(selectedNodes); 096 if (targetNode != null) { 097 Node targetLocationNode = selectTargetLocationNode(selectedNodes); 098 Command cmd = mergeNodes(selectedNodes, targetNode, targetLocationNode); 099 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 ImageProvider.get("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 if (selection == null || selection.isEmpty() 368 || selection.stream().map(OsmPrimitive::getDataSet).anyMatch(DataSet::isLocked)) { 369 setEnabled(false); 370 return; 371 } 372 boolean ok = true; 373 for (OsmPrimitive osm : selection) { 374 if (!(osm instanceof Node)) { 375 ok = false; 376 break; 377 } 378 } 379 setEnabled(ok); 380 } 381}