Files
iD/modules/core/validator.js

417 lines
14 KiB
JavaScript

import { dispatch as d3_dispatch } from 'd3-dispatch';
import { coreDifference } from './difference';
import { geoExtent } from '../geo';
import { osmEntity } from '../osm';
import { utilArrayGroupBy, utilCallWhenIdle, utilRebind } from '../util';
import * as Validations from '../validations/index';
export function coreValidator(context) {
var dispatch = d3_dispatch('validated');
var validator = utilRebind({}, dispatch, 'on');
var _rules = {};
var _disabledRules = {};
var _issuesByIssueID = {}; // issue.id -> issue
var _issuesByEntityID = {}; // entity.id -> set(issue.id)
var _validatedGraph = null;
//
// initialize the validator rulesets
//
validator.init = function() {
Object.values(Validations).forEach(function(validation) {
if (typeof validation !== 'function') return;
var fn = validation();
var key = fn.type;
_rules[key] = fn;
});
var disabledRules = context.storage('validate-disabledRules');
if (disabledRules) {
disabledRules.split(',')
.forEach(function(key) { _disabledRules[key] = true; });
}
};
//
// clear caches, called whenever iD resets after a save
//
validator.reset = function() {
// clear caches
_issuesByIssueID = {};
_issuesByEntityID = {};
_validatedGraph = null;
for (var key in _rules) {
if (typeof _rules[key].reset === 'function') {
_rules[key].reset(); // 'crossing_ways' is the only one like this
}
}
};
// options = {
// what: 'edited', // 'all' or 'edited'
// where: 'visible', // 'all' or 'visible'
// };
validator.getIssues = function(options) {
var opts = Object.assign({ what: 'all', where: 'all' }, options);
var issues = Object.values(_issuesByIssueID);
var changes = context.history().difference().changes();
var view = context.map().extent();
return issues.filter(function(issue) {
if (_disabledRules[issue.type]) return false;
// Sanity check: This issue may be for an entity that not longer exists.
// If we detect this, uncache and return false so it is not incluced..
var entities = issue.entities || [];
for (var i = 0; i < entities.length; i++) {
var entity = entities[i];
if (!context.hasEntity(entity.id)) {
delete _issuesByEntityID[entity.id];
delete _issuesByIssueID[issue.id];
return false;
}
}
if (opts.what === 'edited') {
var isEdited = entities.some(function(entity) { return changes[entity.id]; });
if (entities.length && !isEdited) return false;
}
if (opts.where === 'visible') {
var extent = issue.extent(context.graph());
if (!view.intersects(extent)) return false;
}
return true;
});
};
validator.getIssuesBySeverity = function(options) {
var groups = utilArrayGroupBy(validator.getIssues(options), 'severity');
groups.error = groups.error || [];
groups.warning = groups.warning || [];
return groups;
};
validator.getEntityIssues = function(entityID) {
var issueIDs = _issuesByEntityID[entityID];
if (!issueIDs) return [];
return Array.from(issueIDs)
.map(function(id) { return _issuesByIssueID[id]; })
.filter(function(issue) { return !_disabledRules[issue.type]; });
};
validator.getRuleKeys = function() {
return Object.keys(_rules)
.filter(function(key) { return key !== 'maprules'; });
};
validator.isRuleEnabled = function(key) {
return !_disabledRules[key];
};
validator.toggleRule = function(key) {
if (_disabledRules[key]) {
delete _disabledRules[key];
} else {
_disabledRules[key] = true;
}
context.storage('validate-disabledRules', Object.keys(_disabledRules).join(','));
validator.validate();
};
//
// Remove a single entity and all its related issues from the caches
//
function uncacheEntityID(entityID) {
var issueIDs = _issuesByEntityID[entityID];
if (!issueIDs) return;
issueIDs.forEach(function(issueID) {
var issue = _issuesByIssueID[issueID];
if (issue) {
// When multiple entities are involved (e.g. crossing_ways),
// remove this issue from the other entity caches too..
var entities = issue.entities || [];
entities.forEach(function(other) {
if (other.id !== entityID) {
var otherIssueIDs = _issuesByEntityID[other.id];
if (otherIssueIDs) {
otherIssueIDs.delete(issueID);
}
}
});
}
delete _issuesByIssueID[issueID];
});
delete _issuesByEntityID[entityID];
}
//
// Run validation on a single entity
//
function validateEntity(entity) {
var entityIssues = [];
var ran = {};
// runs validation and appends resulting issues,
// returning true if validation passed without issue
function runValidation(key) {
if (ran[key]) return true;
var fn = _rules[key];
if (typeof fn !== 'function') {
console.error('no such validation rule = ' + key); // eslint-disable-line no-console
ran[key] = true;
return true;
}
var detected = fn(entity, context);
entityIssues = entityIssues.concat(detected);
ran[key] = true;
return !detected.length;
}
runValidation('missing_role');
if (entity.type === 'relation') {
if (!runValidation('old_multipolygon')) {
// don't flag missing tags if they are on the outer way
ran.missing_tag = true;
}
}
// other _rules require feature to be tagged
if (!runValidation('missing_tag')) return entityIssues;
// run outdated_tags early
runValidation('outdated_tags');
if (entity.type === 'way') {
runValidation('crossing_ways');
// only check for disconnected way if no almost junctions
if (runValidation('almost_junction')) {
runValidation('disconnected_way');
} else {
ran.disconnected_way = true;
}
runValidation('tag_suggests_area');
}
// run all rules not yet run
Object.keys(_rules).forEach(runValidation);
return entityIssues;
}
//
// Run validation for several entities, supplied `entityIDs`
//
validator.validateEntities = function(entityIDs) {
var graph = context.graph();
var entityIDsToCheck = entityIDs.reduce(function(acc, entityID) {
if (acc.has(entityID)) return acc;
acc.add(entityID);
var entity = graph.entity(entityID);
var checkParentRels = [entity];
if (entity.type === 'node') { // include parent ways
graph.parentWays(entity).forEach(function(parentWay) {
checkParentRels.push(parentWay);
acc.add(parentWay.id);
});
}
checkParentRels.forEach(function(entity) { // include parent relations
if (entity.type !== 'relation') { // but not super-relations
graph.parentRelations(entity).forEach(function(parentRelation) {
acc.add(parentRelation.id);
});
}
});
return acc;
}, new Set());
// clear caches for existing issues related to changed entities
entityIDsToCheck.forEach(uncacheEntityID);
// detect new issues and update caches
entityIDsToCheck.forEach(function(entityID) {
var entity = graph.entity(entityID);
var issues = validateEntity(entity);
issues.forEach(function(issue) {
var entities = issue.entities || [];
entities.forEach(function(entity) {
if (!_issuesByEntityID[entity.id]) {
_issuesByEntityID[entity.id] = new Set();
}
_issuesByEntityID[entity.id].add(issue.id);
});
_issuesByIssueID[issue.id] = issue;
});
});
dispatch.call('validated');
};
//
// Validates anything that has changed since the last time it was run.
// Also updates the "validatedGraph" to be the current graph
// and dispatches a `validated` event when finished.
//
validator.validate = function() {
var currGraph = context.graph();
_validatedGraph = _validatedGraph || context.history().base();
if (currGraph === _validatedGraph) {
dispatch.call('validated');
return;
}
var difference = coreDifference(_validatedGraph, currGraph);
_validatedGraph = currGraph;
for (var key in _rules) {
if (typeof _rules[key].reset === 'function') {
_rules[key].reset(); // 'crossing_ways' is the only one like this
}
}
var entityIDs = difference.extantIDs(); // created and modified
difference.deleted().forEach(uncacheEntityID); // deleted
validator.validateEntities(entityIDs); // dispatches 'validated'
};
// WHEN TO RUN VALIDATION:
// When graph changes:
context.history()
.on('restore.validator', validator.validate) // restore saved history
.on('undone.validator', validator.validate) // undo
.on('redone.validator', validator.validate); // redo
// but not on 'change' (e.g. while drawing)
// When user chages editing modes:
context
.on('exit.validator', validator.validate);
// When merging fetched data:
context.history()
.on('merge.validator', function(entities) {
if (!entities) return;
var ids = entities.map(function(entity) { return entity.id; });
utilCallWhenIdle(function() { validator.validateEntities(ids); })();
});
return validator;
}
export function validationIssue(attrs) {
this.type = attrs.type; // required - name of rule that created the issue (e.g. 'missing_tag')
this.severity = attrs.severity; // required - 'warning' or 'error'
this.message = attrs.message; // required - localized string
this.reference = attrs.reference; // optional - function(selection) to render reference information
this.entities = attrs.entities; // optional - array of entities involved in the issue
this.loc = attrs.loc; // optional - [lon, lat] to zoom in on to see the issue
this.data = attrs.data; // optional - object containing extra data for the fixes
this.fixes = attrs.fixes; // optional - array of validationIssueFix objects
this.hash = attrs.hash; // optional - string to further differentiate the issue
this.id = generateID.apply(this); // generated - see below
this.autoFix = null; // generated - if autofix exists, will be set below
// A unique, deterministic string hash.
// Issues with identical id values are considered identical.
function generateID() {
var parts = [this.type];
if (this.hash) { // subclasses can pass in their own differentiator
parts.push(this.hash);
}
// include entities this issue is for
// (sort them so the id is deterministic)
if (this.entities) {
var entityKeys = this.entities.map(osmEntity.key).sort();
parts.push.apply(parts, entityKeys);
}
// include loc since two separate issues can have an
// idential type and entities, e.g. in crossing_ways
if (this.loc) {
parts.push.apply(parts, this.loc);
}
return parts.join(':');
}
var _extent;
this.extent = function(resolver) {
if (_extent) return _extent;
if (this.loc) {
return _extent = geoExtent(this.loc);
}
if (this.entities && this.entities.length) {
return _extent = this.entities.reduce(function(extent, entity) {
return extent.extend(entity.extent(resolver));
}, geoExtent());
}
return null;
};
if (this.fixes) { // add a reference in the fixes to the issue for use in fix actions
for (var i = 0; i < this.fixes.length; i++) {
var fix = this.fixes[i];
fix.issue = this;
if (fix.autoArgs) {
this.autoFix = fix;
}
}
}
}
export function validationIssueFix(attrs) {
this.title = attrs.title; // Required
this.onClick = attrs.onClick; // Required
this.icon = attrs.icon; // Optional - shows 'iD-icon-wrench' if not set
this.entityIds = attrs.entityIds || []; // Optional - Used for hover-higlighting.
this.autoArgs = attrs.autoArgs; // Optional - pass [actions, annotation] arglist if this fix can automatically run
this.issue = null; // Generated link - added by ValidationIssue constructor
}