| 1 | //License: GPL. Copyright 2007 by Immanuel Scholz and others. See LICENSE file for details. |
|---|
| 2 | package org.openstreetmap.josm.actions; |
|---|
| 3 | |
|---|
| 4 | import static org.openstreetmap.josm.gui.help.HelpUtil.ht; |
|---|
| 5 | import static org.openstreetmap.josm.tools.I18n.tr; |
|---|
| 6 | |
|---|
| 7 | import java.awt.event.ActionEvent; |
|---|
| 8 | import java.awt.event.KeyEvent; |
|---|
| 9 | import java.util.ArrayList; |
|---|
| 10 | import java.util.Collection; |
|---|
| 11 | import java.util.HashSet; |
|---|
| 12 | import java.util.LinkedList; |
|---|
| 13 | import java.util.List; |
|---|
| 14 | import java.util.Set; |
|---|
| 15 | |
|---|
| 16 | import javax.swing.JOptionPane; |
|---|
| 17 | |
|---|
| 18 | import org.openstreetmap.josm.Main; |
|---|
| 19 | import org.openstreetmap.josm.command.ChangeCommand; |
|---|
| 20 | import org.openstreetmap.josm.command.ChangeNodesCommand; |
|---|
| 21 | import org.openstreetmap.josm.command.Command; |
|---|
| 22 | import org.openstreetmap.josm.command.DeleteCommand; |
|---|
| 23 | import org.openstreetmap.josm.command.SequenceCommand; |
|---|
| 24 | import org.openstreetmap.josm.data.coor.EastNorth; |
|---|
| 25 | import org.openstreetmap.josm.data.coor.LatLon; |
|---|
| 26 | import org.openstreetmap.josm.data.osm.Node; |
|---|
| 27 | import org.openstreetmap.josm.data.osm.OsmPrimitive; |
|---|
| 28 | import org.openstreetmap.josm.data.osm.RelationToChildReference; |
|---|
| 29 | import org.openstreetmap.josm.data.osm.TagCollection; |
|---|
| 30 | import org.openstreetmap.josm.data.osm.Way; |
|---|
| 31 | import org.openstreetmap.josm.gui.DefaultNameFormatter; |
|---|
| 32 | import org.openstreetmap.josm.gui.HelpAwareOptionPane; |
|---|
| 33 | import org.openstreetmap.josm.gui.HelpAwareOptionPane.ButtonSpec; |
|---|
| 34 | import org.openstreetmap.josm.gui.conflict.tags.CombinePrimitiveResolverDialog; |
|---|
| 35 | import org.openstreetmap.josm.gui.conflict.tags.TagConflictResolutionUtil; |
|---|
| 36 | import org.openstreetmap.josm.gui.layer.OsmDataLayer; |
|---|
| 37 | import org.openstreetmap.josm.tools.CheckParameterUtil; |
|---|
| 38 | import org.openstreetmap.josm.tools.ImageProvider; |
|---|
| 39 | import org.openstreetmap.josm.tools.Shortcut; |
|---|
| 40 | |
|---|
| 41 | /** |
|---|
| 42 | * Merges a collection of nodes into one node. |
|---|
| 43 | * |
|---|
| 44 | * The "surviving" node will be the one with the lowest positive id. |
|---|
| 45 | * (I.e. it was uploaded to the server and is the oldest one.) |
|---|
| 46 | * |
|---|
| 47 | * However we use the location of the node that was selected *last*. |
|---|
| 48 | * The "surviving" node will be moved to that location if it is |
|---|
| 49 | * different from the last selected node. |
|---|
| 50 | */ |
|---|
| 51 | public class MergeNodesAction extends JosmAction { |
|---|
| 52 | |
|---|
| 53 | public MergeNodesAction() { |
|---|
| 54 | super(tr("Merge Nodes"), "mergenodes", tr("Merge nodes into the oldest one."), |
|---|
| 55 | Shortcut.registerShortcut("tools:mergenodes", tr("Tool: {0}", tr("Merge Nodes")), KeyEvent.VK_M, Shortcut.GROUP_EDIT), true); |
|---|
| 56 | putValue("help", ht("/Action/MergeNodes")); |
|---|
| 57 | } |
|---|
| 58 | |
|---|
| 59 | public void actionPerformed(ActionEvent event) { |
|---|
| 60 | if (!isEnabled()) |
|---|
| 61 | return; |
|---|
| 62 | Collection<OsmPrimitive> selection = getCurrentDataSet().getSelected(); |
|---|
| 63 | List<Node> selectedNodes = OsmPrimitive.getFilteredList(selection, Node.class); |
|---|
| 64 | |
|---|
| 65 | if (selectedNodes.size() == 1) { |
|---|
| 66 | List<Node> nearestNodes = Main.map.mapView.getNearestNodes(Main.map.mapView.getPoint(selectedNodes.get(0)), selectedNodes, OsmPrimitive.isUsablePredicate); |
|---|
| 67 | if (nearestNodes.isEmpty()) { |
|---|
| 68 | JOptionPane.showMessageDialog( |
|---|
| 69 | Main.parent, |
|---|
| 70 | tr("Please select at least two nodes to merge or a node that is close to another node."), |
|---|
| 71 | tr("Warning"), |
|---|
| 72 | JOptionPane.WARNING_MESSAGE |
|---|
| 73 | ); |
|---|
| 74 | |
|---|
| 75 | return; |
|---|
| 76 | } |
|---|
| 77 | selectedNodes.addAll(nearestNodes); |
|---|
| 78 | } |
|---|
| 79 | |
|---|
| 80 | Node targetNode = selectTargetNode(selectedNodes); |
|---|
| 81 | Node targetLocationNode = selectTargetLocationNode(selectedNodes); |
|---|
| 82 | Command cmd = mergeNodes(Main.main.getEditLayer(), selectedNodes, targetNode, targetLocationNode); |
|---|
| 83 | if (cmd != null) { |
|---|
| 84 | Main.main.undoRedo.add(cmd); |
|---|
| 85 | Main.main.getEditLayer().data.setSelected(targetNode); |
|---|
| 86 | } |
|---|
| 87 | } |
|---|
| 88 | |
|---|
| 89 | /** |
|---|
| 90 | * Select the location of the target node after merge. |
|---|
| 91 | * |
|---|
| 92 | * @param candidates the collection of candidate nodes |
|---|
| 93 | * @return the coordinates of this node are later used for the target node |
|---|
| 94 | */ |
|---|
| 95 | public static Node selectTargetLocationNode(List<Node> candidates) { |
|---|
| 96 | int size = candidates.size(); |
|---|
| 97 | if (size == 0) |
|---|
| 98 | throw new IllegalArgumentException("empty list"); |
|---|
| 99 | |
|---|
| 100 | switch (Main.pref.getInteger("merge-nodes.mode", 0)) { |
|---|
| 101 | case 0: { |
|---|
| 102 | Node targetNode = candidates.get(size - 1); |
|---|
| 103 | for (final Node n : candidates) { // pick last one |
|---|
| 104 | targetNode = n; |
|---|
| 105 | } |
|---|
| 106 | return targetNode; |
|---|
| 107 | } |
|---|
| 108 | case 1: { |
|---|
| 109 | double east = 0, north = 0; |
|---|
| 110 | for (final Node n : candidates) { |
|---|
| 111 | east += n.getEastNorth().east(); |
|---|
| 112 | north += n.getEastNorth().north(); |
|---|
| 113 | } |
|---|
| 114 | |
|---|
| 115 | return new Node(new EastNorth(east / size, north / size)); |
|---|
| 116 | } |
|---|
| 117 | case 2: { |
|---|
| 118 | final double[] weights = new double[size]; |
|---|
| 119 | |
|---|
| 120 | for (int i = 0; i < size; i++) { |
|---|
| 121 | final LatLon c1 = candidates.get(i).getCoor(); |
|---|
| 122 | for (int j = i + 1; j < size; j++) { |
|---|
| 123 | final LatLon c2 = candidates.get(j).getCoor(); |
|---|
| 124 | final double d = c1.distance(c2); |
|---|
| 125 | weights[i] += d; |
|---|
| 126 | weights[j] += d; |
|---|
| 127 | } |
|---|
| 128 | } |
|---|
| 129 | |
|---|
| 130 | double east = 0, north = 0, weight = 0; |
|---|
| 131 | for (int i = 0; i < size; i++) { |
|---|
| 132 | final EastNorth en = candidates.get(i).getEastNorth(); |
|---|
| 133 | final double w = weights[i]; |
|---|
| 134 | east += en.east() * w; |
|---|
| 135 | north += en.north() * w; |
|---|
| 136 | weight += w; |
|---|
| 137 | } |
|---|
| 138 | |
|---|
| 139 | return new Node(new EastNorth(east / weight, north / weight)); |
|---|
| 140 | } |
|---|
| 141 | default: |
|---|
| 142 | throw new RuntimeException("unacceptable merge-nodes.mode"); |
|---|
| 143 | } |
|---|
| 144 | |
|---|
| 145 | } |
|---|
| 146 | |
|---|
| 147 | /** |
|---|
| 148 | * Find which node to merge into (i.e. which one will be left) |
|---|
| 149 | * |
|---|
| 150 | * @param candidates the collection of candidate nodes |
|---|
| 151 | * @return the selected target node |
|---|
| 152 | */ |
|---|
| 153 | public static Node selectTargetNode(List<Node> candidates) { |
|---|
| 154 | Node targetNode = null; |
|---|
| 155 | Node lastNode = null; |
|---|
| 156 | for (Node n : candidates) { |
|---|
| 157 | if (!n.isNew()) { |
|---|
| 158 | if (targetNode == null) { |
|---|
| 159 | targetNode = n; |
|---|
| 160 | } else if (n.getId() < targetNode.getId()) { |
|---|
| 161 | targetNode = n; |
|---|
| 162 | } |
|---|
| 163 | } |
|---|
| 164 | lastNode = n; |
|---|
| 165 | } |
|---|
| 166 | if (targetNode == null) { |
|---|
| 167 | targetNode = lastNode; |
|---|
| 168 | } |
|---|
| 169 | return targetNode; |
|---|
| 170 | } |
|---|
| 171 | |
|---|
| 172 | |
|---|
| 173 | /** |
|---|
| 174 | * Fixes the parent ways referring to one of the nodes. |
|---|
| 175 | * |
|---|
| 176 | * Replies null, if the ways could not be fixed, i.e. because a way would have to be deleted |
|---|
| 177 | * which is referred to by a relation. |
|---|
| 178 | * |
|---|
| 179 | * @param nodesToDelete the collection of nodes to be deleted |
|---|
| 180 | * @param targetNode the target node the other nodes are merged to |
|---|
| 181 | * @return a list of commands; null, if the ways could not be fixed |
|---|
| 182 | */ |
|---|
| 183 | protected static List<Command> fixParentWays(Collection<Node> nodesToDelete, Node targetNode) { |
|---|
| 184 | List<Command> cmds = new ArrayList<Command>(); |
|---|
| 185 | Set<Way> waysToDelete = new HashSet<Way>(); |
|---|
| 186 | |
|---|
| 187 | for (Way w: OsmPrimitive.getFilteredList(OsmPrimitive.getReferrer(nodesToDelete), Way.class)) { |
|---|
| 188 | ArrayList<Node> newNodes = new ArrayList<Node>(w.getNodesCount()); |
|---|
| 189 | for (Node n: w.getNodes()) { |
|---|
| 190 | if (! nodesToDelete.contains(n) && n != targetNode) { |
|---|
| 191 | newNodes.add(n); |
|---|
| 192 | } else if (newNodes.isEmpty()) { |
|---|
| 193 | newNodes.add(targetNode); |
|---|
| 194 | } else if (newNodes.get(newNodes.size()-1) != targetNode) { |
|---|
| 195 | // make sure we collapse a sequence of deleted nodes |
|---|
| 196 | // to exactly one occurrence of the merged target node |
|---|
| 197 | // |
|---|
| 198 | newNodes.add(targetNode); |
|---|
| 199 | } else { |
|---|
| 200 | // drop the node |
|---|
| 201 | } |
|---|
| 202 | } |
|---|
| 203 | if (newNodes.size() < 2) { |
|---|
| 204 | if (w.getReferrers().isEmpty()) { |
|---|
| 205 | waysToDelete.add(w); |
|---|
| 206 | } else { |
|---|
| 207 | ButtonSpec[] options = new ButtonSpec[] { |
|---|
| 208 | new ButtonSpec( |
|---|
| 209 | tr("Abort Merging"), |
|---|
| 210 | ImageProvider.get("cancel"), |
|---|
| 211 | tr("Click to abort merging nodes"), |
|---|
| 212 | null /* no special help topic */ |
|---|
| 213 | ) |
|---|
| 214 | }; |
|---|
| 215 | HelpAwareOptionPane.showOptionDialog( |
|---|
| 216 | Main.parent, |
|---|
| 217 | tr( |
|---|
| 218 | "Cannot merge nodes: Would have to delete way ''{0}'' which is still used.", |
|---|
| 219 | w.getDisplayName(DefaultNameFormatter.getInstance()) |
|---|
| 220 | ), |
|---|
| 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 Command mergeNodes(OsmDataLayer layer, Collection<Node> nodes, Node targetNode) { |
|---|
| 243 | return mergeNodes(layer, nodes, targetNode, targetNode); |
|---|
| 244 | } |
|---|
| 245 | |
|---|
| 246 | /** |
|---|
| 247 | * Merges the nodes in <code>nodes</code> onto one of the nodes. Uses the dataset |
|---|
| 248 | * managed by <code>layer</code> as reference. |
|---|
| 249 | * |
|---|
| 250 | * @param layer layer the reference data layer. Must not be null. |
|---|
| 251 | * @param nodes the collection of nodes. Ignored if null. |
|---|
| 252 | * @param targetNode the target node the collection of nodes is merged to. Must not be null. |
|---|
| 253 | * @param targetLocationNode this node's location will be used for the targetNode. |
|---|
| 254 | * @throw IllegalArgumentException thrown if layer is null |
|---|
| 255 | */ |
|---|
| 256 | public static Command mergeNodes(OsmDataLayer layer, Collection<Node> nodes, Node targetNode, Node targetLocationNode) { |
|---|
| 257 | CheckParameterUtil.ensureParameterNotNull(layer, "layer"); |
|---|
| 258 | CheckParameterUtil.ensureParameterNotNull(targetNode, "targetNode"); |
|---|
| 259 | if (nodes == null) |
|---|
| 260 | return null; |
|---|
| 261 | |
|---|
| 262 | Set<RelationToChildReference> relationToNodeReferences = RelationToChildReference.getRelationToChildReferences(nodes); |
|---|
| 263 | |
|---|
| 264 | // build the tag collection |
|---|
| 265 | // |
|---|
| 266 | TagCollection nodeTags = TagCollection.unionOfAllPrimitives(nodes); |
|---|
| 267 | TagConflictResolutionUtil.combineTigerTags(nodeTags); |
|---|
| 268 | TagConflictResolutionUtil.normalizeTagCollectionBeforeEditing(nodeTags, nodes); |
|---|
| 269 | TagCollection nodeTagsToEdit = new TagCollection(nodeTags); |
|---|
| 270 | TagConflictResolutionUtil.completeTagCollectionForEditing(nodeTagsToEdit); |
|---|
| 271 | |
|---|
| 272 | // launch a conflict resolution dialog, if necessary |
|---|
| 273 | // |
|---|
| 274 | CombinePrimitiveResolverDialog dialog = CombinePrimitiveResolverDialog.getInstance(); |
|---|
| 275 | dialog.getTagConflictResolverModel().populate(nodeTagsToEdit, nodeTags.getKeysWithMultipleValues()); |
|---|
| 276 | dialog.getRelationMemberConflictResolverModel().populate(relationToNodeReferences); |
|---|
| 277 | dialog.setTargetPrimitive(targetNode); |
|---|
| 278 | dialog.prepareDefaultDecisions(); |
|---|
| 279 | // conflict resolution is necessary if there are conflicts in the merged tags |
|---|
| 280 | // or if at least one of the merged nodes is referred to by a relation |
|---|
| 281 | // |
|---|
| 282 | if (! nodeTags.isApplicableToPrimitive() || relationToNodeReferences.size() > 1) { |
|---|
| 283 | dialog.setVisible(true); |
|---|
| 284 | if (dialog.isCanceled()) |
|---|
| 285 | return null; |
|---|
| 286 | } |
|---|
| 287 | LinkedList<Command> cmds = new LinkedList<Command>(); |
|---|
| 288 | |
|---|
| 289 | // the nodes we will have to delete |
|---|
| 290 | // |
|---|
| 291 | Collection<Node> nodesToDelete = new HashSet<Node>(nodes); |
|---|
| 292 | nodesToDelete.remove(targetNode); |
|---|
| 293 | |
|---|
| 294 | // fix the ways referring to at least one of the merged nodes |
|---|
| 295 | // |
|---|
| 296 | Collection<Way> waysToDelete= new HashSet<Way>(); |
|---|
| 297 | List<Command> wayFixCommands = fixParentWays( |
|---|
| 298 | nodesToDelete, |
|---|
| 299 | targetNode |
|---|
| 300 | ); |
|---|
| 301 | if (wayFixCommands == null) |
|---|
| 302 | return null; |
|---|
| 303 | cmds.addAll(wayFixCommands); |
|---|
| 304 | |
|---|
| 305 | // build the commands |
|---|
| 306 | // |
|---|
| 307 | if (targetNode != targetLocationNode) { |
|---|
| 308 | Node newTargetNode = new Node(targetNode); |
|---|
| 309 | newTargetNode.setCoor(targetLocationNode.getCoor()); |
|---|
| 310 | cmds.add(new ChangeCommand(targetNode, newTargetNode)); |
|---|
| 311 | } |
|---|
| 312 | cmds.addAll(dialog.buildResolutionCommands()); |
|---|
| 313 | if (!nodesToDelete.isEmpty()) { |
|---|
| 314 | cmds.add(new DeleteCommand(nodesToDelete)); |
|---|
| 315 | } |
|---|
| 316 | if (!waysToDelete.isEmpty()) { |
|---|
| 317 | cmds.add(new DeleteCommand(waysToDelete)); |
|---|
| 318 | } |
|---|
| 319 | Command cmd = new SequenceCommand(tr("Merge {0} nodes", nodes.size()), cmds); |
|---|
| 320 | return cmd; |
|---|
| 321 | } |
|---|
| 322 | |
|---|
| 323 | @Override |
|---|
| 324 | protected void updateEnabledState() { |
|---|
| 325 | if (getCurrentDataSet() == null) { |
|---|
| 326 | setEnabled(false); |
|---|
| 327 | } else { |
|---|
| 328 | updateEnabledState(getCurrentDataSet().getSelected()); |
|---|
| 329 | } |
|---|
| 330 | } |
|---|
| 331 | |
|---|
| 332 | @Override |
|---|
| 333 | protected void updateEnabledState(Collection<? extends OsmPrimitive> selection) { |
|---|
| 334 | if (selection == null || selection.isEmpty()) { |
|---|
| 335 | setEnabled(false); |
|---|
| 336 | return; |
|---|
| 337 | } |
|---|
| 338 | boolean ok = true; |
|---|
| 339 | for (OsmPrimitive osm : selection) { |
|---|
| 340 | if (!(osm instanceof Node)) { |
|---|
| 341 | ok = false; |
|---|
| 342 | break; |
|---|
| 343 | } |
|---|
| 344 | } |
|---|
| 345 | setEnabled(ok); |
|---|
| 346 | } |
|---|
| 347 | } |
|---|