Files
iD/modules/services/improveOSM.js
T
SilentSpike 21824e6377 Fix coincident errors
Potential for multiple missing turn restrictions on one node and I've
also seen a case of missing one-way along the same stretch of road in
opposite directions!

Missing geometry is tile based so can't really be coincident, but
doesn't hurt to check in case they happen to land on a one-way or turn
restriction.
2019-02-01 22:51:04 +00:00

432 lines
15 KiB
JavaScript

import _extend from 'lodash-es/extend';
import _find from 'lodash-es/find';
import _forEach from 'lodash-es/forEach';
import rbush from 'rbush';
import { dispatch as d3_dispatch } from 'd3-dispatch';
import { json as d3_json } from 'd3-request';
import { request as d3_request } from 'd3-request';
import { geoExtent, geoVecAdd } from '../geo';
import { iOsmError } from '../osm';
import { services } 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: 'http://directionofflow.skobbler.net/directionOfFlowService',
mr: 'http://missingroads.skobbler.net/missingGeoService',
tr: 'http://turnrestrictionservice.skobbler.net/turnRestrictionService'
};
function abortRequest(i) {
_forEach(i, function(v) {
if (v) {
v.abort();
}
});
}
function abortUnwantedRequests(cache, tiles) {
_forEach(cache.inflightTile, function(v, k) {
var wanted = _find(tiles, function(tile) {
return k === tile.id;
});
if (!wanted) {
abortRequest(v);
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="kr_error_object_link">' + d + '</a>';
}
function linkEntity(d) {
return '<a class="kr_error_entity_link">' + d + '</a>';
}
function pointAverage(points) {
var x = 0;
var y = 0;
_forEach(points, function(v) {
x += v.lon;
y += v.lat;
});
x /= points.length;
y /= points.length;
return [x, y];
}
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) {
_forEach(_erCache.inflightTile, abortRequest);
}
_erCache = {
data: {},
loadedTile: {},
inflightTile: {},
inflightPost: {},
closed: {},
rtree: rbush()
};
},
loadErrors: function(projection) {
var options = {
client: 'iD',
confidenceLevel: 'C1', // most confident only, still have false positives
status: 'OPEN',
type: 'PARKING,ROAD,BOTH,PATH', // exclude WATER as it doesn't seem useful
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 = _extend({}, options, { east: rect[0], south: rect[3], west: rect[2], north: rect[1] });
// 3 separate requests to store for each tile
var requests = {};
_forEach(_impOsmUrls, function(v, k) {
var url = v + '/search?' + utilQsString(params);
requests[k] = d3_json(url,
function(err, data) {
delete _erCache.inflightTile[tile.id];
if (err) return;
_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 iOsmError({
loc: loc,
comments: null,
error_subtype: '',
error_type: k,
icon: 'fas-arrow-circle-up', //TODO: Change arrow based on direction?
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) {
// Average of recorded points should land on the missing geometry
var loc = pointAverage(feature.points);
// Missing geometry could happen to land on another error
loc = preventCoincident(loc, false);
var geoType = feature.type.toLowerCase();
var geoIcons = {
road: 'maki-car',
parking: 'maki-parking',
both: 'maki-car',
path: 'maki-shoe'
};
var d = new iOsmError({
loc: loc,
comments: null,
error_subtype: geoType,
error_type: k,
icon: geoIcons[geoType],
identifier: { x: feature.x, y: feature.y },
status: feature.status
});
d.replacements = {
num_trips: feature.numberOfTrips,
geometry_type: t('QA.improveOSM.geometry_types.' + geoType)
};
_erCache.data[d.id] = d;
_erCache.rtree.insert(encodeErrorRtree(d));
});
}
// Entities at high zoom == turn restrictions
if (data.entities) {
data.entities.forEach(function(feature) {
var loc = feature.point;
// Turn restrictions could be missing at same junction
// We also want to bump the error up so node is accessible
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];
// 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));
var d = new iOsmError({
loc: loc,
comments: null,
error_subtype: '',
error_type: k,
icon: 'temaki-junction',
identifier: feature.id,
object_id: via_node,
object_type: 'node',
status: feature.status
});
// 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));
});
}
}
);
});
_erCache.inflightTile[tile.id] = requests;
dispatch.call('loaded');
});
},
postUpdate: function(d, callback) {
if (!services.osm.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 osmUsername = services.osm.userDetails(function(err, user) {
if (err) return '';
return user.display_name;
});
var that = this;
var type = d.error_type;
var payload = {
username: osmUsername
};
// Each error type has different data for identification
if (type === 'ow') {
payload.roadSegments = [ d.identifier ];
} else if (type === 'mr') {
payload.tiles = [ d.identifier ];
} else if (type === 'tr') {
payload.targetIds = [ d.identifier ];
}
// Separate requests required to comment and change status
var url = _impOsmUrls[type] + '/comment';
// Comments don't currently work
// if (d.newComment !== undefined) {
// payload.text = d.newComment;
// }
if (d.newStatus !== d.status) {
payload.status = d.newStatus;
payload.text = 'status changed';
_erCache.inflightPost[d.id] = d3_request(url)
.header('Content-Type', 'application/json')
.post(JSON.stringify(payload), function(err) {
delete _erCache.inflightPost[d.id];
if (d.newStatus === 'INVALID') {
that.removeError(d);
} else if (d.newStatus === 'SOLVED') {
that.removeError(d);
//TODO the identifiers are ugly and can't be used frontend, use error position instead?
// or perhaps don't track this at all?
//_erCache.closed[d.error_type + ':' + d.identifier] = true;
}
return callback(err, d);
});
}
},
// 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 iOsmError) || !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 iOsmError) || !error.id) return;
delete _erCache.data[error.id];
updateRtree(encodeErrorRtree(error), false); // false = remove
}
};