modules/services/keepRight.js (373 lines of code) (raw):

import RBush from 'rbush'; import { dispatch as d3_dispatch } from 'd3-dispatch'; import { json as d3_json } from 'd3-fetch'; import { geoExtent, geoVecAdd } from '../geo'; import { qaError } from '../osm'; import { t } from '../util/locale'; import { utilRebind, utilTiler, utilQsString } from '../util'; import { errorTypes, localizeStrings } from '../../data/keepRight.json'; var tiler = utilTiler(); var dispatch = d3_dispatch('loaded'); var _krCache; var _krZoom = 14; var _krUrlRoot = 'https://www.keepright.at/'; var _krRuleset = [ // no 20 - multiple node on same spot - these are mostly boundaries overlapping roads 30, 40, 50, 60, 70, 90, 100, 110, 120, 130, 150, 160, 170, 180, 190, 191, 192, 193, 194, 195, 196, 197, 198, 200, 201, 202, 203, 204, 205, 206, 207, 208, 210, 220, 230, 231, 232, 270, 280, 281, 282, 283, 284, 285, 290, 291, 292, 293, 294, 295, 296, 297, 298, 300, 310, 311, 312, 313, 320, 350, 360, 370, 380, 390, 400, 401, 402, 410, 411, 412, 413 ]; function abortRequest(controller) { if (controller) { controller.abort(); } } function abortUnwantedRequests(cache, tiles) { Object.keys(cache.inflightTile).forEach(function(k) { var wanted = tiles.find(function(tile) { return k === tile.id; }); if (!wanted) { abortRequest(cache.inflightTile[k]); delete cache.inflightTile[k]; } }); } function encodeErrorRtree(d) { return { minX: d.loc[0], minY: d.loc[1], maxX: d.loc[0], maxY: d.loc[1], data: d }; } // replace or remove error from rtree function updateRtree(item, replace) { _krCache.rtree.remove(item, function isEql(a, b) { return a.data.id === b.data.id; }); if (replace) { _krCache.rtree.insert(item); } } function tokenReplacements(d) { if (!(d instanceof qaError)) return; var htmlRegex = new RegExp(/<\/[a-z][\s\S]*>/); var replacements = {}; var errorTemplate = errorTypes[d.which_type]; if (!errorTemplate) { /* eslint-disable no-console */ console.log('No Template: ', d.which_type); console.log(' ', d.description); /* eslint-enable no-console */ return; } // some descriptions are just fixed text if (!errorTemplate.regex) return; // regex pattern should match description with variable details captured var errorRegex = new RegExp(errorTemplate.regex, 'i'); var errorMatch = errorRegex.exec(d.description); if (!errorMatch) { /* eslint-disable no-console */ console.log('Unmatched: ', d.which_type); console.log(' ', d.description); console.log(' ', errorRegex); /* eslint-enable no-console */ return; } for (var i = 1; i < errorMatch.length; i++) { // skip first var capture = errorMatch[i]; var idType; idType = 'IDs' in errorTemplate ? errorTemplate.IDs[i-1] : ''; if (idType && capture) { // link IDs if present in the capture capture = parseError(capture, idType); } else if (htmlRegex.test(capture)) { // escape any html in non-IDs capture = '\\' + capture + '\\'; } else { var compare = capture.toLowerCase(); if (localizeStrings[compare]) { // some replacement strings can be localized capture = t('QA.keepRight.error_parts.' + localizeStrings[compare]); } } replacements['var' + i] = capture; } return replacements; } function parseError(capture, idType) { var compare = capture.toLowerCase(); if (localizeStrings[compare]) { // some replacement strings can be localized capture = t('QA.keepRight.error_parts.' + localizeStrings[compare]); } switch (idType) { // link a string like "this node" case 'this': capture = linkErrorObject(capture); break; case 'url': capture = linkURL(capture); break; // link an entity ID case 'n': case 'w': case 'r': capture = linkEntity(idType + capture); break; // some errors have more complex ID lists/variance case '20': capture = parse20(capture); break; case '211': capture = parse211(capture); break; case '231': capture = parse231(capture); break; case '294': capture = parse294(capture); break; case '370': capture = parse370(capture); break; } return capture; function linkErrorObject(d) { return '<a class="error_object_link">' + d + '</a>'; } function linkEntity(d) { return '<a class="error_entity_link">' + d + '</a>'; } function linkURL(d) { return '<a class="kr_external_link" target="_blank" href="' + d + '">' + d + '</a>'; } // arbitrary node list of form: #ID, #ID, #ID... function parse211(capture) { var newList = []; var items = capture.split(', '); items.forEach(function(item) { // ID has # at the front var id = linkEntity('n' + item.slice(1)); newList.push(id); }); return newList.join(', '); } // arbitrary way list of form: #ID(layer),#ID(layer),#ID(layer)... function parse231(capture) { var newList = []; // unfortunately 'layer' can itself contain commas, so we split on '),' var items = capture.split('),'); items.forEach(function(item) { var match = item.match(/\#(\d+)\((.+)\)?/); if (match !== null && match.length > 2) { newList.push(linkEntity('w' + match[1]) + ' ' + t('QA.keepRight.errorTypes.231.layer', { layer: match[2] }) ); } }); return newList.join(', '); } // arbitrary node/relation list of form: from node #ID,to relation #ID,to node #ID... function parse294(capture) { var newList = []; var items = capture.split(','); items.forEach(function(item) { var role; var idType; var id; // item of form "from/to node/relation #ID" item = item.split(' '); // to/from role is more clear in quotes role = '"' + item[0] + '"'; // first letter of node/relation provides the type idType = item[1].slice(0,1); // ID has # at the front id = item[2].slice(1); id = linkEntity(idType + id); item = [role, item[1], id].join(' '); newList.push(item); }); return newList.join(', '); } // may or may not include the string "(including the name 'name')" function parse370(capture) { if (!capture) return ''; var match = capture.match(/\(including the name (\'.+\')\)/); if (match !== null && match.length) { return t('QA.keepRight.errorTypes.370.including_the_name', { name: match[1] }); } return ''; } // arbitrary node list of form: #ID,#ID,#ID... function parse20(capture) { var newList = []; var items = capture.split(','); items.forEach(function(item) { // ID has # at the front var id = linkEntity('n' + item.slice(1)); newList.push(id); }); return newList.join(', '); } } export default { init: function() { if (!_krCache) { this.reset(); } this.event = utilRebind(this, dispatch, 'on'); }, reset: function() { if (_krCache) { Object.values(_krCache.inflightTile).forEach(abortRequest); } _krCache = { data: {}, loadedTile: {}, inflightTile: {}, inflightPost: {}, closed: {}, rtree: new RBush() }; }, // KeepRight API: http://osm.mueschelsoft.de/keepright/interfacing.php loadErrors: function(projection) { var options = { format: 'geojson' }; var rules = _krRuleset.join(); // determine the needed tiles to cover the view var tiles = tiler .zoomExtent([_krZoom, _krZoom]) .getTiles(projection); // abort inflight requests that are no longer needed abortUnwantedRequests(_krCache, tiles); // issue new requests.. tiles.forEach(function(tile) { if (_krCache.loadedTile[tile.id] || _krCache.inflightTile[tile.id]) return; var rect = tile.extent.rectangle(); var params = Object.assign({}, options, { left: rect[0], bottom: rect[3], right: rect[2], top: rect[1] }); var url = _krUrlRoot + 'export.php?' + utilQsString(params) + '&ch=' + rules; var controller = new AbortController(); _krCache.inflightTile[tile.id] = controller; d3_json(url, { signal: controller.signal }) .then(function(data) { delete _krCache.inflightTile[tile.id]; _krCache.loadedTile[tile.id] = true; if (!data || !data.features || !data.features.length) { throw new Error('No Data'); } data.features.forEach(function(feature) { var loc = feature.geometry.coordinates; var props = feature.properties; // if there is a parent, save its error type e.g.: // Error 191 = "highway-highway" // Error 190 = "intersections without junctions" (parent) var errorType = props.error_type; var errorTemplate = errorTypes[errorType]; var parentErrorType = (Math.floor(errorType / 10) * 10).toString(); // try to handle error type directly, fallback to parent error type. var whichType = errorTemplate ? errorType : parentErrorType; var whichTemplate = errorTypes[whichType]; // Rewrite a few of the errors at this point.. // This is done to make them easier to linkify and translate. switch (whichType) { case '170': props.description = 'This feature has a FIXME tag: ' + props.description; break; case '292': case '293': props.description = props.description.replace('A turn-', 'This turn-'); break; case '294': case '295': case '296': case '297': case '298': props.description = 'This turn-restriction~' + props.description; break; case '300': props.description = 'This highway is missing a maxspeed tag'; break; case '411': case '412': case '413': props.description = 'This feature~' + props.description; break; } // - move markers slightly so it doesn't obscure the geometry, // - then move markers away from other coincident markers var coincident = false; do { // first time, move marker up. after that, move marker right. var delta = coincident ? [0.00001, 0] : [0, 0.00001]; loc = geoVecAdd(loc, delta); var bbox = geoExtent(loc).bbox(); coincident = _krCache.rtree.search(bbox).length; } while (coincident); var d = new qaError({ // Required values loc: loc, service: 'keepRight', error_type: errorType, // Extra values for this service id: props.error_id, comment: props.comment || null, description: props.description || '', error_id: props.error_id, which_type: whichType, parent_error_type: parentErrorType, severity: whichTemplate.severity || 'error', object_id: props.object_id, object_type: props.object_type, schema: props.schema, title: props.title }); d.replacements = tokenReplacements(d); _krCache.data[d.id] = d; _krCache.rtree.insert(encodeErrorRtree(d)); }); dispatch.call('loaded'); }) .catch(function() { delete _krCache.inflightTile[tile.id]; _krCache.loadedTile[tile.id] = true; }); }); }, postKeepRightUpdate: function(d, callback) { if (_krCache.inflightPost[d.id]) { return callback({ message: 'Error update already inflight', status: -2 }, d); } var that = this; var params = { schema: d.schema, id: d.error_id }; if (d.state) { params.st = d.state; } if (d.newComment !== undefined) { params.co = d.newComment; } // NOTE: This throws a CORS err, but it seems successful. // We don't care too much about the response, so this is fine. var url = _krUrlRoot + 'comment.php?' + utilQsString(params); var controller = new AbortController(); _krCache.inflightPost[d.id] = controller; fetch(url, { method: 'POST', signal: controller.signal }) .then(function(response) { delete _krCache.inflightPost[d.id]; if (!response.ok) { throw new Error(response.status + ' ' + response.statusText); } if (d.state === 'ignore') { // ignore permanently (false positive) that.removeError(d); } else if (d.state === 'ignore_t') { // ignore temporarily (error fixed) that.removeError(d); _krCache.closed[d.schema + ':' + d.error_id] = true; } else { d = that.replaceError(d.update({ comment: d.newComment, newComment: undefined, state: undefined })); } if (callback) callback(null, d); }) .catch(function(err) { delete _krCache.inflightPost[d.id]; if (callback) callback(err.message); }); }, // get all cached errors covering the viewport getErrors: function(projection) { var viewport = projection.clipExtent(); var min = [viewport[0][0], viewport[1][1]]; var max = [viewport[1][0], viewport[0][1]]; var bbox = geoExtent(projection.invert(min), projection.invert(max)).bbox(); return _krCache.rtree.search(bbox).map(function(d) { return d.data; }); }, // get a single error from the cache getError: function(id) { return _krCache.data[id]; }, // replace a single error in the cache replaceError: function(error) { if (!(error instanceof qaError) || !error.id) return; _krCache.data[error.id] = error; updateRtree(encodeErrorRtree(error), true); // true = replace return error; }, // remove a single error from the cache removeError: function(error) { if (!(error instanceof qaError) || !error.id) return; delete _krCache.data[error.id]; updateRtree(encodeErrorRtree(error), false); // false = remove }, errorURL: function(error) { return _krUrlRoot + 'report_map.php?schema=' + error.schema + '&error=' + error.id; }, // Get an array of errors closed during this session. // Used to populate `closed:keepright` changeset tag getClosedIDs: function() { return Object.keys(_krCache.closed).sort(); } };