source: josm/trunk/src/org/openstreetmap/josm/actions/CreateMultipolygonAction.java@ 15652

Last change on this file since 15652 was 15531, checked in by GerdP, 5 years ago

see #18341: Improve performance when incomplete multipolygon is updated (Ctrl+Shift+B)
Only download a set of incomplete members if the number is not bigger than 100, else download the full relation.
This is expected to be much faster and less stressing for the server when the multipolygon is very complex (thousands of members).

  • Property svn:eol-style set to native
File size: 21.7 KB
Line 
1// License: GPL. For details, see LICENSE file.
2package org.openstreetmap.josm.actions;
3
4import static org.openstreetmap.josm.tools.I18n.tr;
5import static org.openstreetmap.josm.tools.I18n.trn;
6
7import java.awt.event.ActionEvent;
8import java.awt.event.KeyEvent;
9import java.util.ArrayList;
10import java.util.Arrays;
11import java.util.Collection;
12import java.util.Collections;
13import java.util.HashMap;
14import java.util.HashSet;
15import java.util.Iterator;
16import java.util.LinkedHashSet;
17import java.util.List;
18import java.util.Map;
19import java.util.Map.Entry;
20import java.util.Set;
21import java.util.TreeSet;
22
23import javax.swing.JOptionPane;
24import javax.swing.SwingUtilities;
25
26import org.openstreetmap.josm.actions.relation.DownloadSelectedIncompleteMembersAction;
27import org.openstreetmap.josm.command.AddCommand;
28import org.openstreetmap.josm.command.ChangeCommand;
29import org.openstreetmap.josm.command.ChangePropertyCommand;
30import org.openstreetmap.josm.command.Command;
31import org.openstreetmap.josm.command.SequenceCommand;
32import org.openstreetmap.josm.data.UndoRedoHandler;
33import org.openstreetmap.josm.data.osm.DataSet;
34import org.openstreetmap.josm.data.osm.IPrimitive;
35import org.openstreetmap.josm.data.osm.OsmPrimitive;
36import org.openstreetmap.josm.data.osm.OsmUtils;
37import org.openstreetmap.josm.data.osm.Relation;
38import org.openstreetmap.josm.data.osm.RelationMember;
39import org.openstreetmap.josm.data.osm.Way;
40import org.openstreetmap.josm.data.validation.TestError;
41import org.openstreetmap.josm.data.validation.tests.MultipolygonTest;
42import org.openstreetmap.josm.gui.MainApplication;
43import org.openstreetmap.josm.gui.Notification;
44import org.openstreetmap.josm.gui.dialogs.relation.DownloadRelationMemberTask;
45import org.openstreetmap.josm.gui.dialogs.relation.DownloadRelationTask;
46import org.openstreetmap.josm.gui.dialogs.relation.RelationEditor;
47import org.openstreetmap.josm.gui.dialogs.relation.sort.RelationSorter;
48import org.openstreetmap.josm.gui.layer.OsmDataLayer;
49import org.openstreetmap.josm.gui.util.GuiHelper;
50import org.openstreetmap.josm.spi.preferences.Config;
51import org.openstreetmap.josm.tools.Pair;
52import org.openstreetmap.josm.tools.Shortcut;
53import org.openstreetmap.josm.tools.SubclassFilteredCollection;
54import org.openstreetmap.josm.tools.Utils;
55
56/**
57 * Create multipolygon from selected ways automatically.
58 *
59 * New relation with type=multipolygon is created.
60 *
61 * If one or more of ways is already in relation with type=multipolygon or the
62 * way is not closed, then error is reported and no relation is created.
63 *
64 * The "inner" and "outer" roles are guessed automatically. First, bbox is
65 * calculated for each way. then the largest area is assumed to be outside and
66 * the rest inside. In cases with one "outside" area and several cut-ins, the
67 * guess should be always good ... In more complex (multiple outer areas) or
68 * buggy (inner and outer ways intersect) scenarios the result is likely to be
69 * wrong.
70 */
71public class CreateMultipolygonAction extends JosmAction {
72
73 private final boolean update;
74 private static final int MAX_MEMBERS_TO_DOWNLOAD = 100;
75
76 /**
77 * Constructs a new {@code CreateMultipolygonAction}.
78 * @param update {@code true} if the multipolygon must be updated, {@code false} if it must be created
79 */
80 public CreateMultipolygonAction(final boolean update) {
81 super(getName(update), /* ICON */ "multipoly_create", getName(update),
82 /* at least three lines for each shortcut or the server extractor fails */
83 update ? Shortcut.registerShortcut("tools:multipoly_update",
84 tr("Tool: {0}", getName(true)),
85 KeyEvent.VK_B, Shortcut.CTRL_SHIFT)
86 : Shortcut.registerShortcut("tools:multipoly_create",
87 tr("Tool: {0}", getName(false)),
88 KeyEvent.VK_B, Shortcut.CTRL),
89 true, update ? "multipoly_update" : "multipoly_create", true);
90 this.update = update;
91 }
92
93 private static String getName(boolean update) {
94 return update ? tr("Update multipolygon") : tr("Create multipolygon");
95 }
96
97 private static final class CreateUpdateMultipolygonTask implements Runnable {
98 private final Collection<Way> selectedWays;
99 private final Relation multipolygonRelation;
100
101 private CreateUpdateMultipolygonTask(Collection<Way> selectedWays, Relation multipolygonRelation) {
102 this.selectedWays = selectedWays;
103 this.multipolygonRelation = multipolygonRelation;
104 }
105
106 @Override
107 public void run() {
108 final Pair<SequenceCommand, Relation> commandAndRelation = createMultipolygonCommand(selectedWays, multipolygonRelation);
109 if (commandAndRelation == null) {
110 return;
111 }
112 final Command command = commandAndRelation.a;
113 final Relation relation = commandAndRelation.b;
114
115 // to avoid EDT violations
116 SwingUtilities.invokeLater(() -> {
117 UndoRedoHandler.getInstance().add(command);
118
119 // Use 'SwingUtilities.invokeLater' to make sure the relationListDialog
120 // knows about the new relation before we try to select it.
121 // (Yes, we are already in event dispatch thread. But DatasetEventManager
122 // uses 'SwingUtilities.invokeLater' to fire events so we have to do the same.)
123 SwingUtilities.invokeLater(() -> {
124 MainApplication.getMap().relationListDialog.selectRelation(relation);
125 if (Config.getPref().getBoolean("multipoly.show-relation-editor", false)) {
126 //Open relation edit window, if set up in preferences
127 RelationEditor editor = RelationEditor.getEditor(
128 MainApplication.getLayerManager().getEditLayer(), relation, null);
129 editor.setModal(true);
130 editor.setVisible(true);
131 } else {
132 MainApplication.getLayerManager().getEditLayer().setRecentRelation(relation);
133 }
134 });
135 });
136 }
137 }
138
139 @Override
140 public void actionPerformed(ActionEvent e) {
141 DataSet dataSet = getLayerManager().getEditDataSet();
142 if (dataSet == null) {
143 new Notification(
144 tr("No data loaded."))
145 .setIcon(JOptionPane.WARNING_MESSAGE)
146 .setDuration(Notification.TIME_SHORT)
147 .show();
148 return;
149 }
150
151 final Collection<Way> selectedWays = dataSet.getSelectedWays();
152
153 if (selectedWays.isEmpty()) {
154 // Sometimes it make sense creating multipoly of only one way (so it will form outer way)
155 // and then splitting the way later (so there are multiple ways forming outer way)
156 new Notification(
157 tr("You must select at least one way."))
158 .setIcon(JOptionPane.INFORMATION_MESSAGE)
159 .setDuration(Notification.TIME_SHORT)
160 .show();
161 return;
162 }
163
164 final Collection<Relation> selectedRelations = dataSet.getSelectedRelations();
165 final Relation multipolygonRelation = update
166 ? getSelectedMultipolygonRelation(selectedWays, selectedRelations)
167 : null;
168
169 if (update && multipolygonRelation == null)
170 return;
171 // download incomplete relation or incomplete members if necessary
172 OsmDataLayer editLayer = getLayerManager().getEditLayer();
173 if (multipolygonRelation != null && editLayer != null && editLayer.isDownloadable()) {
174 if (!multipolygonRelation.isNew() && multipolygonRelation.isIncomplete()) {
175 MainApplication.worker
176 .submit(new DownloadRelationTask(Collections.singleton(multipolygonRelation), editLayer));
177 } else if (multipolygonRelation.hasIncompleteMembers()) {
178 // with complex relations the download of the full relation is much faster than download of almost all members, see #18341
179 SubclassFilteredCollection<IPrimitive, OsmPrimitive> incompleteMembers = Utils
180 .filteredCollection(DownloadSelectedIncompleteMembersAction.buildSetOfIncompleteMembers(
181 Collections.singleton(multipolygonRelation)), OsmPrimitive.class);
182
183 if (incompleteMembers.size() <= MAX_MEMBERS_TO_DOWNLOAD) {
184 MainApplication.worker
185 .submit(new DownloadRelationMemberTask(multipolygonRelation, incompleteMembers, editLayer));
186 } else {
187 MainApplication.worker
188 .submit(new DownloadRelationTask(Collections.singleton(multipolygonRelation), editLayer));
189
190 }
191 }
192 }
193 // create/update multipolygon relation
194 MainApplication.worker.submit(new CreateUpdateMultipolygonTask(selectedWays, multipolygonRelation));
195 }
196
197 private static Relation getSelectedMultipolygonRelation(Collection<Way> selectedWays, Collection<Relation> selectedRelations) {
198 Relation candidate = null;
199 if (selectedRelations.size() == 1) {
200 candidate = selectedRelations.iterator().next();
201 if (!candidate.hasTag("type", "multipolygon"))
202 candidate = null;
203 } else if (!selectedWays.isEmpty()) {
204 for (final Way w : selectedWays) {
205 for (OsmPrimitive r : w.getReferrers()) {
206 if (r != candidate && !r.isDisabled() && r instanceof Relation && r.hasTag("type", "multipolygon")) {
207 if (candidate != null)
208 return null; // found another multipolygon relation
209 candidate = (Relation) r;
210 }
211 }
212 }
213 }
214 return candidate;
215 }
216
217 /**
218 * Returns a {@link Pair} of the old multipolygon {@link Relation} (or null) and the newly created/modified multipolygon {@link Relation}.
219 * @param selectedWays selected ways
220 * @param selectedMultipolygonRelation selected multipolygon relation
221 * @return pair of old and new multipolygon relation if a difference was found, else the pair contains the old relation twice
222 */
223 public static Pair<Relation, Relation> updateMultipolygonRelation(Collection<Way> selectedWays, Relation selectedMultipolygonRelation) {
224
225 // add ways of existing relation to include them in polygon analysis
226 Set<Way> ways = new HashSet<>(selectedWays);
227 ways.addAll(selectedMultipolygonRelation.getMemberPrimitives(Way.class));
228
229 // even if no way was added the inner/outer roles might be different
230 MultipolygonTest mpTest = new MultipolygonTest();
231 Relation calculated = mpTest.makeFromWays(ways);
232 if (mpTest.getErrors().isEmpty()) {
233 return mergeRelationsMembers(selectedMultipolygonRelation, calculated);
234 }
235 showErrors(mpTest.getErrors());
236 return null; //could not make multipolygon.
237 }
238
239 /**
240 * Merge members of multipolygon relation. Maintains the order of the old relation. May change roles,
241 * removes duplicate and non-way members and adds new members found in {@code calculated}.
242 * @param old old multipolygon relation
243 * @param calculated calculated multipolygon relation
244 * @return pair of old and new multipolygon relation if a difference was found, else the pair contains the old relation twice
245 */
246 private static Pair<Relation, Relation> mergeRelationsMembers(Relation old, Relation calculated) {
247 Set<RelationMember> merged = new LinkedHashSet<>();
248 boolean foundDiff = false;
249 int nonWayMember = 0;
250 // maintain order of members in updated relation
251 for (RelationMember oldMem :old.getMembers()) {
252 if (oldMem.isNode() || oldMem.isRelation()) {
253 nonWayMember++;
254 continue;
255 }
256 for (RelationMember newMem : calculated.getMembers()) {
257 if (newMem.getMember().equals(oldMem.getMember())) {
258 if (!newMem.getRole().equals(oldMem.getRole())) {
259 foundDiff = true;
260 }
261 foundDiff |= !merged.add(newMem); // detect duplicate members in old relation
262 break;
263 }
264 }
265 }
266 if (nonWayMember > 0) {
267 foundDiff = true;
268 String msg = trn("Non-Way member removed from multipolygon", "Non-Way members removed from multipolygon", nonWayMember);
269 GuiHelper.runInEDT(() -> new Notification(msg).setIcon(JOptionPane.WARNING_MESSAGE).show());
270 }
271 foundDiff |= merged.addAll(calculated.getMembers());
272 if (!foundDiff) {
273 return Pair.create(old, old); // unchanged
274 }
275 Relation toModify = new Relation(old);
276 toModify.setMembers(new ArrayList<>(merged));
277 return Pair.create(old, toModify);
278 }
279
280 /**
281 * Returns a {@link Pair} null and the newly created/modified multipolygon {@link Relation}.
282 * @param selectedWays selected ways
283 * @param showNotif if {@code true}, shows a notification if an error occurs
284 * @return pair of null and new multipolygon relation
285 */
286 public static Pair<Relation, Relation> createMultipolygonRelation(Collection<Way> selectedWays, boolean showNotif) {
287 MultipolygonTest mpTest = new MultipolygonTest();
288 Relation calculated = mpTest.makeFromWays(selectedWays);
289 calculated.setMembers(RelationSorter.sortMembersByConnectivity(calculated.getMembers()));
290 if (mpTest.getErrors().isEmpty())
291 return Pair.create(null, calculated);
292 if (showNotif) {
293 showErrors(mpTest.getErrors());
294 }
295 return null; //could not make multipolygon.
296 }
297
298 private static void showErrors(List<TestError> errors) {
299 if (!errors.isEmpty()) {
300 StringBuilder sb = new StringBuilder();
301 Set<String> errorMessages = new LinkedHashSet<>();
302 errors.forEach(e-> errorMessages.add(e.getMessage()));
303 Iterator<String> iter = errorMessages.iterator();
304 while (iter.hasNext()) {
305 sb.append(iter.next());
306 if (iter.hasNext())
307 sb.append('\n');
308 }
309 GuiHelper.runInEDT(() -> new Notification(sb.toString()).setIcon(JOptionPane.INFORMATION_MESSAGE).show());
310 }
311 }
312
313 /**
314 * Returns a {@link Pair} of a multipolygon creating/modifying {@link Command} as well as the multipolygon {@link Relation}.
315 * @param selectedWays selected ways
316 * @param selectedMultipolygonRelation selected multipolygon relation
317 * @return pair of command and multipolygon relation
318 */
319 public static Pair<SequenceCommand, Relation> createMultipolygonCommand(Collection<Way> selectedWays,
320 Relation selectedMultipolygonRelation) {
321
322 final Pair<Relation, Relation> rr = selectedMultipolygonRelation == null
323 ? createMultipolygonRelation(selectedWays, true)
324 : updateMultipolygonRelation(selectedWays, selectedMultipolygonRelation);
325 if (rr == null) {
326 return null;
327 }
328 boolean unchanged = rr.a == rr.b;
329 final Relation existingRelation = rr.a;
330 final Relation relation = rr.b;
331
332 final List<Command> list = removeTagsFromWaysIfNeeded(relation);
333 final String commandName;
334 if (existingRelation == null) {
335 list.add(new AddCommand(selectedWays.iterator().next().getDataSet(), relation));
336 commandName = getName(false);
337 } else {
338 if (!unchanged) {
339 list.add(new ChangeCommand(existingRelation, relation));
340 }
341 if (list.isEmpty()) {
342 if (unchanged) {
343 MultipolygonTest mpTest = new MultipolygonTest();
344 mpTest.visit(existingRelation);
345 if (!mpTest.getErrors().isEmpty()) {
346 showErrors(mpTest.getErrors());
347 return null;
348 }
349 }
350
351 GuiHelper.runInEDT(() -> new Notification(tr("Nothing changed")).setDuration(Notification.TIME_SHORT)
352 .setIcon(JOptionPane.INFORMATION_MESSAGE).show());
353 return null;
354 }
355 commandName = getName(true);
356 }
357 return Pair.create(new SequenceCommand(commandName, list), relation);
358 }
359
360 /** Enable this action only if something is selected */
361 @Override
362 protected void updateEnabledState() {
363 updateEnabledStateOnCurrentSelection();
364 }
365
366 /**
367 * Enable this action only if something is selected
368 *
369 * @param selection the current selection, gets tested for emptiness
370 */
371 @Override
372 protected void updateEnabledState(Collection<? extends OsmPrimitive> selection) {
373 DataSet ds = getLayerManager().getEditDataSet();
374 if (ds == null || selection.isEmpty()) {
375 setEnabled(false);
376 } else if (update) {
377 setEnabled(getSelectedMultipolygonRelation(ds.getSelectedWays(), ds.getSelectedRelations()) != null);
378 } else {
379 setEnabled(!ds.getSelectedWays().isEmpty());
380 }
381 }
382
383 private static final List<String> DEFAULT_LINEAR_TAGS = Arrays.asList("barrier", "fence_type", "source");
384
385 /**
386 * This method removes tags/value pairs from inner and outer ways and put them on relation if necessary
387 * Function was extended in reltoolbox plugin by Zverikk and copied back to the core
388 * @param relation the multipolygon style relation to process
389 * @return a list of commands to execute
390 */
391 public static List<Command> removeTagsFromWaysIfNeeded(Relation relation) {
392 Map<String, String> values = new HashMap<>(relation.getKeys());
393
394 List<Way> innerWays = new ArrayList<>();
395 List<Way> outerWays = new ArrayList<>();
396
397 Set<String> conflictingKeys = new TreeSet<>();
398
399 for (RelationMember m : relation.getMembers()) {
400
401 if (m.hasRole() && "inner".equals(m.getRole()) && m.isWay() && m.getWay().hasKeys()) {
402 innerWays.add(m.getWay());
403 }
404
405 if (m.hasRole() && "outer".equals(m.getRole()) && m.isWay() && m.getWay().hasKeys()) {
406 Way way = m.getWay();
407 outerWays.add(way);
408
409 for (String key : way.keySet()) {
410 if (!values.containsKey(key)) { //relation values take precedence
411 values.put(key, way.get(key));
412 } else if (!relation.hasKey(key) && !values.get(key).equals(way.get(key))) {
413 conflictingKeys.add(key);
414 }
415 }
416 }
417 }
418
419 // filter out empty key conflicts - we need second iteration
420 if (!Config.getPref().getBoolean("multipoly.alltags", false)) {
421 for (RelationMember m : relation.getMembers()) {
422 if (m.hasRole() && "outer".equals(m.getRole()) && m.isWay()) {
423 for (String key : values.keySet()) {
424 if (!m.getWay().hasKey(key) && !relation.hasKey(key)) {
425 conflictingKeys.add(key);
426 }
427 }
428 }
429 }
430 }
431
432 for (String key : conflictingKeys) {
433 values.remove(key);
434 }
435
436 for (String linearTag : Config.getPref().getList("multipoly.lineartagstokeep", DEFAULT_LINEAR_TAGS)) {
437 values.remove(linearTag);
438 }
439
440 if ("coastline".equals(values.get("natural")))
441 values.remove("natural");
442
443 values.put("area", OsmUtils.TRUE_VALUE);
444
445 List<Command> commands = new ArrayList<>();
446 boolean moveTags = Config.getPref().getBoolean("multipoly.movetags", true);
447
448 for (Entry<String, String> entry : values.entrySet()) {
449 List<OsmPrimitive> affectedWays = new ArrayList<>();
450 String key = entry.getKey();
451 String value = entry.getValue();
452
453 for (Way way : innerWays) {
454 if (value.equals(way.get(key))) {
455 affectedWays.add(way);
456 }
457 }
458
459 if (moveTags) {
460 // remove duplicated tags from outer ways
461 for (Way way : outerWays) {
462 if (way.hasKey(key)) {
463 affectedWays.add(way);
464 }
465 }
466 }
467
468 if (!affectedWays.isEmpty()) {
469 // reset key tag on affected ways
470 commands.add(new ChangePropertyCommand(affectedWays, key, null));
471 }
472 }
473
474 if (moveTags) {
475 // add those tag values to the relation
476 boolean fixed = false;
477 Relation r2 = new Relation(relation);
478 for (Entry<String, String> entry : values.entrySet()) {
479 String key = entry.getKey();
480 if (!r2.hasKey(key) && !"area".equals(key)) {
481 if (relation.isNew())
482 relation.put(key, entry.getValue());
483 else
484 r2.put(key, entry.getValue());
485 fixed = true;
486 }
487 }
488 if (fixed && !relation.isNew()) {
489 DataSet ds = relation.getDataSet();
490 if (ds == null) {
491 ds = MainApplication.getLayerManager().getEditDataSet();
492 }
493 commands.add(new ChangeCommand(ds, relation, r2));
494 }
495 }
496
497 return commands;
498 }
499}
Note: See TracBrowser for help on using the repository browser.