Merge pull request #7095 from openstreetmap/osmose

Add Osmose Q/A layer
This commit is contained in:
SilentSpike
2020-02-04 21:47:28 +00:00
committed by GitHub
37 changed files with 1344 additions and 94 deletions
+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;
}
}
+3 -8
View File
@@ -44,13 +44,8 @@ Object.assign(qaError.prototype, {
if (this.service && this.error_type) {
var serviceInfo = services[this.service];
if (serviceInfo) {
var errInfo = serviceInfo.errorTypes[this.error_type];
if (errInfo) {
this.icon = errInfo.icon;
this.category = errInfo.category;
}
if (serviceInfo && serviceInfo.errorIcons) {
this.icon = serviceInfo.errorIcons[this.error_type];
}
}
@@ -65,4 +60,4 @@ Object.assign(qaError.prototype, {
update: function(attrs) {
return qaError(this, attrs); // {v: 1 + (this.v || 0)}
}
});
});
+8 -6
View File
@@ -436,9 +436,11 @@ export default {
} else {
that.removeError(d);
if (d.newStatus === 'SOLVED') {
// 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;
// No error identifier, so we give a count of each category
if (!(d.error_key in _erCache.closed)) {
_erCache.closed[d.error_key] = 0;
}
_erCache.closed[d.error_key] += 1;
}
}
if (callback) callback(null, d);
@@ -486,7 +488,7 @@ export default {
},
// Used to populate `closed:improveosm` changeset tag
getClosedIDs: function() {
return Object.keys(_erCache.closed).sort();
getClosedCounts: function() {
return _erCache.closed;
}
};
};
+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
};
};
+351
View File
@@ -0,0 +1,351 @@
import RBush from 'rbush';
import { dispatch as d3_dispatch } from 'd3-dispatch';
import { json as d3_json } from 'd3-fetch';
import { currentLocale } from '../util/locale';
import { geoExtent, geoVecAdd } from '../geo';
import { qaError } from '../osm';
import { utilRebind, utilTiler, utilQsString } from '../util';
import { services as qaServices } from '../../data/qa_errors.json';
const tiler = utilTiler();
const dispatch = d3_dispatch('loaded');
const _osmoseUrlRoot = 'https://osmose.openstreetmap.fr/en/api/0.3beta';
const _osmoseItems =
Object.keys(qaServices.osmose.errorIcons)
.map(s => s.split('-')[0])
.reduce((unique, item) => unique.indexOf(item) !== -1 ? unique : [...unique, item], []);
const _erZoom = 14;
const _stringCache = {};
const _colorCache = {};
// This gets reassigned if reset
let _erCache;
function abortRequest(controller) {
if (controller) {
controller.abort();
}
}
function abortUnwantedRequests(cache, tiles) {
Object.keys(cache.inflightTile).forEach(k => {
let wanted = tiles.find(tile => 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, (a, b) => a.data.id === b.data.id);
if (replace) {
_erCache.rtree.insert(item);
}
}
// Errors shouldn't obscure eachother
function preventCoincident(loc) {
let coincident = false;
do {
// first time, move marker up. after that, move marker right.
let delta = coincident ? [0.00001, 0] : [0, 0.00001];
loc = geoVecAdd(loc, delta);
let bbox = geoExtent(loc).bbox();
coincident = _erCache.rtree.search(bbox).length;
} while (coincident);
return loc;
}
export default {
init() {
if (!_erCache) {
this.reset();
}
this.event = utilRebind(this, dispatch, 'on');
},
reset() {
if (_erCache) {
Object.values(_erCache.inflightTile).forEach(abortRequest);
}
_erCache = {
data: {},
loadedTile: {},
inflightTile: {},
inflightPost: {},
closed: {},
rtree: new RBush()
};
},
loadErrors(projection) {
let params = {
// Tiles return a maximum # of errors
// So we want to filter our request for only types iD supports
item: _osmoseItems
};
// determine the needed tiles to cover the view
let tiles = tiler
.zoomExtent([_erZoom, _erZoom])
.getTiles(projection);
// abort inflight requests that are no longer needed
abortUnwantedRequests(_erCache, tiles);
// issue new requests..
tiles.forEach(tile => {
if (_erCache.loadedTile[tile.id] || _erCache.inflightTile[tile.id]) return;
let [ x, y, z ] = tile.xyz;
let url = `${_osmoseUrlRoot}/issues/${z}/${x}/${y}.json?` + utilQsString(params);
let controller = new AbortController();
_erCache.inflightTile[tile.id] = controller;
d3_json(url, { signal: controller.signal })
.then(data => {
delete _erCache.inflightTile[tile.id];
_erCache.loadedTile[tile.id] = true;
if (data.features) {
data.features.forEach(issue => {
const { item, class: error_class, uuid: identifier } = issue.properties;
// Item is the type of error, w/ class tells us the sub-type
const error_type = `${item}-${error_class}`;
// Filter out unsupported error types (some are too specific or advanced)
if (error_type in qaServices.osmose.errorIcons) {
let loc = issue.geometry.coordinates; // lon, lat
loc = preventCoincident(loc);
let d = new qaError({
// Info required for every error
loc,
service: 'osmose',
error_type,
// Extra details needed for this service
identifier, // needed to query and update the error
item // category of the issue for styling
});
// Setting elems here prevents UI error detail requests
if (d.item === 8300 || d.item === 8360) {
d.elems = [];
}
_erCache.data[d.id] = d;
_erCache.rtree.insert(encodeErrorRtree(d));
}
});
}
dispatch.call('loaded');
})
.catch(() => {
delete _erCache.inflightTile[tile.id];
_erCache.loadedTile[tile.id] = true;
});
});
},
loadErrorDetail(d) {
// Error details only need to be fetched once
if (d.elems !== undefined) {
return Promise.resolve(d);
}
const url = `${_osmoseUrlRoot}/issue/${d.identifier}?langs=${currentLocale}`;
const cacheDetails = data => {
// Associated elements used for highlighting
// Assign directly for immediate use in the callback
d.elems = data.elems.map(e => e.type.substring(0,1) + e.id);
// Some issues have instance specific detail in a subtitle
d.detail = data.subtitle;
this.replaceError(d);
};
return jsonPromise(url, cacheDetails)
.then(() => d);
},
loadStrings(callback, locale=currentLocale) {
const issueTypes = Object.keys(qaServices.osmose.errorIcons);
if (
locale in _stringCache
&& Object.keys(_stringCache[locale]).length === issueTypes.length
) {
if (callback) callback(null, _stringCache[locale]);
return;
}
// May be partially populated already if some requests were successful
if (!(locale in _stringCache)) {
_stringCache[locale] = {};
}
const format = string => {
// Some strings contain markdown syntax
string = string.replace(/\[((?:.|\n)+?)\]\((.+?)\)/g, '<a href="$2">$1</a>');
return string.replace(/`(.+?)`/g, '<code>$1</code>');
};
// Only need to cache strings for supported issue types
// Using multiple individual item + class requests to reduce fetched data size
const allRequests = issueTypes.map(issueType => {
// No need to request data we already have
if (issueType in _stringCache[locale]) return;
const cacheData = data => {
// Bunch of nested single value arrays of objects
const [ cat = {items:[]} ] = data.categories;
const [ item = {class:[]} ] = cat.items;
const [ cl = null ] = item.class;
// If null default value is reached, data wasn't as expected (or was empty)
if (!cl) {
/* eslint-disable no-console */
console.log(`Osmose strings request (${issueType}) had unexpected data`);
/* eslint-enable no-console */
return;
}
// Cache served item colors to automatically style issue markers later
const { item: itemInt, color } = item;
if (/^#[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}/.test(color)) {
_colorCache[itemInt] = color;
}
// Value of root key will be null if no string exists
// If string exists, value is an object with key 'auto' for string
const { title, detail, fix, trap } = cl;
let issueStrings = {};
if (title) issueStrings.title = title.auto;
if (detail) issueStrings.detail = format(detail.auto);
if (trap) issueStrings.trap = format(trap.auto);
if (fix) issueStrings.fix = format(fix.auto);
_stringCache[locale][issueType] = issueStrings;
};
const [ item, cl ] = issueType.split('-');
// Osmose API falls back to English strings where untranslated or if locale doesn't exist
const url = `${_osmoseUrlRoot}/items/${item}/class/${cl}?langs=${locale}`;
return jsonPromise(url, cacheData);
});
Promise.all(allRequests)
.then(() => { if (callback) callback(null, _stringCache[locale]); })
.catch(err => { if (callback) callback(err); });
},
getStrings(issueType, locale=currentLocale) {
// No need to fallback to English, Osmose API handles this for us
return (locale in _stringCache) ? _stringCache[locale][issueType] : {};
},
getColor(itemType) {
return (itemType in _colorCache) ? _colorCache[itemType] : '#FFFFFF';
},
postUpdate(d, callback) {
if (_erCache.inflightPost[d.id]) {
return callback({ message: 'Error update already inflight', status: -2 }, d);
}
// UI sets the status to either 'done' or 'false'
let url = `${_osmoseUrlRoot}/issue/${d.identifier}/${d.newStatus}`;
let controller = new AbortController();
_erCache.inflightPost[d.id] = controller;
fetch(url, { signal: controller.signal })
.then(() => {
delete _erCache.inflightPost[d.id];
this.removeError(d);
if (d.newStatus === 'done') {
// No error identifier, so we give a count of each category
if (!(d.item in _erCache.closed)) {
_erCache.closed[d.item] = 0;
}
_erCache.closed[d.item] += 1;
}
if (callback) callback(null, d);
})
.catch(err => {
delete _erCache.inflightPost[d.id];
if (callback) callback(err.message);
});
},
// get all cached errors covering the viewport
getErrors(projection) {
let viewport = projection.clipExtent();
let min = [viewport[0][0], viewport[1][1]];
let max = [viewport[1][0], viewport[0][1]];
let bbox = geoExtent(projection.invert(min), projection.invert(max)).bbox();
return _erCache.rtree.search(bbox).map(d => {
return d.data;
});
},
// get a single error from the cache
getError(id) {
return _erCache.data[id];
},
// replace a single error in the cache
replaceError(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(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 tags
getClosedCounts() {
return _erCache.closed;
}
};
function jsonPromise(url, then) {
return new Promise((resolve, reject) => {
d3_json(url)
.then(data => {
then(data);
resolve();
})
.catch(err => {
reject(err);
});
});
}
+2 -3
View File
@@ -119,8 +119,7 @@ export function svgImproveOSM(projection, context, dispatch) {
'qa_error',
d.service,
'error_id-' + d.id,
'error_type-' + d.error_type,
'category-' + d.category
'error_type-' + d.error_type
].join(' ');
});
@@ -258,4 +257,4 @@ export function svgImproveOSM(projection, context, dispatch) {
return drawImproveOSM;
}
}
+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');
}
}
+265
View File
@@ -0,0 +1,265 @@
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() {
// Strings supplied by Osmose fetched before showing layer for first time
// NOTE: Currently no way to change locale in iD at runtime, would need to re-call this method if that's ever implemented
// FIXME: If layer is toggled quickly multiple requests are sent
// FIXME: No error handling in place
getService().loadStrings(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,
'item-' + d.item
].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', d => getService().getColor(d.item))
.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;
}
+15 -4
View File
@@ -25,7 +25,11 @@ var readOnlyTags = [
/^host$/,
/^locale$/,
/^warnings:/,
/^resolved:/
/^resolved:/,
/^closed:note$/,
/^closed:keepright$/,
/^closed:improveosm:/,
/^closed:osmose:/
];
// treat most punctuation (except -, _, +, &) as hashtag delimiters - #4398
@@ -134,6 +138,7 @@ export function uiCommit(context) {
// assign tags for closed issues and notes
var osmClosed = osm.getClosedIDs();
var issueType;
if (osmClosed.length) {
tags['closed:note'] = osmClosed.join(';').substr(0, tagCharLimit);
}
@@ -144,9 +149,15 @@ export function uiCommit(context) {
}
}
if (services.improveOSM) {
var iOsmClosed = services.improveOSM.getClosedIDs();
if (iOsmClosed.length) {
tags['closed:improveosm'] = iOsmClosed.join(';').substr(0, tagCharLimit);
var iOsmClosed = services.improveOSM.getClosedCounts();
for (issueType in iOsmClosed) {
tags['closed:improveosm:' + issueType] = iOsmClosed[issueType].toString().substr(0, tagCharLimit);
}
}
if (services.osmose) {
var osmoseClosed = services.osmose.getClosedCounts();
for (issueType in osmoseClosed) {
tags['closed:osmose:' + issueType] = osmoseClosed[issueType].toString().substr(0, tagCharLimit);
}
}
+4 -3
View File
@@ -78,11 +78,11 @@ export function uiImproveOsmDetails(context) {
// Add click handler
link
.on('mouseover', function() {
.on('mouseenter', function() {
context.surface().selectAll(utilEntityOrMemberSelector([entityID], context.graph()))
.classed('hover', true);
})
.on('mouseout', function() {
.on('mouseleave', function() {
context.surface().selectAll('.hover')
.classed('hover', false);
})
@@ -122,6 +122,7 @@ export function uiImproveOsmDetails(context) {
// Don't hide entities related to this error - #5880
context.features().forceVisible(relatedEntities);
context.map().pan([0,0]); // trigger a redraw
}
@@ -133,4 +134,4 @@ export function uiImproveOsmDetails(context) {
return improveOsmDetails;
}
}
+2 -3
View File
@@ -51,8 +51,7 @@ export function uiImproveOsmHeader() {
'qa_error',
d.service,
'error_id-' + d.id,
'error_type-' + d.error_type,
'category-' + d.category
'error_type-' + d.error_type
].join(' ');
});
@@ -94,4 +93,4 @@ export function uiImproveOsmHeader() {
return improveOsmHeader;
}
}
+4 -3
View File
@@ -80,11 +80,11 @@ export function uiKeepRightDetails(context) {
// Add click handler
link
.on('mouseover', function() {
.on('mouseenter', function() {
context.surface().selectAll(utilEntityOrMemberSelector([entityID], context.graph()))
.classed('hover', true);
})
.on('mouseout', function() {
.on('mouseleave', function() {
context.surface().selectAll('.hover')
.classed('hover', false);
})
@@ -124,6 +124,7 @@ export function uiKeepRightDetails(context) {
// Don't hide entities related to this error - #5880
context.features().forceVisible(relatedEntities);
context.map().pan([0,0]); // trigger a redraw
}
@@ -135,4 +136,4 @@ export function uiKeepRightDetails(context) {
return keepRightDetails;
}
}
+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;
}
}
+208
View File
@@ -0,0 +1,208 @@
import {
event as d3_event,
select as d3_select
} from 'd3-selection';
import { modeSelect } from '../modes/select';
import { t } from '../util/locale';
import { services } from '../services';
import { utilDisplayName, utilEntityOrMemberSelector } from '../util';
export function uiOsmoseDetails(context) {
let _error;
function issueString(d, type) {
if (!d) return '';
// Issue strings are cached from Osmose API
const s = services.osmose.getStrings(d.error_type);
return (type in s) ? s[type] : '';
}
function osmoseDetails(selection) {
const details = selection.selectAll('.error-details')
.data(
_error ? [_error] : [],
d => `${d.id}-${d.status || 0}`
);
details.exit()
.remove();
const detailsEnter = details.enter()
.append('div')
.attr('class', 'error-details error-details-container');
// Description
if (issueString(_error, 'detail')) {
const div = detailsEnter
.append('div')
.attr('class', 'error-details-subsection');
div
.append('h4')
.text(() => t('QA.keepRight.detail_description'));
div
.append('p')
.attr('class', 'error-details-description-text')
.html(d => issueString(d, 'detail'));
}
// Elements (populated later as data is requested)
const detailsDiv = detailsEnter
.append('div')
.attr('class', 'error-details-subsection');
const elemsDiv = detailsEnter
.append('div')
.attr('class', 'error-details-subsection');
// Suggested Fix (musn't exist for every issue type)
if (issueString(_error, 'fix')) {
const div = detailsEnter
.append('div')
.attr('class', 'error-details-subsection');
div
.append('h4')
.text(() => t('QA.osmose.fix_title'));
div
.append('p')
.html(d => issueString(d, 'fix'));
}
// Common Pitfalls (musn't exist for every issue type)
if (issueString(_error, 'trap')) {
const div = detailsEnter
.append('div')
.attr('class', 'error-details-subsection');
div
.append('h4')
.text(() => t('QA.osmose.trap_title'));
div
.append('p')
.html(d => issueString(d, 'trap'));
}
// Translation link below details container
selection
.append('div')
.attr('class', 'translation-link')
.append('a')
.attr('target', '_blank')
.attr('rel', 'noopener noreferrer') // security measure
.attr('href', 'https://www.transifex.com/openstreetmap-france/osmose')
.text(() => t('QA.osmose.translation'))
.append('svg')
.attr('class', 'icon inline')
.append('use')
.attr('href', '#iD-icon-out-link');
services.osmose.loadErrorDetail(_error)
.then(d => {
// No details to add if there are no associated issue elements
if (!d.elems || d.elems.length === 0) return;
// TODO: Do nothing if UI has moved on by the time this resolves
// Things like keys and values are dynamically added to a subtitle string
if (d.detail) {
detailsDiv
.append('h4')
.attr('class', 'error-details-subtitle')
.text(() => t('QA.osmose.detail_title'));
detailsDiv
.append('p')
.html(d => d.detail);
}
// Create list of linked issue elements
elemsDiv
.append('h4')
.attr('class', 'error-details-subtitle')
.text(() => t('QA.osmose.elems_title'));
elemsDiv
.append('ul')
.attr('class', 'error-details-elements')
.selectAll('.error_entity_link')
.data(d.elems)
.enter()
.append('li')
.append('a')
.attr('class', 'error_entity_link')
.text(d => d)
.each(function() {
const link = d3_select(this);
const entityID = this.textContent;
const entity = context.hasEntity(entityID);
// Add click handler
link
.on('mouseenter', () => {
context.surface().selectAll(utilEntityOrMemberSelector([entityID], context.graph()))
.classed('hover', true);
})
.on('mouseleave', () => {
context.surface().selectAll('.hover')
.classed('hover', false);
})
.on('click', () => {
d3_event.preventDefault();
const osmlayer = context.layers().layer('osm');
if (!osmlayer.enabled()) {
osmlayer.enabled(true);
}
context.map().centerZoom(d.loc, 20);
if (entity) {
context.enter(modeSelect(context, [entityID]));
} else {
context.loadEntity(entityID, () => {
context.enter(modeSelect(context, [entityID]));
});
}
});
// Replace with friendly name if possible
// (The entity may not yet be loaded into the graph)
if (entity) {
let name = utilDisplayName(entity); // try to use common name
if (!name) {
const preset = context.presets().match(entity, context.graph());
name = preset && !preset.isFallback() && preset.name(); // fallback to preset name
}
if (name) {
this.innerText = name;
}
}
});
// Don't hide entities related to this error - #5880
context.features().forceVisible(d.elems);
context.map().pan([0,0]); // trigger a redraw
})
.catch(err => {}); // TODO: Handle failed json request gracefully in some way
}
osmoseDetails.error = val => {
if (!arguments.length) return _error;
_error = val;
return osmoseDetails;
};
return osmoseDetails;
}
+170
View File
@@ -0,0 +1,170 @@
import { dispatch as d3_dispatch } from 'd3-dispatch';
import { t } from '../util/locale';
import { services } from '../services';
import { modeBrowse } from '../modes/browse';
import { svgIcon } from '../svg/icon';
import { uiOsmoseDetails } from './osmose_details';
import { uiOsmoseHeader } from './osmose_header';
import { uiQuickLinks } from './quick_links';
import { uiTooltipHtml } from './tooltipHtml';
import { utilRebind } from '../util';
export function uiOsmoseEditor(context) {
var dispatch = d3_dispatch('change');
var errorDetails = uiOsmoseDetails(context);
var errorHeader = uiOsmoseHeader(context);
var quickLinks = uiQuickLinks();
var _error;
function osmoseEditor(selection) {
// quick links
var choices = [{
id: 'zoom_to',
label: 'inspector.zoom_to.title',
tooltip: function() {
return uiTooltipHtml(t('inspector.zoom_to.tooltip_issue'), t('inspector.zoom_to.key'));
},
click: function zoomTo() {
context.mode().zoomToSelected();
}
}];
var header = selection.selectAll('.header')
.data([0]);
var headerEnter = header.enter()
.append('div')
.attr('class', 'header fillL');
headerEnter
.append('button')
.attr('class', 'fr error-editor-close')
.on('click', function() {
context.enter(modeBrowse(context));
})
.call(svgIcon('#iD-icon-close'));
headerEnter
.append('h3')
.text(t('QA.osmose.title'));
var body = selection.selectAll('.body')
.data([0]);
body = body.enter()
.append('div')
.attr('class', 'body')
.merge(body);
var editor = body.selectAll('.error-editor')
.data([0]);
editor.enter()
.append('div')
.attr('class', 'modal-section error-editor')
.merge(editor)
.call(errorHeader.error(_error))
.call(quickLinks.choices(choices))
.call(errorDetails.error(_error))
.call(osmoseSaveSection);
}
function osmoseSaveSection(selection) {
var isSelected = (_error && _error.id === context.selectedErrorID());
var isShown = (_error && isSelected);
var saveSection = selection.selectAll('.error-save')
.data(
(isShown ? [_error] : []),
function(d) { return d.id + '-' + (d.status || 0); }
);
// exit
saveSection.exit()
.remove();
// enter
var saveSectionEnter = saveSection.enter()
.append('div')
.attr('class', 'error-save save-section cf');
// update
saveSection = saveSectionEnter
.merge(saveSection)
.call(errorSaveButtons);
}
function errorSaveButtons(selection) {
var isSelected = (_error && _error.id === context.selectedErrorID());
var buttonSection = selection.selectAll('.buttons')
.data((isSelected ? [_error] : []), function(d) { return d.status + d.id; });
// exit
buttonSection.exit()
.remove();
// enter
var buttonEnter = buttonSection.enter()
.append('div')
.attr('class', 'buttons');
buttonEnter
.append('button')
.attr('class', 'button close-button action');
buttonEnter
.append('button')
.attr('class', 'button ignore-button action');
// update
buttonSection = buttonSection
.merge(buttonEnter);
buttonSection.select('.close-button')
.text(function() {
return t('QA.keepRight.close');
})
.on('click.close', function(d) {
this.blur(); // avoid keeping focus on the button - #4641
var errorService = services.osmose;
if (errorService) {
d.newStatus = 'done';
errorService.postUpdate(d, function(err, error) {
dispatch.call('change', error);
});
}
});
buttonSection.select('.ignore-button')
.text(function() {
return t('QA.keepRight.ignore');
})
.on('click.ignore', function(d) {
this.blur(); // avoid keeping focus on the button - #4641
var errorService = services.osmose;
if (errorService) {
d.newStatus = 'false';
errorService.postUpdate(d, function(err, error) {
dispatch.call('change', error);
});
}
});
}
osmoseEditor.error = function(val) {
if (!arguments.length) return _error;
_error = val;
return osmoseEditor;
};
return utilRebind(osmoseEditor, dispatch, 'on');
}
+93
View File
@@ -0,0 +1,93 @@
import { services } from '../services';
import { t } from '../util/locale';
export function uiOsmoseHeader() {
var _error;
function errorTitle(d) {
var unknown = t('inspector.unknown');
if (!d) return unknown;
// Issue titles supplied by Osmose
var s = services.osmose.getStrings(d.error_type);
return ('title' in s) ? s.title : unknown;
}
function osmoseHeader(selection) {
var header = selection.selectAll('.error-header')
.data(
(_error ? [_error] : []),
function(d) { return d.id + '-' + (d.status || 0); }
);
header.exit()
.remove();
var headerEnter = header.enter()
.append('div')
.attr('class', 'error-header');
var iconEnter = headerEnter
.append('div')
.attr('class', 'error-header-icon')
.classed('new', function(d) { return d.id < 0; });
var svgEnter = iconEnter
.append('svg')
.attr('width', '20px')
.attr('height', '30px')
.attr('viewbox', '0 0 20 30')
.attr('class', function(d) {
return [
'preset-icon-28',
'qa_error',
d.service,
'error_id-' + d.id,
'error_type-' + d.error_type,
'item-' + d.item
].join(' ');
});
svgEnter
.append('polygon')
.attr('fill', d => services.osmose.getColor(d.item))
.attr('class', 'qa_error-fill')
.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');
svgEnter
.append('use')
.attr('class', 'icon-annotation')
.attr('width', '11px')
.attr('height', '11px')
.attr('transform', 'translate(4.5, 7)')
.attr('xlink:href', function(d) {
var picon = d.icon;
if (!picon) {
return '';
} else {
var isMaki = /^maki-/.test(picon);
return '#' + picon + (isMaki ? '-11' : '');
}
});
headerEnter
.append('div')
.attr('class', 'error-header-label')
.text(errorTitle);
}
osmoseHeader.error = function(val) {
if (!arguments.length) return _error;
_error = val;
return osmoseHeader;
};
return osmoseHeader;
}
+12 -3
View File
@@ -16,6 +16,7 @@ import { uiFeatureList } from './feature_list';
import { uiInspector } from './inspector';
import { uiImproveOsmEditor } from './improveOSM_editor';
import { uiKeepRightEditor } from './keepRight_editor';
import { uiOsmoseEditor } from './osmose_editor';
import { uiNoteEditor } from './note_editor';
import { textDirection } from '../util/locale';
@@ -26,6 +27,7 @@ export function uiSidebar(context) {
var noteEditor = uiNoteEditor(context);
var improveOsmEditor = uiImproveOsmEditor(context);
var keepRightEditor = uiKeepRightEditor(context);
var osmoseEditor = uiOsmoseEditor(context);
var _current;
var _wasData = false;
var _wasNote = false;
@@ -147,8 +149,15 @@ export function uiSidebar(context) {
datum = errService.getError(datum.id);
}
// Temporary solution while only two services
var errEditor = (datum.service === 'keepRight') ? keepRightEditor : improveOsmEditor;
// Currently only three possible services
var errEditor;
if (datum.service === 'keepRight') {
errEditor = keepRightEditor;
} else if (datum.service === 'osmose') {
errEditor = osmoseEditor;
} else {
errEditor = improveOsmEditor;
}
d3_selectAll('.qa_error.' + datum.service)
.classed('hover', function(d) { return d.id === datum.id; });
@@ -357,4 +366,4 @@ export function uiSidebar(context) {
sidebar.toggle = function() {};
return sidebar;
}
}
+2 -2
View File
@@ -45,7 +45,7 @@ export function t(s, o, loc) {
if (rep !== undefined) {
if (o) {
for (var k in o) {
var variable = '{' + k + '}';
var variable = '\\{' + k + '\\}';
var re = new RegExp(variable, 'g'); // check globally for variables
rep = rep.replace(re, o[k]);
}
@@ -124,4 +124,4 @@ export function languageName(context, code, options) {
}
}
return code; // if not found, use the code
}
}