modules/services/osmose.js (237 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 marked from 'marked'; import RBush from 'rbush'; import { fileFetcher } from '../core/file_fetcher'; import { localizer } from '../core/localizer'; import { QAItem } from '../osm'; import { utilRebind } from '../util'; const TILEZOOM = 14; const tiler = new Tiler().zoomRange(TILEZOOM); const dispatch = d3_dispatch('loaded'); const _osmoseUrlRoot = 'https://osmose.openstreetmap.fr/api/0.3'; let _osmoseData = { icons: {}, items: [] }; // This gets reassigned if reset let _cache; 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 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); } } // Issues shouldn't obscure each other 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 = vecAdd(loc, delta); let bbox = new Extent(loc).bbox(); coincident = _cache.rtree.search(bbox).length; } while (coincident); return loc; } export default { title: 'osmose', init() { fileFetcher.get('qa_data') .then(d => { _osmoseData = d.osmose; _osmoseData.items = Object.keys(d.osmose.icons) .map(s => s.split('-')[0]) .reduce((unique, item) => unique.indexOf(item) !== -1 ? unique : [...unique, item], []); }); if (!_cache) { this.reset(); } this.event = utilRebind(this, dispatch, 'on'); }, reset() { let _strings = {}; let _colors = {}; if (_cache) { Object.values(_cache.inflightTile).forEach(abortRequest); // Strings and colors are static and should not be re-populated _strings = _cache.strings; _colors = _cache.colors; } _cache = { data: {}, loadedTile: {}, inflightTile: {}, inflightPost: {}, closed: {}, rtree: new RBush(), strings: _strings, colors: _colors }; }, loadIssues(projection) { let params = { // Tiles return a maximum # of issues // So we want to filter our request for only types iD supports item: _osmoseData.items }; // 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 [ x, y, z ] = tile.xyz; const url = `${_osmoseUrlRoot}/issues/${z}/${x}/${y}.json?` + utilQsString(params); let 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.features) { data.features.forEach(issue => { const { item, class: cl, uuid: id } = issue.properties; /* Osmose issues are uniquely identified by a unique `item` and `class` combination (both integer values) */ const itemType = `${item}-${cl}`; // Filter out unsupported issue types (some are too specific or advanced) if (itemType in _osmoseData.icons) { let loc = issue.geometry.coordinates; // lon, lat loc = preventCoincident(loc); let d = new QAItem(loc, this, itemType, id, { item }); // Setting elems here prevents UI detail requests if (item === 8300 || item === 8360) { d.elems = []; } _cache.data[d.id] = d; _cache.rtree.insert(encodeIssueRtree(d)); } }); } dispatch.call('loaded'); }) .catch(() => { delete _cache.inflightTile[tile.id]; _cache.loadedTile[tile.id] = true; }); }); }, loadIssueDetail(issue) { // Issue details only need to be fetched once if (issue.elems !== undefined) { return Promise.resolve(issue); } const url = `${_osmoseUrlRoot}/issue/${issue.id}?langs=${localizer.localeCode()}`; const cacheDetails = data => { // Associated elements used for highlighting // Assign directly for immediate use in the callback issue.elems = data.elems.map(e => e.type.substring(0,1) + e.id); // Some issues have instance specific detail in a subtitle issue.detail = data.subtitle ? marked(data.subtitle.auto) : ''; this.replaceItem(issue); }; return d3_json(url).then(cacheDetails).then(() => issue); }, loadStrings(locale=localizer.localeCode()) { const items = Object.keys(_osmoseData.icons); if ( locale in _cache.strings && Object.keys(_cache.strings[locale]).length === items.length ) { return Promise.resolve(_cache.strings[locale]); } // May be partially populated already if some requests were successful if (!(locale in _cache.strings)) { _cache.strings[locale] = {}; } // Only need to cache strings for supported issue types // Using multiple individual item + class requests to reduce fetched data size const allRequests = items.map(itemType => { // No need to request data we already have if (itemType in _cache.strings[locale]) return null; 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 (${itemType}) 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)) { _cache.colors[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; // Osmose titles shouldn't contain markdown let issueStrings = {}; if (title) issueStrings.title = title.auto; if (detail) issueStrings.detail = marked(detail.auto); if (trap) issueStrings.trap = marked(trap.auto); if (fix) issueStrings.fix = marked(fix.auto); _cache.strings[locale][itemType] = issueStrings; }; const [ item, cl ] = itemType.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 d3_json(url).then(cacheData); }).filter(Boolean); return Promise.all(allRequests).then(() => _cache.strings[locale]); }, getStrings(itemType, locale=localizer.localeCode()) { // No need to fallback to English, Osmose API handles this for us return (locale in _cache.strings) ? _cache.strings[locale][itemType] : {}; }, getColor(itemType) { return (itemType in _cache.colors) ? _cache.colors[itemType] : '#FFFFFF'; }, postUpdate(issue, callback) { if (_cache.inflightPost[issue.id]) { return callback({ message: 'Issue update already inflight', status: -2 }, issue); } // UI sets the status to either 'done' or 'false' const url = `${_osmoseUrlRoot}/issue/${issue.id}/${issue.newStatus}`; const controller = new AbortController(); const after = () => { delete _cache.inflightPost[issue.id]; this.removeItem(issue); if (issue.newStatus === 'done') { // Keep track of the number of issues closed per `item` to tag the changeset if (!(issue.item in _cache.closed)) { _cache.closed[issue.item] = 0; } _cache.closed[issue.item] += 1; } if (callback) callback(null, issue); }; _cache.inflightPost[issue.id] = controller; fetch(url, { signal: controller.signal }) .then(after) .catch(err => { delete _cache.inflightPost[issue.id]; if (callback) callback(err.message); }); }, // 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]; }, // get the name of the icon to display for this item getIcon(itemType) { return _osmoseData.icons[itemType]; }, // 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 }, // Used to populate `closed:osmose:*` changeset tags getClosedCounts() { return _cache.closed; }, itemURL(item) { return `https://osmose.openstreetmap.fr/en/error/${item.id}`; } };