modules/core/validator.js (473 lines of code) (raw):

import { dispatch as d3_dispatch } from 'd3-dispatch'; import { Extent } from '@id-sdk/math'; import { utilArrayChunk, utilArrayGroupBy, utilEntityAndDeepMemberIDs } from '@id-sdk/util'; import { prefs } from './preferences'; import { coreDifference } from './difference'; import { modeSelect } from '../modes/select'; import { utilRebind } from '../util'; import * as Validations from '../validations/index'; export function coreValidator(context) { let dispatch = d3_dispatch('validated', 'focusedIssue'); let validator = utilRebind({}, dispatch, 'on'); let _rules = {}; let _disabledRules = {}; let _ignoredIssueIDs = new Set(); let _resolvedIssueIDs = new Set(); let _baseCache = validationCache('base'); // issues before any user edits let _headCache = validationCache('head'); // issues after all user edits let _completeDiff = {}; // complete diff base -> head of what the user changed let _headIsCurrent = false; let _deferredRIC = new Set(); // Set( RequestIdleCallback handles ) let _deferredST = new Set(); // Set( SetTimeout handles ) let _headPromise; // Promise fulfilled when validation is performed up to headGraph snapshot const RETRY = 5000; // wait 5sec before revalidating provisional entities // Allow validation severity to be overridden by url queryparams... // See: https://github.com/openstreetmap/iD/pull/8243 // // Each param should contain a urlencoded comma separated list of // `type/subtype` rules. `*` may be used as a wildcard.. // Examples: // `validationError=disconnected_way/*` // `validationError=disconnected_way/highway` // `validationError=crossing_ways/bridge*` // `validationError=crossing_ways/bridge*,crossing_ways/tunnel*` const _errorOverrides = parseHashParam(context.initialHashParams.validationError); const _warningOverrides = parseHashParam(context.initialHashParams.validationWarning); const _disableOverrides = parseHashParam(context.initialHashParams.validationDisable); // `parseHashParam()` (private) // Checks hash parameters for severity overrides // Arguments // `param` - a url hash parameter (`validationError`, `validationWarning`, or `validationDisable`) // Returns // Array of Objects like { type: RegExp, subtype: RegExp } // function parseHashParam(param) { let result = []; let rules = (param || '').split(','); rules.forEach(rule => { rule = rule.trim(); const parts = rule.split('/', 2); // "type/subtype" const type = parts[0]; const subtype = parts[1] || '*'; if (!type || !subtype) return; result.push({ type: makeRegExp(type), subtype: makeRegExp(subtype) }); }); return result; function makeRegExp(str) { const escaped = str .replace(/[-\/\\^$+?.()|[\]{}]/g, '\\$&') // escape all reserved chars except for the '*' .replace(/\*/g, '.*'); // treat a '*' like '.*' return new RegExp('^' + escaped + '$'); } } // `init()` // Initialize the validator, called once on iD startup // validator.init = () => { Object.values(Validations).forEach(validation => { if (typeof validation !== 'function') return; const fn = validation(context); const key = fn.type; _rules[key] = fn; }); const disabledRules = prefs('validate-disabledRules'); if (disabledRules) { disabledRules.split(',').forEach(k => _disabledRules[k] = true); } }; // `reset()` (private) // Cancels deferred work and resets all caches // // Arguments // `resetIgnored` - `true` to clear the list of user-ignored issues // function reset(resetIgnored) { // cancel deferred work _deferredRIC.forEach(window.cancelIdleCallback); _deferredRIC.clear(); _deferredST.forEach(window.clearTimeout); _deferredST.clear(); // empty queues and resolve any pending promise _baseCache.queue = []; _headCache.queue = []; processQueue(_headCache); processQueue(_baseCache); // clear caches if (resetIgnored) _ignoredIssueIDs.clear(); _resolvedIssueIDs.clear(); _baseCache = validationCache('base'); _headCache = validationCache('head'); _completeDiff = {}; _headIsCurrent = false; } // `reset()` // clear caches, called whenever iD resets after a save or switches sources // (clears out the _ignoredIssueIDs set also) // validator.reset = () => { reset(true); }; // `resetIgnoredIssues()` // clears out the _ignoredIssueIDs Set // validator.resetIgnoredIssues = () => { _ignoredIssueIDs.clear(); dispatch.call('validated'); // redraw UI }; // `revalidateUnsquare()` // Called whenever the user changes the unsquare threshold // It reruns just the "unsquare_way" validation on all buildings. // validator.revalidateUnsquare = () => { revalidateUnsquare(_headCache); revalidateUnsquare(_baseCache); dispatch.call('validated'); }; function revalidateUnsquare(cache) { const checkUnsquareWay = _rules.unsquare_way; if (!cache.graph || typeof checkUnsquareWay !== 'function') return; // uncache existing cache.uncacheIssuesOfType('unsquare_way'); const buildings = context.history().tree().intersects(new Extent([-180,-90],[180, 90]), cache.graph) // everywhere .filter(entity => (entity.type === 'way' && entity.tags.building && entity.tags.building !== 'no')); // rerun for all buildings buildings.forEach(entity => { const detected = checkUnsquareWay(entity, cache.graph); if (!detected.length) return; cache.cacheIssues(detected); }); } // `getIssues()` // Gets all issues that match the given options // This is called by many other places // // Arguments // `options` Object like: // { // what: 'all', // 'all' or 'edited' // where: 'all', // 'all' or 'visible' // includeIgnored: false, // true, false, or 'only' // includeDisabledRules: false // true, false, or 'only' // } // // Returns // An Array containing the issues // validator.getIssues = (options) => { const opts = Object.assign({ what: 'all', where: 'all', includeIgnored: false, includeDisabledRules: false }, options); const view = context.map().extent(); let seen = new Set(); let results = []; // collect head issues - present in the user edits if (_headCache.graph && _headCache.graph !== _baseCache.graph) { Object.values(_headCache.issuesByIssueID).forEach(issue => { // In the head cache, only count features that the user is responsible for - #8632 // For example, a user can undo some work and an issue will still present in the // head graph, but we don't want to credit the user for causing that issue. const userModified = (issue.entityIds || []).some(id => _completeDiff.hasOwnProperty(id)); if (opts.what === 'edited' && !userModified) return; // present in head but user didn't touch it if (!filter(issue)) return; seen.add(issue.id); results.push(issue); }); } // collect base issues - present before user edits if (opts.what === 'all') { Object.values(_baseCache.issuesByIssueID).forEach(issue => { if (!filter(issue)) return; seen.add(issue.id); results.push(issue); }); } return results; // Filter the issue set to include only what the calling code wants to see. // Note that we use `context.graph()`/`context.hasEntity()` here, not `cache.graph`, // because that is the graph that the calling code will be using. function filter(issue) { if (!issue) return false; if (seen.has(issue.id)) return false; if (_resolvedIssueIDs.has(issue.id)) return false; if (opts.includeDisabledRules === 'only' && !_disabledRules[issue.type]) return false; if (!opts.includeDisabledRules && _disabledRules[issue.type]) return false; if (opts.includeIgnored === 'only' && !_ignoredIssueIDs.has(issue.id)) return false; if (!opts.includeIgnored && _ignoredIssueIDs.has(issue.id)) return false; // This issue may involve an entity that doesn't exist in context.graph() // This can happen because validation is async and rendering the issue lists is async. if ((issue.entityIds || []).some(id => !context.hasEntity(id))) return false; if (opts.where === 'visible') { const extent = issue.extent(context.graph()); if (!view.intersects(extent)) return false; } return true; } }; // `getResolvedIssues()` // Gets the issues that have been fixed by the user. // // Resolved issues are tracked in the `_resolvedIssueIDs` Set, // and they should all be issues that exist in the _baseCache. // // Returns // An Array containing the issues // validator.getResolvedIssues = () => { return Array.from(_resolvedIssueIDs) .map(issueID => _baseCache.issuesByIssueID[issueID]) .filter(Boolean); }; // `focusIssue()` // Adjusts the map to focus on the given issue. // (requires the issue to have a reasonable extent defined) // // Arguments // `issue` - the issue to focus on // validator.focusIssue = (issue) => { // Note that we use `context.graph()`/`context.hasEntity()` here, not `cache.graph`, // because that is the graph that the calling code will be using. const graph = context.graph(); let selectID; let focusCenter; // Try to focus the map at the center of the issue.. const issueExtent = issue.extent(graph); if (issueExtent) { focusCenter = issueExtent.center(); } // Try to select the first entity in the issue.. if (issue.entityIds && issue.entityIds.length) { selectID = issue.entityIds[0]; // If a relation, focus on one of its members instead. // Otherwise we might be focusing on a part of map where the relation is not visible. if (selectID && selectID.charAt(0) === 'r') { // relation const ids = utilEntityAndDeepMemberIDs([selectID], graph); let nodeID = ids.find(id => id.charAt(0) === 'n' && graph.hasEntity(id)); if (!nodeID) { // relation has no downloaded nodes to focus on const wayID = ids.find(id => id.charAt(0) === 'w' && graph.hasEntity(id)); if (wayID) { nodeID = graph.entity(wayID).first(); // focus on the first node of this way } } if (nodeID) { focusCenter = graph.entity(nodeID).loc; } } } if (focusCenter) { // Adjust the view const setZoom = Math.max(context.map().zoom(), 19); context.map().unobscuredCenterZoomEase(focusCenter, setZoom); } if (selectID) { // Enter select mode window.setTimeout(() => { context.enter(modeSelect(context, [selectID])); dispatch.call('focusedIssue', this, issue); }, 250); // after ease } }; // `getIssuesBySeverity()` // Gets the issues then groups them by error/warning // (This just calls getIssues, then puts issues in groups) // // Arguments // `options` - (see `getIssues`) // Returns // Object result like: // { // error: Array of errors, // warning: Array of warnings // } // validator.getIssuesBySeverity = (options) => { let groups = utilArrayGroupBy(validator.getIssues(options), 'severity'); groups.error = groups.error || []; groups.warning = groups.warning || []; return groups; }; // `getEntityIssues()` // Gets the issues that the given entity IDs have in common, matching the given options // (This just calls getIssues, then filters for the given entity IDs) // The issues are sorted for relevance // // Arguments // `entityIDs` - Array or Set of entityIDs to get issues for // `options` - (see `getIssues`) // Returns // An Array containing the issues // validator.getSharedEntityIssues = (entityIDs, options) => { const orderedIssueTypes = [ // Show some issue types in a particular order: 'missing_tag', 'missing_role', // - missing data first 'outdated_tags', 'mismatched_geometry', // - identity issues 'crossing_ways', 'almost_junction', // - geometry issues where fixing them might solve connectivity issues 'disconnected_way', 'impossible_oneway' // - finally connectivity issues ]; const allIssues = validator.getIssues(options); const forEntityIDs = new Set(entityIDs); return allIssues .filter(issue => (issue.entityIds || []).some(entityID => forEntityIDs.has(entityID))) .sort((issue1, issue2) => { if (issue1.type === issue2.type) { // issues of the same type, sort deterministically return issue1.id < issue2.id ? -1 : 1; } const index1 = orderedIssueTypes.indexOf(issue1.type); const index2 = orderedIssueTypes.indexOf(issue2.type); if (index1 !== -1 && index2 !== -1) { // both issue types have explicit sort orders return index1 - index2; } else if (index1 === -1 && index2 === -1) { // neither issue type has an explicit sort order, sort by type return issue1.type < issue2.type ? -1 : 1; } else { // order explicit types before everything else return index1 !== -1 ? -1 : 1; } }); }; // `getEntityIssues()` // Get an array of detected issues for the given entityID. // (This just calls getSharedEntityIssues for a single entity) // // Arguments // `entityID` - the entity ID to get the issues for // `options` - (see `getIssues`) // Returns // An Array containing the issues // validator.getEntityIssues = (entityID, options) => { return validator.getSharedEntityIssues([entityID], options); }; // `getRuleKeys()` // // Returns // An Array containing the rule keys // validator.getRuleKeys = () => { return Object.keys(_rules); }; // `isRuleEnabled()` // // Arguments // `key` - the rule to check (e.g. 'crossing_ways') // Returns // `true`/`false` // validator.isRuleEnabled = (key) => { return !_disabledRules[key]; }; // `toggleRule()` // Toggles a single validation rule, // then reruns the validation so that the user sees something happen in the UI // // Arguments // `key` - the rule to toggle (e.g. 'crossing_ways') // validator.toggleRule = (key) => { if (_disabledRules[key]) { delete _disabledRules[key]; } else { _disabledRules[key] = true; } prefs('validate-disabledRules', Object.keys(_disabledRules).join(',')); validator.validate(); }; // `disableRules()` // Disables given validation rules, // then reruns the validation so that the user sees something happen in the UI // // Arguments // `keys` - Array or Set containing rule keys to disable // validator.disableRules = (keys) => { _disabledRules = {}; keys.forEach(k => _disabledRules[k] = true); prefs('validate-disabledRules', Object.keys(_disabledRules).join(',')); validator.validate(); }; // `ignoreIssue()` // Don't show the given issue in lists // // Arguments // `issueID` - the issueID // validator.ignoreIssue = (issueID) => { _ignoredIssueIDs.add(issueID); }; // `validate()` // Validates anything that has changed in the head graph since the last time it was run. // (head graph contains user's edits) // // Returns // A Promise fulfilled when the validation has completed and then dispatches a `validated` event. // This may take time but happen in the background during browser idle time. // validator.validate = () => { // Make sure the caches have graphs assigned to them. // (we don't do this in `reset` because context is still resetting things and `history.base()` is unstable then) const baseGraph = context.history().base(); if (!_headCache.graph) _headCache.graph = baseGraph; if (!_baseCache.graph) _baseCache.graph = baseGraph; const prevGraph = _headCache.graph; const currGraph = context.graph(); if (currGraph === prevGraph) { // _headCache.graph is current - we are caught up _headIsCurrent = true; dispatch.call('validated'); return Promise.resolve(); } if (_headPromise) { // Validation already in process, but we aren't caught up to current _headIsCurrent = false; // We will need to catch up after the validation promise fulfills return _headPromise; } // If we get here, its time to start validating stuff. _headCache.graph = currGraph; // take snapshot _completeDiff = context.history().difference().complete(); const incrementalDiff = coreDifference(prevGraph, currGraph); let entityIDs = Object.keys(incrementalDiff.complete()); entityIDs = _headCache.withAllRelatedEntities(entityIDs); // expand set if (!entityIDs.size) { dispatch.call('validated'); return Promise.resolve(); } _headPromise = validateEntitiesAsync(entityIDs, _headCache) .then(() => updateResolvedIssues(entityIDs)) .then(() => dispatch.call('validated')) .catch(() => { /* ignore */ }) .then(() => { _headPromise = null; if (!_headIsCurrent) { validator.validate(); // run it again to catch up to current graph } }); return _headPromise; }; // register event handlers: // WHEN TO RUN VALIDATION: // When history changes: context.history() .on('restore.validator', validator.validate) // on restore saved history .on('undone.validator', validator.validate) // on undo .on('redone.validator', validator.validate) // on redo .on('reset.validator', () => { // on history reset - happens after save, or enter/exit walkthrough reset(false); // cached issues aren't valid any longer if the history has been reset validator.validate(); }); // but not on 'change' (e.g. while drawing) // When user changes editing modes (to catch recent changes e.g. drawing) context .on('exit.validator', validator.validate); // When merging fetched data, validate base graph: context.history() .on('merge.validator', entities => { if (!entities) return; // Make sure the caches have graphs assigned to them. // (we don't do this in `reset` because context is still resetting things and `history.base()` is unstable then) const baseGraph = context.history().base(); if (!_headCache.graph) _headCache.graph = baseGraph; if (!_baseCache.graph) _baseCache.graph = baseGraph; let entityIDs = entities.map(entity => entity.id); entityIDs = _baseCache.withAllRelatedEntities(entityIDs); // expand set validateEntitiesAsync(entityIDs, _baseCache); }); // `validateEntity()` (private) // Runs all validation rules on a single entity. // Some things to note: // - Graph is passed in from whenever the validation was started. Validators shouldn't use // `context.graph()` because this all happens async, and the graph might have changed // (for example, nodes getting deleted before the validation can run) // - Validator functions may still be waiting on something and return a "provisional" result. // In this situation, we will schedule to revalidate the entity sometime later. // // Arguments // `entity` - The entity // `graph` - graph containing the entity // // Returns // Object result like: // { // issues: Array of detected issues // provisional: `true` if provisional result, `false` if final result // } // function validateEntity(entity, graph) { let result = { issues: [], provisional: false }; Object.keys(_rules).forEach(runValidation); // run all rules return result; // runs validation and appends resulting issues function runValidation(key) { const fn = _rules[key]; if (typeof fn !== 'function') { console.error('no such validation rule = ' + key); // eslint-disable-line no-console return; } let detected = fn(entity, graph); if (detected.provisional) { // this validation should be run again later result.provisional = true; } detected = detected.filter(applySeverityOverrides); result.issues = result.issues.concat(detected); // If there are any override rules that match the issue type/subtype, // adjust severity (or disable it) and keep/discard as quickly as possible. function applySeverityOverrides(issue) { const type = issue.type; const subtype = issue.subtype || ''; let i; for (i = 0; i < _errorOverrides.length; i++) { if (_errorOverrides[i].type.test(type) && _errorOverrides[i].subtype.test(subtype)) { issue.severity = 'error'; return true; } } for (i = 0; i < _warningOverrides.length; i++) { if (_warningOverrides[i].type.test(type) && _warningOverrides[i].subtype.test(subtype)) { issue.severity = 'warning'; return true; } } for (i = 0; i < _disableOverrides.length; i++) { if (_disableOverrides[i].type.test(type) && _disableOverrides[i].subtype.test(subtype)) { return false; } } return true; } } } // `updateResolvedIssues()` (private) // Determine if any issues were resolved for the given entities. // This is called by `validate()` after validation of the head graph // // Give the user credit for fixing an issue if: // - the issue is in the base cache // - the issue is not in the head cache // - the user did something to one of the entities involved in the issue // // Arguments // `entityIDs` - Array or Set containing entity IDs. // function updateResolvedIssues(entityIDs) { entityIDs.forEach(entityID => { const baseIssues = _baseCache.issuesByEntityID[entityID]; if (!baseIssues) return; baseIssues.forEach(issueID => { // Check if the user did something to one of the entities involved in this issue. // (This issue could involve multiple entities, e.g. disconnected routable features) const issue = _baseCache.issuesByIssueID[issueID]; const userModified = (issue.entityIds || []).some(id => _completeDiff.hasOwnProperty(id)); if (userModified && !_headCache.issuesByIssueID[issueID]) { // issue seems fixed _resolvedIssueIDs.add(issueID); } else { // issue still not resolved _resolvedIssueIDs.delete(issueID); // (did undo, or possibly fixed and then re-caused the issue) } }); }); } // `validateEntitiesAsync()` (private) // Schedule validation for many entities. // // Arguments // `entityIDs` - Array or Set containing entityIDs. // `graph` - the graph to validate that contains those entities // `cache` - the cache to store results in (_headCache or _baseCache) // // Returns // A Promise fulfilled when the validation has completed. // This may take time but happen in the background during browser idle time. // function validateEntitiesAsync(entityIDs, cache) { // Enqueue the work const jobs = Array.from(entityIDs).map(entityID => { if (cache.queuedEntityIDs.has(entityID)) return null; // queued already cache.queuedEntityIDs.add(entityID); // Clear caches for existing issues related to this entity cache.uncacheEntityID(entityID); return () => { cache.queuedEntityIDs.delete(entityID); const graph = cache.graph; if (!graph) return; // was reset? const entity = graph.hasEntity(entityID); // Sanity check: don't validate deleted entities if (!entity) return; // detect new issues and update caches const result = validateEntity(entity, graph); if (result.provisional) { // provisional result cache.provisionalEntityIDs.add(entityID); // we'll need to revalidate this entity again later } cache.cacheIssues(result.issues); // update cache }; }).filter(Boolean); // Perform the work in chunks. // Because this will happen during idle callbacks, we want to choose a chunk size // that won't make the browser stutter too badly. cache.queue = cache.queue.concat(utilArrayChunk(jobs, 100)); // Perform the work if (cache.queuePromise) return cache.queuePromise; cache.queuePromise = processQueue(cache) .then(() => revalidateProvisionalEntities(cache)) .catch(() => { /* ignore */ }) .finally(() => cache.queuePromise = null); return cache.queuePromise; } // `revalidateProvisionalEntities()` (private) // Sometimes a validator will return a "provisional" result. // In this situation, we'll need to revalidate the entity later. // This function waits a delay, then places them back into the validation queue. // // Arguments // `cache` - The cache (_headCache or _baseCache) // function revalidateProvisionalEntities(cache) { if (!cache.provisionalEntityIDs.size) return; // nothing to do const handle = window.setTimeout(() => { _deferredST.delete(handle); if (!cache.provisionalEntityIDs.size) return; // nothing to do validateEntitiesAsync(Array.from(cache.provisionalEntityIDs), cache); }, RETRY); _deferredST.add(handle); } // `processQueue(queue)` (private) // Process the next chunk of deferred validation work // // Arguments // `cache` - The cache (_headCache or _baseCache) // // Returns // A Promise fulfilled when the validation has completed. // This may take time but happen in the background during browser idle time. // function processQueue(cache) { // console.log(`${cache.which} queue length ${cache.queue.length}`); if (!cache.queue.length) return Promise.resolve(); // we're done const chunk = cache.queue.pop(); return new Promise(resolvePromise => { const handle = window.requestIdleCallback(() => { _deferredRIC.delete(handle); // const t0 = performance.now(); chunk.forEach(job => job()); // const t1 = performance.now(); // console.log('chunk processed in ' + (t1 - t0) + ' ms'); resolvePromise(); }); _deferredRIC.add(handle); }) .then(() => { // dispatch an event sometimes to redraw various UI things if (cache.queue.length % 25 === 0) dispatch.call('validated'); }) .then(() => processQueue(cache)); } return validator; } // `validationCache()` (private) // Creates a cache to store validation state // We create 2 of these: // `_baseCache` for validation on the base graph (unedited) // `_headCache` for validation on the head graph (user edits applied) // // Arguments // `which` - just a String 'base' or 'head' to keep track of it // function validationCache(which) { let cache = { which: which, graph: null, queue: [], queuePromise: null, queuedEntityIDs: new Set(), provisionalEntityIDs: new Set(), issuesByIssueID: {}, // issue.id -> issue issuesByEntityID: {} // entity.id -> Set(issue.id) }; cache.cacheIssue = (issue) => { (issue.entityIds || []).forEach(entityID => { if (!cache.issuesByEntityID[entityID]) { cache.issuesByEntityID[entityID] = new Set(); } cache.issuesByEntityID[entityID].add(issue.id); }); cache.issuesByIssueID[issue.id] = issue; }; cache.uncacheIssue = (issue) => { (issue.entityIds || []).forEach(entityID => { if (cache.issuesByEntityID[entityID]) { cache.issuesByEntityID[entityID].delete(issue.id); } }); delete cache.issuesByIssueID[issue.id]; }; cache.cacheIssues = (issues) => { issues.forEach(cache.cacheIssue); }; cache.uncacheIssues = (issues) => { issues.forEach(cache.uncacheIssue); }; cache.uncacheIssuesOfType = (type) => { const issuesOfType = Object.values(cache.issuesByIssueID) .filter(issue => issue.type === type); cache.uncacheIssues(issuesOfType); }; // Remove a single entity and all its related issues from the caches cache.uncacheEntityID = (entityID) => { const entityIssueIDs = cache.issuesByEntityID[entityID]; if (entityIssueIDs) { entityIssueIDs.forEach(issueID => { const issue = cache.issuesByIssueID[issueID]; if (issue) { cache.uncacheIssue(issue); } else { // shouldn't happen, clean up delete cache.issuesByIssueID[issueID]; } }); } delete cache.issuesByEntityID[entityID]; cache.provisionalEntityIDs.delete(entityID); }; // Return the expandeded set of entityIDs related to issues for the given entityIDs // // Arguments // `entityIDs` - Array or Set containing entityIDs. // cache.withAllRelatedEntities = (entityIDs) => { let result = new Set(); (entityIDs || []).forEach(entityID => { result.add(entityID); // include self const entityIssueIDs = cache.issuesByEntityID[entityID]; if (entityIssueIDs) { entityIssueIDs.forEach(issueID => { const issue = cache.issuesByIssueID[issueID]; if (issue) { (issue.entityIds || []).forEach(relatedID => result.add(relatedID)); } else { // shouldn't happen, clean up delete cache.issuesByIssueID[issueID]; } }); } }); return result; }; return cache; }