Files
iD/modules/actions/join.js
John Firebaugh a14cf49710 Disable merge operation when it would damage relations
The operation is disabled when attempting to join ways which don't belong to identical sets of relations. Restriction relations are excluded, because they are already handled with slightly different logic.

Fixes #8674
Fixes #8645
Fixes #3825
Fixes #1512
2021-09-04 15:42:07 -07:00

206 lines
7.5 KiB
JavaScript

import { actionDeleteRelation } from './delete_relation';
import { actionDeleteWay } from './delete_way';
import { osmIsInterestingTag } from '../osm/tags';
import { osmJoinWays } from '../osm/multipolygon';
import { geoPathIntersections } from '../geo';
import { utilArrayGroupBy, utilArrayIdentical, utilArrayIntersection } from '../util';
// Join ways at the end node they share.
//
// This is the inverse of `iD.actionSplit`.
//
// Reference:
// https://github.com/systemed/potlatch2/blob/master/net/systemeD/halcyon/connection/actions/MergeWaysAction.as
// https://github.com/openstreetmap/josm/blob/mirror/src/org/openstreetmap/josm/actions/CombineWayAction.java
//
export function actionJoin(ids) {
function groupEntitiesByGeometry(graph) {
var entities = ids.map(function(id) { return graph.entity(id); });
return Object.assign(
{ line: [] },
utilArrayGroupBy(entities, function(entity) { return entity.geometry(graph); })
);
}
var action = function(graph) {
var ways = ids.map(graph.entity, graph);
var survivorID = ways[0].id;
// if any of the ways are sided (e.g. coastline, cliff, kerb)
// sort them first so they establish the overall order - #6033
ways.sort(function(a, b) {
var aSided = a.isSided();
var bSided = b.isSided();
return (aSided && !bSided) ? -1
: (bSided && !aSided) ? 1
: 0;
});
// Prefer to keep an existing way.
for (var i = 0; i < ways.length; i++) {
if (!ways[i].isNew()) {
survivorID = ways[i].id;
break;
}
}
var sequences = osmJoinWays(ways, graph);
var joined = sequences[0];
// We might need to reverse some of these ways before joining them. #4688
// `joined.actions` property will contain any actions we need to apply.
graph = sequences.actions.reduce(function(g, action) { return action(g); }, graph);
var survivor = graph.entity(survivorID);
survivor = survivor.update({ nodes: joined.nodes.map(function(n) { return n.id; }) });
graph = graph.replace(survivor);
joined.forEach(function(way) {
if (way.id === survivorID) return;
graph.parentRelations(way).forEach(function(parent) {
graph = graph.replace(parent.replaceMember(way, survivor));
});
survivor = survivor.mergeTags(way.tags);
graph = graph.replace(survivor);
graph = actionDeleteWay(way.id)(graph);
});
// Finds if the join created a single-member multipolygon,
// and if so turns it into a basic area instead
function checkForSimpleMultipolygon() {
if (!survivor.isClosed()) return;
var multipolygons = graph.parentMultipolygons(survivor).filter(function(multipolygon) {
// find multipolygons where the survivor is the only member
return multipolygon.members.length === 1;
});
// skip if this is the single member of multiple multipolygons
if (multipolygons.length !== 1) return;
var multipolygon = multipolygons[0];
for (var key in survivor.tags) {
if (multipolygon.tags[key] &&
// don't collapse if tags cannot be cleanly merged
multipolygon.tags[key] !== survivor.tags[key]) return;
}
survivor = survivor.mergeTags(multipolygon.tags);
graph = graph.replace(survivor);
graph = actionDeleteRelation(multipolygon.id, true /* allow untagged members */)(graph);
var tags = Object.assign({}, survivor.tags);
if (survivor.geometry(graph) !== 'area') {
// ensure the feature persists as an area
tags.area = 'yes';
}
delete tags.type; // remove type=multipolygon
survivor = survivor.update({ tags: tags });
graph = graph.replace(survivor);
}
checkForSimpleMultipolygon();
return graph;
};
// Returns the number of nodes the resultant way is expected to have
action.resultingWayNodesLength = function(graph) {
return ids.reduce(function(count, id) {
return count + graph.entity(id).nodes.length;
}, 0) - ids.length - 1;
};
action.disabled = function(graph) {
var geometries = groupEntitiesByGeometry(graph);
if (ids.length < 2 || ids.length !== geometries.line.length) {
return 'not_eligible';
}
var joined = osmJoinWays(ids.map(graph.entity, graph), graph);
if (joined.length > 1) {
return 'not_adjacent';
}
var i;
// All joined ways must belong to the same set of (non-restriction) relations.
// Restriction relations have different logic, below, which allows some cases
// this prohibits, and prohibits some cases this allows.
var sortedParentRelations = function (id) {
return graph.parentRelations(graph.entity(id))
.filter((rel) => !rel.isRestriction())
.sort((a, b) => a.id - b.id);
};
var relsA = sortedParentRelations(ids[0]);
for (i = 1; i < ids.length; i++) {
var relsB = sortedParentRelations(ids[i]);
if (!utilArrayIdentical(relsA, relsB)) {
return 'conflicting_relations';
}
}
// Loop through all combinations of path-pairs
// to check potential intersections between all pairs
for (i = 0; i < ids.length - 1; i++) {
for (var j = i + 1; j < ids.length; j++) {
var path1 = graph.childNodes(graph.entity(ids[i]))
.map(function(e) { return e.loc; });
var path2 = graph.childNodes(graph.entity(ids[j]))
.map(function(e) { return e.loc; });
var intersections = geoPathIntersections(path1, path2);
// Check if intersections are just nodes lying on top of
// each other/the line, as opposed to crossing it
var common = utilArrayIntersection(
joined[0].nodes.map(function(n) { return n.loc.toString(); }),
intersections.map(function(n) { return n.toString(); })
);
if (common.length !== intersections.length) {
return 'paths_intersect';
}
}
}
var nodeIds = joined[0].nodes.map(function(n) { return n.id; }).slice(1, -1);
var relation;
var tags = {};
var conflicting = false;
joined[0].forEach(function(way) {
var parents = graph.parentRelations(way);
parents.forEach(function(parent) {
if (parent.isRestriction() && parent.members.some(function(m) { return nodeIds.indexOf(m.id) >= 0; })) {
relation = parent;
}
});
for (var k in way.tags) {
if (!(k in tags)) {
tags[k] = way.tags[k];
} else if (tags[k] && osmIsInterestingTag(k) && tags[k] !== way.tags[k]) {
conflicting = true;
}
}
});
if (relation) {
return 'restriction';
}
if (conflicting) {
return 'conflicting_tags';
}
};
return action;
}