Files
iD/modules/services/osmose.js
T
SilentSpike 087867d5c8 Use Promises for Osmose issue detail requests
Also ES6ify the details script
2020-02-04 21:08:02 +00:00

352 lines
10 KiB
JavaScript

import RBush from 'rbush';
import { dispatch as d3_dispatch } from 'd3-dispatch';
import { json as d3_json } from 'd3-fetch';
import { currentLocale } from '../util/locale';
import { geoExtent, geoVecAdd } from '../geo';
import { qaError } from '../osm';
import { utilRebind, utilTiler, utilQsString } from '../util';
import { services as qaServices } from '../../data/qa_errors.json';
const tiler = utilTiler();
const dispatch = d3_dispatch('loaded');
const _osmoseUrlRoot = 'https://osmose.openstreetmap.fr/en/api/0.3beta';
const _osmoseItems =
Object.keys(qaServices.osmose.errorIcons)
.map(s => s.split('-')[0])
.reduce((unique, item) => unique.indexOf(item) !== -1 ? unique : [...unique, item], []);
const _erZoom = 14;
const _stringCache = {};
const _colorCache = {};
// This gets reassigned if reset
let _erCache;
function abortRequest(controller) {
if (controller) {
controller.abort();
}
}
function abortUnwantedRequests(cache, tiles) {
Object.keys(cache.inflightTile).forEach(k => {
let wanted = tiles.find(tile => 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) {
_erCache.rtree.remove(item, (a, b) => a.data.id === b.data.id);
if (replace) {
_erCache.rtree.insert(item);
}
}
// Errors shouldn't obscure eachother
function preventCoincident(loc) {
let coincident = false;
do {
// first time, move marker up. after that, move marker right.
let delta = coincident ? [0.00001, 0] : [0, 0.00001];
loc = geoVecAdd(loc, delta);
let bbox = geoExtent(loc).bbox();
coincident = _erCache.rtree.search(bbox).length;
} while (coincident);
return loc;
}
export default {
init() {
if (!_erCache) {
this.reset();
}
this.event = utilRebind(this, dispatch, 'on');
},
reset() {
if (_erCache) {
Object.values(_erCache.inflightTile).forEach(abortRequest);
}
_erCache = {
data: {},
loadedTile: {},
inflightTile: {},
inflightPost: {},
closed: {},
rtree: new RBush()
};
},
loadErrors(projection) {
let params = {
// Tiles return a maximum # of errors
// So we want to filter our request for only types iD supports
item: _osmoseItems
};
// determine the needed tiles to cover the view
let tiles = tiler
.zoomExtent([_erZoom, _erZoom])
.getTiles(projection);
// abort inflight requests that are no longer needed
abortUnwantedRequests(_erCache, tiles);
// issue new requests..
tiles.forEach(tile => {
if (_erCache.loadedTile[tile.id] || _erCache.inflightTile[tile.id]) return;
let [ x, y, z ] = tile.xyz;
let url = `${_osmoseUrlRoot}/issues/${z}/${x}/${y}.json?` + utilQsString(params);
let controller = new AbortController();
_erCache.inflightTile[tile.id] = controller;
d3_json(url, { signal: controller.signal })
.then(data => {
delete _erCache.inflightTile[tile.id];
_erCache.loadedTile[tile.id] = true;
if (data.features) {
data.features.forEach(issue => {
const { item, class: error_class, uuid: identifier } = issue.properties;
// Item is the type of error, w/ class tells us the sub-type
const error_type = `${item}-${error_class}`;
// Filter out unsupported error types (some are too specific or advanced)
if (error_type in qaServices.osmose.errorIcons) {
let loc = issue.geometry.coordinates; // lon, lat
loc = preventCoincident(loc);
let d = new qaError({
// Info required for every error
loc,
service: 'osmose',
error_type,
// Extra details needed for this service
identifier, // needed to query and update the error
item // category of the issue for styling
});
// Setting elems here prevents UI error detail requests
if (d.item === 8300 || d.item === 8360) {
d.elems = [];
}
_erCache.data[d.id] = d;
_erCache.rtree.insert(encodeErrorRtree(d));
}
});
}
dispatch.call('loaded');
})
.catch(() => {
delete _erCache.inflightTile[tile.id];
_erCache.loadedTile[tile.id] = true;
});
});
},
loadErrorDetail(d) {
// Error details only need to be fetched once
if (d.elems !== undefined) {
return Promise.resolve(d);
}
const url = `${_osmoseUrlRoot}/issue/${d.identifier}?langs=${currentLocale}`;
const cacheDetails = data => {
// Associated elements used for highlighting
// Assign directly for immediate use in the callback
d.elems = data.elems.map(e => e.type.substring(0,1) + e.id);
// Some issues have instance specific detail in a subtitle
d.detail = data.subtitle;
this.replaceError(d);
};
return jsonPromise(url, cacheDetails)
.then(() => d);
},
loadStrings(callback, locale=currentLocale) {
const issueTypes = Object.keys(qaServices.osmose.errorIcons);
if (
locale in _stringCache
&& Object.keys(_stringCache[locale]).length === issueTypes.length
) {
if (callback) callback(null, _stringCache[locale]);
return;
}
// May be partially populated already if some requests were successful
if (!(locale in _stringCache)) {
_stringCache[locale] = {};
}
const format = string => {
// Some strings contain markdown syntax
string = string.replace(/\[((?:.|\n)+?)\]\((.+?)\)/g, '<a href="$2">$1</a>');
return string.replace(/`(.+?)`/g, '<code>$1</code>');
};
// Only need to cache strings for supported issue types
// Using multiple individual item + class requests to reduce fetched data size
const allRequests = issueTypes.map(issueType => {
// No need to request data we already have
if (issueType in _stringCache[locale]) return;
const cacheData = data => {
// Bunch of nested single value arrays of objects
const [ cat = {items:[]} ] = data.categories;
const [ item = {class:[]} ] = cat.items;
const [ cl = null ] = item.class;
// If null default value is reached, data wasn't as expected (or was empty)
if (!cl) {
/* eslint-disable no-console */
console.log(`Osmose strings request (${issueType}) had unexpected data`);
/* eslint-enable no-console */
return;
}
// Cache served item colors to automatically style issue markers later
const { item: itemInt, color } = item;
if (/^#[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}/.test(color)) {
_colorCache[itemInt] = color;
}
// Value of root key will be null if no string exists
// If string exists, value is an object with key 'auto' for string
const { title, detail, fix, trap } = cl;
let issueStrings = {};
if (title) issueStrings.title = title.auto;
if (detail) issueStrings.detail = format(detail.auto);
if (trap) issueStrings.trap = format(trap.auto);
if (fix) issueStrings.fix = format(fix.auto);
_stringCache[locale][issueType] = issueStrings;
};
const [ item, cl ] = issueType.split('-');
// Osmose API falls back to English strings where untranslated or if locale doesn't exist
const url = `${_osmoseUrlRoot}/items/${item}/class/${cl}?langs=${locale}`;
return jsonPromise(url, cacheData);
});
Promise.all(allRequests)
.then(() => { if (callback) callback(null, _stringCache[locale]); })
.catch(err => { if (callback) callback(err); });
},
getStrings(issueType, locale=currentLocale) {
// No need to fallback to English, Osmose API handles this for us
return (locale in _stringCache) ? _stringCache[locale][issueType] : {};
},
getColor(itemType) {
return (itemType in _colorCache) ? _colorCache[itemType] : '#FFFFFF';
},
postUpdate(d, callback) {
if (_erCache.inflightPost[d.id]) {
return callback({ message: 'Error update already inflight', status: -2 }, d);
}
// UI sets the status to either 'done' or 'false'
let url = `${_osmoseUrlRoot}/issue/${d.identifier}/${d.newStatus}`;
let controller = new AbortController();
_erCache.inflightPost[d.id] = controller;
fetch(url, { signal: controller.signal })
.then(() => {
delete _erCache.inflightPost[d.id];
this.removeError(d);
if (d.newStatus === 'done') {
// No error identifier, so we give a count of each category
if (!(d.item in _erCache.closed)) {
_erCache.closed[d.item] = 0;
}
_erCache.closed[d.item] += 1;
}
if (callback) callback(null, d);
})
.catch(err => {
delete _erCache.inflightPost[d.id];
if (callback) callback(err.message);
});
},
// get all cached errors covering the viewport
getErrors(projection) {
let viewport = projection.clipExtent();
let min = [viewport[0][0], viewport[1][1]];
let max = [viewport[1][0], viewport[0][1]];
let bbox = geoExtent(projection.invert(min), projection.invert(max)).bbox();
return _erCache.rtree.search(bbox).map(d => {
return d.data;
});
},
// get a single error from the cache
getError(id) {
return _erCache.data[id];
},
// replace a single error in the cache
replaceError(error) {
if (!(error instanceof qaError) || !error.id) return;
_erCache.data[error.id] = error;
updateRtree(encodeErrorRtree(error), true); // true = replace
return error;
},
// remove a single error from the cache
removeError(error) {
if (!(error instanceof qaError) || !error.id) return;
delete _erCache.data[error.id];
updateRtree(encodeErrorRtree(error), false); // false = remove
},
// Used to populate `closed:osmose:*` changeset tags
getClosedCounts() {
return _erCache.closed;
}
};
function jsonPromise(url, then) {
return new Promise((resolve, reject) => {
d3_json(url)
.then(data => {
then(data);
resolve();
})
.catch(err => {
reject(err);
});
});
}