modules/validations/close_nodes.js (226 lines of code) (raw):

import { actionMergeNodes } from '../actions/merge_nodes'; import { utilDisplayLabel } from '../util'; import { t } from '../core/localizer'; import { validationIssue, validationIssueFix } from '../core/validation'; import { osmPathHighwayTagValues } from '../osm/tags'; import { geoMetersToLat, geoMetersToLon, geoSphericalDistance } from '@id-sdk/geo'; import { Extent } from '@id-sdk/extent'; export function validationCloseNodes(context) { var type = 'close_nodes'; var pointThresholdMeters = 0.2; var validation = function(entity, graph) { if (entity.type === 'node') { return getIssuesForNode(entity); } else if (entity.type === 'way') { return getIssuesForWay(entity); } return []; function getIssuesForNode(node) { var parentWays = graph.parentWays(node); if (parentWays.length) { return getIssuesForVertex(node, parentWays); } else { return getIssuesForDetachedPoint(node); } } function wayTypeFor(way) { if (way.tags.boundary && way.tags.boundary !== 'no') return 'boundary'; if (way.tags.indoor && way.tags.indoor !== 'no') return 'indoor'; if ((way.tags.building && way.tags.building !== 'no') || (way.tags['building:part'] && way.tags['building:part'] !== 'no')) return 'building'; if (osmPathHighwayTagValues[way.tags.highway]) return 'path'; var parentRelations = graph.parentRelations(way); for (var i in parentRelations) { var relation = parentRelations[i]; if (relation.tags.type === 'boundary') return 'boundary'; if (relation.isMultipolygon()) { if (relation.tags.indoor && relation.tags.indoor !== 'no') return 'indoor'; if ((relation.tags.building && relation.tags.building !== 'no') || (relation.tags['building:part'] && relation.tags['building:part'] !== 'no')) return 'building'; } } return 'other'; } function shouldCheckWay(way) { // don't flag issues where merging would create degenerate ways if (way.nodes.length <= 2 || (way.isClosed() && way.nodes.length <= 4)) return false; var bbox = way.extent(graph).bbox(); var hypotenuseMeters = geoSphericalDistance([bbox.minX, bbox.minY], [bbox.maxX, bbox.maxY]); // don't flag close nodes in very small ways if (hypotenuseMeters < 1.5) return false; return true; } function getIssuesForWay(way) { if (!shouldCheckWay(way)) return []; var issues = [], nodes = graph.childNodes(way); for (var i = 0; i < nodes.length - 1; i++) { var node1 = nodes[i]; var node2 = nodes[i+1]; var issue = getWayIssueIfAny(node1, node2, way); if (issue) issues.push(issue); } return issues; } function getIssuesForVertex(node, parentWays) { var issues = []; function checkForCloseness(node1, node2, way) { var issue = getWayIssueIfAny(node1, node2, way); if (issue) issues.push(issue); } for (var i = 0; i < parentWays.length; i++) { var parentWay = parentWays[i]; if (!shouldCheckWay(parentWay)) continue; var lastIndex = parentWay.nodes.length - 1; for (var j = 0; j < parentWay.nodes.length; j++) { if (j !== 0) { if (parentWay.nodes[j-1] === node.id) { checkForCloseness(node, graph.entity(parentWay.nodes[j]), parentWay); } } if (j !== lastIndex) { if (parentWay.nodes[j+1] === node.id) { checkForCloseness(graph.entity(parentWay.nodes[j]), node, parentWay); } } } } return issues; } function thresholdMetersForWay(way) { if (!shouldCheckWay(way)) return 0; var wayType = wayTypeFor(way); // don't flag boundaries since they might be highly detailed and can't be easily verified if (wayType === 'boundary') return 0; // expect some features to be mapped with higher levels of detail if (wayType === 'indoor') return 0.01; if (wayType === 'building') return 0.05; if (wayType === 'path') return 0.1; return 0.2; } function getIssuesForDetachedPoint(node) { var issues = []; var lon = node.loc[0]; var lat = node.loc[1]; var lon_range = geoMetersToLon(pointThresholdMeters, lat) / 2; var lat_range = geoMetersToLat(pointThresholdMeters) / 2; var queryExtent = new Extent( [lon - lon_range, lat - lat_range], [lon + lon_range, lat + lat_range] ); var intersected = context.history().tree().intersects(queryExtent, graph); for (var j = 0; j < intersected.length; j++) { var nearby = intersected[j]; if (nearby.id === node.id) continue; if (nearby.type !== 'node' || nearby.geometry(graph) !== 'point') continue; if (nearby.loc === node.loc || geoSphericalDistance(node.loc, nearby.loc) < pointThresholdMeters) { // allow very close points if tags indicate the z-axis might vary var zAxisKeys = { layer: true, level: true, 'addr:housenumber': true, 'addr:unit': true }; var zAxisDifferentiates = false; for (var key in zAxisKeys) { var nodeValue = node.tags[key] || '0'; var nearbyValue = nearby.tags[key] || '0'; if (nodeValue !== nearbyValue) { zAxisDifferentiates = true; break; } } if (zAxisDifferentiates) continue; issues.push(new validationIssue({ type: type, subtype: 'detached', severity: 'warning', message: function(context) { var entity = context.hasEntity(this.entityIds[0]), entity2 = context.hasEntity(this.entityIds[1]); return (entity && entity2) ? t.html('issues.close_nodes.detached.message', { feature: utilDisplayLabel(entity, context.graph()), feature2: utilDisplayLabel(entity2, context.graph()) }) : ''; }, reference: showReference, entityIds: [node.id, nearby.id], dynamicFixes: function() { return [ new validationIssueFix({ icon: 'iD-operation-disconnect', title: t.html('issues.fix.move_points_apart.title') }), new validationIssueFix({ icon: 'iD-icon-layers', title: t.html('issues.fix.use_different_layers_or_levels.title') }) ]; } })); } } return issues; function showReference(selection) { var referenceText = t('issues.close_nodes.detached.reference'); selection.selectAll('.issue-reference') .data([0]) .enter() .append('div') .attr('class', 'issue-reference') .html(referenceText); } } function getWayIssueIfAny(node1, node2, way) { if (node1.id === node2.id || (node1.hasInterestingTags() && node2.hasInterestingTags())) { return null; } if (node1.loc !== node2.loc) { var parentWays1 = graph.parentWays(node1); var parentWays2 = new Set(graph.parentWays(node2)); var sharedWays = parentWays1.filter(function(parentWay) { return parentWays2.has(parentWay); }); var thresholds = sharedWays.map(function(parentWay) { return thresholdMetersForWay(parentWay); }); var threshold = Math.min(...thresholds); var distance = geoSphericalDistance(node1.loc, node2.loc); if (distance > threshold) return null; } return new validationIssue({ type: type, subtype: 'vertices', severity: 'warning', message: function(context) { var entity = context.hasEntity(this.entityIds[0]); return entity ? t.html('issues.close_nodes.message', { way: utilDisplayLabel(entity, context.graph()) }) : ''; }, reference: showReference, entityIds: [way.id, node1.id, node2.id], loc: node1.loc, dynamicFixes: function() { return [ new validationIssueFix({ icon: 'iD-icon-plus', title: t.html('issues.fix.merge_points.title'), onClick: function(context) { var entityIds = this.issue.entityIds; var action = actionMergeNodes([entityIds[1], entityIds[2]]); context.perform(action, t('issues.fix.merge_close_vertices.annotation')); } }), new validationIssueFix({ icon: 'iD-operation-disconnect', title: t.html('issues.fix.move_points_apart.title') }) ]; } }); function showReference(selection) { var referenceText = t('issues.close_nodes.reference'); selection.selectAll('.issue-reference') .data([0]) .enter() .append('div') .attr('class', 'issue-reference') .html(referenceText); } } }; validation.type = type; return validation; }