mirror of
https://github.com/FoggedLens/iD.git
synced 2026-02-14 01:33:03 +00:00
I've converted the improveOSM errors to use this new generic QA error structure which should allow for more general code to be used in behaviour and UI. Sidebar preview is currently broken, but will be fixed shortly.
433 lines
16 KiB
JavaScript
433 lines
16 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 { 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: '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',
|
|
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 = _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) {
|
|
// 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 = _extend({}, 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)
|
|
};
|
|
|
|
_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');
|
|
});
|
|
},
|
|
|
|
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 ];
|
|
}
|
|
|
|
// Comments don't currently work, if they ever do in future
|
|
// it looks as though they require a separate post
|
|
// 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];
|
|
|
|
// Unsuccessful response status, keep issue open
|
|
if (err.status !== 200) { return callback(err, d); }
|
|
|
|
that.removeError(d);
|
|
|
|
// No pretty identifier, so we just use coordinates
|
|
if (d.newStatus === 'SOLVED') {
|
|
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();
|
|
}
|
|
}; |