Files
iD/modules/services/improveOSM.js
2019-02-01 22:51:02 +00:00

397 lines
14 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]);
}
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) {
// Travel direction along way segment clarifies one-way direction
var p1 = feature.points[0];
var p2 = feature.points[1];
var dir_of_travel = cardinalDirection(relativeBearing(p1, p2));
var d = new iOsmError({
loc: pointAverage(feature.points), // TODO: This isn't great for curved roads, would be better to find actual midpoint of segment
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')),
travel_direction: dir_of_travel
};
_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();
var geoIcons = {
road: 'maki-car',
parking: 'maki-parking',
both: 'maki-car',
path: 'maki-shoe'
};
var d = new iOsmError({
loc: pointAverage(feature.points),
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;
// Bump position slightly so junction node is accessible
loc = geoVecAdd([loc.lon, loc.lat], [0, 0.00001]);
// 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
};
_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
}
};