mirror of
https://github.com/FoggedLens/iD.git
synced 2026-02-13 09:12:52 +00:00
481 lines
18 KiB
JavaScript
481 lines
18 KiB
JavaScript
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 { qaError } 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: 'https://directionofflow.skobbler.net/directionOfFlowService',
|
|
mr: 'https://missingroads.skobbler.net/missingGeoService',
|
|
tr: 'https://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 = tiles.find(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="error_object_link">' + d + '</a>';
|
|
}
|
|
|
|
function linkEntity(d) {
|
|
return '<a class="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',
|
|
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 = {};
|
|
|
|
_forEach(_impOsmUrls, function(v, 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);
|
|
|
|
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 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));
|
|
});
|
|
}
|
|
}
|
|
);
|
|
});
|
|
|
|
_erCache.inflightTile[tile.id] = requests;
|
|
dispatch.call('loaded');
|
|
});
|
|
},
|
|
|
|
getComments: function(d, callback) {
|
|
// If comments already retrieved no need to do so again
|
|
if (d.comments !== undefined) { return callback({}, d); }
|
|
|
|
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, function(err, data) {
|
|
// comments are served newest to oldest
|
|
var comments = data.comments ? data.comments.reverse() : [];
|
|
|
|
that.replaceError(d.update({
|
|
comments: comments
|
|
}));
|
|
return callback(err, d);
|
|
});
|
|
},
|
|
|
|
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 that = this;
|
|
|
|
// Payload can only be sent once username is established
|
|
services.osm.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;
|
|
}
|
|
|
|
_erCache.inflightPost[d.id] = d3_request(url)
|
|
.header('Content-Type', 'application/json')
|
|
.post(JSON.stringify(payload), function(err) {
|
|
delete _erCache.inflightPost[d.id];
|
|
|
|
// Unsuccessful response status, keep issue open
|
|
if (err.status !== 200) { return callback(err, d); }
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
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 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();
|
|
}
|
|
};
|