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