Files
iD/modules/core/validator.js
Quincy Morgan b4dacdad2a Reduce very close nodes validation threshold (close #6292)
Use spherical distances for very close nodes validation
Don't flag very close nodes from different ways
Don't flag very close nodes if both have interesting tags
Update very close nodes validation reference string
2019-05-02 11:39:12 -07:00

390 lines
13 KiB
JavaScript

import { dispatch as d3_dispatch } from 'd3-dispatch';
import { coreDifference } from './difference';
import { utilArrayGroupBy, utilCallWhenIdle, utilRebind } from '../util';
import { t } from '../util/locale';
import { validationIssueFix } from './validation/models';
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 _ignoredIssueIDs = {}; // issue.id -> true
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
_ignoredIssueIDs = {};
_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
}
}
};
validator.resetIgnoredIssues = function() {
_ignoredIssueIDs = {};
// reload UI
dispatch.call('validated');
};
// options = {
// what: 'all', // 'all' or 'edited'
// where: 'all', // 'all' or 'visible'
// includeIgnored: false // true, false, or 'only'
// includeDisabledRules: false // true, false, or 'only'
// };
validator.getIssues = function(options) {
var opts = Object.assign({ what: 'all', where: 'all', includeIgnored: false, includeDisabledRules: false }, options);
var issues = Object.values(_issuesByIssueID);
var changes = context.history().difference().changes();
var view = context.map().extent();
return issues.filter(function(issue) {
if (opts.includeDisabledRules === 'only' && !_disabledRules[issue.type]) return false;
if (!opts.includeDisabledRules && _disabledRules[issue.type]) return false;
if (opts.includeIgnored === 'only' && !_ignoredIssueIDs[issue.id]) return false;
if (!opts.includeIgnored && _ignoredIssueIDs[issue.id]) 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 entityIds = issue.entityIds || [];
for (var i = 0; i < entityIds.length; i++) {
var entityId = entityIds[i];
if (!context.hasEntity(entityId)) {
delete _issuesByEntityID[entityId];
delete _issuesByIssueID[entityId];
return false;
}
}
if (opts.what === 'edited') {
var isEdited = entityIds.some(function(entityId) { return changes[entityId]; });
if (entityIds.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] && !_ignoredIssueIDs[issue.id]; });
};
validator.getRuleKeys = function() {
return Object.keys(_rules)
.filter(function(key) { return key !== 'maprules'; })
.sort(function(key1, key2) {
// alphabetize by localized title
return t('issues.' + key1 + '.title') < t('issues.' + key2 + '.title') ? -1 : 1;
});
};
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();
};
validator.disableRules = function(keys) {
_disabledRules = {};
keys.forEach(function(k) {
_disabledRules[k] = 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 entityIds = issue.entityIds || [];
entityIds.forEach(function(other) {
if (other !== entityID) {
var otherIssueIDs = _issuesByEntityID[other];
if (otherIssueIDs) {
otherIssueIDs.delete(issueID);
}
}
});
}
delete _issuesByIssueID[issueID];
});
delete _issuesByEntityID[entityID];
}
function ignoreIssue(id) {
_ignoredIssueIDs[id] = true;
}
//
// 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);
detected.forEach(function(issue) {
var hasIgnoreFix = issue.fixes && issue.fixes.length && issue.fixes[issue.fixes.length - 1].type === 'ignore';
if (issue.severity === 'warning' && !hasIgnoreFix) {
var ignoreFix = new validationIssueFix({
title: t('issues.fix.ignore_issue.title'),
icon: 'iD-icon-close',
onClick: function() {
ignoreIssue(this.issue.id);
}
});
ignoreFix.type = 'ignore';
ignoreFix.issue = issue;
issue.fixes.push(ignoreFix);
}
});
entityIssues = entityIssues.concat(detected);
ran[key] = true;
return !detected.length;
}
runValidation('missing_role');
if (entity.type === 'relation') {
if (!runValidation('outdated_tags')) {
// 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');
runValidation('almost_junction');
// only check impossible_oneway if no disconnected_way issues
if (runValidation('disconnected_way')) {
runValidation('impossible_oneway');
} else {
ran.impossible_oneway = 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;
var entity = graph.hasEntity(entityID);
if (!entity) return acc;
acc.add(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 entityIds = issue.entityIds || [];
entityIds.forEach(function(entityId) {
if (!_issuesByEntityID[entityId]) {
_issuesByEntityID[entityId] = new Set();
}
_issuesByEntityID[entityId].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;
}