modules/services/improveOSM.js (393 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, vecScale} 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 { serviceOsm } from './index';
import { t } from '../core/localizer';
import { utilRebind } from '../util';
const TILEZOOM = 14;
const tiler = new Tiler().zoomRange(TILEZOOM);
const dispatch = d3_dispatch('loaded');
const _impOsmUrls = {
ow: 'https://grab.community.improve-osm.org/directionOfFlowService',
mr: 'https://grab.community.improve-osm.org/missingGeoService',
tr: 'https://grab.community.improve-osm.org/turnRestrictionService'
};
let _impOsmData = { icons: {} };
// This gets reassigned if reset
let _cache;
function abortRequest(i) {
Object.values(i).forEach(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 linkErrorObject(d) {
return `<a class="error_object_link">${d}</a>`;
}
function linkEntity(d) {
return `<a class="error_entity_link">${d}</a>`;
}
function pointAverage(points) {
if (points.length) {
const sum = points.reduce(
(acc, point) => vecAdd(acc, [point.lon, point.lat]),
[0,0]
);
return vecScale(sum, 1 / points.length);
} else {
return [0,0];
}
}
function relativeBearing(p1, p2) {
let angle = Math.atan2(p2.lon - p1.lon, p2.lat - p1.lat);
if (angle < 0) {
angle += 2 * Math.PI;
}
// Return degrees
return angle * 180 / Math.PI;
}
// Assuming range [0,360)
function cardinalDirection(bearing) {
const dir = 45 * Math.round(bearing / 45);
const compass = {
0: 'north',
45: 'northeast',
90: 'east',
135: 'southeast',
180: 'south',
225: 'southwest',
270: 'west',
315: 'northwest',
360: 'north'
};
return t(`QA.improveOSM.directions.${compass[dir]}`);
}
// Errors shouldn't obscure each other
function preventCoincident(loc, bumpUp) {
let coincident = false;
do {
// first time, move marker up. after that, move marker right.
let delta = coincident ? [0.00001, 0] :
bumpUp ? [0, 0.00001] :
[0, 0];
loc = vecAdd(loc, delta);
let bbox = new Extent(loc).bbox();
coincident = _cache.rtree.search(bbox).length;
} while (coincident);
return loc;
}
export default {
title: 'improveOSM',
init() {
fileFetcher.get('qa_data')
.then(d => _impOsmData = d.improveOSM);
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()
};
},
loadIssues(projection) {
const options = {
client: 'iD',
status: 'OPEN',
zoom: '19' // Use a high zoom so that clusters aren't returned
};
// 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 [ east, north, west, south ] = tile.wgs84Extent.rectangle();
const params = Object.assign({}, options, { east, south, west, north });
// 3 separate requests to store for each tile
const requests = {};
Object.keys(_impOsmUrls).forEach(k => {
// We exclude WATER from missing geometry as it doesn't seem useful
// We use most confident one-way and turn restrictions only, still have false positives
const kParams = Object.assign({},
params,
(k === 'mr') ? { type: 'PARKING,ROAD,BOTH,PATH' } : { confidenceLevel: 'C1' }
);
const url = `${_impOsmUrls[k]}/search?` + utilQsString(kParams);
const controller = new AbortController();
requests[k] = controller;
d3_json(url, { signal: controller.signal })
.then(data => {
delete _cache.inflightTile[tile.id][k];
if (!Object.keys(_cache.inflightTile[tile.id]).length) {
delete _cache.inflightTile[tile.id];
_cache.loadedTile[tile.id] = true;
}
// Road segments at high zoom == oneways
if (data.roadSegments) {
data.roadSegments.forEach(feature => {
// Position error at the approximate middle of the segment
const { points, wayId, fromNodeId, toNodeId } = feature;
const itemId = `${wayId}${fromNodeId}${toNodeId}`;
let mid = points.length / 2;
let loc;
// Even number of points, find midpoint of the middle two
// Odd number of points, use position of very middle point
if (mid % 1 === 0) {
loc = pointAverage([points[mid - 1], points[mid]]);
} else {
mid = points[Math.floor(mid)];
loc = [mid.lon, mid.lat];
}
// One-ways can land on same segment in opposite direction
loc = preventCoincident(loc, false);
let d = new QAItem(loc, this, k, itemId, {
issueKey: k, // used as a category
identifier: { // used to post changes
wayId,
fromNodeId,
toNodeId
},
objectId: wayId,
objectType: 'way'
});
// Variables used in the description
d.replacements = {
percentage: feature.percentOfTrips,
num_trips: feature.numberOfTrips,
highway: linkErrorObject(t('QA.keepRight.error_parts.highway')),
from_node: linkEntity('n' + feature.fromNodeId),
to_node: linkEntity('n' + feature.toNodeId)
};
_cache.data[d.id] = d;
_cache.rtree.insert(encodeIssueRtree(d));
});
}
// Tiles at high zoom == missing roads
if (data.tiles) {
data.tiles.forEach(feature => {
const { type, x, y, numberOfTrips } = feature;
const geoType = type.toLowerCase();
const itemId = `${geoType}${x}${y}${numberOfTrips}`;
// Average of recorded points should land on the missing geometry
// Missing geometry could happen to land on another error
let loc = pointAverage(feature.points);
loc = preventCoincident(loc, false);
let d = new QAItem(loc, this, `${k}-${geoType}`, itemId, {
issueKey: k,
identifier: { x, y }
});
d.replacements = {
num_trips: numberOfTrips,
geometry_type: t(`QA.improveOSM.geometry_types.${geoType}`)
};
// -1 trips indicates data came from a 3rd party
if (numberOfTrips === -1) {
d.desc = t('QA.improveOSM.error_types.mr.description_alt', d.replacements);
}
_cache.data[d.id] = d;
_cache.rtree.insert(encodeIssueRtree(d));
});
}
// Entities at high zoom == turn restrictions
if (data.entities) {
data.entities.forEach(feature => {
const { point, id, segments, numberOfPasses, turnType } = feature;
const itemId = `${id.replace(/[,:+#]/g, '_')}`;
// Turn restrictions could be missing at same junction
// We also want to bump the error up so node is accessible
const loc = preventCoincident([point.lon, point.lat], true);
// Elements are presented in a strange way
const ids = id.split(',');
const from_way = ids[0];
const via_node = ids[3];
const to_way = ids[2].split(':')[1];
let d = new QAItem(loc, this, k, itemId, {
issueKey: k,
identifier: id,
objectId: via_node,
objectType: 'node'
});
// Travel direction along from_way clarifies the turn restriction
const [ p1, p2 ] = segments[0].points;
const dir_of_travel = cardinalDirection(relativeBearing(p1, p2));
// Variables used in the description
d.replacements = {
num_passed: numberOfPasses,
num_trips: segments[0].numberOfTrips,
turn_restriction: turnType.toLowerCase(),
from_way: linkEntity('w' + from_way),
to_way: linkEntity('w' + to_way),
travel_direction: dir_of_travel,
junction: linkErrorObject(t('QA.keepRight.error_parts.this_node'))
};
_cache.data[d.id] = d;
_cache.rtree.insert(encodeIssueRtree(d));
dispatch.call('loaded');
});
}
})
.catch(() => {
delete _cache.inflightTile[tile.id][k];
if (!Object.keys(_cache.inflightTile[tile.id]).length) {
delete _cache.inflightTile[tile.id];
_cache.loadedTile[tile.id] = true;
}
});
});
_cache.inflightTile[tile.id] = requests;
});
},
getComments(item) {
// If comments already retrieved no need to do so again
if (item.comments) {
return Promise.resolve(item);
}
const key = item.issueKey;
let qParams = {};
if (key === 'ow') {
qParams = item.identifier;
} else if (key === 'mr') {
qParams.tileX = item.identifier.x;
qParams.tileY = item.identifier.y;
} else if (key === 'tr') {
qParams.targetId = item.identifier;
}
const url = `${_impOsmUrls[key]}/retrieveComments?` + utilQsString(qParams);
const cacheComments = data => {
// Assign directly for immediate use afterwards
// comments are served newest to oldest
item.comments = data.comments ? data.comments.reverse() : [];
this.replaceItem(item);
};
return d3_json(url).then(cacheComments).then(() => item);
},
postUpdate(d, callback) {
if (!serviceOsm.authenticated()) { // Username required in payload
return callback({ message: 'Not Authenticated', status: -3}, d);
}
if (_cache.inflightPost[d.id]) {
return callback({ message: 'Error update already inflight', status: -2 }, d);
}
// Payload can only be sent once username is established
serviceOsm.userDetails(sendPayload.bind(this));
function sendPayload(err, user) {
if (err) { return callback(err, d); }
const key = d.issueKey;
const url = `${_impOsmUrls[key]}/comment`;
const payload = {
username: user.display_name,
targetIds: [ d.identifier ]
};
if (d.newStatus) {
payload.status = d.newStatus;
payload.text = 'status changed';
}
// Comment take place of default text
if (d.newComment) {
payload.text = d.newComment;
}
const controller = new AbortController();
_cache.inflightPost[d.id] = controller;
const options = {
method: 'POST',
signal: controller.signal,
body: JSON.stringify(payload)
};
d3_json(url, options)
.then(() => {
delete _cache.inflightPost[d.id];
// Just a comment, update error in cache
if (!d.newStatus) {
const now = new Date();
let comments = d.comments ? d.comments : [];
comments.push({
username: payload.username,
text: payload.text,
timestamp: now.getTime() / 1000
});
this.replaceItem(d.update({
comments: comments,
newComment: undefined
}));
} else {
this.removeItem(d);
if (d.newStatus === 'SOLVED') {
// Keep track of the number of issues closed per type to tag the changeset
if (!(d.issueKey in _cache.closed)) {
_cache.closed[d.issueKey] = 0;
}
_cache.closed[d.issueKey] += 1;
}
}
if (callback) callback(null, d);
})
.catch(err => {
delete _cache.inflightPost[d.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 _impOsmData.icons[itemType];
},
// Replace a single QAItem in the cache
replaceItem(issue) {
if (!(issue instanceof QAItem) || !issue.id) return;
_cache.data[issue.id] = issue;
updateRtree(encodeIssueRtree(issue), true); // true = replace
return issue;
},
// Remove a single QAItem from the cache
removeItem(issue) {
if (!(issue instanceof QAItem) || !issue.id) return;
delete _cache.data[issue.id];
updateRtree(encodeIssueRtree(issue), false); // false = remove
},
// Used to populate `closed:improveosm:*` changeset tags
getClosedCounts() {
return _cache.closed;
}
};