modules/services/improveOSM.js (411 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, geoVecScale } from '../geo'; import { qaError } from '../osm'; import { serviceOsm } from './index'; import { t } from '../util/locale'; import { utilRebind, utilTiler, utilQsString } from '../util'; var tiler = utilTiler(); var dispatch = d3_dispatch('loaded'); var _erCache; var _erZoom = 14; var _impOsmUrls = { ow: 'https://directionofflow.skobbler.net/directionOfFlowService', mr: 'https://missingroads.skobbler.net/missingGeoService', tr: 'https://turnrestrictionservice.skobbler.net/turnRestrictionService' }; function abortRequest(i) { Object.values(i).forEach(function(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) { _erCache.rtree.remove(item, function isEql(a, b) { return a.data.id === b.data.id; }); if (replace) { _erCache.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) { var sum = points.reduce(function(acc, point) { return geoVecAdd(acc, [point.lon, point.lat]); }, [0,0]); return geoVecScale(sum, 1 / points.length); } else { return [0,0]; } } function relativeBearing(p1, p2) { var 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) { var dir = 45 * Math.round(bearing / 45); var 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 eachother function preventCoincident(loc, bumpUp) { var coincident = false; do { // first time, move marker up. after that, move marker right. var delta = coincident ? [0.00001, 0] : (bumpUp ? [0, 0.00001] : [0, 0]); loc = geoVecAdd(loc, delta); var bbox = geoExtent(loc).bbox(); coincident = _erCache.rtree.search(bbox).length; } while (coincident); return loc; } export default { init: function() { if (!_erCache) { this.reset(); } this.event = utilRebind(this, dispatch, 'on'); }, reset: function() { if (_erCache) { Object.values(_erCache.inflightTile).forEach(abortRequest); } _erCache = { data: {}, loadedTile: {}, inflightTile: {}, inflightPost: {}, closed: {}, rtree: new RBush() }; }, loadErrors: function(projection) { var 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 var tiles = tiler .zoomExtent([_erZoom, _erZoom]) .getTiles(projection); // abort inflight requests that are no longer needed abortUnwantedRequests(_erCache, tiles); // issue new requests.. tiles.forEach(function(tile) { if (_erCache.loadedTile[tile.id] || _erCache.inflightTile[tile.id]) return; var rect = tile.extent.rectangle(); var params = Object.assign({}, options, { east: rect[0], south: rect[3], west: rect[2], north: rect[1] }); // 3 separate requests to store for each tile var requests = {}; Object.keys(_impOsmUrls).forEach(function(k) { var v = _impOsmUrls[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 var kParams = Object.assign({}, params, (k === 'mr') ? { type: 'PARKING,ROAD,BOTH,PATH' } : { confidenceLevel: 'C1' } ); var url = v + '/search?' + utilQsString(kParams); var controller = new AbortController(); requests[k] = controller; d3_json(url, { signal: controller.signal }) .then(function(data) { delete _erCache.inflightTile[tile.id][k]; if (!Object.keys(_erCache.inflightTile[tile.id]).length) { delete _erCache.inflightTile[tile.id]; _erCache.loadedTile[tile.id] = true; } // Road segments at high zoom == oneways if (data.roadSegments) { data.roadSegments.forEach(function(feature) { // Position error at the approximate middle of the segment var points = feature.points; var mid = points.length / 2; var 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); var d = new qaError({ // Info required for every error loc: loc, service: 'improveOSM', error_type: k, // Extra details needed for this service error_key: k, identifier: { // this is used to post changes to the error wayId: feature.wayId, fromNodeId: feature.fromNodeId, toNodeId: feature.toNodeId }, object_id: feature.wayId, object_type: 'way', status: feature.status }); // 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) }; _erCache.data[d.id] = d; _erCache.rtree.insert(encodeErrorRtree(d)); }); } // Tiles at high zoom == missing roads if (data.tiles) { data.tiles.forEach(function(feature) { var geoType = feature.type.toLowerCase(); // Average of recorded points should land on the missing geometry // Missing geometry could happen to land on another error var loc = pointAverage(feature.points); loc = preventCoincident(loc, false); var d = new qaError({ // Info required for every error loc: loc, service: 'improveOSM', error_type: k + '-' + geoType, // Extra details needed for this service error_key: k, identifier: { x: feature.x, y: feature.y }, status: feature.status }); d.replacements = { num_trips: feature.numberOfTrips, geometry_type: t('QA.improveOSM.geometry_types.' + geoType) }; // -1 trips indicates data came from a 3rd party if (feature.numberOfTrips === -1) { d.desc = t('QA.improveOSM.error_types.mr.description_alt', d.replacements); } _erCache.data[d.id] = d; _erCache.rtree.insert(encodeErrorRtree(d)); }); } // Entities at high zoom == turn restrictions if (data.entities) { data.entities.forEach(function(feature) { // Turn restrictions could be missing at same junction // We also want to bump the error up so node is accessible var loc = feature.point; loc = preventCoincident([loc.lon, loc.lat], true); // Elements are presented in a strange way var ids = feature.id.split(','); var from_way = ids[0]; var via_node = ids[3]; var to_way = ids[2].split(':')[1]; var d = new qaError({ // Info required for every error loc: loc, service: 'improveOSM', error_type: k, // Extra details needed for this service error_key: k, identifier: feature.id, object_id: via_node, object_type: 'node', status: feature.status }); // Travel direction along from_way clarifies the turn restriction var p1 = feature.segments[0].points[0]; var p2 = feature.segments[0].points[1]; var dir_of_travel = cardinalDirection(relativeBearing(p1, p2)); // Variables used in the description d.replacements = { num_passed: feature.numberOfPasses, num_trips: feature.segments[0].numberOfTrips, turn_restriction: feature.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')) }; _erCache.data[d.id] = d; _erCache.rtree.insert(encodeErrorRtree(d)); dispatch.call('loaded'); }); } }) .catch(function() { delete _erCache.inflightTile[tile.id][k]; if (!Object.keys(_erCache.inflightTile[tile.id]).length) { delete _erCache.inflightTile[tile.id]; _erCache.loadedTile[tile.id] = true; } }); }); _erCache.inflightTile[tile.id] = requests; }); }, getComments: function(d, callback) { // If comments already retrieved no need to do so again if (d.comments !== undefined) { if (callback) callback({}, d); return; } var key = d.error_key; var qParams = {}; if (key === 'ow') { qParams = d.identifier; } else if (key === 'mr') { qParams.tileX = d.identifier.x; qParams.tileY = d.identifier.y; } else if (key === 'tr') { qParams.targetId = d.identifier; } var url = _impOsmUrls[key] + '/retrieveComments?' + utilQsString(qParams); var that = this; d3_json(url) .then(function(data) { // comments are served newest to oldest var comments = data.comments ? data.comments.reverse() : []; that.replaceError(d.update({ comments: comments })); if (callback) callback(null, d); }) .catch(function(err) { if (callback) callback(err.message); }); }, postUpdate: function(d, callback) { if (!serviceOsm.authenticated()) { // Username required in payload return callback({ message: 'Not Authenticated', status: -3}, d); } if (_erCache.inflightPost[d.id]) { return callback({ message: 'Error update already inflight', status: -2 }, d); } var that = this; // Payload can only be sent once username is established serviceOsm.userDetails(sendPayload); function sendPayload(err, user) { if (err) { return callback(err, d); } var key = d.error_key; var url = _impOsmUrls[key] + '/comment'; var payload = { username: user.display_name }; // Each error type has different data for identification if (key === 'ow') { payload.roadSegments = [ d.identifier ]; } else if (key === 'mr') { payload.tiles = [ d.identifier ]; } else if (key === 'tr') { payload.targetIds = [ d.identifier ]; } if (d.newStatus !== undefined) { payload.status = d.newStatus; payload.text = 'status changed'; } // Comment take place of default text if (d.newComment !== undefined) { payload.text = d.newComment; } var controller = new AbortController(); _erCache.inflightPost[d.id] = controller; var options = { method: 'POST', signal: controller.signal, body: JSON.stringify(payload) }; d3_json(url, options) .then(function() { delete _erCache.inflightPost[d.id]; // Just a comment, update error in cache if (d.newStatus === undefined) { var now = new Date(); var comments = d.comments ? d.comments : []; comments.push({ username: payload.username, text: payload.text, timestamp: now.getTime() / 1000 }); that.replaceError(d.update({ comments: comments, newComment: undefined })); } else { that.removeError(d); if (d.newStatus === 'SOLVED') { // No pretty identifier, so we just use coordinates var closedID = d.loc[1].toFixed(5) + '/' + d.loc[0].toFixed(5); _erCache.closed[key + ':' + closedID] = true; } } if (callback) callback(null, d); }) .catch(function(err) { delete _erCache.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 _erCache.rtree.search(bbox).map(function(d) { return d.data; }); }, // get a single error from the cache getError: function(id) { return _erCache.data[id]; }, // replace a single error in the cache replaceError: function(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: function(error) { if (!(error instanceof qaError) || !error.id) return; delete _erCache.data[error.id]; updateRtree(encodeErrorRtree(error), false); // false = remove }, // Used to populate `closed:improveosm` changeset tag getClosedIDs: function() { return Object.keys(_erCache.closed).sort(); } };