modules/actions/join.js (151 lines of code) (raw):

import { geomPathIntersections } from '@id-sdk/math'; import { utilArrayGroupBy, utilArrayIdentical, utilArrayIntersection } from '@id-sdk/util'; import { actionDeleteRelation } from './delete_relation'; import { actionDeleteWay } from './delete_way'; import { osmIsInterestingTag } from '../osm/tags'; import { osmJoinWays } from '../osm/multipolygon'; // RapiD import { prefs } from '../core/preferences'; // 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); // 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. // if there are multiple existing ways, keep the oldest one // the oldest way is determined by the ID of the way const survivorID = ( ways .filter((way) => !way.isNew()) .sort((a, b) => +a.osmId() - +b.osmId())[0] || ways[0] ).id; 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); }); // RapiD tagnosticRoadCombine var tagnosticRoadCombine = prefs('rapid-internal-feature.tagnosticRoadCombine') === 'true'; if (tagnosticRoadCombine && ways.length && ways[0].tags.highway) { var newTags = Object.assign({}, survivor.tags); newTags.highway = ways[0].tags.highway; survivor = survivor.update({ tags: newTags }); graph = graph.replace(survivor); } // 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() && !rel.isConnectivity()) .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 = geomPathIntersections(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.isConnectivity()) && 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; // RapiD tagnosticRoadCombine var tagnosticRoadCombine = prefs('rapid-internal-feature.tagnosticRoadCombine') === 'true'; if (k === 'highway' && tagnosticRoadCombine && !window.mocha) { conflicting = false; } } } }); if (relation) { return relation.isRestriction() ? 'restriction' : 'connectivity'; } if (conflicting) { return 'conflicting_tags'; } }; return action; }