import { dispatch as d3_dispatch } from 'd3-dispatch'; import { geoExtent } from '../geo'; import { osmEntity } from '../osm'; import { utilRebind } from '../util'; import * as Validations from '../validations/index'; export function coreValidator(context) { var dispatch = d3_dispatch('reload'); var validator = {}; var _rules = {}; var _disabledRules = {}; var _entityRules = []; // var _changesRules = []; // skip for now var _issuesByIssueID = {}; var _issuesByEntityID = {}; validator.init = function() { Object.values(Validations).forEach(function(validation) { if (typeof validation !== 'function') return; var fn = validation(); var key = fn.type; _rules[key] = fn; if (fn.inputType === 'changes') { // 'many_deletions' is the only one like this // _changesRules.push(key); // skip for now } else { _entityRules.push(key); } }); }; validator.reset = function() { // clear caches _issuesByIssueID = {}; _issuesByEntityID = {}; for (var key in _rules) { if (typeof _rules[key].reset === 'function') { _rules[key].reset(); // 'crossing_ways' is the only one like this } } }; validator.getIssues = function() { return Object.values(_issuesByIssueID); }; validator.getWarnings = function() { return Object.values(_issuesByIssueID) .filter(function(d) { return d.severity === 'warning'; }); }; validator.getErrors = function() { return Object.values(_issuesByIssueID) .filter(function(d) { return d.severity === 'error'; }); }; validator.getEntityIssues = function(entityID) { return _issuesByEntityID[entityID] || []; }; 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; } validator.validate(); }; validator.validateEntity = function(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; } if (_disabledRules[key]) { // skip disabled rules, but mark as having run 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 manually _entityRules.forEach(runValidation); return entityIssues; }; validator.validate = function(difference) { difference = difference || context.history().difference(); for (var key in _rules) { if (typeof _rules[key].reset === 'function') { _rules[key].reset(); // 'crossing_ways' is the only one like this } } // _issues = utilArrayFlatten(_changesRules.map(function(ruleID) { // if (_disabledRules[ruleID]) return []; // var fn = _rules[ruleID]; // return fn(changes, context); // })); var graph = context.graph(); var entityIDs = difference.extantIDs(); // created and modified var entitiesToCheck = entityIDs.reduce(function(acc, entityID) { var entity = graph.entity(entityID); if (acc.has(entity)) return acc; acc.add(entity); var checkParentRels = [entity]; if (entity.type === 'node') { // include parent ways graph.parentWays(entity).forEach(function(parentWay) { checkParentRels.push(parentWay); acc.add(parentWay); }); } checkParentRels.forEach(function(entity) { // include parent relations if (entity.type !== 'relation') { // but not super-relations graph.parentRelations(entity).forEach(function(parentRel) { acc.add(parentRel); }); } }); return acc; }, new Set()); // clear caches for existing issues related to changed entities difference.deleted().forEach(clearCaches); entitiesToCheck.forEach(clearCaches); // detect new issues and update caches entitiesToCheck.forEach(function(entity) { var issues = validator.validateEntity(entity); issues.forEach(function(issue) { var entities = issue.entities || []; entities.forEach(function(entity) { if (!_issuesByEntityID[entity.id]) { _issuesByEntityID[entity.id] = []; } _issuesByEntityID[entity.id].push(issue); }); _issuesByIssueID[issue.id] = issue; }); }); dispatch.call('reload'); }; return utilRebind(validator, dispatch, 'on'); function clearCaches(entity) { var issues = _issuesByEntityID[entity.id] || []; issues.forEach(function(issue) { var entities = issue.entities || []; entities.forEach(function(entity) { delete _issuesByEntityID[entity.id]; }); delete _issuesByIssueID[issue.id]; }); } } export function validationIssue(attrs) { this.type = attrs.type; // required this.severity = attrs.severity; // required - 'warning' or 'error' this.message = attrs.message; // required - localized string this.tooltip = attrs.tooltip; // required - localized string this.entities = attrs.entities; // optional - array of entities this.loc = attrs.loc; // optional - expect a [lon, lat] array this.info = attrs.info; // optional - object containing arbitrary extra information 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 // 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(':'); } this.extent = function(resolver) { if (this.loc) { return geoExtent(this.loc); } if (this.entities && this.entities.length) { return 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++) { this.fixes[i].issue = this; } } } export function validationIssueFix(attrs) { this.icon = attrs.icon; this.title = attrs.title; this.onClick = attrs.onClick; this.entityIds = attrs.entityIds || []; // Used for hover-higlighting. this.issue = null; // the issue this fix is for }