modules/validations/mismatched_geometry.js (324 lines of code) (raw):

import { geoSphericalDistance } from '@id-sdk/math'; import { utilTagText } from '@id-sdk/util'; import deepEqual from 'fast-deep-equal'; import { actionAddVertex } from '../actions/add_vertex'; import { actionChangeTags } from '../actions/change_tags'; import { actionMergeNodes } from '../actions/merge_nodes'; import { actionExtract } from '../actions/extract'; import { modeSelect } from '../modes/select'; import { osmJoinWays } from '../osm/multipolygon'; import { osmNodeGeometriesForTags, osmTagSuggestingArea } from '../osm/tags'; import { presetManager } from '../presets'; import { geoHasSelfIntersections } from '../geo'; import { t } from '../core/localizer'; import { utilDisplayLabel } from '../util'; import { validationIssue, validationIssueFix } from '../core/validation'; export function validationMismatchedGeometry() { var type = 'mismatched_geometry'; function tagSuggestingLineIsArea(entity) { if (entity.type !== 'way' || entity.isClosed()) return null; var tagSuggestingArea = entity.tagSuggestingArea(); if (!tagSuggestingArea) { return null; } var asLine = presetManager.matchTags(tagSuggestingArea, 'line'); var asArea = presetManager.matchTags(tagSuggestingArea, 'area'); if (asLine && asArea && (asLine === asArea)) { // these tags also allow lines and making this an area wouldn't matter return null; } return tagSuggestingArea; } function makeConnectEndpointsFixOnClick(way, graph) { // must have at least three nodes to close this automatically if (way.nodes.length < 3) return null; var nodes = graph.childNodes(way), testNodes; var firstToLastDistanceMeters = geoSphericalDistance(nodes[0].loc, nodes[nodes.length-1].loc); // if the distance is very small, attempt to merge the endpoints if (firstToLastDistanceMeters < 0.75) { testNodes = nodes.slice(); // shallow copy testNodes.pop(); testNodes.push(testNodes[0]); // make sure this will not create a self-intersection if (!geoHasSelfIntersections(testNodes, testNodes[0].id)) { return function(context) { var way = context.entity(this.issue.entityIds[0]); context.perform( actionMergeNodes([way.nodes[0], way.nodes[way.nodes.length-1]], nodes[0].loc), t('issues.fix.connect_endpoints.annotation') ); }; } } // if the points were not merged, attempt to close the way testNodes = nodes.slice(); // shallow copy testNodes.push(testNodes[0]); // make sure this will not create a self-intersection if (!geoHasSelfIntersections(testNodes, testNodes[0].id)) { return function(context) { var wayId = this.issue.entityIds[0]; var way = context.entity(wayId); var nodeId = way.nodes[0]; var index = way.nodes.length; context.perform( actionAddVertex(wayId, nodeId, index), t('issues.fix.connect_endpoints.annotation') ); }; } } function lineTaggedAsAreaIssue(entity) { var tagSuggestingArea = tagSuggestingLineIsArea(entity); if (!tagSuggestingArea) return null; return new validationIssue({ type: type, subtype: 'area_as_line', severity: 'warning', message: function(context) { var entity = context.hasEntity(this.entityIds[0]); return entity ? t.html('issues.tag_suggests_area.message', { feature: utilDisplayLabel(entity, 'area', true /* verbose */), tag: utilTagText({ tags: tagSuggestingArea }) }) : ''; }, reference: showReference, entityIds: [entity.id], hash: JSON.stringify(tagSuggestingArea), dynamicFixes: function(context) { var fixes = []; var entity = context.entity(this.entityIds[0]); var connectEndsOnClick = makeConnectEndpointsFixOnClick(entity, context.graph()); fixes.push(new validationIssueFix({ title: t.html('issues.fix.connect_endpoints.title'), onClick: connectEndsOnClick })); fixes.push(new validationIssueFix({ icon: 'iD-operation-delete', title: t.html('issues.fix.remove_tag.title'), onClick: function(context) { var entityId = this.issue.entityIds[0]; var entity = context.entity(entityId); var tags = Object.assign({}, entity.tags); // shallow copy for (var key in tagSuggestingArea) { delete tags[key]; } context.perform( actionChangeTags(entityId, tags), t('issues.fix.remove_tag.annotation') ); } })); return fixes; } }); function showReference(selection) { selection.selectAll('.issue-reference') .data([0]) .enter() .append('div') .attr('class', 'issue-reference') .html(t.html('issues.tag_suggests_area.reference')); } } function vertexPointIssue(entity, graph) { // we only care about nodes if (entity.type !== 'node') return null; // ignore tagless points if (Object.keys(entity.tags).length === 0) return null; // address lines are special so just ignore them if (entity.isOnAddressLine(graph)) return null; var geometry = entity.geometry(graph); var allowedGeometries = osmNodeGeometriesForTags(entity.tags); if (geometry === 'point' && !allowedGeometries.point && allowedGeometries.vertex) { return new validationIssue({ type: type, subtype: 'vertex_as_point', severity: 'warning', message: function(context) { var entity = context.hasEntity(this.entityIds[0]); return entity ? t.html('issues.vertex_as_point.message', { feature: utilDisplayLabel(entity, 'vertex', true /* verbose */) }) : ''; }, reference: function showReference(selection) { selection.selectAll('.issue-reference') .data([0]) .enter() .append('div') .attr('class', 'issue-reference') .html(t.html('issues.vertex_as_point.reference')); }, entityIds: [entity.id] }); } else if (geometry === 'vertex' && !allowedGeometries.vertex && allowedGeometries.point) { return new validationIssue({ type: type, subtype: 'point_as_vertex', severity: 'warning', message: function(context) { var entity = context.hasEntity(this.entityIds[0]); return entity ? t.html('issues.point_as_vertex.message', { feature: utilDisplayLabel(entity, 'point', true /* verbose */) }) : ''; }, reference: function showReference(selection) { selection.selectAll('.issue-reference') .data([0]) .enter() .append('div') .attr('class', 'issue-reference') .html(t.html('issues.point_as_vertex.reference')); }, entityIds: [entity.id], dynamicFixes: extractPointDynamicFixes }); } return null; } function otherMismatchIssue(entity, graph) { // ignore boring features if (!entity.hasInterestingTags()) return null; if (entity.type !== 'node' && entity.type !== 'way') return null; // address lines are special so just ignore them if (entity.type === 'node' && entity.isOnAddressLine(graph)) return null; var sourceGeom = entity.geometry(graph); var targetGeoms = entity.type === 'way' ? ['point', 'vertex'] : ['line', 'area']; if (sourceGeom === 'area') targetGeoms.unshift('line'); var asSource = presetManager.match(entity, graph); var targetGeom = targetGeoms.find(nodeGeom => { var asTarget = presetManager.matchTags(entity.tags, nodeGeom); if (!asSource || !asTarget || asSource === asTarget || // sometimes there are two presets with the same tags for different geometries deepEqual(asSource.tags, asTarget.tags)) return false; if (asTarget.isFallback()) return false; var primaryKey = Object.keys(asTarget.tags)[0]; // special case: buildings-as-points are discouraged by iD, but common in OSM, so ignore them if (primaryKey === 'building') return false; if (asTarget.tags[primaryKey] === '*') return false; return asSource.isFallback() || asSource.tags[primaryKey] === '*'; }); if (!targetGeom) return null; var subtype = targetGeom + '_as_' + sourceGeom; if (targetGeom === 'vertex') targetGeom = 'point'; if (sourceGeom === 'vertex') sourceGeom = 'point'; var referenceId = targetGeom + '_as_' + sourceGeom; var dynamicFixes; if (targetGeom === 'point') { dynamicFixes = extractPointDynamicFixes; } else if (sourceGeom === 'area' && targetGeom === 'line') { dynamicFixes = lineToAreaDynamicFixes; } return new validationIssue({ type: type, subtype: subtype, severity: 'warning', message: function(context) { var entity = context.hasEntity(this.entityIds[0]); return entity ? t.html('issues.' + referenceId + '.message', { feature: utilDisplayLabel(entity, targetGeom, true /* verbose */) }) : ''; }, reference: function showReference(selection) { selection.selectAll('.issue-reference') .data([0]) .enter() .append('div') .attr('class', 'issue-reference') .html(t.html('issues.mismatched_geometry.reference')); }, entityIds: [entity.id], dynamicFixes: dynamicFixes }); } function lineToAreaDynamicFixes(context) { var convertOnClick; var entityId = this.entityIds[0]; var entity = context.entity(entityId); var tags = Object.assign({}, entity.tags); // shallow copy delete tags.area; if (!osmTagSuggestingArea(tags)) { // if removing the area tag would make this a line, offer that as a quick fix convertOnClick = function(context) { var entityId = this.issue.entityIds[0]; var entity = context.entity(entityId); var tags = Object.assign({}, entity.tags); // shallow copy if (tags.area) { delete tags.area; } context.perform( actionChangeTags(entityId, tags), t('issues.fix.convert_to_line.annotation') ); }; } return [ new validationIssueFix({ icon: 'iD-icon-line', title: t.html('issues.fix.convert_to_line.title'), onClick: convertOnClick }) ]; } function extractPointDynamicFixes(context) { var entityId = this.entityIds[0]; var extractOnClick = null; if (!context.hasHiddenConnections(entityId)) { extractOnClick = function(context) { var entityId = this.issue.entityIds[0]; var action = actionExtract(entityId, context.projection); context.perform( action, t('operations.extract.annotation', { n: 1 }) ); // re-enter mode to trigger updates context.enter(modeSelect(context, [action.getExtractedNodeID()])); }; } return [ new validationIssueFix({ icon: 'iD-operation-extract', title: t.html('issues.fix.extract_point.title'), onClick: extractOnClick }) ]; } function unclosedMultipolygonPartIssues(entity, graph) { if (entity.type !== 'relation' || !entity.isMultipolygon() || entity.isDegenerate() || // cannot determine issues for incompletely-downloaded relations !entity.isComplete(graph)) return []; var sequences = osmJoinWays(entity.members, graph); var issues = []; for (var i in sequences) { var sequence = sequences[i]; if (!sequence.nodes) continue; var firstNode = sequence.nodes[0]; var lastNode = sequence.nodes[sequence.nodes.length - 1]; // part is closed if the first and last nodes are the same if (firstNode === lastNode) continue; var issue = new validationIssue({ type: type, subtype: 'unclosed_multipolygon_part', severity: 'warning', message: function(context) { var entity = context.hasEntity(this.entityIds[0]); return entity ? t.html('issues.unclosed_multipolygon_part.message', { feature: utilDisplayLabel(entity, context.graph(), true /* verbose */) }) : ''; }, reference: showReference, loc: sequence.nodes[0].loc, entityIds: [entity.id], hash: sequence.map(function(way) { return way.id; }).join() }); issues.push(issue); } return issues; function showReference(selection) { selection.selectAll('.issue-reference') .data([0]) .enter() .append('div') .attr('class', 'issue-reference') .html(t.html('issues.unclosed_multipolygon_part.reference')); } } var validation = function checkMismatchedGeometry(entity, graph) { var vertexPoint = vertexPointIssue(entity, graph); if (vertexPoint) return [vertexPoint]; var lineAsArea = lineTaggedAsAreaIssue(entity); if (lineAsArea) return [lineAsArea]; var mismatch = otherMismatchIssue(entity, graph); if (mismatch) return [mismatch]; return unclosedMultipolygonPartIssues(entity, graph); }; validation.type = type; return validation; }