Add Osmose QA layer and service

Initial implementation - need to add UI for the errors and correctly set
up support for the desired error types provided by osmose.
This commit is contained in:
SilentSpike
2019-12-07 15:35:51 +00:00
parent dff56c17d5
commit e11d97b38c
11 changed files with 556 additions and 21 deletions
+8 -2
View File
@@ -3,7 +3,8 @@
.error-header-icon .qa_error-fill,
.layer-keepRight .qa_error .qa_error-fill,
.layer-improveOSM .qa_error .qa_error-fill {
.layer-improveOSM .qa_error .qa_error-fill,
.layer-osmose .qa_error .qa_error-fill {
stroke: #333;
stroke-width: 1.3px; /* NOTE: likely a better way to scale the icon stroke */
}
@@ -152,6 +153,11 @@
color: #EC1C24;
}
/* Osmose Errors
------------------------------------------------------- */
.osmose {
color: #FFFFFF;
}
/* Custom Map Data (geojson, gpx, kml, vector tile) */
.layer-mapdata {
@@ -211,4 +217,4 @@
stroke: #000;
stroke-width: 5px;
stroke-miterlimit: 1;
}
}
+5 -2
View File
@@ -630,6 +630,9 @@ en:
improveOSM:
tooltip: Missing data automatically detected by improveosm.org
title: ImproveOSM Issues
osmose:
tooltip: Automatically detected map issues from osmose.openstreetmap.fr
title: Osmose Issues
custom:
tooltip: "Drag and drop a data file onto the page, or click the button to setup"
title: Custom Map Data
@@ -1343,7 +1346,7 @@ en:
title: Quality Assurance
intro: "*Quality Assurance* (Q/A) tools can find improper tags, disconnected roads, and other issues with OpenStreetMap, which mappers can then fix. To view existing Q/A issues, click the {data} **Map data** panel to enable a specific Q/A layer."
tools_h: "Tools"
tools: "The following tools are currently supported: [KeepRight](https://www.keepright.at/) and [ImproveOSM](https://improveosm.org/en/). Expect iD to support [Osmose](https://osmose.openstreetmap.fr/) and more Q/A tools in the future."
tools: "The following tools are currently supported: [KeepRight](https://www.keepright.at/), [ImproveOSM](https://improveosm.org/en/) and [Osmose](https://osmose.openstreetmap.fr/). Expect iD to support more Q/A tools in the future."
issues_h: "Handling Issues"
issues: "Handling Q/A issues is similar to handling notes. Click on a marker to view the issue details in the sidebar. Each tool has its own capabilities, but generally you can comment and/or close an issue."
field:
@@ -2058,4 +2061,4 @@ en:
wikidata:
identifier: "Identifier"
label: "Label"
description: "Description"
description: "Description"
+5 -1
View File
@@ -782,6 +782,10 @@
"tooltip": "Missing data automatically detected by improveosm.org",
"title": "ImproveOSM Issues"
},
"osmose": {
"tooltip": "Automatically detected map issues from osmose.openstreetmap.fr",
"title": "Osmose Issues"
},
"custom": {
"tooltip": "Drag and drop a data file onto the page, or click the button to setup",
"title": "Custom Map Data",
@@ -1653,7 +1657,7 @@
"title": "Quality Assurance",
"intro": "*Quality Assurance* (Q/A) tools can find improper tags, disconnected roads, and other issues with OpenStreetMap, which mappers can then fix. To view existing Q/A issues, click the {data} **Map data** panel to enable a specific Q/A layer.",
"tools_h": "Tools",
"tools": "The following tools are currently supported: [KeepRight](https://www.keepright.at/) and [ImproveOSM](https://improveosm.org/en/). Expect iD to support [Osmose](https://osmose.openstreetmap.fr/) and more Q/A tools in the future.",
"tools": "The following tools are currently supported: [KeepRight](https://www.keepright.at/), [ImproveOSM](https://improveosm.org/en/) and [Osmose](https://osmose.openstreetmap.fr/). Expect iD to support more Q/A tools in the future.",
"issues_h": "Handling Issues",
"issues": "Handling Q/A issues is similar to handling notes. Click on a marker to view the issue details in the sidebar. Each tool has its own capabilities, but generally you can comment and/or close an issue."
},
+12 -1
View File
@@ -15,6 +15,7 @@ import { modeDragNode } from './drag_node';
import { modeDragNote } from './drag_note';
import { uiImproveOsmEditor } from '../ui/improveOSM_editor';
import { uiKeepRightEditor } from '../ui/keepRight_editor';
import { uiOsmoseEditor } from '../ui/osmose_editor';
import { utilKeybinding } from '../util';
@@ -49,6 +50,16 @@ export function modeSelectError(context, selectedErrorID, selectedErrorService)
.show(errorEditor.error(error));
});
break;
case 'osmose':
errorEditor = uiOsmoseEditor(context)
.on('change', function() {
context.map().pan([0,0]); // trigger a redraw
var error = checkSelectedID();
if (!error) return;
context.ui().sidebar
.show(errorEditor.error(error));
});
break;
}
@@ -154,4 +165,4 @@ export function modeSelectError(context, selectedErrorID, selectedErrorService)
return mode;
}
}
+4 -1
View File
@@ -1,5 +1,6 @@
import serviceKeepRight from './keepRight';
import serviceImproveOSM from './improveOSM';
import serviceOsmose from './osmose';
import serviceMapillary from './mapillary';
import serviceMapRules from './maprules';
import serviceNominatim from './nominatim';
@@ -17,6 +18,7 @@ export var services = {
geocoder: serviceNominatim,
keepRight: serviceKeepRight,
improveOSM: serviceImproveOSM,
osmose: serviceOsmose,
mapillary: serviceMapillary,
openstreetcam: serviceOpenstreetcam,
osm: serviceOsm,
@@ -32,6 +34,7 @@ export var services = {
export {
serviceKeepRight,
serviceImproveOSM,
serviceOsmose,
serviceMapillary,
serviceMapRules,
serviceNominatim,
@@ -43,4 +46,4 @@ export {
serviceVectorTile,
serviceWikidata,
serviceWikipedia
};
};
+238
View File
@@ -0,0 +1,238 @@
import RBush from 'rbush';
import { dispatch as d3_dispatch } from 'd3-dispatch';
import { json as d3_json } from 'd3-fetch';
import { geoExtent, geoVecAdd, geoVecScale } from '../geo';
import { qaError } 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 _osmoseUrlRoot = 'https://osmose.openstreetmap.fr/en/api/0.3beta/';
function abortRequest(controller) {
if (controller) {
controller.abort();
}
}
function abortUnwantedRequests(cache, tiles) {
Object.keys(cache.inflightTile).forEach(function(k) {
var wanted = tiles.find(function(tile) { return k === tile.id; });
if (!wanted) {
abortRequest(cache.inflightTile[k]);
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>';
}
// 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) {
Object.values(_erCache.inflightTile).forEach(abortRequest);
}
_erCache = {
data: {},
loadedTile: {},
inflightTile: {},
inflightPost: {},
closed: {},
rtree: new RBush()
};
},
loadErrors: function(projection) {
var options = {
full: 'true', // Returns element IDs
level: '1,2,3',
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.loadedTile[tile.id] || _erCache.inflightTile[tile.id]) return;
var rect = tile.extent.rectangle(); // E, N, W, S
var params = Object.assign({}, options, { bbox: [rect[0], rect[1], rect[2], rect[3]].join() });
var url = _osmoseUrlRoot + 'issues?' + utilQsString(params);
var controller = new AbortController();
_erCache.inflightTile[tile.id] = controller;
d3_json(url, { signal: controller.signal })
.then(function(data) {
delete _erCache.inflightTile[tile.id];
_erCache.loadedTile[tile.id] = true;
if (data.issues) {
data.issues.forEach(function(issue) {
// Elements provided as string, separated by _ character
var elems = issue.elems.split('_');
var loc = [issue.lon, issue.lat];
loc = preventCoincident(loc, true);
var d = new qaError({
// Info required for every error
loc: loc,
service: 'osmose',
error_type: [issue.item, issue.classs].join('-'),
// Extra details needed for this service
identifier: issue.id, // this is used to post changes to the error
elems: elems
//object_id: elems[0],
//object_type: elems[0].substring(0,1)
});
// Variables used in the description
d.replacements = {
};
_erCache.data[d.id] = d;
_erCache.rtree.insert(encodeErrorRtree(d));
});
}
})
.catch(function() {
delete _erCache.inflightTile[tile.id];
_erCache.loadedTile[tile.id] = true;
});
});
},
postUpdate: function(d, callback) {
if (_erCache.inflightPost[d.id]) {
return callback({ message: 'Error update already inflight', status: -2 }, d);
}
var that = this;
if (err) { return callback(err, d); }
// UI sets the status to either '/done' or '/false'
var url = _osmoseUrlRoot + 'issue/' + d.identifier + d.newStatus;
var controller = new AbortController();
_erCache.inflightPost[d.id] = controller;
fetch(url, { method: 'POST', signal: controller.signal })
.then(function() {
delete _erCache.inflightPost[d.id];
that.removeError(d);
if (d.newStatus === '/done') {
// 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;
}
if (callback) callback(null, d);
})
.catch(function(err) {
delete _erCache.inflightPost[d.id];
if (callback) callback(err.message);
});
},
// 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:osmose` changeset tag
getClosedIDs: function() {
return Object.keys(_erCache.closed).sort();
}
};
+3 -1
View File
@@ -6,6 +6,7 @@ import { svgDebug } from './debug';
import { svgGeolocate } from './geolocate';
import { svgKeepRight } from './keepRight';
import { svgImproveOSM } from './improveOSM';
import { svgOsmose } from './osmose';
import { svgStreetside } from './streetside';
import { svgMapillaryImages } from './mapillary_images';
import { svgMapillarySigns } from './mapillary_signs';
@@ -27,6 +28,7 @@ export function svgLayers(projection, context) {
{ id: 'data', layer: svgData(projection, context, dispatch) },
{ id: 'keepRight', layer: svgKeepRight(projection, context, dispatch) },
{ id: 'improveOSM', layer: svgImproveOSM(projection, context, dispatch) },
{ id: 'osmose', layer: svgOsmose(projection, context, dispatch) },
{ id: 'streetside', layer: svgStreetside(projection, context, dispatch)},
{ id: 'mapillary', layer: svgMapillaryImages(projection, context, dispatch) },
{ id: 'mapillary-map-features', layer: svgMapillaryMapFeatures(projection, context, dispatch) },
@@ -116,4 +118,4 @@ export function svgLayers(projection, context) {
return utilRebind(drawLayers, dispatch, 'on');
}
}
+261
View File
@@ -0,0 +1,261 @@
import _throttle from 'lodash-es/throttle';
import { select as d3_select } from 'd3-selection';
import { modeBrowse } from '../modes/browse';
import { svgPointTransform } from './helpers';
import { services } from '../services';
var _osmoseEnabled = false;
var _errorService;
export function svgOsmose(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 _osmoseVisible = false;
function markerPath(selection, klass) {
selection
.attr('class', klass)
.attr('transform', 'translate(-10, -28)')
.attr('points', '16,3 4,3 1,6 1,17 4,20 7,20 10,27 13,20 16,20 19,17.033 19,6');
}
// Loosely-coupled osmose service for fetching errors.
function getService() {
if (services.osmose && !_errorService) {
_errorService = services.osmose;
_errorService.on('loaded', throttledRedraw);
} else if (!services.osmose && _errorService) {
_errorService = null;
}
return _errorService;
}
// Show the errors
function editOn() {
if (!_osmoseVisible) {
_osmoseVisible = true;
drawLayer
.style('display', 'block');
}
}
// Immediately remove the errors and their touch targets
function editOff() {
if (_osmoseVisible) {
_osmoseVisible = false;
drawLayer
.style('display', 'none');
drawLayer.selectAll('.qa_error.osmose')
.remove();
touchLayer.selectAll('.qa_error.osmose')
.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('.qa_error.osmose')
.remove();
drawLayer
.transition()
.duration(250)
.style('opacity', 0)
.on('end interrupt', function () {
editOff();
dispatch.call('change');
});
}
// Update the error markers
function updateMarkers() {
if (!_osmoseVisible || !_osmoseEnabled) return;
var service = getService();
var selectedID = context.selectedErrorID();
var data = (service ? service.getErrors(projection) : []);
var getTransform = svgPointTransform(projection);
// Draw markers..
var markers = drawLayer.selectAll('.qa_error.osmose')
.data(data, function(d) { return d.id; });
// exit
markers.exit()
.remove();
// enter
var markersEnter = markers.enter()
.append('g')
.attr('class', function(d) {
return [
'qa_error',
d.service,
'error_id-' + d.id,
'error_type-' + d.error_type,
'category-' + d.category
].join(' ');
});
markersEnter
.append('polygon')
.call(markerPath, 'shadow');
markersEnter
.append('ellipse')
.attr('cx', 0)
.attr('cy', 0)
.attr('rx', 4.5)
.attr('ry', 2)
.attr('class', 'stroke');
markersEnter
.append('polygon')
.attr('fill', 'currentColor')
.call(markerPath, 'qa_error-fill');
markersEnter
.append('use')
.attr('transform', 'translate(-5.5, -21)')
.attr('class', 'icon-annotation')
.attr('width', '11px')
.attr('height', '11px')
.attr('xlink:href', function(d) {
var picon = d.icon;
if (!picon) {
return '';
} else {
var isMaki = /^maki-/.test(picon);
return '#' + picon + (isMaki ? '-11' : '');
}
});
// 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('.qa_error.osmose')
.data(data, function(d) { return d.id; });
// exit
targets.exit()
.remove();
// enter/update
targets.enter()
.append('rect')
.attr('width', '20px')
.attr('height', '30px')
.attr('x', '-10px')
.attr('y', '-28px')
.merge(targets)
.sort(sortY)
.attr('class', function(d) {
return 'qa_error ' + d.service + ' target error_id-' + d.id + ' ' + fillClass;
})
.attr('transform', getTransform);
function sortY(a, b) {
return (a.id === selectedID) ? 1
: (b.id === selectedID) ? -1
: b.loc[1] - a.loc[1];
}
}
// Draw the Osmose layer and schedule loading errors and updating markers.
function drawOsmose(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-osmose')
.data(service ? [0] : []);
drawLayer.exit()
.remove();
drawLayer = drawLayer.enter()
.append('g')
.attr('class', 'layer-osmose')
.style('display', _osmoseEnabled ? 'block' : 'none')
.merge(drawLayer);
if (_osmoseEnabled) {
if (service && ~~context.map().zoom() >= minZoom) {
editOn();
service.loadErrors(projection);
updateMarkers();
} else {
editOff();
}
}
}
// Toggles the layer on and off
drawOsmose.enabled = function(val) {
if (!arguments.length) return _osmoseEnabled;
_osmoseEnabled = val;
if (_osmoseEnabled) {
layerOn();
} else {
layerOff();
if (context.selectedErrorID()) {
context.enter(modeBrowse(context));
}
}
dispatch.call('change');
return this;
};
drawOsmose.supported = function() {
return !!getService();
};
return drawOsmose;
}
+7 -1
View File
@@ -149,6 +149,12 @@ export function uiCommit(context) {
tags['closed:improveosm'] = iOsmClosed.join(';').substr(0, tagCharLimit);
}
}
if (services.osmose) {
var osmoseClosed = services.osmose.getClosedIDs();
if (osmoseClosed.length) {
tags['closed:osmose'] = osmoseClosed.join(';').substr(0, 255);
}
}
// remove existing issue counts
for (var key in tags) {
@@ -585,4 +591,4 @@ export function uiCommit(context) {
return utilRebind(commit, dispatch, 'on');
}
}
+2 -2
View File
@@ -341,7 +341,7 @@ export function uiMapData(context) {
function drawQAItems(selection) {
var qaKeys = ['keepRight', 'improveOSM'];
var qaKeys = ['keepRight', 'improveOSM', 'osmose'];
var qaLayers = layers.all().filter(function(obj) { return qaKeys.indexOf(obj.id) !== -1; });
var ul = selection
@@ -916,4 +916,4 @@ export function uiMapData(context) {
};
return uiMapData;
}
}
+11 -10
View File
@@ -26,20 +26,21 @@ describe('iD.svgLayers', function () {
it('creates default data layers', function () {
container.call(iD.svgLayers(projection, context));
var nodes = container.selectAll('svg .data-layer').nodes();
expect(nodes.length).to.eql(13);
expect(nodes.length).to.eql(14);
expect(d3.select(nodes[0]).classed('osm')).to.be.true;
expect(d3.select(nodes[1]).classed('notes')).to.be.true;
expect(d3.select(nodes[2]).classed('data')).to.be.true;
expect(d3.select(nodes[3]).classed('keepRight')).to.be.true;
expect(d3.select(nodes[4]).classed('improveOSM')).to.be.true;
expect(d3.select(nodes[5]).classed('streetside')).to.be.true;
expect(d3.select(nodes[6]).classed('mapillary')).to.be.true;
expect(d3.select(nodes[7]).classed('mapillary-map-features')).to.be.true;
expect(d3.select(nodes[8]).classed('mapillary-signs')).to.be.true;
expect(d3.select(nodes[9]).classed('openstreetcam')).to.be.true;
expect(d3.select(nodes[10]).classed('debug')).to.be.true;
expect(d3.select(nodes[11]).classed('geolocate')).to.be.true;
expect(d3.select(nodes[12]).classed('touch')).to.be.true;
expect(d3.select(nodes[5]).classed('osmose')).to.be.true;
expect(d3.select(nodes[6]).classed('streetside')).to.be.true;
expect(d3.select(nodes[7]).classed('mapillary')).to.be.true;
expect(d3.select(nodes[8]).classed('mapillary-map-features')).to.be.true;
expect(d3.select(nodes[9]).classed('mapillary-signs')).to.be.true;
expect(d3.select(nodes[10]).classed('openstreetcam')).to.be.true;
expect(d3.select(nodes[11]).classed('debug')).to.be.true;
expect(d3.select(nodes[12]).classed('geolocate')).to.be.true;
expect(d3.select(nodes[13]).classed('touch')).to.be.true;
});
});
});