modules/services/keepRight.js (373 lines of code) (raw):
import { dispatch as d3_dispatch } from 'd3-dispatch';
import { json as d3_json } from 'd3-fetch';
import { Extent, Projection, Tiler, vecAdd} from '@id-sdk/math';
import { utilQsString } from '@id-sdk/util';
import RBush from 'rbush';
import { fileFetcher } from '../core/file_fetcher';
import { QAItem } from '../osm';
import { t } from '../core/localizer';
import { utilRebind } from '../util';
const TILEZOOM = 14;
const tiler = new Tiler().zoomRange(TILEZOOM);
const dispatch = d3_dispatch('loaded');
const _krUrlRoot = 'https://www.keepright.at';
let _krData = { errorTypes: {}, localizeStrings: {} };
// This gets reassigned if reset
let _cache;
const _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(k => {
const wanted = tiles.find(tile => k === tile.id);
if (!wanted) {
abortRequest(cache.inflightTile[k]);
delete cache.inflightTile[k];
}
});
}
function encodeIssueRtree(d) {
return { minX: d.loc[0], minY: d.loc[1], maxX: d.loc[0], maxY: d.loc[1], data: d };
}
// Replace or remove QAItem from rtree
function updateRtree(item, replace) {
_cache.rtree.remove(item, (a, b) => a.data.id === b.data.id);
if (replace) {
_cache.rtree.insert(item);
}
}
function tokenReplacements(d) {
if (!(d instanceof QAItem)) return;
const htmlRegex = new RegExp(/<\/[a-z][\s\S]*>/);
const replacements = {};
const issueTemplate = _krData.errorTypes[d.whichType];
if (!issueTemplate) {
/* eslint-disable no-console */
console.log('No Template: ', d.whichType);
console.log(' ', d.description);
/* eslint-enable no-console */
return;
}
// some descriptions are just fixed text
if (!issueTemplate.regex) return;
// regex pattern should match description with variable details captured
const errorRegex = new RegExp(issueTemplate.regex, 'i');
const errorMatch = errorRegex.exec(d.description);
if (!errorMatch) {
/* eslint-disable no-console */
console.log('Unmatched: ', d.whichType);
console.log(' ', d.description);
console.log(' ', errorRegex);
/* eslint-enable no-console */
return;
}
for (let i = 1; i < errorMatch.length; i++) { // skip first
let capture = errorMatch[i];
let idType;
idType = 'IDs' in issueTemplate ? issueTemplate.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 {
const compare = capture.toLowerCase();
if (_krData.localizeStrings[compare]) { // some replacement strings can be localized
capture = t('QA.keepRight.error_parts.' + _krData.localizeStrings[compare]);
}
}
replacements['var' + i] = capture;
}
return replacements;
}
function parseError(capture, idType) {
const compare = capture.toLowerCase();
if (_krData.localizeStrings[compare]) { // some replacement strings can be localized
capture = t('QA.keepRight.error_parts.' + _krData.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) {
let newList = [];
const items = capture.split(', ');
items.forEach(item => {
// ID has # at the front
let 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) {
let newList = [];
// unfortunately 'layer' can itself contain commas, so we split on '),'
const items = capture.split('),');
items.forEach(item => {
const 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) {
let newList = [];
const items = capture.split(',');
items.forEach(item => {
// item of form "from/to node/relation #ID"
item = item.split(' ');
// to/from role is more clear in quotes
const role = `"${item[0]}"`;
// first letter of node/relation provides the type
const idType = item[1].slice(0,1);
// ID has # at the front
let id = item[2].slice(1);
id = linkEntity(idType + id);
newList.push(`${role} ${item[1]} ${id}`);
});
return newList.join(', ');
}
// may or may not include the string "(including the name 'name')"
function parse370(capture) {
if (!capture) return '';
const match = capture.match(/\(including the name (\'.+\')\)/);
if (match && 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) {
let newList = [];
const items = capture.split(',');
items.forEach(item => {
// ID has # at the front
const id = linkEntity('n' + item.slice(1));
newList.push(id);
});
return newList.join(', ');
}
}
export default {
title: 'keepRight',
init() {
fileFetcher.get('keepRight')
.then(d => _krData = d);
if (!_cache) {
this.reset();
}
this.event = utilRebind(this, dispatch, 'on');
},
reset() {
if (_cache) {
Object.values(_cache.inflightTile).forEach(abortRequest);
}
_cache = {
data: {},
loadedTile: {},
inflightTile: {},
inflightPost: {},
closed: {},
rtree: new RBush()
};
},
// KeepRight API: http://osm.mueschelsoft.de/keepright/interfacing.php
loadIssues(projection) {
const options = {
format: 'geojson',
ch: _krRuleset
};
// determine the needed tiles to cover the view
const proj = new Projection().transform(projection.transform()).dimensions(projection.clipExtent());
const tiles = tiler.getTiles(proj).tiles;
// abort inflight requests that are no longer needed
abortUnwantedRequests(_cache, tiles);
// issue new requests..
tiles.forEach(tile => {
if (_cache.loadedTile[tile.id] || _cache.inflightTile[tile.id]) return;
const [ left, top, right, bottom ] = tile.wgs84Extent.rectangle();
const params = Object.assign({}, options, { left, bottom, right, top });
const url = `${_krUrlRoot}/export.php?` + utilQsString(params);
const controller = new AbortController();
_cache.inflightTile[tile.id] = controller;
d3_json(url, { signal: controller.signal })
.then(data => {
delete _cache.inflightTile[tile.id];
_cache.loadedTile[tile.id] = true;
if (!data || !data.features || !data.features.length) {
throw new Error('No Data');
}
data.features.forEach(feature => {
const {
properties: {
error_type: itemType,
error_id: id,
comment = null,
object_id: objectId,
object_type: objectType,
schema,
title
}
} = feature;
let {
geometry: { coordinates: loc },
properties: { description = '' }
} = feature;
// if there is a parent, save its error type e.g.:
// Error 191 = "highway-highway"
// Error 190 = "intersections without junctions" (parent)
const issueTemplate = _krData.errorTypes[itemType];
const parentIssueType = (Math.floor(itemType / 10) * 10).toString();
// try to handle error type directly, fallback to parent error type.
const whichType = issueTemplate ? itemType : parentIssueType;
const whichTemplate = _krData.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':
description = `This feature has a FIXME tag: ${description}`;
break;
case '292':
case '293':
description = description.replace('A turn-', 'This turn-');
break;
case '294':
case '295':
case '296':
case '297':
case '298':
description = `This turn-restriction~${description}`;
break;
case '300':
description = 'This highway is missing a maxspeed tag';
break;
case '411':
case '412':
case '413':
description = `This feature~${description}`;
break;
}
// move markers slightly so it doesn't obscure the geometry,
// then move markers away from other coincident markers
let coincident = false;
do {
// first time, move marker up. after that, move marker right.
let delta = coincident ? [0.00001, 0] : [0, 0.00001];
loc = vecAdd(loc, delta);
let bbox = new Extent(loc).bbox();
coincident = _cache.rtree.search(bbox).length;
} while (coincident);
let d = new QAItem(loc, this, itemType, id, {
comment,
description,
whichType,
parentIssueType,
severity: whichTemplate.severity || 'error',
objectId,
objectType,
schema,
title
});
d.replacements = tokenReplacements(d);
_cache.data[id] = d;
_cache.rtree.insert(encodeIssueRtree(d));
});
dispatch.call('loaded');
})
.catch(() => {
delete _cache.inflightTile[tile.id];
_cache.loadedTile[tile.id] = true;
});
});
},
postUpdate(d, callback) {
if (_cache.inflightPost[d.id]) {
return callback({ message: 'Error update already inflight', status: -2 }, d);
}
const params = { schema: d.schema, id: d.id };
if (d.newStatus) {
params.st = d.newStatus;
}
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.
const url = `${_krUrlRoot}/comment.php?` + utilQsString(params);
const controller = new AbortController();
_cache.inflightPost[d.id] = controller;
// Since this is expected to throw an error just continue as if it worked
// (worst case scenario the request truly fails and issue will show up if iD restarts)
d3_json(url, { signal: controller.signal })
.finally(() => {
delete _cache.inflightPost[d.id];
if (d.newStatus === 'ignore') {
// ignore permanently (false positive)
this.removeItem(d);
} else if (d.newStatus === 'ignore_t') {
// ignore temporarily (error fixed)
this.removeItem(d);
_cache.closed[`${d.schema}:${d.id}`] = true;
} else {
d = this.replaceItem(d.update({
comment: d.newComment,
newComment: undefined,
newState: undefined
}));
}
if (callback) callback(null, d);
});
},
// Get all cached QAItems covering the viewport
getItems(projection) {
const viewport = projection.clipExtent();
const min = [viewport[0][0], viewport[1][1]];
const max = [viewport[1][0], viewport[0][1]];
const bbox = new Extent(projection.invert(min), projection.invert(max)).bbox();
return _cache.rtree.search(bbox).map(d => d.data);
},
// Get a QAItem from cache
// NOTE: Don't change method name until UI v3 is merged
getError(id) {
return _cache.data[id];
},
// Replace a single QAItem in the cache
replaceItem(item) {
if (!(item instanceof QAItem) || !item.id) return;
_cache.data[item.id] = item;
updateRtree(encodeIssueRtree(item), true); // true = replace
return item;
},
// Remove a single QAItem from the cache
removeItem(item) {
if (!(item instanceof QAItem) || !item.id) return;
delete _cache.data[item.id];
updateRtree(encodeIssueRtree(item), false); // false = remove
},
issueURL(item) {
return `${_krUrlRoot}/report_map.php?schema=${item.schema}&error=${item.id}`;
},
// Get an array of issues closed during this session.
// Used to populate `closed:keepright` changeset tag
getClosedIDs() {
return Object.keys(_cache.closed).sort();
}
};