Add ImproveOSM service

Only processing one-way errors for now
This commit is contained in:
SilentSpike
2019-01-15 13:49:50 +00:00
parent 9d8a0b7843
commit 4d89810721
12 changed files with 557 additions and 3 deletions
+8 -1
View File
@@ -33,7 +33,8 @@
/* No interactivity except what we specifically allow */
.data-layer.osm *,
.data-layer.notes *,
.data-layer.keepRight * {
.data-layer.keepRight *,
.data-layer.improveOSM * {
pointer-events: none;
}
@@ -44,6 +45,7 @@
/* `.target` objects are interactive */
/* They can be picked up, clicked, hovered, or things can connect to them */
.iOSM_error.target,
.kr_error.target,
.note.target,
.node.target,
@@ -83,6 +85,7 @@
/* points, notes & QA */
/* points, notes, markers */
g.iOSM_error .stroke,
g.kr_error .stroke,
g.note .stroke {
stroke: #222;
@@ -91,6 +94,7 @@ g.note .stroke {
opacity: 0.6;
}
g.iOSM_error.active .stroke,
g.kr_error.active .stroke,
g.note.active .stroke {
stroke: #222;
@@ -105,6 +109,7 @@ g.point .stroke {
fill: #fff;
}
g.iOSM_error .shadow,
g.kr_error .shadow,
g.point .shadow,
g.note .shadow {
@@ -114,6 +119,7 @@ g.note .shadow {
stroke-opacity: 0;
}
g.iOSM_error.hover:not(.selected) .shadow,
g.kr_error.hover:not(.selected) .shadow,
g.note.hover:not(.selected) .shadow,
g.point.related:not(.selected) .shadow,
@@ -121,6 +127,7 @@ g.point.hover:not(.selected) .shadow {
stroke-opacity: 0.5;
}
g.iOSM_error.selected .shadow,
g.kr_error.selected .shadow,
g.note.selected .shadow,
g.point.selected .shadow {
+2
View File
@@ -98,8 +98,10 @@
.mode-browse .note,
.mode-browse .kr_error,
.mode-browse .iOSM_error,
.mode-select .note,
.mode-select .kr_error,
.mode-select .iOSM_error,
.turn rect,
.turn circle {
cursor: pointer;
+16 -1
View File
@@ -2,7 +2,8 @@
/* OSM Notes and KeepRight Layers */
.kr_error-header-icon .kr_error-fill,
.layer-keepRight .kr_error .kr_error-fill {
.layer-keepRight .kr_error .kr_error-fill,
.layer-improveOSM .iOSM_error .iOSM_error-fill {
stroke: #333;
stroke-width: 1.3px; /* NOTE: likely a better way to scale the icon stroke */
}
@@ -115,6 +116,20 @@
color: #c35;
}
/* ImproveOSM Errors
------------------------------------------------------- */
.iOSM_error_type_ow { /* missing one way */
color: #EE7600;
}
.iOSM_error_type_mr { /* missing road */
color: #B0171F;
}
.iOSM_error_type_tr { /* missing turn restriction */
color: #1E90FF;
}
/* Custom Map Data (geojson, gpx, kml, vector tile) */
.layer-mapdata {
+3
View File
@@ -491,6 +491,9 @@ en:
keepRight:
tooltip: Automatically detected map issues from keepright.at
title: KeepRight Issues
improveOSM:
tooltip: Missing data automatically detected by improveosm.org
title: ImproveOSM Issues
custom:
tooltip: "Drag and drop a data file onto the page, or click the button to setup"
title: Custom Map Data
+4
View File
@@ -596,6 +596,10 @@
"tooltip": "Automatically detected map issues from keepright.at",
"title": "KeepRight Issues"
},
"improveOSM": {
"tooltip": "Missing data automatically detected by improveosm.org",
"title": "ImproveOSM Issues"
},
"custom": {
"tooltip": "Drag and drop a data file onto the page, or click the button to setup",
"title": "Custom Map Data",
+50
View File
@@ -0,0 +1,50 @@
import _extend from 'lodash-es/extend';
export function impOsmError() {
if (!(this instanceof impOsmError)) {
return (new impOsmError()).initialize(arguments);
} else if (arguments.length) {
this.initialize(arguments);
}
}
// ImproveOSM has no error IDs unfortunately
// So no way to explicitly refer to each error in their DB
impOsmError.id = function() {
return impOsmError.id.next--;
};
impOsmError.id.next = -1;
_extend(impOsmError.prototype, {
type: 'impOsmError',
initialize: function(sources) {
for (var i = 0; i < sources.length; ++i) {
var source = sources[i];
for (var prop in source) {
if (Object.prototype.hasOwnProperty.call(source, prop)) {
if (source[prop] === undefined) {
delete this[prop];
} else {
this[prop] = source[prop];
}
}
}
}
if (!this.id) {
this.id = impOsmError.id() + ''; // as string
}
return this;
},
update: function(attrs) {
return impOsmError(this, attrs); // {v: 1 + (this.v || 0)}
}
});
+1
View File
@@ -1,6 +1,7 @@
export { osmChangeset } from './changeset';
export { osmEntity } from './entity';
export { krError } from './keepRight';
export { impOsmError } from './improveOSM';
export { osmNode } from './node';
export { osmNote } from './note';
export { osmRelation } from './relation';
+222
View File
@@ -0,0 +1,222 @@
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 } from '../geo';
import { impOsmError } from '../osm';
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'
};
var _missingTypes = {
PARKING: 'Unmapped parking',
ROAD: 'Unmapped road(s)',
BOTH: 'Unmapped road(s) and parking',
PATH: 'Unmapped path(s)',
WATER: 'Unmapped water feature' // ?
};
function abortRequest(i) {
_forEach(i, function(v) {
if (v) {
v.abort();
}
});
}
function abortUnwantedRequests(cache, tiles) {
_forEach(cache.inflight, function(v, k) {
var wanted = _find(tiles, function(tile) {
return k === tile.id;
});
if (!wanted) {
abortRequest(v);
delete cache.inflight[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);
}
}
export default {
init: function() {
if (!_erCache) {
this.reset();
}
this.event = utilRebind(this, dispatch, 'on');
},
reset: function() {
if (_erCache) {
_forEach(_erCache.inflight, abortRequest);
}
_erCache = {
data: {},
loaded: {},
inflight: {},
closed: {},
rtree: rbush()
};
},
loadErrors: function(projection) {
var options = {
client: 'iD',
confidenceLevel: 'C1', // most confident cases only for now
status: 'OPEN',
type: 'PARKING,ROAD,BOTH,PATH', // exclude WATER
zoom: '19'
};
// 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.loaded[tile.id] || _erCache.inflight[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 = {};
// TODO: Just implement TRs and One-ways for now, much more simple
_forEach(_impOsmUrls, function(v, k) {
var url = v + '/search?' + utilQsString(params);
if (k == 'mr' || k == 'tr') return
requests[k] = d3_json(url,
function(err, data) {
delete _erCache.inflight[tile.id];
if (err) return;
_erCache.loaded[tile.id] = true;
// Clusters are returned at low zoom
// if (data.clusters) {
// data.clusters.forEach(function(feature) {
// var loc = feature.point;
// var size = feature.size;
// });
// }
// Road segments at high zoom == oneways
if (data.roadSegments) {
data.roadSegments.forEach(function(feature) {
var loc = feature.points[0];
var d = new impOsmError({
loc: [loc.lon, loc.lat],
comment: null,
description: '',
error_type: feature.type,
parent_error_type: k,
title: 'Missing One-way'
});
_erCache.data[d.id] = d;
_erCache.rtree.insert(encodeErrorRtree(d));
})
}
// Tiles at high zoom == missing roads
// if (data.tiles) {
// data.tiles.forEach(function(feature) {
// // Get description based on type
// var desc = _missingTypes[feature.type];
// var d = new impOsmError({
// loc: [feature.x, feature.y],
// comment: null,
// description: desc || '',
// error_type: feature.type,
// parent_error_type: k,
// title: 'Missing Roads'
// });
// _erCache.data[d.id] = d;
// _erCache.rtree.insert(encodeErrorRtree(d));
// })
// }
// if (data.entities) {
// data.entities.forEach(function(feature) {
// var loc = feature.point;
// var d = new impOsmError({
// loc: [loc.lat, loc.lon],
// comment: null,
// description: desc || '',
// error_type: feature.turnType,
// parent_error_type: k,
// title: 'Missing Turn Restriction'
// });
// _erCache.data[d.id] = d;
// _erCache.rtree.insert(encodeErrorRtree(d));
// })
// }
}
);
});
_erCache.inflight[tile.id] = requests;
dispatch.call('loaded');
})
},
// 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;
});
}
};
+3
View File
@@ -1,4 +1,5 @@
import serviceKeepRight from './keepRight';
import serviceImproveOSM from './improveOSM';
import serviceMapillary from './mapillary';
import serviceMapRules from './maprules';
import serviceNominatim from './nominatim';
@@ -15,6 +16,7 @@ import serviceWikipedia from './wikipedia';
export var services = {
geocoder: serviceNominatim,
keepRight: serviceKeepRight,
improveOSM: serviceImproveOSM,
mapillary: serviceMapillary,
openstreetcam: serviceOpenstreetcam,
osm: serviceOsm,
@@ -29,6 +31,7 @@ export var services = {
export {
serviceKeepRight,
serviceImproveOSM,
serviceMapillary,
serviceMapRules,
serviceNominatim,
+245
View File
@@ -0,0 +1,245 @@
import _throttle from 'lodash-es/throttle';
import { select as d3_select } from 'd3-selection';
import { modeBrowse } from '../modes';
import { svgPointTransform } from './index';
import { services } from '../services';
var _improveOsmEnabled = false;
var _errorService;
export function svgImproveOSM(projection, context, dispatch) {
var throttledRedraw = _throttle(function () { dispatch.call('change'); }, 1000);
var minZoom = 12;
var touchLayer = d3_select(null);
var drawLayer = d3_select(null);
var _improveOsmVisible = false;
function markerPath(selection, klass) {
selection
.attr('class', klass)
.attr('transform', 'translate(-4, -24)')
.attr('d', 'M11.6,6.2H7.1l1.4-5.1C8.6,0.6,8.1,0,7.5,0H2.2C1.7,0,1.3,0.3,1.3,0.8L0,10.2c-0.1,0.6,0.4,1.1,0.9,1.1h4.6l-1.8,7.6C3.6,19.4,4.1,20,4.7,20c0.3,0,0.6-0.2,0.8-0.5l6.9-11.9C12.7,7,12.3,6.2,11.6,6.2z');
}
// Loosely-coupled improveOSM service for fetching errors.
function getService() {
if (services.improveOSM && !_errorService) {
_errorService = services.improveOSM;
_errorService.on('loaded', throttledRedraw);
} else if (!services.improveOSM && _errorService) {
_errorService = null;
}
return _errorService;
}
// Show the errors
function editOn() {
if (!_improveOsmVisible) {
_improveOsmVisible = true;
drawLayer
.style('display', 'block');
}
}
// Immediately remove the errors and their touch targets
function editOff() {
if (_improveOsmVisible) {
_improveOsmVisible = false;
drawLayer
.style('display', 'none');
drawLayer.selectAll('.iOSM_error')
.remove();
touchLayer.selectAll('.iOSM_error')
.remove();
}
}
// Enable the layer. This shows the errors and transitions them to visible.
function layerOn() {
editOn();
drawLayer
.style('opacity', 0)
.transition()
.duration(250)
.style('opacity', 1)
.on('end interrupt', function () {
dispatch.call('change');
});
}
// Disable the layer. This transitions the layer invisible and then hides the errors.
function layerOff() {
throttledRedraw.cancel();
drawLayer.interrupt();
touchLayer.selectAll('.iOSM_error')
.remove();
drawLayer
.transition()
.duration(250)
.style('opacity', 0)
.on('end interrupt', function () {
editOff();
dispatch.call('change');
});
}
// Update the error markers
function updateMarkers() {
if (!_improveOsmVisible || !_improveOsmEnabled) return;
var service = getService();
var selectedID = context.selectedErrorID();
var data = (service ? service.getErrors(projection) : []);
var getTransform = svgPointTransform(projection);
// Draw markers..
var markers = drawLayer.selectAll('.iOSM_error')
.data(data, function(d) { return d.id; });
// exit
markers.exit()
.remove();
// enter
var markersEnter = markers.enter()
.append('g')
.attr('class', function(d) {
return 'iOSM_error iOSM_error-' + d.id + ' iOSM_error_type_' + d.parent_error_type; }
);
markersEnter
.append('ellipse')
.attr('cx', 0.5)
.attr('cy', 1)
.attr('rx', 6.5)
.attr('ry', 3)
.attr('class', 'stroke');
markersEnter
.append('path')
.call(markerPath, 'shadow');
markersEnter
.append('use')
.attr('class', 'iOSM_error-fill')
.attr('width', '20px')
.attr('height', '20px')
.attr('x', '-8px')
.attr('y', '-22px')
.attr('xlink:href', '#iD-icon-bolt');
// update
markers
.merge(markersEnter)
.sort(sortY)
.classed('selected', function(d) { return d.id === selectedID; })
.attr('transform', getTransform);
// Draw targets..
if (touchLayer.empty()) return;
var fillClass = context.getDebug('target') ? 'pink ' : 'nocolor ';
var targets = touchLayer.selectAll('.iOSM_error')
.data(data, function(d) { return d.id; });
// exit
targets.exit()
.remove();
// enter/update
targets.enter()
.append('rect')
.attr('width', '20px')
.attr('height', '20px')
.attr('x', '-8px')
.attr('y', '-22px')
.merge(targets)
.sort(sortY)
.attr('class', function(d) {
return 'iOSM_error target iOSM_error-' + d.id + ' ' + fillClass;
})
.attr('transform', getTransform);
function sortY(a, b) {
return (a.id === selectedID) ? 1
: (b.id === selectedID) ? -1
: (a.severity === 'error' && b.severity !== 'error') ? 1
: (b.severity === 'error' && a.severity !== 'error') ? -1
: b.loc[1] - a.loc[1];
}
}
// Draw the ImproveOSM layer and schedule loading errors and updating markers.
function drawImproveOSM(selection) {
var service = getService();
var surface = context.surface();
if (surface && !surface.empty()) {
touchLayer = surface.selectAll('.data-layer.touch .layer-touch.markers');
}
drawLayer = selection.selectAll('.layer-improveOSM')
.data(service ? [0] : []);
drawLayer.exit()
.remove();
drawLayer = drawLayer.enter()
.append('g')
.attr('class', 'layer-improveOSM')
.style('display', _improveOsmEnabled ? 'block' : 'none')
.merge(drawLayer);
if (_improveOsmEnabled) {
if (service && ~~context.map().zoom() >= minZoom) {
editOn();
service.loadErrors(projection);
updateMarkers();
} else {
editOff();
}
}
}
// Toggles the layer on and off
drawImproveOSM.enabled = function(val) {
if (!arguments.length) return _improveOsmEnabled;
_improveOsmEnabled = val;
if (_improveOsmEnabled) {
layerOn();
} else {
layerOff();
if (context.selectedErrorID()) {
context.enter(modeBrowse(context));
}
}
dispatch.call('change');
return this;
};
drawImproveOSM.supported = function() {
return !!getService();
};
return drawImproveOSM;
}
+2
View File
@@ -11,6 +11,7 @@ import { svgData } from './data';
import { svgDebug } from './debug';
import { svgGeolocate } from './geolocate';
import { svgKeepRight } from './keepRight';
import { svgImproveOSM } from './improveOSM';
import { svgStreetside } from './streetside';
import { svgMapillaryImages } from './mapillary_images';
import { svgMapillarySigns } from './mapillary_signs';
@@ -30,6 +31,7 @@ export function svgLayers(projection, context) {
{ id: 'notes', layer: svgNotes(projection, context, dispatch) },
{ id: 'data', layer: svgData(projection, context, dispatch) },
{ id: 'keepRight', layer: svgKeepRight(projection, context, dispatch) },
{ id: 'improveOSM', layer: svgImproveOSM(projection, context, dispatch) },
{ id: 'streetside', layer: svgStreetside(projection, context, dispatch)},
{ id: 'mapillary-images', layer: svgMapillaryImages(projection, context, dispatch) },
{ id: 'mapillary-signs', layer: svgMapillarySigns(projection, context, dispatch) },
+1 -1
View File
@@ -227,7 +227,7 @@ export function uiMapData(context) {
function drawQAItems(selection) {
var qaKeys = ['keepRight'];
var qaKeys = ['keepRight', 'improveOSM'];
var qaLayers = layers.all().filter(function(obj) { return qaKeys.indexOf(obj.id) !== -1; });
var ul = selection