diff --git a/css/65_data.css b/css/65_data.css index 8de9ca09cb..1687909e6d 100644 --- a/css/65_data.css +++ b/css/65_data.css @@ -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,7 +153,6 @@ color: #EC1C24; } - /* Custom Map Data (geojson, gpx, kml, vector tile) */ .layer-mapdata { pointer-events: none; @@ -211,4 +211,4 @@ stroke: #000; stroke-width: 5px; stroke-miterlimit: 1; -} +} \ No newline at end of file diff --git a/css/80_app.css b/css/80_app.css index b5b29af136..67f1b31489 100644 --- a/css/80_app.css +++ b/css/80_app.css @@ -2734,18 +2734,19 @@ input.key-trap { padding-top: 20px; } -.error-details { - padding: 10px; -} .error-details-container { background: #ececec; padding: 10px; margin-top: 20px; border-radius: 4px; border: 1px solid #ccc; + display: flex; + flex-direction: column; } .error-details-description { margin-bottom: 10px; + display: flex; + flex-direction: column; } .error-details-description-text::first-letter { text-transform: capitalize; @@ -2753,6 +2754,24 @@ input.key-trap { [dir='rtl'] .error-details-description-text::first-letter { text-transform: none; /* #5877 */ } +.error-details-subsection h4 { + padding-top: 10px; + padding-bottom: 0; +} +.error-details code { + padding: .2em .4em; + margin: 0; + font-size: 85%; + font-family: monospace; + background-color: rgba(27,31,35,.05); + border-radius: 3px; +} +.error-details + .translation-link { + margin-top: 5px; + display: flex; + flex-direction: row; + justify-content: flex-end; +} .note-save .new-comment-input, .error-save .new-comment-input { @@ -5590,4 +5609,4 @@ li.hide + li.version .badge .tooltip .popover-arrow { } [dir='rtl'] .list-item-photos.list-item-mapillary-map-features .request-data-link { float: left; -} +} \ No newline at end of file diff --git a/data/core.yaml b/data/core.yaml index d68008f9af..69f809997a 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -625,11 +625,14 @@ en: tooltip: Note data from OpenStreetMap title: OpenStreetMap notes keepRight: - tooltip: Automatically detected map issues from keepright.at + tooltip: Data issues detected by keepright.at title: KeepRight Issues improveOSM: - tooltip: Missing data automatically detected by improveosm.org + tooltip: Missing data detected by improveosm.org title: ImproveOSM Issues + osmose: + tooltip: Data issues detected by 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 @@ -824,6 +827,13 @@ en: cannot_zoom: "Cannot zoom out further in current mode." full_screen: Toggle Full Screen QA: + osmose: + title: Osmose Issue + detail_title: Details + elems_title: Features + fix_title: Fix Guidelines + trap_title: Common Mistakes + translation: Translations provided by Osmose improveOSM: title: ImproveOSM Detection geometry_types: @@ -1343,7 +1353,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/)." 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 +2068,4 @@ en: wikidata: identifier: "Identifier" label: "Label" - description: "Description" + description: "Description" \ No newline at end of file diff --git a/data/qa_errors.json b/data/qa_errors.json index d14306dcdc..0d6ed3d0f9 100644 --- a/data/qa_errors.json +++ b/data/qa_errors.json @@ -1,36 +1,92 @@ { "services": { "improveOSM": { - "errorTypes": { - "ow": { - "icon": "fas-long-arrow-alt-right", - "category": "routing" - }, - "mr-both": { - "icon": "maki-car", - "category": "geometry" - }, - "mr-parking": { - "icon": "maki-parking", - "category": "geometry" - }, - "mr-path": { - "icon": "maki-shoe", - "category": "geometry" - }, - "mr-road": { - "icon": "maki-car", - "category": "geometry" - }, - "tr": { - "icon": "temaki-junction", - "category": "routing" - } + "errorIcons": { + "ow": "fas-long-arrow-alt-right", + "mr-both": "maki-car", + "mr-parking": "maki-parking", + "mr-path": "maki-shoe", + "mr-road": "maki-car", + "tr": "temaki-junction" } }, - "keepRight": { - "errorTypes": { - + "osmose": { + "errorIcons": { + "0-1": "maki-home", + "0-2": "maki-home", + "1040-1": "maki-square-stroked", + "1050-1": "maki-circle-stroked", + "1050-1050": "maki-circle-stroked", + "1070-1": "maki-home", + "1070-4": "maki-dam", + "1070-5": "maki-dam", + "1070-8": "maki-cross", + "1070-10": "maki-cross", + "1150-1": "far-clone", + "1150-2": "far-clone", + "1150-3": "far-clone", + "1190-10": "fas-share-alt", + "1190-20": "fas-share-alt", + "1190-30": "fas-share-alt", + "1280-1": "maki-attraction", + "2110-21101": "temaki-plaque", + "2110-21102": "fas-shapes", + "3040-3040": "far-times-circle", + "3090-3090": "fas-calendar-alt", + "3161-1": "maki-parking", + "3161-2": "maki-parking", + "3200-32001": "fas-vector-square", + "3200-32002": "fas-vector-square", + "3200-32003": "fas-vector-square", + "3220-32200": "maki-roadblock", + "3220-32201": "maki-roadblock", + "3250-32501": "maki-watch", + "4010-4010": "maki-waste-basket", + "4010-40102": "maki-waste-basket", + "4030-900": "fas-yin-yang", + "4080-1": "far-dot-circle", + "4080-2": "far-dot-circle", + "4080-3": "far-dot-circle", + "5010-803": "fas-sort-alpha-up", + "5010-903": "fas-rocket", + "5070-50703": "fas-tint-slash", + "5070-50704": "fas-code", + "5070-50705": "fas-question", + "7040-1": "temaki-power_tower", + "7040-2": "temaki-power", + "7040-4": "maki-marker", + "7040-6": "temaki-power", + "7090-1": "maki-rail", + "7090-3": "maki-circle", + "8300-1": "fas-tachometer-alt", + "8300-2": "fas-tachometer-alt", + "8300-3": "fas-tachometer-alt", + "8300-4": "fas-tachometer-alt", + "8300-5": "fas-tachometer-alt", + "8300-6": "fas-tachometer-alt", + "8300-7": "fas-tachometer-alt", + "8300-8": "fas-tachometer-alt", + "8300-9": "fas-tachometer-alt", + "8300-10": "fas-tachometer-alt", + "8300-11": "fas-tachometer-alt", + "8300-12": "fas-tachometer-alt", + "8300-13": "fas-tachometer-alt", + "8300-14": "fas-tachometer-alt", + "8300-15": "fas-tachometer-alt", + "8300-16": "fas-tachometer-alt", + "8300-17": "fas-tachometer-alt", + "8300-20": "temaki-height_restrictor", + "8300-21": "fas-weight-hanging", + "8300-32": "maki-circle-stroked", + "8300-34": "temaki-diamond", + "8300-39": "temaki-pedestrian", + "8360-1": "temaki-bench", + "8360-2": "maki-bicycle", + "8360-3": "temaki-security_camera", + "8360-4": "temaki-fire_hydrant", + "8360-5": "temaki-traffic_signals", + "9010-9010001": "fas-tags", + "9010-9010003": "temaki-plaque" } } } diff --git a/dist/locales/en.json b/dist/locales/en.json index 1aa7361e5d..6440278628 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -775,13 +775,17 @@ "title": "OpenStreetMap notes" }, "keepRight": { - "tooltip": "Automatically detected map issues from keepright.at", + "tooltip": "Data issues detected by keepright.at", "title": "KeepRight Issues" }, "improveOSM": { - "tooltip": "Missing data automatically detected by improveosm.org", + "tooltip": "Missing data detected by improveosm.org", "title": "ImproveOSM Issues" }, + "osmose": { + "tooltip": "Data issues detected by 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", @@ -1027,6 +1031,14 @@ "cannot_zoom": "Cannot zoom out further in current mode.", "full_screen": "Toggle Full Screen", "QA": { + "osmose": { + "title": "Osmose Issue", + "detail_title": "Details", + "elems_title": "Features", + "fix_title": "Fix Guidelines", + "trap_title": "Common Mistakes", + "translation": "Translations provided by Osmose" + }, "improveOSM": { "title": "ImproveOSM Detection", "geometry_types": { @@ -1653,7 +1665,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/).", "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." }, diff --git a/modules/modes/select_error.js b/modules/modes/select_error.js index 774822b686..0af8b3acb7 100644 --- a/modules/modes/select_error.js +++ b/modules/modes/select_error.js @@ -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; -} +} \ No newline at end of file diff --git a/modules/osm/qa_error.js b/modules/osm/qa_error.js index 7e18de2371..d76893d015 100644 --- a/modules/osm/qa_error.js +++ b/modules/osm/qa_error.js @@ -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)} } -}); +}); \ No newline at end of file diff --git a/modules/services/improveOSM.js b/modules/services/improveOSM.js index 71c5f98978..281052a08f 100644 --- a/modules/services/improveOSM.js +++ b/modules/services/improveOSM.js @@ -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; } -}; +}; \ No newline at end of file diff --git a/modules/services/index.js b/modules/services/index.js index 838ed435f0..ab9aa55034 100644 --- a/modules/services/index.js +++ b/modules/services/index.js @@ -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 -}; +}; \ No newline at end of file diff --git a/modules/services/osmose.js b/modules/services/osmose.js new file mode 100644 index 0000000000..e34260e0e4 --- /dev/null +++ b/modules/services/osmose.js @@ -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, '$1'); + return string.replace(/`(.+?)`/g, '$1'); + }; + + // 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); + }); + }); +} diff --git a/modules/svg/improveOSM.js b/modules/svg/improveOSM.js index 1c55402996..9dee085535 100644 --- a/modules/svg/improveOSM.js +++ b/modules/svg/improveOSM.js @@ -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; -} +} \ No newline at end of file diff --git a/modules/svg/layers.js b/modules/svg/layers.js index 12595a7d7b..d0c46df537 100644 --- a/modules/svg/layers.js +++ b/modules/svg/layers.js @@ -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'); -} +} \ No newline at end of file diff --git a/modules/svg/osmose.js b/modules/svg/osmose.js new file mode 100644 index 0000000000..1fb565080e --- /dev/null +++ b/modules/svg/osmose.js @@ -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; +} diff --git a/modules/ui/commit.js b/modules/ui/commit.js index 3713705df7..dc2af6da2a 100644 --- a/modules/ui/commit.js +++ b/modules/ui/commit.js @@ -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); } } diff --git a/modules/ui/improveOSM_details.js b/modules/ui/improveOSM_details.js index c7d33a0760..435c09c519 100644 --- a/modules/ui/improveOSM_details.js +++ b/modules/ui/improveOSM_details.js @@ -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; -} +} \ No newline at end of file diff --git a/modules/ui/improveOSM_header.js b/modules/ui/improveOSM_header.js index f9ae383279..bc0fb28b38 100644 --- a/modules/ui/improveOSM_header.js +++ b/modules/ui/improveOSM_header.js @@ -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; -} +} \ No newline at end of file diff --git a/modules/ui/keepRight_details.js b/modules/ui/keepRight_details.js index c292bf0aea..966e75f0e1 100644 --- a/modules/ui/keepRight_details.js +++ b/modules/ui/keepRight_details.js @@ -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; -} +} \ No newline at end of file diff --git a/modules/ui/map_data.js b/modules/ui/map_data.js index 2f1fa57bdf..d6362a5182 100644 --- a/modules/ui/map_data.js +++ b/modules/ui/map_data.js @@ -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; -} +} \ No newline at end of file diff --git a/modules/ui/osmose_details.js b/modules/ui/osmose_details.js new file mode 100644 index 0000000000..1740099007 --- /dev/null +++ b/modules/ui/osmose_details.js @@ -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; +} diff --git a/modules/ui/osmose_editor.js b/modules/ui/osmose_editor.js new file mode 100644 index 0000000000..4571eeb56a --- /dev/null +++ b/modules/ui/osmose_editor.js @@ -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'); +} \ No newline at end of file diff --git a/modules/ui/osmose_header.js b/modules/ui/osmose_header.js new file mode 100644 index 0000000000..4016b4b711 --- /dev/null +++ b/modules/ui/osmose_header.js @@ -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; +} diff --git a/modules/ui/sidebar.js b/modules/ui/sidebar.js index 74ceeb52f3..ebf9dd6a8f 100644 --- a/modules/ui/sidebar.js +++ b/modules/ui/sidebar.js @@ -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; -} +} \ No newline at end of file diff --git a/modules/util/locale.js b/modules/util/locale.js index 44710a5462..e003b759b6 100644 --- a/modules/util/locale.js +++ b/modules/util/locale.js @@ -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 -} +} \ No newline at end of file diff --git a/scripts/build_data.js b/scripts/build_data.js index 1dfbee72f2..30e2f49797 100644 --- a/scripts/build_data.js +++ b/scripts/build_data.js @@ -67,7 +67,6 @@ function buildData() { let faIcons = { 'fas-i-cursor': {}, 'fas-lock': {}, - 'fas-long-arrow-alt-right': {}, 'fas-th-list': {}, 'fas-user-cog': {} }; @@ -90,7 +89,7 @@ function buildData() { 'dist/data/*', 'svg/fontawesome/*.svg', ]); - + readQAErrorIcons(faIcons, tnpIcons); let categories = generateCategories(tstrings, faIcons, tnpIcons); let fields = generateFields(tstrings, faIcons, tnpIcons, searchableFieldIDs); let presets = generatePresets(tstrings, faIcons, tnpIcons, searchableFieldIDs); @@ -172,6 +171,27 @@ function validate(file, instance, schema) { } +function readQAErrorIcons(faIcons, tnpIcons) { + const qa = read('data/qa_errors.json'); + + for (const service in qa.services) { + for (const error in qa.services[service].errorIcons) { + const icon = qa.services[service] + .errorIcons[error]; + + // fontawesome icon, remember for later + if (/^fa[srb]-/.test(icon)) { + faIcons[icon] = {}; + } + // noun project icon, remember for later + if (/^tnp-/.test(icon)) { + tnpIcons[icon] = {}; + } + } + } +} + + function generateCategories(tstrings, faIcons, tnpIcons) { let categories = {}; diff --git a/svg/fontawesome/far-clone.svg b/svg/fontawesome/far-clone.svg new file mode 100644 index 0000000000..c3e65e21e7 --- /dev/null +++ b/svg/fontawesome/far-clone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svg/fontawesome/far-dot-circle.svg b/svg/fontawesome/far-dot-circle.svg new file mode 100644 index 0000000000..9decb11459 --- /dev/null +++ b/svg/fontawesome/far-dot-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svg/fontawesome/far-times-circle.svg b/svg/fontawesome/far-times-circle.svg new file mode 100644 index 0000000000..25b42d56e3 --- /dev/null +++ b/svg/fontawesome/far-times-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svg/fontawesome/fas-calendar-alt.svg b/svg/fontawesome/fas-calendar-alt.svg new file mode 100644 index 0000000000..872f8a4ee0 --- /dev/null +++ b/svg/fontawesome/fas-calendar-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svg/fontawesome/fas-question.svg b/svg/fontawesome/fas-question.svg new file mode 100644 index 0000000000..2a3a5c6639 --- /dev/null +++ b/svg/fontawesome/fas-question.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svg/fontawesome/fas-shapes.svg b/svg/fontawesome/fas-shapes.svg new file mode 100644 index 0000000000..46100668b2 --- /dev/null +++ b/svg/fontawesome/fas-shapes.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svg/fontawesome/fas-share-alt.svg b/svg/fontawesome/fas-share-alt.svg new file mode 100644 index 0000000000..40bb2fae51 --- /dev/null +++ b/svg/fontawesome/fas-share-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svg/fontawesome/fas-sort-alpha-up.svg b/svg/fontawesome/fas-sort-alpha-up.svg new file mode 100644 index 0000000000..8ebf95f8f3 --- /dev/null +++ b/svg/fontawesome/fas-sort-alpha-up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svg/fontawesome/fas-tags.svg b/svg/fontawesome/fas-tags.svg new file mode 100644 index 0000000000..39e3969bea --- /dev/null +++ b/svg/fontawesome/fas-tags.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svg/fontawesome/fas-tint-slash.svg b/svg/fontawesome/fas-tint-slash.svg new file mode 100644 index 0000000000..e424cff658 --- /dev/null +++ b/svg/fontawesome/fas-tint-slash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svg/fontawesome/fas-weight-hanging.svg b/svg/fontawesome/fas-weight-hanging.svg new file mode 100644 index 0000000000..10ce728a47 --- /dev/null +++ b/svg/fontawesome/fas-weight-hanging.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svg/fontawesome/fas-yin-yang.svg b/svg/fontawesome/fas-yin-yang.svg new file mode 100644 index 0000000000..5d70c62f43 --- /dev/null +++ b/svg/fontawesome/fas-yin-yang.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test/spec/svg/layers.js b/test/spec/svg/layers.js index 09b2b85ce7..4a24eaacbe 100644 --- a/test/spec/svg/layers.js +++ b/test/spec/svg/layers.js @@ -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; }); -}); +}); \ No newline at end of file