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