From 4d8981072134a41bb224c3c8f423c1d785396d73 Mon Sep 17 00:00:00 2001 From: SilentSpike Date: Tue, 15 Jan 2019 13:49:50 +0000 Subject: [PATCH] Add ImproveOSM service Only processing one-way errors for now --- css/20_map.css | 9 +- css/55_cursors.css | 2 + css/65_data.css | 17 ++- data/core.yaml | 3 + dist/locales/en.json | 4 + modules/osm/improveOSM.js | 50 +++++++ modules/osm/index.js | 1 + modules/services/improveOSM.js | 222 +++++++++++++++++++++++++++++ modules/services/index.js | 3 + modules/svg/improveOSM.js | 245 +++++++++++++++++++++++++++++++++ modules/svg/layers.js | 2 + modules/ui/map_data.js | 2 +- 12 files changed, 557 insertions(+), 3 deletions(-) create mode 100644 modules/osm/improveOSM.js create mode 100644 modules/services/improveOSM.js create mode 100644 modules/svg/improveOSM.js diff --git a/css/20_map.css b/css/20_map.css index 6c9ed5c4b..72d604a7e 100644 --- a/css/20_map.css +++ b/css/20_map.css @@ -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 { diff --git a/css/55_cursors.css b/css/55_cursors.css index f473301c4..efc293266 100644 --- a/css/55_cursors.css +++ b/css/55_cursors.css @@ -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; diff --git a/css/65_data.css b/css/65_data.css index 71aafab9c..5aebce603 100644 --- a/css/65_data.css +++ b/css/65_data.css @@ -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 { diff --git a/data/core.yaml b/data/core.yaml index c28e04da6..0e9fd2fc8 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -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 diff --git a/dist/locales/en.json b/dist/locales/en.json index 020477d53..e8667b2f6 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -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", diff --git a/modules/osm/improveOSM.js b/modules/osm/improveOSM.js new file mode 100644 index 000000000..b337fcfb6 --- /dev/null +++ b/modules/osm/improveOSM.js @@ -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)} + } +}); diff --git a/modules/osm/index.js b/modules/osm/index.js index f8d7addc1..72f7b3ac4 100644 --- a/modules/osm/index.js +++ b/modules/osm/index.js @@ -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'; diff --git a/modules/services/improveOSM.js b/modules/services/improveOSM.js new file mode 100644 index 000000000..f4695766a --- /dev/null +++ b/modules/services/improveOSM.js @@ -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; + }); + } +}; diff --git a/modules/services/index.js b/modules/services/index.js index 8b75d7418..838ed435f 100644 --- a/modules/services/index.js +++ b/modules/services/index.js @@ -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, diff --git a/modules/svg/improveOSM.js b/modules/svg/improveOSM.js new file mode 100644 index 000000000..738b63533 --- /dev/null +++ b/modules/svg/improveOSM.js @@ -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; +} diff --git a/modules/svg/layers.js b/modules/svg/layers.js index 59c9ab22c..ffe380c89 100644 --- a/modules/svg/layers.js +++ b/modules/svg/layers.js @@ -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) }, diff --git a/modules/ui/map_data.js b/modules/ui/map_data.js index 2f115a9f3..d32b5d530 100644 --- a/modules/ui/map_data.js +++ b/modules/ui/map_data.js @@ -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