From 51efd5b7144fda0d9771cf1f6995b9a7bb4cdbe7 Mon Sep 17 00:00:00 2001 From: SilentSpike Date: Wed, 5 Feb 2020 14:23:34 +0000 Subject: [PATCH] Update and standardise QA implementations - ES6ify (now using class syntax to define QAItem objects) - Fix bug with KeepRight marker rendering not updating properly - Use `qa-` prefix for the UI element classes to differentiate from iD validation error related UI element classes - Move away from "error" where possible in source - Move away from snake_case naming where possible Note that some function/method names have been untouched to make life easier for v3 development. Have added note comments where appropriate. --- css/20_map.css | 14 +- css/55_cursors.css | 6 +- css/65_data.css | 98 ++-- css/80_app.css | 38 +- data/qa_data.json | 91 +++ data/qa_errors.json | 93 --- modules/behavior/hover.js | 6 +- modules/behavior/select.js | 4 +- modules/core/context.js | 1 + modules/modes/select_error.js | 8 +- modules/osm/index.js | 2 +- modules/osm/qa_error.js | 63 -- modules/osm/qa_item.js | 46 ++ modules/services/improveOSM.js | 876 ++++++++++++++-------------- modules/services/keepRight.js | 921 +++++++++++++++--------------- modules/services/osmose.js | 223 ++++---- modules/svg/improveOSM.js | 446 +++++++-------- modules/svg/keepRight.js | 422 +++++++------- modules/svg/osmose.js | 458 +++++++-------- modules/ui/commit.js | 10 +- modules/ui/improveOSM_comments.js | 141 +++-- modules/ui/improveOSM_details.js | 231 ++++---- modules/ui/improveOSM_editor.js | 350 ++++++------ modules/ui/improveOSM_header.js | 140 ++--- modules/ui/keepRight_details.js | 234 ++++---- modules/ui/keepRight_editor.js | 369 ++++++------ modules/ui/keepRight_header.js | 95 ++- modules/ui/osmose_details.js | 42 +- modules/ui/osmose_editor.js | 243 ++++---- modules/ui/osmose_header.js | 133 ++--- modules/ui/sidebar.js | 18 +- modules/ui/view_on_keepRight.js | 65 +-- scripts/build_data.js | 15 +- 33 files changed, 2823 insertions(+), 3079 deletions(-) create mode 100644 data/qa_data.json delete mode 100644 data/qa_errors.json delete mode 100644 modules/osm/qa_error.js create mode 100644 modules/osm/qa_item.js diff --git a/css/20_map.css b/css/20_map.css index 01962274b..230a8100a 100644 --- a/css/20_map.css +++ b/css/20_map.css @@ -45,7 +45,7 @@ /* `.target` objects are interactive */ /* They can be picked up, clicked, hovered, or things can connect to them */ -.qa_error.target, +.qaItem.target, .note.target, .node.target, .turn .target { @@ -83,7 +83,7 @@ /* points, notes & QA */ /* points, notes, markers */ -g.qa_error .stroke, +g.qaItem .stroke, g.note .stroke { stroke: #222; stroke-width: 1; @@ -91,7 +91,7 @@ g.note .stroke { opacity: 0.6; } -g.qa_error.active .stroke, +g.qaItem.active .stroke, g.note.active .stroke { stroke: #222; stroke-width: 1; @@ -106,7 +106,7 @@ g.point .stroke { } -g.qa_error .shadow, +g.qaItem .shadow, g.point .shadow, g.note .shadow { fill: none; @@ -115,14 +115,14 @@ g.note .shadow { stroke-opacity: 0; } -g.qa_error.hover:not(.selected) .shadow, +g.qaItem.hover:not(.selected) .shadow, g.note.hover:not(.selected) .shadow, g.point.related:not(.selected) .shadow, g.point.hover:not(.selected) .shadow { stroke-opacity: 0.5; } -g.qa_error.selected .shadow, +g.qaItem.selected .shadow, g.note.selected .shadow, g.point.selected .shadow { stroke-opacity: 0.7; @@ -430,4 +430,4 @@ g.vertex.highlighted .shadow { .highlight-edited path.line.shadow.geometry-edited, .highlight-edited path.area.shadow.geometry-edited { stroke: rgb(255, 126, 46); -} +} \ No newline at end of file diff --git a/css/55_cursors.css b/css/55_cursors.css index 0a5adabbe..d28e33e99 100644 --- a/css/55_cursors.css +++ b/css/55_cursors.css @@ -98,10 +98,10 @@ } .mode-browse .note, -.mode-browse .qa_error, +.mode-browse .qaItem, .mode-select .note, -.mode-select .qa_error, +.mode-select .qaItem, .turn rect, .turn circle { cursor: pointer; -} +} \ No newline at end of file diff --git a/css/65_data.css b/css/65_data.css index 1687909e6..371dcfaea 100644 --- a/css/65_data.css +++ b/css/65_data.css @@ -1,10 +1,10 @@ -/* OSM Notes and KeepRight Layers */ +/* OSM Notes and QA Layers */ -.error-header-icon .qa_error-fill, -.layer-keepRight .qa_error .qa_error-fill, -.layer-improveOSM .qa_error .qa_error-fill, -.layer-osmose .qa_error .qa_error-fill { +.qa-header-icon .qaItem-fill, +.layer-keepRight .qaItem .qaItem-fill, +.layer-improveOSM .qaItem .qaItem-fill, +.layer-osmose .qaItem .qaItem-fill { stroke: #333; stroke-width: 1.3px; /* NOTE: likely a better way to scale the icon stroke */ } @@ -43,113 +43,111 @@ height: 15px; } -/* adjustment for error icon */ - -.error-header-icon .preset-icon-28 { +/* adjustment to center QA icons */ +.qa-header-icon .preset-icon-28 { top: auto; left: auto; } - -.error-header-icon { +.qa-header-icon { display: flex; align-items: center; justify-content: center; } -/* Keep Right Errors +/* Keep Right Issues ------------------------------------------------------- */ -.keepRight.error_type-20, /* multiple nodes on same spot */ -.keepRight.error_type-40, /* impossible oneways */ -.keepRight.error_type-210, /* self intersecting ways */ -.keepRight.error_type-270, /* unusual motorway connection */ -.keepRight.error_type-310, /* roundabout issues */ -.keepRight.error_type-320, /* improper _link */ -.keepRight.error_type-350 { /* improper bridge tag */ +.keepRight.itemType-20, /* multiple nodes on same spot */ +.keepRight.itemType-40, /* impossible oneways */ +.keepRight.itemType-210, /* self intersecting ways */ +.keepRight.itemType-270, /* unusual motorway connection */ +.keepRight.itemType-310, /* roundabout issues */ +.keepRight.itemType-320, /* improper _link */ +.keepRight.itemType-350 { /* improper bridge tag */ color: #ff9; } -.keepRight.error_type-50 { /* almost junctions */ +.keepRight.itemType-50 { /* almost junctions */ color: #88f; } -.keepRight.error_type-60, /* deprecated tags */ -.keepRight.error_type-70, /* tagging issues */ -.keepRight.error_type-90, /* motorway without ref */ -.keepRight.error_type-100, /* place of worship without religion */ -.keepRight.error_type-110, /* poi without name */ -.keepRight.error_type-150, /* railway crossing without tag */ -.keepRight.error_type-220, /* misspelled tag */ -.keepRight.error_type-380 { /* non-physical sport tag */ +.keepRight.itemType-60, /* deprecated tags */ +.keepRight.itemType-70, /* tagging issues */ +.keepRight.itemType-90, /* motorway without ref */ +.keepRight.itemType-100, /* place of worship without religion */ +.keepRight.itemType-110, /* poi without name */ +.keepRight.itemType-150, /* railway crossing without tag */ +.keepRight.itemType-220, /* misspelled tag */ +.keepRight.itemType-380 { /* non-physical sport tag */ color: #5d0; } -.keepRight.error_type-130 { /* disconnected ways */ +.keepRight.itemType-130 { /* disconnected ways */ color: #fa3; } -.keepRight.error_type-170 { /* FIXME tag */ +.keepRight.itemType-170 { /* FIXME tag */ color: #ff0; } -.keepRight.error_type-190 { /* intersection without junction */ +.keepRight.itemType-190 { /* intersection without junction */ color: #f33; } -.keepRight.error_type-200 { /* overlapping ways */ +.keepRight.itemType-200 { /* overlapping ways */ color: #fdbf6f; } -.keepRight.error_type-160, /* railway layer conflict */ -.keepRight.error_type-230 { /* layer conflict */ +.keepRight.itemType-160, /* railway layer conflict */ +.keepRight.itemType-230 { /* layer conflict */ color: #b60; } -.keepRight.error_type-280 { /* boundary issues */ +.keepRight.itemType-280 { /* boundary issues */ color: #5f47a0; } -.keepRight.error_type-180, /* relation without type */ -.keepRight.error_type-290 { /* turn restriction issues */ +.keepRight.itemType-180, /* relation without type */ +.keepRight.itemType-290 { /* turn restriction issues */ color: #ace; } -.keepRight.error_type-300, /* missing maxspeed */ -.keepRight.error_type-390 { /* missing tracktype */ +.keepRight.itemType-300, /* missing maxspeed */ +.keepRight.itemType-390 { /* missing tracktype */ color: #090; } -.keepRight.error_type-360, /* language unknown */ -.keepRight.error_type-370, /* doubled places */ -.keepRight.error_type-410 { /* website issues */ +.keepRight.itemType-360, /* language unknown */ +.keepRight.itemType-370, /* doubled places */ +.keepRight.itemType-410 { /* website issues */ color: #f9b; } -.keepRight.error_type-120, /* way without nodes */ -.keepRight.error_type-400 { /* geometry / turn angles */ +.keepRight.itemType-120, /* way without nodes */ +.keepRight.itemType-400 { /* geometry / turn angles */ color: #c35; } -/* ImproveOSM Errors +/* ImproveOSM Issues ------------------------------------------------------- */ -.improveOSM.error_type-ow { /* missing one way */ +.improveOSM.itemType-ow { /* missing one way */ color: #1E90FF; } -.improveOSM.error_type-mr-road { /* missing road */ +.improveOSM.itemType-mr-road { /* missing road */ color: #B452CD; } -.improveOSM.error_type-mr-path { /* missing path */ +.improveOSM.itemType-mr-path { /* missing path */ color: #A0522D; } -.improveOSM.error_type-mr-parking { /* missing parking */ +.improveOSM.itemType-mr-parking { /* missing parking */ color: #EEEE00; } -.improveOSM.error_type-mr-both { /* missing road+parking */ +.improveOSM.itemType-mr-both { /* missing road+parking */ color: #FFA500; } -.improveOSM.error_type-tr { /* missing turn restriction */ +.improveOSM.itemType-tr { /* missing turn restriction */ color: #EC1C24; } diff --git a/css/80_app.css b/css/80_app.css index 67f1b3148..c062a4a3e 100644 --- a/css/80_app.css +++ b/css/80_app.css @@ -649,7 +649,7 @@ button.add-note svg.icon { .field-help-title button.close, .sidebar-component .header button.data-editor-close, .sidebar-component .header button.note-editor-close, -.sidebar-component .header button.error-editor-close, +.sidebar-component .header button.qa-editor-close, .entity-editor-pane .header button.preset-close, .preset-list-pane .header button.preset-choose { position: absolute; @@ -659,7 +659,7 @@ button.add-note svg.icon { [dir='rtl'] .field-help-title button.close, [dir='rtl'] .sidebar-component .header button.data-editor-close, [dir='rtl'] .sidebar-component .header button.note-editor-close, -[dir='rtl'] .sidebar-component .header button.error-editor-close, +[dir='rtl'] .sidebar-component .header button.qa-editor-close, [dir='rtl'] .entity-editor-pane .header button.preset-close, [dir='rtl'] .preset-list-pane .header button.preset-choose { left: 0; @@ -2607,10 +2607,10 @@ input.key-trap { } -/* OSM Note / KeepRight Editors +/* OSM Note / QA Editors ------------------------------------------------------- */ .note-header, -.error-header { +.qa-header { background-color: #f6f6f6; border-radius: 5px; border: 1px solid #ccc; @@ -2620,7 +2620,7 @@ input.key-trap { } .note-header-icon, -.error-header-icon { +.qa-header-icon { background-color: #fff; padding: 10px; flex: 0 0 62px; @@ -2631,14 +2631,14 @@ input.key-trap { border-radius: 5px 0 0 5px; } [dir='rtl'] .note-header-icon, -[dir='rtl'] .error-header-icon { +[dir='rtl'] .qa-header-icon { border-right: unset; border-left: 1px solid #ccc; border-radius: 0 5px 5px 0; } .note-header-icon .icon-wrap, -.error-header-icon .icon-wrap { +.qa-header-icon .icon-wrap { position: absolute; top: 0px; } @@ -2654,7 +2654,7 @@ input.key-trap { } .note-header-label, -.error-header-label { +.qa-header-label { background-color: #f6f6f6; padding: 0 15px; flex: 1 1 100%; @@ -2663,7 +2663,7 @@ input.key-trap { border-radius: 0 5px 5px 0; } [dir='rtl'] .note-header-label, -[dir='rtl'] .error-header-label { +[dir='rtl'] .qa-header-label { border-radius: 5px 0 0 5px; } @@ -2730,11 +2730,11 @@ input.key-trap { } .note-save, -.error-save { +.qa-save { padding-top: 20px; } -.error-details-container { +.qa-details-container { background: #ececec; padding: 10px; margin-top: 20px; @@ -2743,22 +2743,22 @@ input.key-trap { display: flex; flex-direction: column; } -.error-details-description { +.qa-details-description { margin-bottom: 10px; display: flex; flex-direction: column; } -.error-details-description-text::first-letter { +.qa-details-description-text::first-letter { text-transform: capitalize; } -[dir='rtl'] .error-details-description-text::first-letter { +[dir='rtl'] .qa-details-description-text::first-letter { text-transform: none; /* #5877 */ } -.error-details-subsection h4 { +.qa-details-subsection h4 { padding-top: 10px; padding-bottom: 0; } -.error-details code { +.qa-details-container code { padding: .2em .4em; margin: 0; font-size: 85%; @@ -2766,7 +2766,7 @@ input.key-trap { background-color: rgba(27,31,35,.05); border-radius: 3px; } -.error-details + .translation-link { +.qa-details-container + .translation-link { margin-top: 5px; display: flex; flex-direction: row; @@ -2774,7 +2774,7 @@ input.key-trap { } .note-save .new-comment-input, -.error-save .new-comment-input { +.qa-save .new-comment-input { width: 100%; height: 100px; max-height: 300px; @@ -2782,7 +2782,7 @@ input.key-trap { } .note-save .detail-section, -.error-save .detail-section { +.qa-save .detail-section { margin: 10px 0; } diff --git a/data/qa_data.json b/data/qa_data.json new file mode 100644 index 000000000..ad73cc7dd --- /dev/null +++ b/data/qa_data.json @@ -0,0 +1,91 @@ +{ + "improveOSM": { + "icons": { + "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" + } + }, + "osmose": { + "icons": { + "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" + } + } +} \ No newline at end of file diff --git a/data/qa_errors.json b/data/qa_errors.json deleted file mode 100644 index 0d6ed3d0f..000000000 --- a/data/qa_errors.json +++ /dev/null @@ -1,93 +0,0 @@ -{ - "services": { - "improveOSM": { - "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" - } - }, - "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" - } - } - } -} \ No newline at end of file diff --git a/modules/behavior/hover.js b/modules/behavior/hover.js index 6cc286b93..df007dc9b 100644 --- a/modules/behavior/hover.js +++ b/modules/behavior/hover.js @@ -5,7 +5,7 @@ import { select as d3_select } from 'd3-selection'; -import { osmEntity, osmNote, qaError } from '../osm'; +import { osmEntity, osmNote, QAItem } from '../osm'; import { utilKeybinding, utilRebind } from '../util'; /* @@ -131,9 +131,9 @@ export function behaviorHover(context) { entity = datum; selector = '.data' + datum.__featurehash__; - } else if (datum instanceof qaError) { + } else if (datum instanceof QAItem) { entity = datum; - selector = '.' + datum.service + '.error_id-' + datum.id; + selector = '.' + datum.service + '.itemId-' + datum.id; } else if (datum instanceof osmNote) { entity = datum; diff --git a/modules/behavior/select.js b/modules/behavior/select.js index 311db7da0..511c1b827 100644 --- a/modules/behavior/select.js +++ b/modules/behavior/select.js @@ -6,7 +6,7 @@ import { modeSelect } from '../modes/select'; import { modeSelectData } from '../modes/select_data'; import { modeSelectNote } from '../modes/select_note'; import { modeSelectError } from '../modes/select_error'; -import { osmEntity, osmNote, qaError } from '../osm'; +import { osmEntity, osmNote, QAItem } from '../osm'; export function behaviorSelect(context) { @@ -173,7 +173,7 @@ export function behaviorSelect(context) { .selectedNoteID(datum.id) .enter(modeSelectNote(context, datum.id)); - } else if (datum instanceof qaError & !isMultiselect) { // clicked an external QA error + } else if (datum instanceof QAItem & !isMultiselect) { // clicked an external QA issue context .selectedErrorID(datum.id) .enter(modeSelectError(context, datum.id, datum.service)); diff --git a/modules/core/context.js b/modules/core/context.js index 6520fdb75..589e89ac9 100644 --- a/modules/core/context.js +++ b/modules/core/context.js @@ -292,6 +292,7 @@ export function coreContext() { return context; }; + // NOTE: Don't change the name of this until UI v3 is merged let _selectedErrorID; context.selectedErrorID = function(errorID) { if (!arguments.length) return _selectedErrorID; diff --git a/modules/modes/select_error.js b/modules/modes/select_error.js index 0af8b3acb..751d972b2 100644 --- a/modules/modes/select_error.js +++ b/modules/modes/select_error.js @@ -18,7 +18,7 @@ import { uiKeepRightEditor } from '../ui/keepRight_editor'; import { uiOsmoseEditor } from '../ui/osmose_editor'; import { utilKeybinding } from '../util'; - +// NOTE: Don't change name of this until UI v3 is merged export function modeSelectError(context, selectedErrorID, selectedErrorService) { var mode = { id: 'select-error', @@ -118,7 +118,7 @@ export function modeSelectError(context, selectedErrorID, selectedErrorService) if (!checkSelectedID()) return; var selection = context.surface() - .selectAll('.error_id-' + selectedErrorID + '.' + selectedErrorService); + .selectAll('.itemId-' + selectedErrorID + '.' + selectedErrorService); if (selection.empty()) { // Return to browse mode if selected DOM elements have @@ -150,7 +150,7 @@ export function modeSelectError(context, selectedErrorID, selectedErrorService) .call(keybinding.unbind); context.surface() - .selectAll('.qa_error.selected') + .selectAll('.qaItem.selected') .classed('selected hover', false); context.map() @@ -165,4 +165,4 @@ export function modeSelectError(context, selectedErrorID, selectedErrorService) return mode; -} \ No newline at end of file +} diff --git a/modules/osm/index.js b/modules/osm/index.js index 87b34bdd4..8b437967f 100644 --- a/modules/osm/index.js +++ b/modules/osm/index.js @@ -4,7 +4,7 @@ export { osmNode } from './node'; export { osmNote } from './note'; export { osmRelation } from './relation'; export { osmWay } from './way'; -export { qaError } from './qa_error'; +export { QAItem } from './qa_item'; export { osmIntersection, diff --git a/modules/osm/qa_error.js b/modules/osm/qa_error.js deleted file mode 100644 index d76893d01..000000000 --- a/modules/osm/qa_error.js +++ /dev/null @@ -1,63 +0,0 @@ -import { services } from '../../data/qa_errors.json'; - - -export function qaError() { - if (!(this instanceof qaError)) { - return (new qaError()).initialize(arguments); - } else if (arguments.length) { - this.initialize(arguments); - } -} - -// Generic handling for services without nice IDs -qaError.id = function() { - return qaError.id.next--; -}; - -qaError.id.next = -1; - -Object.assign(qaError.prototype, { - type: 'qaError', - - // All errors need a position - loc: [0, 0], - - // These should be passed in, used to retrieve from qa_errors.json - service: '', - error_type: '', - - initialize: function(sources) { - for (var i = 0; i < sources.length; ++i) { - var source = sources[i]; - for (var prop in source) { - if (Object.prototype.hasOwnProperty.call(source, prop)) { - if (source[prop] === undefined) { - delete this[prop]; - } else { - this[prop] = source[prop]; - } - } - } - } - - // Extract common error information from data - if (this.service && this.error_type) { - var serviceInfo = services[this.service]; - - if (serviceInfo && serviceInfo.errorIcons) { - this.icon = serviceInfo.errorIcons[this.error_type]; - } - } - - // All errors must have an ID for selection - if (!this.id) { - this.id = qaError.id() + ''; // as string - } - - return this; - }, - - update: function(attrs) { - return qaError(this, attrs); // {v: 1 + (this.v || 0)} - } -}); \ No newline at end of file diff --git a/modules/osm/qa_item.js b/modules/osm/qa_item.js new file mode 100644 index 000000000..7f9c48859 --- /dev/null +++ b/modules/osm/qa_item.js @@ -0,0 +1,46 @@ +import * as qaServices from '../../data/qa_data.json'; + +export class QAItem { + constructor(loc, service, itemType, id, props) { + // Store required properties + this.loc = loc; + this.service = service; + this.itemType = itemType; + + // All issues must have an ID for selection, use generic if none specified + this.id = id ? id : `${QAItem.id()}`; + + this.update(props); + + // Some QA services have marker icons to differentiate issues + if (service && itemType) { + const serviceInfo = qaServices[service]; + + if (serviceInfo && serviceInfo.icons) { + this.icon = serviceInfo.icons[itemType]; + } + } + + return this; + } + + update(props) { + // You can't override this inital information + const { loc, service, itemType, id } = this; + + Object.keys(props).forEach(prop => this[prop] = props[prop]); + + this.loc = loc; + this.service = service; + this.itemType = itemType; + this.id = id; + + return this; + } + + // Generic handling for services without nice IDs + static id() { + return this.nextId--; + } +} +QAItem.nextId = -1; diff --git a/modules/services/improveOSM.js b/modules/services/improveOSM.js index 281052a08..6620f1e8e 100644 --- a/modules/services/improveOSM.js +++ b/modules/services/improveOSM.js @@ -4,491 +4,469 @@ import { dispatch as d3_dispatch } from 'd3-dispatch'; import { json as d3_json } from 'd3-fetch'; import { geoExtent, geoVecAdd, geoVecScale } from '../geo'; -import { qaError } from '../osm'; +import { QAItem } from '../osm'; import { serviceOsm } from './index'; import { t } from '../util/locale'; import { utilRebind, utilTiler, utilQsString } from '../util'; -var tiler = utilTiler(); -var dispatch = d3_dispatch('loaded'); - -var _erCache; -var _erZoom = 14; - -var _impOsmUrls = { - ow: 'https://grab.community.improve-osm.org/directionOfFlowService', - mr: 'https://grab.community.improve-osm.org/missingGeoService', - tr: 'https://grab.community.improve-osm.org/turnRestrictionService' +const tiler = utilTiler(); +const dispatch = d3_dispatch('loaded'); +const _tileZoom = 14; +const _impOsmUrls = { + ow: 'https://grab.community.improve-osm.org/directionOfFlowService', + mr: 'https://grab.community.improve-osm.org/missingGeoService', + tr: 'https://grab.community.improve-osm.org/turnRestrictionService' }; +// This gets reassigned if reset +let _cache; + function abortRequest(i) { - Object.values(i).forEach(function(controller) { - if (controller) { - controller.abort(); - } - }); + Object.values(i).forEach(controller => { + if (controller) { + controller.abort(); + } + }); } function abortUnwantedRequests(cache, tiles) { - Object.keys(cache.inflightTile).forEach(function(k) { - var wanted = tiles.find(function(tile) { return k === tile.id; }); - if (!wanted) { - abortRequest(cache.inflightTile[k]); - delete cache.inflightTile[k]; - } - }); -} - - -function encodeErrorRtree(d) { - return { minX: d.loc[0], minY: d.loc[1], maxX: d.loc[0], maxY: d.loc[1], data: d }; -} - - -// replace or remove error from rtree -function updateRtree(item, replace) { - _erCache.rtree.remove(item, function isEql(a, b) { - return a.data.id === b.data.id; - }); - - if (replace) { - _erCache.rtree.insert(item); + Object.keys(cache.inflightTile).forEach(k => { + const wanted = tiles.find(tile => k === tile.id); + if (!wanted) { + abortRequest(cache.inflightTile[k]); + delete cache.inflightTile[k]; } + }); +} + +function encodeIssueRtree(d) { + return { minX: d.loc[0], minY: d.loc[1], maxX: d.loc[0], maxY: d.loc[1], data: d }; +} + +// Replace or remove QAItem from rtree +function updateRtree(item, replace) { + _cache.rtree.remove(item, (a, b) => a.data.id === b.data.id); + + if (replace) { + _cache.rtree.insert(item); + } } function linkErrorObject(d) { - return '' + d + ''; + return `${d}`; } function linkEntity(d) { - return '' + d + ''; + return `${d}`; } function pointAverage(points) { - if (points.length) { - var sum = points.reduce(function(acc, point) { - return geoVecAdd(acc, [point.lon, point.lat]); - }, [0,0]); - return geoVecScale(sum, 1 / points.length); - } else { - return [0,0]; - } + if (points.length) { + const sum = points.reduce( + (acc, point) => geoVecAdd(acc, [point.lon, point.lat]), + [0,0] + ); + return geoVecScale(sum, 1 / points.length); + } else { + return [0,0]; + } } function relativeBearing(p1, p2) { - var angle = Math.atan2(p2.lon - p1.lon, p2.lat - p1.lat); - if (angle < 0) { - angle += 2 * Math.PI; - } + let angle = Math.atan2(p2.lon - p1.lon, p2.lat - p1.lat); + if (angle < 0) { + angle += 2 * Math.PI; + } - // Return degrees - return angle * 180 / Math.PI; + // Return degrees + return angle * 180 / Math.PI; } // Assuming range [0,360) function cardinalDirection(bearing) { - var dir = 45 * Math.round(bearing / 45); - var compass = { - 0: 'north', - 45: 'northeast', - 90: 'east', - 135: 'southeast', - 180: 'south', - 225: 'southwest', - 270: 'west', - 315: 'northwest', - 360: 'north' - }; + const dir = 45 * Math.round(bearing / 45); + const compass = { + 0: 'north', + 45: 'northeast', + 90: 'east', + 135: 'southeast', + 180: 'south', + 225: 'southwest', + 270: 'west', + 315: 'northwest', + 360: 'north' + }; - return t('QA.improveOSM.directions.' + compass[dir]); + return t(`QA.improveOSM.directions.${compass[dir]}`); } // Errors shouldn't obscure eachother function preventCoincident(loc, bumpUp) { - var coincident = false; - do { - // first time, move marker up. after that, move marker right. - var delta = coincident ? [0.00001, 0] : (bumpUp ? [0, 0.00001] : [0, 0]); - loc = geoVecAdd(loc, delta); - var bbox = geoExtent(loc).bbox(); - coincident = _erCache.rtree.search(bbox).length; - } while (coincident); + let coincident = false; + do { + // first time, move marker up. after that, move marker right. + let delta = coincident ? [0.00001, 0] : (bumpUp ? [0, 0.00001] : [0, 0]); + loc = geoVecAdd(loc, delta); + let bbox = geoExtent(loc).bbox(); + coincident = _cache.rtree.search(bbox).length; + } while (coincident); - return loc; + return loc; } export default { - init: function() { - if (!_erCache) { - this.reset(); - } - - this.event = utilRebind(this, dispatch, 'on'); - }, - - reset: function() { - if (_erCache) { - Object.values(_erCache.inflightTile).forEach(abortRequest); - } - _erCache = { - data: {}, - loadedTile: {}, - inflightTile: {}, - inflightPost: {}, - closed: {}, - rtree: new RBush() - }; - }, - - loadErrors: function(projection) { - var options = { - client: 'iD', - status: 'OPEN', - zoom: '19' // Use a high zoom so that clusters aren't returned - }; - - // determine the needed tiles to cover the view - var tiles = tiler - .zoomExtent([_erZoom, _erZoom]) - .getTiles(projection); - - // abort inflight requests that are no longer needed - abortUnwantedRequests(_erCache, tiles); - - // issue new requests.. - tiles.forEach(function(tile) { - if (_erCache.loadedTile[tile.id] || _erCache.inflightTile[tile.id]) return; - - var rect = tile.extent.rectangle(); - var params = Object.assign({}, options, { east: rect[0], south: rect[3], west: rect[2], north: rect[1] }); - - // 3 separate requests to store for each tile - var requests = {}; - - Object.keys(_impOsmUrls).forEach(function(k) { - var v = _impOsmUrls[k]; - // We exclude WATER from missing geometry as it doesn't seem useful - // We use most confident one-way and turn restrictions only, still have false positives - var kParams = Object.assign({}, - params, - (k === 'mr') ? { type: 'PARKING,ROAD,BOTH,PATH' } : { confidenceLevel: 'C1' } - ); - var url = v + '/search?' + utilQsString(kParams); - - var controller = new AbortController(); - requests[k] = controller; - - d3_json(url, { signal: controller.signal }) - .then(function(data) { - delete _erCache.inflightTile[tile.id][k]; - if (!Object.keys(_erCache.inflightTile[tile.id]).length) { - delete _erCache.inflightTile[tile.id]; - _erCache.loadedTile[tile.id] = true; - } - - // Road segments at high zoom == oneways - if (data.roadSegments) { - data.roadSegments.forEach(function(feature) { - // Position error at the approximate middle of the segment - var points = feature.points; - var mid = points.length / 2; - var loc; - - // Even number of points, find midpoint of the middle two - // Odd number of points, use position of very middle point - if (mid % 1 === 0) { - loc = pointAverage([points[mid - 1], points[mid]]); - } else { - mid = points[Math.floor(mid)]; - loc = [mid.lon, mid.lat]; - } - - // One-ways can land on same segment in opposite direction - loc = preventCoincident(loc, false); - - var d = new qaError({ - // Info required for every error - loc: loc, - service: 'improveOSM', - error_type: k, - // Extra details needed for this service - error_key: k, - identifier: { // this is used to post changes to the error - wayId: feature.wayId, - fromNodeId: feature.fromNodeId, - toNodeId: feature.toNodeId - }, - object_id: feature.wayId, - object_type: 'way', - status: feature.status - }); - - // Variables used in the description - d.replacements = { - percentage: feature.percentOfTrips, - num_trips: feature.numberOfTrips, - highway: linkErrorObject(t('QA.keepRight.error_parts.highway')), - from_node: linkEntity('n' + feature.fromNodeId), - to_node: linkEntity('n' + feature.toNodeId) - }; - - _erCache.data[d.id] = d; - _erCache.rtree.insert(encodeErrorRtree(d)); - }); - } - - // Tiles at high zoom == missing roads - if (data.tiles) { - data.tiles.forEach(function(feature) { - var geoType = feature.type.toLowerCase(); - - // Average of recorded points should land on the missing geometry - // Missing geometry could happen to land on another error - var loc = pointAverage(feature.points); - loc = preventCoincident(loc, false); - - var d = new qaError({ - // Info required for every error - loc: loc, - service: 'improveOSM', - error_type: k + '-' + geoType, - // Extra details needed for this service - error_key: k, - identifier: { x: feature.x, y: feature.y }, - status: feature.status - }); - - d.replacements = { - num_trips: feature.numberOfTrips, - geometry_type: t('QA.improveOSM.geometry_types.' + geoType) - }; - - // -1 trips indicates data came from a 3rd party - if (feature.numberOfTrips === -1) { - d.desc = t('QA.improveOSM.error_types.mr.description_alt', d.replacements); - } - - _erCache.data[d.id] = d; - _erCache.rtree.insert(encodeErrorRtree(d)); - }); - } - - // Entities at high zoom == turn restrictions - if (data.entities) { - data.entities.forEach(function(feature) { - // Turn restrictions could be missing at same junction - // We also want to bump the error up so node is accessible - var loc = feature.point; - loc = preventCoincident([loc.lon, loc.lat], true); - - // Elements are presented in a strange way - var ids = feature.id.split(','); - var from_way = ids[0]; - var via_node = ids[3]; - var to_way = ids[2].split(':')[1]; - - var d = new qaError({ - // Info required for every error - loc: loc, - service: 'improveOSM', - error_type: k, - // Extra details needed for this service - error_key: k, - identifier: feature.id, - object_id: via_node, - object_type: 'node', - status: feature.status - }); - - // Travel direction along from_way clarifies the turn restriction - var p1 = feature.segments[0].points[0]; - var p2 = feature.segments[0].points[1]; - - var dir_of_travel = cardinalDirection(relativeBearing(p1, p2)); - - // Variables used in the description - d.replacements = { - num_passed: feature.numberOfPasses, - num_trips: feature.segments[0].numberOfTrips, - turn_restriction: feature.turnType.toLowerCase(), - from_way: linkEntity('w' + from_way), - to_way: linkEntity('w' + to_way), - travel_direction: dir_of_travel, - junction: linkErrorObject(t('QA.keepRight.error_parts.this_node')) - }; - - _erCache.data[d.id] = d; - _erCache.rtree.insert(encodeErrorRtree(d)); - dispatch.call('loaded'); - }); - } - }) - .catch(function() { - delete _erCache.inflightTile[tile.id][k]; - if (!Object.keys(_erCache.inflightTile[tile.id]).length) { - delete _erCache.inflightTile[tile.id]; - _erCache.loadedTile[tile.id] = true; - } - }); - }); - - _erCache.inflightTile[tile.id] = requests; - }); - }, - - getComments: function(d, callback) { - // If comments already retrieved no need to do so again - if (d.comments !== undefined) { - if (callback) callback({}, d); - return; - } - - var key = d.error_key; - var qParams = {}; - - if (key === 'ow') { - qParams = d.identifier; - } else if (key === 'mr') { - qParams.tileX = d.identifier.x; - qParams.tileY = d.identifier.y; - } else if (key === 'tr') { - qParams.targetId = d.identifier; - } - - var url = _impOsmUrls[key] + '/retrieveComments?' + utilQsString(qParams); - - var that = this; - d3_json(url) - .then(function(data) { - // Assign directly for immediate use in the callback - // comments are served newest to oldest - d.comments = data.comments ? data.comments.reverse() : []; - that.replaceError(d); - if (callback) callback(null, d); - }) - .catch(function(err) { - if (callback) callback(err.message); - }); - }, - - postUpdate: function(d, callback) { - if (!serviceOsm.authenticated()) { // Username required in payload - return callback({ message: 'Not Authenticated', status: -3}, d); - } - if (_erCache.inflightPost[d.id]) { - return callback({ message: 'Error update already inflight', status: -2 }, d); - } - - var that = this; - - // Payload can only be sent once username is established - serviceOsm.userDetails(sendPayload); - - function sendPayload(err, user) { - if (err) { return callback(err, d); } - - var key = d.error_key; - var url = _impOsmUrls[key] + '/comment'; - var payload = { - username: user.display_name, - targetIds: [ d.identifier ] - }; - - if (d.newStatus !== undefined) { - payload.status = d.newStatus; - payload.text = 'status changed'; - } - - // Comment take place of default text - if (d.newComment !== undefined) { - payload.text = d.newComment; - } - - var controller = new AbortController(); - _erCache.inflightPost[d.id] = controller; - - var options = { - method: 'POST', - signal: controller.signal, - body: JSON.stringify(payload) - }; - - d3_json(url, options) - .then(function() { - delete _erCache.inflightPost[d.id]; - - // Just a comment, update error in cache - if (d.newStatus === undefined) { - var now = new Date(); - var comments = d.comments ? d.comments : []; - - comments.push({ - username: payload.username, - text: payload.text, - timestamp: now.getTime() / 1000 - }); - - that.replaceError(d.update({ - comments: comments, - newComment: undefined - })); - } else { - that.removeError(d); - if (d.newStatus === 'SOLVED') { - // 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); - }) - .catch(function(err) { - delete _erCache.inflightPost[d.id]; - if (callback) callback(err.message); - }); - } - }, - - - // get all cached errors covering the viewport - getErrors: function(projection) { - var viewport = projection.clipExtent(); - var min = [viewport[0][0], viewport[1][1]]; - var max = [viewport[1][0], viewport[0][1]]; - var bbox = geoExtent(projection.invert(min), projection.invert(max)).bbox(); - - return _erCache.rtree.search(bbox).map(function(d) { - return d.data; - }); - }, - - // get a single error from the cache - getError: function(id) { - return _erCache.data[id]; - }, - - // replace a single error in the cache - replaceError: function(error) { - if (!(error instanceof qaError) || !error.id) return; - - _erCache.data[error.id] = error; - updateRtree(encodeErrorRtree(error), true); // true = replace - return error; - }, - - // remove a single error from the cache - removeError: function(error) { - if (!(error instanceof qaError) || !error.id) return; - - delete _erCache.data[error.id]; - updateRtree(encodeErrorRtree(error), false); // false = remove - }, - - // Used to populate `closed:improveosm` changeset tag - getClosedCounts: function() { - return _erCache.closed; + init() { + if (!_cache) { + this.reset(); } -}; \ No newline at end of file + + this.event = utilRebind(this, dispatch, 'on'); + }, + + reset() { + if (_cache) { + Object.values(_cache.inflightTile).forEach(abortRequest); + } + _cache = { + data: {}, + loadedTile: {}, + inflightTile: {}, + inflightPost: {}, + closed: {}, + rtree: new RBush() + }; + }, + + loadIssues(projection) { + const options = { + client: 'iD', + status: 'OPEN', + zoom: '19' // Use a high zoom so that clusters aren't returned + }; + + // determine the needed tiles to cover the view + const tiles = tiler + .zoomExtent([_tileZoom, _tileZoom]) + .getTiles(projection); + + // abort inflight requests that are no longer needed + abortUnwantedRequests(_cache, tiles); + + // issue new requests.. + tiles.forEach(tile => { + if (_cache.loadedTile[tile.id] || _cache.inflightTile[tile.id]) return; + + const [ east, north, west, south ] = tile.extent.rectangle(); + const params = Object.assign({}, options, { east, south, west, north }); + + // 3 separate requests to store for each tile + const requests = {}; + + Object.keys(_impOsmUrls).forEach(k => { + // We exclude WATER from missing geometry as it doesn't seem useful + // We use most confident one-way and turn restrictions only, still have false positives + const kParams = Object.assign({}, + params, + (k === 'mr') ? { type: 'PARKING,ROAD,BOTH,PATH' } : { confidenceLevel: 'C1' } + ); + const url = `${_impOsmUrls[k]}/search?` + utilQsString(kParams); + const controller = new AbortController(); + + requests[k] = controller; + + d3_json(url, { signal: controller.signal }) + .then(data => { + delete _cache.inflightTile[tile.id][k]; + if (!Object.keys(_cache.inflightTile[tile.id]).length) { + delete _cache.inflightTile[tile.id]; + _cache.loadedTile[tile.id] = true; + } + + // Road segments at high zoom == oneways + if (data.roadSegments) { + data.roadSegments.forEach(feature => { + // Position error at the approximate middle of the segment + const { points, wayId, fromNodeId, toNodeId } = feature; + const itemId = `${wayId}${fromNodeId}${toNodeId}`; + let mid = points.length / 2; + let loc; + + // Even number of points, find midpoint of the middle two + // Odd number of points, use position of very middle point + if (mid % 1 === 0) { + loc = pointAverage([points[mid - 1], points[mid]]); + } else { + mid = points[Math.floor(mid)]; + loc = [mid.lon, mid.lat]; + } + + // One-ways can land on same segment in opposite direction + loc = preventCoincident(loc, false); + + let d = new QAItem(loc, 'improveOSM', k, itemId, { + issueKey: k, // used as a category + identifier: { // used to post changes + wayId, + fromNodeId, + toNodeId + }, + objectId: wayId, + objectType: 'way' + }); + + // Variables used in the description + d.replacements = { + percentage: feature.percentOfTrips, + num_trips: feature.numberOfTrips, + highway: linkErrorObject(t('QA.keepRight.error_parts.highway')), + from_node: linkEntity('n' + feature.fromNodeId), + to_node: linkEntity('n' + feature.toNodeId) + }; + + _cache.data[d.id] = d; + _cache.rtree.insert(encodeIssueRtree(d)); + }); + } + + // Tiles at high zoom == missing roads + if (data.tiles) { + data.tiles.forEach(feature => { + const { type, x, y, numberOfTrips } = feature; + const geoType = type.toLowerCase(); + const itemId = `${geoType}${x}${y}${numberOfTrips}`; + + // Average of recorded points should land on the missing geometry + // Missing geometry could happen to land on another error + let loc = pointAverage(feature.points); + loc = preventCoincident(loc, false); + + let d = new QAItem(loc, 'improveOSM', `${k}-${geoType}`, itemId, { + issueKey: k, + identifier: { x, y } + }); + + d.replacements = { + num_trips: numberOfTrips, + geometry_type: t(`QA.improveOSM.geometry_types.${geoType}`) + }; + + // -1 trips indicates data came from a 3rd party + if (numberOfTrips === -1) { + d.desc = t('QA.improveOSM.error_types.mr.description_alt', d.replacements); + } + + _cache.data[d.id] = d; + _cache.rtree.insert(encodeIssueRtree(d)); + }); + } + + // Entities at high zoom == turn restrictions + if (data.entities) { + data.entities.forEach(feature => { + const { point, id, segments, numberOfPasses, turnType } = feature; + const itemId = `${id.replace(/[,:+#]/g, "_")}`; + + // Turn restrictions could be missing at same junction + // We also want to bump the error up so node is accessible + const loc = preventCoincident([point.lon, point.lat], true); + + // Elements are presented in a strange way + const ids = id.split(','); + const from_way = ids[0]; + const via_node = ids[3]; + const to_way = ids[2].split(':')[1]; + + let d = new QAItem(loc, 'improveOSM', k, itemId, { + issueKey: k, + identifier: id, + objectId: via_node, + objectType: 'node' + }); + + // Travel direction along from_way clarifies the turn restriction + const [ p1, p2 ] = segments[0].points; + const dir_of_travel = cardinalDirection(relativeBearing(p1, p2)); + + // Variables used in the description + d.replacements = { + num_passed: numberOfPasses, + num_trips: segments[0].numberOfTrips, + turn_restriction: turnType.toLowerCase(), + from_way: linkEntity('w' + from_way), + to_way: linkEntity('w' + to_way), + travel_direction: dir_of_travel, + junction: linkErrorObject(t('QA.keepRight.error_parts.this_node')) + }; + + _cache.data[d.id] = d; + _cache.rtree.insert(encodeIssueRtree(d)); + dispatch.call('loaded'); + }); + } + }) + .catch(() => { + delete _cache.inflightTile[tile.id][k]; + if (!Object.keys(_cache.inflightTile[tile.id]).length) { + delete _cache.inflightTile[tile.id]; + _cache.loadedTile[tile.id] = true; + } + }); + }); + + _cache.inflightTile[tile.id] = requests; + }); + }, + + getComments(d, callback) { + // If comments already retrieved no need to do so again + if (d.comments) { + if (callback) callback({}, d); + return; + } + + const key = d.issueKey; + let qParams = {}; + + if (key === 'ow') { + qParams = d.identifier; + } else if (key === 'mr') { + qParams.tileX = d.identifier.x; + qParams.tileY = d.identifier.y; + } else if (key === 'tr') { + qParams.targetId = d.identifier; + } + + const url = `${_impOsmUrls[key]}/retrieveComments?` + utilQsString(qParams); + const after = data => { + // Assign directly for immediate use in the callback + // comments are served newest to oldest + d.comments = data.comments ? data.comments.reverse() : []; + this.replaceItem(d); + if (callback) callback(null, d); + }; + + d3_json(url) + .then(after) + .catch(err => { + if (callback) callback(err.message); + }); + }, + + postUpdate(d, callback) { + if (!serviceOsm.authenticated()) { // Username required in payload + return callback({ message: 'Not Authenticated', status: -3}, d); + } + if (_cache.inflightPost[d.id]) { + return callback({ message: 'Error update already inflight', status: -2 }, d); + } + + // Payload can only be sent once username is established + serviceOsm.userDetails(sendPayload.bind(this)); + + function sendPayload(err, user) { + if (err) { return callback(err, d); } + + const key = d.issueKey; + const url = `${_impOsmUrls[key]}/comment`; + const payload = { + username: user.display_name, + targetIds: [ d.identifier ] + }; + + if (d.newStatus) { + payload.status = d.newStatus; + payload.text = 'status changed'; + } + + // Comment take place of default text + if (d.newComment) { + payload.text = d.newComment; + } + + const controller = new AbortController(); + _cache.inflightPost[d.id] = controller; + + const options = { + method: 'POST', + signal: controller.signal, + body: JSON.stringify(payload) + }; + + d3_json(url, options) + .then(() => { + delete _cache.inflightPost[d.id]; + + // Just a comment, update error in cache + if (!d.newStatus) { + const now = new Date(); + let comments = d.comments ? d.comments : []; + + comments.push({ + username: payload.username, + text: payload.text, + timestamp: now.getTime() / 1000 + }); + + this.replaceItem(d.update({ + comments: comments, + newComment: undefined + })); + } else { + this.removeItem(d); + if (d.newStatus === 'SOLVED') { + // Keep track of the number of issues closed per type to tag the changeset + if (!(d.issueKey in _cache.closed)) { + _cache.closed[d.issueKey] = 0; + } + _cache.closed[d.issueKey] += 1; + } + } + if (callback) callback(null, d); + }) + .catch(err => { + delete _cache.inflightPost[d.id]; + if (callback) callback(err.message); + }); + } + }, + + + // Get all cached QAItems covering the viewport + getItems(projection) { + const viewport = projection.clipExtent(); + const min = [viewport[0][0], viewport[1][1]]; + const max = [viewport[1][0], viewport[0][1]]; + const bbox = geoExtent(projection.invert(min), projection.invert(max)).bbox(); + + return _cache.rtree.search(bbox).map(d => d.data); + }, + + // Get a QAItem from cache + // NOTE: Don't change method name until UI v3 is merged + getError(id) { + return _cache.data[id]; + }, + + // Replace a single QAItem in the cache + replaceItem(issue) { + if (!(issue instanceof QAItem) || !issue.id) return; + + _cache.data[issue.id] = issue; + updateRtree(encodeIssueRtree(issue), true); // true = replace + return issue; + }, + + // Remove a single QAItem from the cache + removeItem(issue) { + if (!(issue instanceof QAItem) || !issue.id) return; + + delete _cache.data[issue.id]; + updateRtree(encodeIssueRtree(issue), false); // false = remove + }, + + // Used to populate `closed:improveosm:*` changeset tags + getClosedCounts() { + return _cache.closed; + } +}; diff --git a/modules/services/keepRight.js b/modules/services/keepRight.js index 48e21da0a..66bfd74b5 100644 --- a/modules/services/keepRight.js +++ b/modules/services/keepRight.js @@ -4,509 +4,496 @@ import { dispatch as d3_dispatch } from 'd3-dispatch'; import { json as d3_json } from 'd3-fetch'; import { geoExtent, geoVecAdd } from '../geo'; -import { qaError } from '../osm'; +import { QAItem } from '../osm'; import { t } from '../util/locale'; import { utilRebind, utilTiler, utilQsString } from '../util'; import { errorTypes, localizeStrings } from '../../data/keepRight.json'; +const tiler = utilTiler(); +const dispatch = d3_dispatch('loaded'); +const _tileZoom = 14; +const _krUrlRoot = 'https://www.keepright.at'; -var tiler = utilTiler(); -var dispatch = d3_dispatch('loaded'); +// This gets reassigned if reset +let _cache; -var _krCache; -var _krZoom = 14; -var _krUrlRoot = 'https://www.keepright.at/'; - -var _krRuleset = [ - // no 20 - multiple node on same spot - these are mostly boundaries overlapping roads - 30, 40, 50, 60, 70, 90, 100, 110, 120, 130, 150, 160, 170, 180, - 190, 191, 192, 193, 194, 195, 196, 197, 198, - 200, 201, 202, 203, 204, 205, 206, 207, 208, 210, 220, - 230, 231, 232, 270, 280, 281, 282, 283, 284, 285, - 290, 291, 292, 293, 294, 295, 296, 297, 298, 300, 310, 311, 312, 313, - 320, 350, 360, 370, 380, 390, 400, 401, 402, 410, 411, 412, 413 +const _krRuleset = [ + // no 20 - multiple node on same spot - these are mostly boundaries overlapping roads + 30, 40, 50, 60, 70, 90, 100, 110, 120, 130, 150, 160, 170, 180, + 190, 191, 192, 193, 194, 195, 196, 197, 198, + 200, 201, 202, 203, 204, 205, 206, 207, 208, 210, 220, + 230, 231, 232, 270, 280, 281, 282, 283, 284, 285, + 290, 291, 292, 293, 294, 295, 296, 297, 298, 300, 310, 311, 312, 313, + 320, 350, 360, 370, 380, 390, 400, 401, 402, 410, 411, 412, 413 ]; function abortRequest(controller) { - if (controller) { - controller.abort(); - } + if (controller) { + controller.abort(); + } } function abortUnwantedRequests(cache, tiles) { - Object.keys(cache.inflightTile).forEach(function(k) { - var wanted = tiles.find(function(tile) { return k === tile.id; }); - if (!wanted) { - abortRequest(cache.inflightTile[k]); - delete cache.inflightTile[k]; - } - }); -} - - -function encodeErrorRtree(d) { - return { minX: d.loc[0], minY: d.loc[1], maxX: d.loc[0], maxY: d.loc[1], data: d }; -} - - -// replace or remove error from rtree -function updateRtree(item, replace) { - _krCache.rtree.remove(item, function isEql(a, b) { - return a.data.id === b.data.id; - }); - - if (replace) { - _krCache.rtree.insert(item); + Object.keys(cache.inflightTile).forEach(k => { + const wanted = tiles.find(tile => k === tile.id); + if (!wanted) { + abortRequest(cache.inflightTile[k]); + delete cache.inflightTile[k]; } + }); +} + + +function encodeIssueRtree(d) { + return { minX: d.loc[0], minY: d.loc[1], maxX: d.loc[0], maxY: d.loc[1], data: d }; +} + + +// Replace or remove QAItem from rtree +function updateRtree(item, replace) { + _cache.rtree.remove(item, (a, b) => a.data.id === b.data.id); + + if (replace) { + _cache.rtree.insert(item); + } } function tokenReplacements(d) { - if (!(d instanceof qaError)) return; + if (!(d instanceof QAItem)) return; - var htmlRegex = new RegExp(/<\/[a-z][\s\S]*>/); - var replacements = {}; + const htmlRegex = new RegExp(/<\/[a-z][\s\S]*>/); + const replacements = {}; - var errorTemplate = errorTypes[d.which_type]; - if (!errorTemplate) { - /* eslint-disable no-console */ - console.log('No Template: ', d.which_type); - console.log(' ', d.description); - /* eslint-enable no-console */ - return; + const issueTemplate = errorTypes[d.whichType]; + if (!issueTemplate) { + /* eslint-disable no-console */ + console.log('No Template: ', d.whichType); + console.log(' ', d.description); + /* eslint-enable no-console */ + return; + } + + // some descriptions are just fixed text + if (!issueTemplate.regex) return; + + // regex pattern should match description with variable details captured + const errorRegex = new RegExp(issueTemplate.regex, 'i'); + const errorMatch = errorRegex.exec(d.description); + if (!errorMatch) { + /* eslint-disable no-console */ + console.log('Unmatched: ', d.whichType); + console.log(' ', d.description); + console.log(' ', errorRegex); + /* eslint-enable no-console */ + return; + } + + for (let i = 1; i < errorMatch.length; i++) { // skip first + let capture = errorMatch[i]; + let idType; + + idType = 'IDs' in issueTemplate ? issueTemplate.IDs[i-1] : ''; + if (idType && capture) { // link IDs if present in the capture + capture = parseError(capture, idType); + } else if (htmlRegex.test(capture)) { // escape any html in non-IDs + capture = '\\' + capture + '\\'; + } else { + const compare = capture.toLowerCase(); + if (localizeStrings[compare]) { // some replacement strings can be localized + capture = t('QA.keepRight.error_parts.' + localizeStrings[compare]); + } } - // some descriptions are just fixed text - if (!errorTemplate.regex) return; + replacements['var' + i] = capture; + } - // regex pattern should match description with variable details captured - var errorRegex = new RegExp(errorTemplate.regex, 'i'); - var errorMatch = errorRegex.exec(d.description); - if (!errorMatch) { - /* eslint-disable no-console */ - console.log('Unmatched: ', d.which_type); - console.log(' ', d.description); - console.log(' ', errorRegex); - /* eslint-enable no-console */ - return; - } - - for (var i = 1; i < errorMatch.length; i++) { // skip first - var capture = errorMatch[i]; - var idType; - - idType = 'IDs' in errorTemplate ? errorTemplate.IDs[i-1] : ''; - if (idType && capture) { // link IDs if present in the capture - capture = parseError(capture, idType); - } else if (htmlRegex.test(capture)) { // escape any html in non-IDs - capture = '\\' + capture + '\\'; - } else { - var compare = capture.toLowerCase(); - if (localizeStrings[compare]) { // some replacement strings can be localized - capture = t('QA.keepRight.error_parts.' + localizeStrings[compare]); - } - } - - replacements['var' + i] = capture; - } - - return replacements; + return replacements; } function parseError(capture, idType) { - var compare = capture.toLowerCase(); - if (localizeStrings[compare]) { // some replacement strings can be localized - capture = t('QA.keepRight.error_parts.' + localizeStrings[compare]); + const compare = capture.toLowerCase(); + if (localizeStrings[compare]) { // some replacement strings can be localized + capture = t('QA.keepRight.error_parts.' + localizeStrings[compare]); + } + + switch (idType) { + // link a string like "this node" + case 'this': + capture = linkErrorObject(capture); + break; + + case 'url': + capture = linkURL(capture); + break; + + // link an entity ID + case 'n': + case 'w': + case 'r': + capture = linkEntity(idType + capture); + break; + + // some errors have more complex ID lists/variance + case '20': + capture = parse20(capture); + break; + case '211': + capture = parse211(capture); + break; + case '231': + capture = parse231(capture); + break; + case '294': + capture = parse294(capture); + break; + case '370': + capture = parse370(capture); + break; + } + + return capture; + + + function linkErrorObject(d) { + return `${d}`; + } + + function linkEntity(d) { + return `${d}`; + } + + function linkURL(d) { + return `${d}`; + } + + // arbitrary node list of form: #ID, #ID, #ID... + function parse211(capture) { + let newList = []; + const items = capture.split(', '); + + items.forEach(item => { + // ID has # at the front + let id = linkEntity('n' + item.slice(1)); + newList.push(id); + }); + + return newList.join(', '); + } + + // arbitrary way list of form: #ID(layer),#ID(layer),#ID(layer)... + function parse231(capture) { + let newList = []; + // unfortunately 'layer' can itself contain commas, so we split on '),' + const items = capture.split('),'); + + items.forEach(item => { + const match = item.match(/\#(\d+)\((.+)\)?/); + if (match !== null && match.length > 2) { + newList.push(linkEntity('w' + match[1]) + ' ' + + t('QA.keepRight.errorTypes.231.layer', { layer: match[2] }) + ); + } + }); + + return newList.join(', '); + } + + // arbitrary node/relation list of form: from node #ID,to relation #ID,to node #ID... + function parse294(capture) { + let newList = []; + const items = capture.split(','); + + items.forEach(item => { + // item of form "from/to node/relation #ID" + item = item.split(' '); + + // to/from role is more clear in quotes + const role = `"${item[0]}"`; + + // first letter of node/relation provides the type + const idType = item[1].slice(0,1); + + // ID has # at the front + let id = item[2].slice(1); + id = linkEntity(idType + id); + + newList.push(`${role} ${item[1]} ${id}`); + }); + + return newList.join(', '); + } + + // may or may not include the string "(including the name 'name')" + function parse370(capture) { + if (!capture) return ''; + + const match = capture.match(/\(including the name (\'.+\')\)/); + if (match && match.length) { + return t('QA.keepRight.errorTypes.370.including_the_name', { name: match[1] }); } + return ''; + } - switch (idType) { - // link a string like "this node" - case 'this': - capture = linkErrorObject(capture); - break; + // arbitrary node list of form: #ID,#ID,#ID... + function parse20(capture) { + let newList = []; + const items = capture.split(','); - case 'url': - capture = linkURL(capture); - break; + items.forEach(item => { + // ID has # at the front + const id = linkEntity('n' + item.slice(1)); + newList.push(id); + }); - // link an entity ID - case 'n': - case 'w': - case 'r': - capture = linkEntity(idType + capture); - break; - - // some errors have more complex ID lists/variance - case '20': - capture = parse20(capture); - break; - case '211': - capture = parse211(capture); - break; - case '231': - capture = parse231(capture); - break; - case '294': - capture = parse294(capture); - break; - case '370': - capture = parse370(capture); - break; - } - - return capture; - - - function linkErrorObject(d) { - return '' + d + ''; - } - - function linkEntity(d) { - return '' + d + ''; - } - - function linkURL(d) { - return '' + d + ''; - } - - // arbitrary node list of form: #ID, #ID, #ID... - function parse211(capture) { - var newList = []; - var items = capture.split(', '); - - items.forEach(function(item) { - // ID has # at the front - var id = linkEntity('n' + item.slice(1)); - newList.push(id); - }); - - return newList.join(', '); - } - - // arbitrary way list of form: #ID(layer),#ID(layer),#ID(layer)... - function parse231(capture) { - var newList = []; - // unfortunately 'layer' can itself contain commas, so we split on '),' - var items = capture.split('),'); - - items.forEach(function(item) { - var match = item.match(/\#(\d+)\((.+)\)?/); - if (match !== null && match.length > 2) { - newList.push(linkEntity('w' + match[1]) + ' ' + - t('QA.keepRight.errorTypes.231.layer', { layer: match[2] }) - ); - } - }); - - return newList.join(', '); - } - - // arbitrary node/relation list of form: from node #ID,to relation #ID,to node #ID... - function parse294(capture) { - var newList = []; - var items = capture.split(','); - - items.forEach(function(item) { - var role; - var idType; - var id; - - // item of form "from/to node/relation #ID" - item = item.split(' '); - - // to/from role is more clear in quotes - role = '"' + item[0] + '"'; - - // first letter of node/relation provides the type - idType = item[1].slice(0,1); - - // ID has # at the front - id = item[2].slice(1); - id = linkEntity(idType + id); - - item = [role, item[1], id].join(' '); - newList.push(item); - }); - - return newList.join(', '); - } - - // may or may not include the string "(including the name 'name')" - function parse370(capture) { - if (!capture) return ''; - - var match = capture.match(/\(including the name (\'.+\')\)/); - if (match !== null && match.length) { - return t('QA.keepRight.errorTypes.370.including_the_name', { name: match[1] }); - } - return ''; - } - - // arbitrary node list of form: #ID,#ID,#ID... - function parse20(capture) { - var newList = []; - var items = capture.split(','); - - items.forEach(function(item) { - // ID has # at the front - var id = linkEntity('n' + item.slice(1)); - newList.push(id); - }); - - return newList.join(', '); - } + return newList.join(', '); + } } export default { - init: function() { - if (!_krCache) { - this.reset(); - } - - this.event = utilRebind(this, dispatch, 'on'); - }, - - reset: function() { - if (_krCache) { - Object.values(_krCache.inflightTile).forEach(abortRequest); - } - - _krCache = { - data: {}, - loadedTile: {}, - inflightTile: {}, - inflightPost: {}, - closed: {}, - rtree: new RBush() - }; - }, - - - // KeepRight API: http://osm.mueschelsoft.de/keepright/interfacing.php - loadErrors: function(projection) { - var options = { format: 'geojson' }; - var rules = _krRuleset.join(); - - // determine the needed tiles to cover the view - var tiles = tiler - .zoomExtent([_krZoom, _krZoom]) - .getTiles(projection); - - // abort inflight requests that are no longer needed - abortUnwantedRequests(_krCache, tiles); - - // issue new requests.. - tiles.forEach(function(tile) { - if (_krCache.loadedTile[tile.id] || _krCache.inflightTile[tile.id]) return; - - var rect = tile.extent.rectangle(); - var params = Object.assign({}, options, { left: rect[0], bottom: rect[3], right: rect[2], top: rect[1] }); - var url = _krUrlRoot + 'export.php?' + utilQsString(params) + '&ch=' + rules; - - var controller = new AbortController(); - _krCache.inflightTile[tile.id] = controller; - - d3_json(url, { signal: controller.signal }) - .then(function(data) { - delete _krCache.inflightTile[tile.id]; - _krCache.loadedTile[tile.id] = true; - if (!data || !data.features || !data.features.length) { - throw new Error('No Data'); - } - - data.features.forEach(function(feature) { - var loc = feature.geometry.coordinates; - var props = feature.properties; - - // if there is a parent, save its error type e.g.: - // Error 191 = "highway-highway" - // Error 190 = "intersections without junctions" (parent) - var errorType = props.error_type; - var errorTemplate = errorTypes[errorType]; - var parentErrorType = (Math.floor(errorType / 10) * 10).toString(); - - // try to handle error type directly, fallback to parent error type. - var whichType = errorTemplate ? errorType : parentErrorType; - var whichTemplate = errorTypes[whichType]; - - // Rewrite a few of the errors at this point.. - // This is done to make them easier to linkify and translate. - switch (whichType) { - case '170': - props.description = 'This feature has a FIXME tag: ' + props.description; - break; - case '292': - case '293': - props.description = props.description.replace('A turn-', 'This turn-'); - break; - case '294': - case '295': - case '296': - case '297': - case '298': - props.description = 'This turn-restriction~' + props.description; - break; - case '300': - props.description = 'This highway is missing a maxspeed tag'; - break; - case '411': - case '412': - case '413': - props.description = 'This feature~' + props.description; - break; - } - - // - move markers slightly so it doesn't obscure the geometry, - // - then move markers away from other coincident markers - var coincident = false; - do { - // first time, move marker up. after that, move marker right. - var delta = coincident ? [0.00001, 0] : [0, 0.00001]; - loc = geoVecAdd(loc, delta); - var bbox = geoExtent(loc).bbox(); - coincident = _krCache.rtree.search(bbox).length; - } while (coincident); - - var d = new qaError({ - // Required values - loc: loc, - service: 'keepRight', - error_type: errorType, - // Extra values for this service - id: props.error_id, - comment: props.comment || null, - description: props.description || '', - error_id: props.error_id, - which_type: whichType, - parent_error_type: parentErrorType, - severity: whichTemplate.severity || 'error', - object_id: props.object_id, - object_type: props.object_type, - schema: props.schema, - title: props.title - }); - - d.replacements = tokenReplacements(d); - - _krCache.data[d.id] = d; - _krCache.rtree.insert(encodeErrorRtree(d)); - }); - - dispatch.call('loaded'); - }) - .catch(function() { - delete _krCache.inflightTile[tile.id]; - _krCache.loadedTile[tile.id] = true; - }); - - }); - }, - - - postKeepRightUpdate: function(d, callback) { - if (_krCache.inflightPost[d.id]) { - return callback({ message: 'Error update already inflight', status: -2 }, d); - } - - var that = this; - var params = { schema: d.schema, id: d.error_id }; - - if (d.state) { - params.st = d.state; - } - if (d.newComment !== undefined) { - params.co = d.newComment; - } - - // NOTE: This throws a CORS err, but it seems successful. - // We don't care too much about the response, so this is fine. - var url = _krUrlRoot + 'comment.php?' + utilQsString(params); - - var controller = new AbortController(); - _krCache.inflightPost[d.id] = controller; - - fetch(url, { method: 'POST', signal: controller.signal }) - .then(function(response) { - delete _krCache.inflightPost[d.id]; - if (!response.ok) { - throw new Error(response.status + ' ' + response.statusText); - } - - if (d.state === 'ignore') { // ignore permanently (false positive) - that.removeError(d); - - } else if (d.state === 'ignore_t') { // ignore temporarily (error fixed) - that.removeError(d); - _krCache.closed[d.schema + ':' + d.error_id] = true; - - } else { - d = that.replaceError(d.update({ - comment: d.newComment, - newComment: undefined, - state: undefined - })); - } - - if (callback) callback(null, d); - }) - .catch(function(err) { - delete _krCache.inflightPost[d.id]; - if (callback) callback(err.message); - }); - }, - - - // get all cached errors covering the viewport - getErrors: function(projection) { - var viewport = projection.clipExtent(); - var min = [viewport[0][0], viewport[1][1]]; - var max = [viewport[1][0], viewport[0][1]]; - var bbox = geoExtent(projection.invert(min), projection.invert(max)).bbox(); - - return _krCache.rtree.search(bbox).map(function(d) { - return d.data; - }); - }, - - - // get a single error from the cache - getError: function(id) { - return _krCache.data[id]; - }, - - - // replace a single error in the cache - replaceError: function(error) { - if (!(error instanceof qaError) || !error.id) return; - - _krCache.data[error.id] = error; - updateRtree(encodeErrorRtree(error), true); // true = replace - return error; - }, - - - // remove a single error from the cache - removeError: function(error) { - if (!(error instanceof qaError) || !error.id) return; - - delete _krCache.data[error.id]; - updateRtree(encodeErrorRtree(error), false); // false = remove - }, - - - errorURL: function(error) { - return _krUrlRoot + 'report_map.php?schema=' + error.schema + '&error=' + error.id; - }, - - - // Get an array of errors closed during this session. - // Used to populate `closed:keepright` changeset tag - getClosedIDs: function() { - return Object.keys(_krCache.closed).sort(); + init() { + if (!_cache) { + this.reset(); } + this.event = utilRebind(this, dispatch, 'on'); + }, + + reset() { + if (_cache) { + Object.values(_cache.inflightTile).forEach(abortRequest); + } + + _cache = { + data: {}, + loadedTile: {}, + inflightTile: {}, + inflightPost: {}, + closed: {}, + rtree: new RBush() + }; + }, + + + // KeepRight API: http://osm.mueschelsoft.de/keepright/interfacing.php + loadIssues(projection) { + const options = { + format: 'geojson', + ch: _krRuleset + }; + + // determine the needed tiles to cover the view + const tiles = tiler + .zoomExtent([_tileZoom, _tileZoom]) + .getTiles(projection); + + // abort inflight requests that are no longer needed + abortUnwantedRequests(_cache, tiles); + + // issue new requests.. + tiles.forEach(tile => { + if (_cache.loadedTile[tile.id] || _cache.inflightTile[tile.id]) return; + + const [ left, top, right, bottom ] = tile.extent.rectangle(); + const params = Object.assign({}, options, { left, bottom, right, top }); + const url = `${_krUrlRoot}/export.php?` + utilQsString(params); + const controller = new AbortController(); + + _cache.inflightTile[tile.id] = controller; + + d3_json(url, { signal: controller.signal }) + .then(data => { + delete _cache.inflightTile[tile.id]; + _cache.loadedTile[tile.id] = true; + if (!data || !data.features || !data.features.length) { + throw new Error('No Data'); + } + + data.features.forEach(feature => { + const { + properties: { + error_type: itemType, + error_id: id, + comment = null, + object_id: objectId, + object_type: objectType, + schema, + title + } + } = feature; + let { + geometry: { coordinates: loc }, + properties: { description = '' } + } = feature; + + // if there is a parent, save its error type e.g.: + // Error 191 = "highway-highway" + // Error 190 = "intersections without junctions" (parent) + const issueTemplate = errorTypes[itemType]; + const parentIssueType = (Math.floor(itemType / 10) * 10).toString(); + + // try to handle error type directly, fallback to parent error type. + const whichType = issueTemplate ? itemType : parentIssueType; + const whichTemplate = errorTypes[whichType]; + + // Rewrite a few of the errors at this point.. + // This is done to make them easier to linkify and translate. + switch (whichType) { + case '170': + description = `This feature has a FIXME tag: ${description}`; + break; + case '292': + case '293': + description = description.replace('A turn-', 'This turn-'); + break; + case '294': + case '295': + case '296': + case '297': + case '298': + description = `This turn-restriction~${description}`; + break; + case '300': + description = 'This highway is missing a maxspeed tag'; + break; + case '411': + case '412': + case '413': + description = `This feature~${description}`; + break; + } + + // move markers slightly so it doesn't obscure the geometry, + // then move markers away from other coincident markers + 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 = _cache.rtree.search(bbox).length; + } while (coincident); + + let d = new QAItem(loc, 'keepRight', itemType, id, { + comment, + description, + whichType, + parentIssueType, + severity: whichTemplate.severity || 'error', + objectId, + objectType, + schema, + title + }); + + d.replacements = tokenReplacements(d); + + _cache.data[id] = d; + _cache.rtree.insert(encodeIssueRtree(d)); + }); + + dispatch.call('loaded'); + }) + .catch(() => { + delete _cache.inflightTile[tile.id]; + _cache.loadedTile[tile.id] = true; + }); + + }); + }, + + + postUpdate(d, callback) { + if (_cache.inflightPost[d.id]) { + return callback({ message: 'Error update already inflight', status: -2 }, d); + } + + const params = { schema: d.schema, id: d.id }; + + if (d.newStatus) { + params.st = d.newStatus; + } + if (d.newComment !== undefined) { + params.co = d.newComment; + } + + // NOTE: This throws a CORS err, but it seems successful. + // We don't care too much about the response, so this is fine. + const url = `${_krUrlRoot}/comment.php?` + utilQsString(params); + const controller = new AbortController(); + + _cache.inflightPost[d.id] = controller; + + // Since this is expected to throw an error just continue as if it worked + // (worst case scenario the request truly fails and issue will show up if iD restarts) + d3_json(url, { signal: controller.signal }) + .finally(() => { + delete _cache.inflightPost[d.id]; + + if (d.newStatus === 'ignore') { + // ignore permanently (false positive) + this.removeItem(d); + } else if (d.newStatus === 'ignore_t') { + // ignore temporarily (error fixed) + this.removeItem(d); + _cache.closed[`${d.schema}:${d.id}`] = true; + } else { + d = this.replaceItem(d.update({ + comment: d.newComment, + newComment: undefined, + newState: undefined + })); + } + + if (callback) callback(null, d); + }); + }, + + // Get all cached QAItems covering the viewport + getItems(projection) { + const viewport = projection.clipExtent(); + const min = [viewport[0][0], viewport[1][1]]; + const max = [viewport[1][0], viewport[0][1]]; + const bbox = geoExtent(projection.invert(min), projection.invert(max)).bbox(); + + return _cache.rtree.search(bbox).map(d => d.data); + }, + + // Get a QAItem from cache + // NOTE: Don't change method name until UI v3 is merged + getError(id) { + return _cache.data[id]; + }, + + // Replace a single QAItem in the cache + replaceItem(item) { + if (!(item instanceof QAItem) || !item.id) return; + + _cache.data[item.id] = item; + updateRtree(encodeIssueRtree(item), true); // true = replace + return item; + }, + + // Remove a single QAItem from the cache + removeItem(item) { + if (!(item instanceof QAItem) || !item.id) return; + + delete _cache.data[item.id]; + updateRtree(encodeIssueRtree(item), false); // false = remove + }, + + issueURL(item) { + return `${_krUrlRoot}/report_map.php?schema=${item.schema}&error=${item.id}`; + }, + + // Get an array of issues closed during this session. + // Used to populate `closed:keepright` changeset tag + getClosedIDs() { + return Object.keys(_cache.closed).sort(); + } + }; diff --git a/modules/services/osmose.js b/modules/services/osmose.js index e34260e0e..d593f8eac 100644 --- a/modules/services/osmose.js +++ b/modules/services/osmose.js @@ -5,23 +5,21 @@ import { json as d3_json } from 'd3-fetch'; import { currentLocale } from '../util/locale'; import { geoExtent, geoVecAdd } from '../geo'; -import { qaError } from '../osm'; +import { QAItem } from '../osm'; import { utilRebind, utilTiler, utilQsString } from '../util'; -import { services as qaServices } from '../../data/qa_errors.json'; +import * as qaServices from '../../data/qa_data.json'; const tiler = utilTiler(); const dispatch = d3_dispatch('loaded'); +const _tileZoom = 14; const _osmoseUrlRoot = 'https://osmose.openstreetmap.fr/en/api/0.3beta'; const _osmoseItems = - Object.keys(qaServices.osmose.errorIcons) + Object.keys(qaServices.osmose.icons) .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; +let _cache; function abortRequest(controller) { if (controller) { @@ -39,20 +37,20 @@ function abortUnwantedRequests(cache, tiles) { }); } -function encodeErrorRtree(d) { +function encodeIssueRtree(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 +// Replace or remove QAItem from rtree function updateRtree(item, replace) { - _erCache.rtree.remove(item, (a, b) => a.data.id === b.data.id); + _cache.rtree.remove(item, (a, b) => a.data.id === b.data.id); if (replace) { - _erCache.rtree.insert(item); + _cache.rtree.insert(item); } } -// Errors shouldn't obscure eachother +// Issues shouldn't obscure eachother function preventCoincident(loc) { let coincident = false; do { @@ -60,7 +58,7 @@ function preventCoincident(loc) { let delta = coincident ? [0.00001, 0] : [0, 0.00001]; loc = geoVecAdd(loc, delta); let bbox = geoExtent(loc).bbox(); - coincident = _erCache.rtree.search(bbox).length; + coincident = _cache.rtree.search(bbox).length; } while (coincident); return loc; @@ -68,7 +66,7 @@ function preventCoincident(loc) { export default { init() { - if (!_erCache) { + if (!_cache) { this.reset(); } @@ -76,77 +74,72 @@ export default { }, reset() { - if (_erCache) { - Object.values(_erCache.inflightTile).forEach(abortRequest); + if (_cache) { + Object.values(_cache.inflightTile).forEach(abortRequest); } - _erCache = { + _cache = { data: {}, loadedTile: {}, inflightTile: {}, inflightPost: {}, closed: {}, - rtree: new RBush() + rtree: new RBush(), + strings: {}, + colors: {} }; }, - loadErrors(projection) { + loadIssues(projection) { let params = { - // Tiles return a maximum # of errors + // Tiles return a maximum # of issues // 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]) + .zoomExtent([_tileZoom, _tileZoom]) .getTiles(projection); // abort inflight requests that are no longer needed - abortUnwantedRequests(_erCache, tiles); + abortUnwantedRequests(_cache, tiles); // issue new requests.. tiles.forEach(tile => { - if (_erCache.loadedTile[tile.id] || _erCache.inflightTile[tile.id]) return; + if (_cache.loadedTile[tile.id] || _cache.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; + _cache.inflightTile[tile.id] = controller; d3_json(url, { signal: controller.signal }) .then(data => { - delete _erCache.inflightTile[tile.id]; - _erCache.loadedTile[tile.id] = true; + delete _cache.inflightTile[tile.id]; + _cache.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}`; + const { item, class: cl, uuid: id } = issue.properties; + /* Osmose issues are uniquely identified by a unique + `item` and `class` combination (both integer values) */ + const itemType = `${item}-${cl}`; - // Filter out unsupported error types (some are too specific or advanced) - if (error_type in qaServices.osmose.errorIcons) { + // Filter out unsupported issue types (some are too specific or advanced) + if (itemType in qaServices.osmose.icons) { 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 - }); + let d = new QAItem(loc, 'osmose', itemType, id, { item }); - // Setting elems here prevents UI error detail requests - if (d.item === 8300 || d.item === 8360) { + // Setting elems here prevents UI detail requests + if (item === 8300 || item === 8360) { d.elems = []; } - _erCache.data[d.id] = d; - _erCache.rtree.insert(encodeErrorRtree(d)); + _cache.data[d.id] = d; + _cache.rtree.insert(encodeIssueRtree(d)); } }); } @@ -154,48 +147,47 @@ export default { dispatch.call('loaded'); }) .catch(() => { - delete _erCache.inflightTile[tile.id]; - _erCache.loadedTile[tile.id] = true; + delete _cache.inflightTile[tile.id]; + _cache.loadedTile[tile.id] = true; }); }); }, - loadErrorDetail(d) { - // Error details only need to be fetched once - if (d.elems !== undefined) { - return Promise.resolve(d); + loadIssueDetail(issue) { + // Issue details only need to be fetched once + if (issue.elems !== undefined) { + return Promise.resolve(issue); } - const url = `${_osmoseUrlRoot}/issue/${d.identifier}?langs=${currentLocale}`; + const url = `${_osmoseUrlRoot}/issue/${issue.id}?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); + issue.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; + issue.detail = data.subtitle; - this.replaceError(d); + this.replaceItem(issue); }; - return jsonPromise(url, cacheDetails) - .then(() => d); + return jsonPromise(url, cacheDetails).then(() => issue); }, loadStrings(callback, locale=currentLocale) { - const issueTypes = Object.keys(qaServices.osmose.errorIcons); + const items = Object.keys(qaServices.osmose.icons); if ( - locale in _stringCache - && Object.keys(_stringCache[locale]).length === issueTypes.length + locale in _cache.strings + && Object.keys(_cache.strings[locale]).length === items.length ) { - if (callback) callback(null, _stringCache[locale]); + if (callback) callback(null, _cache.strings[locale]); return; } // May be partially populated already if some requests were successful - if (!(locale in _stringCache)) { - _stringCache[locale] = {}; + if (!(locale in _cache.strings)) { + _cache.strings[locale] = {}; } const format = string => { @@ -206,9 +198,9 @@ export default { // 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 => { + const allRequests = items.map(itemType => { // No need to request data we already have - if (issueType in _stringCache[locale]) return; + if (itemType in _cache.strings[locale]) return; const cacheData = data => { // Bunch of nested single value arrays of objects @@ -219,7 +211,7 @@ export default { // 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`); + console.log(`Osmose strings request (${itemType}) had unexpected data`); /* eslint-enable no-console */ return; } @@ -227,7 +219,7 @@ export default { // 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; + _cache.colors[itemInt] = color; } // Value of root key will be null if no string exists @@ -240,10 +232,10 @@ export default { if (trap) issueStrings.trap = format(trap.auto); if (fix) issueStrings.fix = format(fix.auto); - _stringCache[locale][issueType] = issueStrings; + _cache.strings[locale][itemType] = issueStrings; }; - const [ item, cl ] = issueType.split('-'); + const [ item, cl ] = itemType.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}`; @@ -252,88 +244,87 @@ export default { }); Promise.all(allRequests) - .then(() => { if (callback) callback(null, _stringCache[locale]); }) + .then(() => { if (callback) callback(null, _cache.strings[locale]); }) .catch(err => { if (callback) callback(err); }); }, - getStrings(issueType, locale=currentLocale) { + getStrings(itemType, locale=currentLocale) { // No need to fallback to English, Osmose API handles this for us - return (locale in _stringCache) ? _stringCache[locale][issueType] : {}; + return (locale in _cache.strings) ? _cache.strings[locale][itemType] : {}; }, getColor(itemType) { - return (itemType in _colorCache) ? _colorCache[itemType] : '#FFFFFF'; + return (itemType in _cache.colors) ? _cache.colors[itemType] : '#FFFFFF'; }, - postUpdate(d, callback) { - if (_erCache.inflightPost[d.id]) { - return callback({ message: 'Error update already inflight', status: -2 }, d); + postUpdate(issue, callback) { + if (_cache.inflightPost[issue.id]) { + return callback({ message: 'Issue update already inflight', status: -2 }, issue); } // UI sets the status to either 'done' or 'false' - let url = `${_osmoseUrlRoot}/issue/${d.identifier}/${d.newStatus}`; + const url = `${_osmoseUrlRoot}/issue/${issue.id}/${issue.newStatus}`; + const controller = new AbortController(); + const after = () => { + delete _cache.inflightPost[issue.id]; - let controller = new AbortController(); - _erCache.inflightPost[d.id] = controller; + this.removeItem(issue); + if (issue.newStatus === 'done') { + // Keep track of the number of issues closed per `item` to tag the changeset + if (!(issue.item in _cache.closed)) { + _cache.closed[issue.item] = 0; + } + _cache.closed[issue.item] += 1; + } + if (callback) callback(null, issue); + }; + + _cache.inflightPost[issue.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); - }) + .then(after) .catch(err => { - delete _erCache.inflightPost[d.id]; + delete _cache.inflightPost[issue.id]; if (callback) callback(err.message); }); }, + // Get all cached QAItems covering the viewport + getItems(projection) { + const viewport = projection.clipExtent(); + const min = [viewport[0][0], viewport[1][1]]; + const max = [viewport[1][0], viewport[0][1]]; + const bbox = geoExtent(projection.invert(min), projection.invert(max)).bbox(); - // 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; - }); + return _cache.rtree.search(bbox).map(d => d.data); }, - // get a single error from the cache + // Get a QAItem from cache + // NOTE: Don't change method name until UI v3 is merged getError(id) { - return _erCache.data[id]; + return _cache.data[id]; }, - // replace a single error in the cache - replaceError(error) { - if (!(error instanceof qaError) || !error.id) return; + // Replace a single QAItem in the cache + replaceItem(item) { + if (!(item instanceof QAItem) || !item.id) return; - _erCache.data[error.id] = error; - updateRtree(encodeErrorRtree(error), true); // true = replace - return error; + _cache.data[item.id] = item; + updateRtree(encodeIssueRtree(item), true); // true = replace + return item; }, - // remove a single error from the cache - removeError(error) { - if (!(error instanceof qaError) || !error.id) return; + // Remove a single QAItem from the cache + removeItem(item) { + if (!(item instanceof QAItem) || !item.id) return; - delete _erCache.data[error.id]; - updateRtree(encodeErrorRtree(error), false); // false = remove + delete _cache.data[item.id]; + updateRtree(encodeIssueRtree(item), false); // false = remove }, // Used to populate `closed:osmose:*` changeset tags getClosedCounts() { - return _erCache.closed; + return _cache.closed; } }; diff --git a/modules/svg/improveOSM.js b/modules/svg/improveOSM.js index 9dee08553..867732116 100644 --- a/modules/svg/improveOSM.js +++ b/modules/svg/improveOSM.js @@ -5,256 +5,232 @@ import { modeBrowse } from '../modes/browse'; import { svgPointTransform } from './helpers'; import { services } from '../services'; -var _improveOsmEnabled = false; -var _errorService; - +let _layerEnabled = false; +let _qaService; export function svgImproveOSM(projection, context, dispatch) { - var throttledRedraw = _throttle(function () { dispatch.call('change'); }, 1000); - var minZoom = 12; - var touchLayer = d3_select(null); - var drawLayer = d3_select(null); - var _improveOsmVisible = false; + const throttledRedraw = _throttle(() => dispatch.call('change'), 1000); + const minZoom = 12; - 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'); + let touchLayer = d3_select(null); + let drawLayer = d3_select(null); + let layerVisible = 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 improveOSM service for fetching issues + function getService() { + if (services.improveOSM && !_qaService) { + _qaService = services.improveOSM; + _qaService.on('loaded', throttledRedraw); + } else if (!services.improveOSM && _qaService) { + _qaService = null; } + return _qaService; + } - // Loosely-coupled improveOSM service for fetching errors. - function getService() { - if (services.improveOSM && !_errorService) { - _errorService = services.improveOSM; - _errorService.on('loaded', throttledRedraw); - } else if (!services.improveOSM && _errorService) { - _errorService = null; - } - - return _errorService; + // Show the markers + function editOn() { + if (!layerVisible) { + layerVisible = true; + drawLayer + .style('display', 'block'); } + } - - // Show the errors - function editOn() { - if (!_improveOsmVisible) { - _improveOsmVisible = true; - drawLayer - .style('display', 'block'); - } + // Immediately remove the markers and their touch targets + function editOff() { + if (layerVisible) { + layerVisible = false; + drawLayer + .style('display', 'none'); + drawLayer.selectAll('.qaItem.improveOSM') + .remove(); + touchLayer.selectAll('.qaItem.improveOSM') + .remove(); } + } + // Enable the layer. This shows the markers and transitions them to visible. + function layerOn() { + editOn(); - // Immediately remove the errors and their touch targets - function editOff() { - if (_improveOsmVisible) { - _improveOsmVisible = false; - drawLayer - .style('display', 'none'); - drawLayer.selectAll('.qa_error.improveOSM') - .remove(); - touchLayer.selectAll('.qa_error.improveOSM') - .remove(); - } - } + drawLayer + .style('opacity', 0) + .transition() + .duration(250) + .style('opacity', 1) + .on('end interrupt', () => dispatch.call('change')); + } + // Disable the layer. This transitions the layer invisible and then hides the markers. + function layerOff() { + throttledRedraw.cancel(); + drawLayer.interrupt(); + touchLayer.selectAll('.qaItem.improveOSM') + .remove(); - // Enable the layer. This shows the errors and transitions them to visible. - function layerOn() { - editOn(); - - drawLayer - .style('opacity', 0) - .transition() - .duration(250) - .style('opacity', 1) - .on('end interrupt', function () { - dispatch.call('change'); - }); - } - - - // Disable the layer. This transitions the layer invisible and then hides the errors. - function layerOff() { - throttledRedraw.cancel(); - drawLayer.interrupt(); - touchLayer.selectAll('.qa_error.improveOSM') - .remove(); - - drawLayer - .transition() - .duration(250) - .style('opacity', 0) - .on('end interrupt', function () { - editOff(); - dispatch.call('change'); - }); - } - - - // Update the error markers - function updateMarkers() { - if (!_improveOsmVisible || !_improveOsmEnabled) return; - - var service = getService(); - var selectedID = context.selectedErrorID(); - var data = (service ? service.getErrors(projection) : []); - var getTransform = svgPointTransform(projection); - - // Draw markers.. - var markers = drawLayer.selectAll('.qa_error.improveOSM') - .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 - ].join(' '); - }); - - markersEnter - .append('polygon') - .call(markerPath, 'shadow'); - - markersEnter - .append('ellipse') - .attr('cx', 0) - .attr('cy', 0) - .attr('rx', 4.5) - .attr('ry', 2) - .attr('class', 'stroke'); - - markersEnter - .append('polygon') - .attr('fill', 'currentColor') - .call(markerPath, 'qa_error-fill'); - - markersEnter - .append('use') - .attr('transform', 'translate(-5.5, -21)') - .attr('class', 'icon-annotation') - .attr('width', '11px') - .attr('height', '11px') - .attr('xlink:href', function(d) { - var picon = d.icon; - - if (!picon) { - return ''; - } else { - var isMaki = /^maki-/.test(picon); - return '#' + picon + (isMaki ? '-11' : ''); - } - }); - - // update - markers - .merge(markersEnter) - .sort(sortY) - .classed('selected', function(d) { return d.id === selectedID; }) - .attr('transform', getTransform); - - - // Draw targets.. - if (touchLayer.empty()) return; - var fillClass = context.getDebug('target') ? 'pink ' : 'nocolor '; - - var targets = touchLayer.selectAll('.qa_error.improveOSM') - .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 ImproveOSM layer and schedule loading errors and updating markers. - function drawImproveOSM(selection) { - var service = getService(); - - var surface = context.surface(); - if (surface && !surface.empty()) { - touchLayer = surface.selectAll('.data-layer.touch .layer-touch.markers'); - } - - drawLayer = selection.selectAll('.layer-improveOSM') - .data(service ? [0] : []); - - drawLayer.exit() - .remove(); - - drawLayer = drawLayer.enter() - .append('g') - .attr('class', 'layer-improveOSM') - .style('display', _improveOsmEnabled ? 'block' : 'none') - .merge(drawLayer); - - if (_improveOsmEnabled) { - if (service && ~~context.map().zoom() >= minZoom) { - editOn(); - service.loadErrors(projection); - updateMarkers(); - } else { - editOff(); - } - } - } - - - // Toggles the layer on and off - drawImproveOSM.enabled = function(val) { - if (!arguments.length) return _improveOsmEnabled; - - _improveOsmEnabled = val; - if (_improveOsmEnabled) { - layerOn(); - } else { - layerOff(); - if (context.selectedErrorID()) { - context.enter(modeBrowse(context)); - } - } - + drawLayer + .transition() + .duration(250) + .style('opacity', 0) + .on('end interrupt', () => { + editOff(); dispatch.call('change'); - return this; - }; + }); + } + + // Update the issue markers + function updateMarkers() { + if (!layerVisible || !_layerEnabled) return; + + const service = getService(); + const selectedID = context.selectedErrorID(); + const data = (service ? service.getItems(projection) : []); + const getTransform = svgPointTransform(projection); + + // Draw markers.. + const markers = drawLayer.selectAll('.qaItem.improveOSM') + .data(data, d => d.id); + + // exit + markers.exit() + .remove(); + + // enter + const markersEnter = markers.enter() + .append('g') + .attr('class', d => `qaItem ${d.service} itemId-${d.id} itemType-${d.itemType}`); + + markersEnter + .append('polygon') + .call(markerPath, 'shadow'); + + markersEnter + .append('ellipse') + .attr('cx', 0) + .attr('cy', 0) + .attr('rx', 4.5) + .attr('ry', 2) + .attr('class', 'stroke'); + + markersEnter + .append('polygon') + .attr('fill', 'currentColor') + .call(markerPath, 'qaItem-fill'); + + markersEnter + .append('use') + .attr('transform', 'translate(-5.5, -21)') + .attr('class', 'icon-annotation') + .attr('width', '11px') + .attr('height', '11px') + .attr('xlink:href', d => { + const picon = d.icon; + + if (!picon) { + return ''; + } else { + const isMaki = /^maki-/.test(picon); + return `#${picon}${isMaki ? '-11' : ''}`; + } + }); + + // update + markers + .merge(markersEnter) + .sort(sortY) + .classed('selected', d => d.id === selectedID) + .attr('transform', getTransform); - drawImproveOSM.supported = function() { - return !!getService(); - }; + // Draw targets.. + if (touchLayer.empty()) return; + const fillClass = context.getDebug('target') ? 'pink ' : 'nocolor '; + const targets = touchLayer.selectAll('.qaItem.improveOSM') + .data(data, d => d.id); - return drawImproveOSM; -} \ No newline at end of file + // 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', d => `qaItem ${d.service} target ${fillClass} itemId-${d.id}`) + .attr('transform', getTransform); + + function sortY(a, b) { + return (a.id === selectedID) ? 1 + : (b.id === selectedID) ? -1 + : b.loc[1] - a.loc[1]; + } + } + + // Draw the ImproveOSM layer and schedule loading issues and updating markers. + function drawImproveOSM(selection) { + const service = getService(); + + const surface = context.surface(); + if (surface && !surface.empty()) { + touchLayer = surface.selectAll('.data-layer.touch .layer-touch.markers'); + } + + drawLayer = selection.selectAll('.layer-improveOSM') + .data(service ? [0] : []); + + drawLayer.exit() + .remove(); + + drawLayer = drawLayer.enter() + .append('g') + .attr('class', 'layer-improveOSM') + .style('display', _layerEnabled ? 'block' : 'none') + .merge(drawLayer); + + if (_layerEnabled) { + if (service && ~~context.map().zoom() >= minZoom) { + editOn(); + service.loadIssues(projection); + updateMarkers(); + } else { + editOff(); + } + } + } + + // Toggles the layer on and off + drawImproveOSM.enabled = function(val) { + if (!arguments.length) return _layerEnabled; + + _layerEnabled = val; + if (_layerEnabled) { + layerOn(); + } else { + layerOff(); + if (context.selectedErrorID()) { + context.enter(modeBrowse(context)); + } + } + + dispatch.call('change'); + return this; + }; + + drawImproveOSM.supported = () => !!getService(); + + return drawImproveOSM; +} diff --git a/modules/svg/keepRight.js b/modules/svg/keepRight.js index 8e19bab42..08c4f578c 100644 --- a/modules/svg/keepRight.js +++ b/modules/svg/keepRight.js @@ -5,246 +5,222 @@ import { modeBrowse } from '../modes/browse'; import { svgPointTransform } from './helpers'; import { services } from '../services'; -var _keepRightEnabled = false; -var _keepRightService; - +let _layerEnabled = false; +let _qaService; export function svgKeepRight(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 _keepRightVisible = false; + const throttledRedraw = _throttle(() => dispatch.call('change'), 1000); + const minZoom = 12; + let touchLayer = d3_select(null); + let drawLayer = d3_select(null); + let layerVisible = false; - function markerPath(selection, klass) { - selection - .attr('class', klass) - .attr('transform', 'translate(-4, -24)') - .attr('d', 'M11.6,6.2H7.1l1.4-5.1C8.6,0.6,8.1,0,7.5,0H2.2C1.7,0,1.3,0.3,1.3,0.8L0,10.2c-0.1,0.6,0.4,1.1,0.9,1.1h4.6l-1.8,7.6C3.6,19.4,4.1,20,4.7,20c0.3,0,0.6-0.2,0.8-0.5l6.9-11.9C12.7,7,12.3,6.2,11.6,6.2z'); + function markerPath(selection, klass) { + selection + .attr('class', klass) + .attr('transform', 'translate(-4, -24)') + .attr('d', 'M11.6,6.2H7.1l1.4-5.1C8.6,0.6,8.1,0,7.5,0H2.2C1.7,0,1.3,0.3,1.3,0.8L0,10.2c-0.1,0.6,0.4,1.1,0.9,1.1h4.6l-1.8,7.6C3.6,19.4,4.1,20,4.7,20c0.3,0,0.6-0.2,0.8-0.5l6.9-11.9C12.7,7,12.3,6.2,11.6,6.2z'); + } + + // Loosely-coupled keepRight service for fetching issues. + function getService() { + if (services.keepRight && !_qaService) { + _qaService = services.keepRight; + _qaService.on('loaded', throttledRedraw); + } else if (!services.keepRight && _qaService) { + _qaService = null; } + return _qaService; + } - // Loosely-coupled keepRight service for fetching errors. - function getService() { - if (services.keepRight && !_keepRightService) { - _keepRightService = services.keepRight; - _keepRightService.on('loaded', throttledRedraw); - } else if (!services.keepRight && _keepRightService) { - _keepRightService = null; - } - - return _keepRightService; + // Show the markers + function editOn() { + if (!layerVisible) { + layerVisible = true; + drawLayer + .style('display', 'block'); } + } - - // Show the errors - function editOn() { - if (!_keepRightVisible) { - _keepRightVisible = true; - drawLayer - .style('display', 'block'); - } + // Immediately remove the markers and their touch targets + function editOff() { + if (layerVisible) { + layerVisible = false; + drawLayer + .style('display', 'none'); + drawLayer.selectAll('.qaItem.keepRight') + .remove(); + touchLayer.selectAll('.qaItem.keepRight') + .remove(); } + } + // Enable the layer. This shows the markers and transitions them to visible. + function layerOn() { + editOn(); - // Immediately remove the errors and their touch targets - function editOff() { - if (_keepRightVisible) { - _keepRightVisible = false; - drawLayer - .style('display', 'none'); - drawLayer.selectAll('.qa_error.keepRight') - .remove(); - touchLayer.selectAll('.qa_error.keepRight') - .remove(); - } - } + drawLayer + .style('opacity', 0) + .transition() + .duration(250) + .style('opacity', 1) + .on('end interrupt', () => dispatch.call('change')); + } + // Disable the layer. This transitions the layer invisible and then hides the markers. + function layerOff() { + throttledRedraw.cancel(); + drawLayer.interrupt(); + touchLayer.selectAll('.qaItem.keepRight') + .remove(); - // Enable the layer. This shows the errors and transitions them to visible. - function layerOn() { - editOn(); - - drawLayer - .style('opacity', 0) - .transition() - .duration(250) - .style('opacity', 1) - .on('end interrupt', function () { - dispatch.call('change'); - }); - } - - - // Disable the layer. This transitions the layer invisible and then hides the errors. - function layerOff() { - throttledRedraw.cancel(); - drawLayer.interrupt(); - touchLayer.selectAll('.qa_error.keepRight') - .remove(); - - drawLayer - .transition() - .duration(250) - .style('opacity', 0) - .on('end interrupt', function () { - editOff(); - dispatch.call('change'); - }); - } - - - // Update the error markers - function updateMarkers() { - if (!_keepRightVisible || !_keepRightEnabled) 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.keepRight') - .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.parent_error_type - ].join(' '); - }); - - markersEnter - .append('ellipse') - .attr('cx', 0.5) - .attr('cy', 1) - .attr('rx', 6.5) - .attr('ry', 3) - .attr('class', 'stroke'); - - markersEnter - .append('path') - .call(markerPath, 'shadow'); - - markersEnter - .append('use') - .attr('class', 'qa_error-fill') - .attr('width', '20px') - .attr('height', '20px') - .attr('x', '-8px') - .attr('y', '-22px') - .attr('xlink:href', '#iD-icon-bolt'); - - // update - markers - .merge(markersEnter) - .sort(sortY) - .classed('selected', function(d) { return d.id === selectedID; }) - .attr('transform', getTransform); - - - // Draw targets.. - if (touchLayer.empty()) return; - var fillClass = context.getDebug('target') ? 'pink ' : 'nocolor '; - - var targets = touchLayer.selectAll('.qa_error.keepRight') - .data(data, function(d) { return d.id; }); - - // exit - targets.exit() - .remove(); - - // enter/update - targets.enter() - .append('rect') - .attr('width', '20px') - .attr('height', '20px') - .attr('x', '-8px') - .attr('y', '-22px') - .merge(targets) - .sort(sortY) - .attr('class', function(d) { - return '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 - : (a.severity === 'error' && b.severity !== 'error') ? 1 - : (b.severity === 'error' && a.severity !== 'error') ? -1 - : b.loc[1] - a.loc[1]; - } - } - - - // Draw the keepRight layer and schedule loading errors and updating markers. - function drawKeepRight(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-keepRight') - .data(service ? [0] : []); - - drawLayer.exit() - .remove(); - - drawLayer = drawLayer.enter() - .append('g') - .attr('class', 'layer-keepRight') - .style('display', _keepRightEnabled ? 'block' : 'none') - .merge(drawLayer); - - if (_keepRightEnabled) { - if (service && ~~context.map().zoom() >= minZoom) { - editOn(); - service.loadErrors(projection); - updateMarkers(); - } else { - editOff(); - } - } - } - - - // Toggles the layer on and off - drawKeepRight.enabled = function(val) { - if (!arguments.length) return _keepRightEnabled; - - _keepRightEnabled = val; - if (_keepRightEnabled) { - layerOn(); - } else { - layerOff(); - if (context.selectedErrorID()) { - context.enter(modeBrowse(context)); - } - } - + drawLayer + .transition() + .duration(250) + .style('opacity', 0) + .on('end interrupt', () => { + editOff(); dispatch.call('change'); - return this; - }; + }); + } + + // Update the issue markers + function updateMarkers() { + if (!layerVisible || !_layerEnabled) return; + + const service = getService(); + const selectedID = context.selectedErrorID(); + const data = (service ? service.getItems(projection) : []); + const getTransform = svgPointTransform(projection); + + // Draw markers.. + const markers = drawLayer.selectAll('.qaItem.keepRight') + .data(data, d => d.id); + + // exit + markers.exit() + .remove(); + + // enter + const markersEnter = markers.enter() + .append('g') + .attr('class', d => `qaItem ${d.service} itemId-${d.id} itemType-${d.parentIssueType}`); + + markersEnter + .append('ellipse') + .attr('cx', 0.5) + .attr('cy', 1) + .attr('rx', 6.5) + .attr('ry', 3) + .attr('class', 'stroke'); + + markersEnter + .append('path') + .call(markerPath, 'shadow'); + + markersEnter + .append('use') + .attr('class', 'qaItem-fill') + .attr('width', '20px') + .attr('height', '20px') + .attr('x', '-8px') + .attr('y', '-22px') + .attr('xlink:href', '#iD-icon-bolt'); + + // update + markers + .merge(markersEnter) + .sort(sortY) + .classed('selected', d => d.id === selectedID) + .attr('transform', getTransform); - drawKeepRight.supported = function() { - return !!getService(); - }; + // Draw targets.. + if (touchLayer.empty()) return; + const fillClass = context.getDebug('target') ? 'pink ' : 'nocolor '; + + const targets = touchLayer.selectAll('.qaItem.keepRight') + .data(data, d => d.id); + + // exit + targets.exit() + .remove(); + + // enter/update + targets.enter() + .append('rect') + .attr('width', '20px') + .attr('height', '20px') + .attr('x', '-8px') + .attr('y', '-22px') + .merge(targets) + .sort(sortY) + .attr('class', d => `qaItem ${d.service} target ${fillClass} itemId-${d.id}`) + .attr('transform', getTransform); - return drawKeepRight; + function sortY(a, b) { + return (a.id === selectedID) ? 1 + : (b.id === selectedID) ? -1 + : (a.severity === 'error' && b.severity !== 'error') ? 1 + : (b.severity === 'error' && a.severity !== 'error') ? -1 + : b.loc[1] - a.loc[1]; + } + } + + // Draw the keepRight layer and schedule loading issues and updating markers. + function drawKeepRight(selection) { + const service = getService(); + + const surface = context.surface(); + if (surface && !surface.empty()) { + touchLayer = surface.selectAll('.data-layer.touch .layer-touch.markers'); + } + + drawLayer = selection.selectAll('.layer-keepRight') + .data(service ? [0] : []); + + drawLayer.exit() + .remove(); + + drawLayer = drawLayer.enter() + .append('g') + .attr('class', 'layer-keepRight') + .style('display', _layerEnabled ? 'block' : 'none') + .merge(drawLayer); + + if (_layerEnabled) { + if (service && ~~context.map().zoom() >= minZoom) { + editOn(); + service.loadIssues(projection); + updateMarkers(); + } else { + editOff(); + } + } + } + + // Toggles the layer on and off + drawKeepRight.enabled = function(val) { + if (!arguments.length) return _layerEnabled; + + _layerEnabled = val; + if (_layerEnabled) { + layerOn(); + } else { + layerOff(); + if (context.selectedErrorID()) { + context.enter(modeBrowse(context)); + } + } + + dispatch.call('change'); + return this; + }; + + drawKeepRight.supported = () => !!getService(); + + return drawKeepRight; } diff --git a/modules/svg/osmose.js b/modules/svg/osmose.js index 1fb565080..e3e7d6617 100644 --- a/modules/svg/osmose.js +++ b/modules/svg/osmose.js @@ -5,261 +5,237 @@ import { modeBrowse } from '../modes/browse'; import { svgPointTransform } from './helpers'; import { services } from '../services'; -var _osmoseEnabled = false; -var _errorService; - +let _layerEnabled = false; +let _qaService; 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; + const throttledRedraw = _throttle(() => dispatch.call('change'), 1000); + const minZoom = 12; - 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'); + let touchLayer = d3_select(null); + let drawLayer = d3_select(null); + let layerVisible = 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 issues + function getService() { + if (services.osmose && !_qaService) { + _qaService = services.osmose; + _qaService.on('loaded', throttledRedraw); + } else if (!services.osmose && _qaService) { + _qaService = null; } + return _qaService; + } - // 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 markers + function editOn() { + if (!layerVisible) { + layerVisible = true; + drawLayer + .style('display', 'block'); } + } - - // Show the errors - function editOn() { - if (!_osmoseVisible) { - _osmoseVisible = true; - drawLayer - .style('display', 'block'); - } + // Immediately remove the markers and their touch targets + function editOff() { + if (layerVisible) { + layerVisible = false; + drawLayer + .style('display', 'none'); + drawLayer.selectAll('.qaItem.osmose') + .remove(); + touchLayer.selectAll('.qaItem.osmose') + .remove(); } + } + // Enable the layer. This shows the markers 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); - // 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)); - } - } - + drawLayer + .style('opacity', 0) + .transition() + .duration(250) + .style('opacity', 1) + .on('end interrupt', () => { dispatch.call('change'); - return this; - }; + }); + } + // Disable the layer. This transitions the layer invisible and then hides the markers. + function layerOff() { + throttledRedraw.cancel(); + drawLayer.interrupt(); + touchLayer.selectAll('.qaItem.osmose') + .remove(); - drawOsmose.supported = function() { - return !!getService(); - }; + drawLayer + .transition() + .duration(250) + .style('opacity', 0) + .on('end interrupt', () => { + editOff(); + dispatch.call('change'); + }); + } + // Update the issue markers + function updateMarkers() { + if (!layerVisible || !_layerEnabled) return; - return drawOsmose; + const service = getService(); + const selectedID = context.selectedErrorID(); + const data = (service ? service.getItems(projection) : []); + const getTransform = svgPointTransform(projection); + + // Draw markers.. + const markers = drawLayer.selectAll('.qaItem.osmose') + .data(data, d => d.id); + + // exit + markers.exit() + .remove(); + + // enter + const markersEnter = markers.enter() + .append('g') + .attr('class', d => `qaItem ${d.service} itemId-${d.id} itemType-${d.itemType}`); + + 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 => service.getColor(d.item)) + .call(markerPath, 'qaItem-fill'); + + markersEnter + .append('use') + .attr('transform', 'translate(-5.5, -21)') + .attr('class', 'icon-annotation') + .attr('width', '11px') + .attr('height', '11px') + .attr('xlink:href', d => { + const picon = d.icon; + + if (!picon) { + return ''; + } else { + const isMaki = /^maki-/.test(picon); + return `#${picon}${isMaki ? '-11' : ''}`; + } + }); + + // update + markers + .merge(markersEnter) + .sort(sortY) + .classed('selected', d => d.id === selectedID) + .attr('transform', getTransform); + + // Draw targets.. + if (touchLayer.empty()) return; + const fillClass = context.getDebug('target') ? 'pink' : 'nocolor'; + + const targets = touchLayer.selectAll('.qaItem.osmose') + .data(data, d => 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', d => `qaItem ${d.service} target ${fillClass} itemId-${d.id}`) + .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 issues and updating markers. + function drawOsmose(selection) { + const service = getService(); + + const 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', _layerEnabled ? 'block' : 'none') + .merge(drawLayer); + + if (_layerEnabled) { + if (service && ~~context.map().zoom() >= minZoom) { + editOn(); + service.loadIssues(projection); + updateMarkers(); + } else { + editOff(); + } + } + } + + // Toggles the layer on and off + drawOsmose.enabled = function(val) { + if (!arguments.length) return _layerEnabled; + + _layerEnabled = val; + if (_layerEnabled) { + layerOn(); + } else { + layerOff(); + if (context.selectedErrorID()) { + context.enter(modeBrowse(context)); + } + } + + dispatch.call('change'); + return this; + }; + + drawOsmose.supported = () => !!getService(); + + return drawOsmose; } diff --git a/modules/ui/commit.js b/modules/ui/commit.js index 3adf1cabb..e3955ca9f 100644 --- a/modules/ui/commit.js +++ b/modules/ui/commit.js @@ -138,7 +138,7 @@ export function uiCommit(context) { // assign tags for closed issues and notes var osmClosed = osm.getClosedIDs(); - var issueType; + var itemType; if (osmClosed.length) { tags['closed:note'] = osmClosed.join(';').substr(0, tagCharLimit); } @@ -150,14 +150,14 @@ export function uiCommit(context) { } if (services.improveOSM) { var iOsmClosed = services.improveOSM.getClosedCounts(); - for (issueType in iOsmClosed) { - tags['closed:improveosm:' + issueType] = iOsmClosed[issueType].toString().substr(0, tagCharLimit); + for (itemType in iOsmClosed) { + tags['closed:improveosm:' + itemType] = iOsmClosed[itemType].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); + for (itemType in osmoseClosed) { + tags['closed:osmose:' + itemType] = osmoseClosed[itemType].toString().substr(0, tagCharLimit); } } diff --git a/modules/ui/improveOSM_comments.js b/modules/ui/improveOSM_comments.js index e1ee629b1..a80d52ddd 100644 --- a/modules/ui/improveOSM_comments.js +++ b/modules/ui/improveOSM_comments.js @@ -6,89 +6,86 @@ import { services } from '../services'; import { utilDetect } from '../util/detect'; export function uiImproveOsmComments() { - var _error; + let _qaItem; + function issueComments(selection) { + // make the div immediately so it appears above the buttons + let comments = selection.selectAll('.comments-container') + .data([0]); - function errorComments(selection) { - // make the div immediately so it appears above the buttons - var comments = selection.selectAll('.comments-container') - .data([0]); + comments = comments.enter() + .append('div') + .attr('class', 'comments-container') + .merge(comments); - comments = comments.enter() - .append('div') - .attr('class', 'comments-container') - .merge(comments); + // must retrieve comments from API before they can be displayed + services.improveOSM.getComments(_qaItem, (err, d) => { + if (!d.comments) return; // nothing to do here - // must retrieve comments from API before they can be displayed - services.improveOSM.getComments(_error, function(err, d) { - if (!d.comments) { return; } // nothing to do here + const commentEnter = comments.selectAll('.comment') + .data(d.comments) + .enter() + .append('div') + .attr('class', 'comment'); - var commentEnter = comments.selectAll('.comment') - .data(d.comments) - .enter() - .append('div') - .attr('class', 'comment'); + commentEnter + .append('div') + .attr('class', 'comment-avatar') + .call(svgIcon('#iD-icon-avatar', 'comment-avatar-icon')); - commentEnter - .append('div') - .attr('class', 'comment-avatar') - .call(svgIcon('#iD-icon-avatar', 'comment-avatar-icon')); + const mainEnter = commentEnter + .append('div') + .attr('class', 'comment-main'); - var mainEnter = commentEnter - .append('div') - .attr('class', 'comment-main'); + const metadataEnter = mainEnter + .append('div') + .attr('class', 'comment-metadata'); - var metadataEnter = mainEnter - .append('div') - .attr('class', 'comment-metadata'); + metadataEnter + .append('div') + .attr('class', 'comment-author') + .each(function(d) { + const osm = services.osm; + let selection = d3_select(this); + if (osm && d.username) { + selection = selection + .append('a') + .attr('class', 'comment-author-link') + .attr('href', osm.userURL(d.username)) + .attr('tabindex', -1) + .attr('target', '_blank'); + } + selection + .text(d => d.username); + }); - metadataEnter - .append('div') - .attr('class', 'comment-author') - .each(function(d) { - var selection = d3_select(this); - var osm = services.osm; - if (osm && d.username) { - selection = selection - .append('a') - .attr('class', 'comment-author-link') - .attr('href', osm.userURL(d.username)) - .attr('tabindex', -1) - .attr('target', '_blank'); - } - selection - .text(function(d) { return d.username; }); - }); + metadataEnter + .append('div') + .attr('class', 'comment-date') + .text(d => t('note.status.commented', { when: localeDateString(d.timestamp) })); - metadataEnter - .append('div') - .attr('class', 'comment-date') - .text(function(d) { - return t('note.status.commented', { when: localeDateString(d.timestamp) }); - }); + mainEnter + .append('div') + .attr('class', 'comment-text') + .append('p') + .text(d => d.text); + }); + } - mainEnter - .append('div') - .attr('class', 'comment-text') - .append('p') - .text(function(d) { return d.text; }); - }); - } + function localeDateString(s) { + if (!s) return null; + const detected = utilDetect(); + const options = { day: 'numeric', month: 'short', year: 'numeric' }; + const d = new Date(s * 1000); // timestamp is served in seconds, date takes ms + if (isNaN(d.getTime())) return null; + return d.toLocaleDateString(detected.locale, options); + } - function localeDateString(s) { - if (!s) return null; - var detected = utilDetect(); - var options = { day: 'numeric', month: 'short', year: 'numeric' }; - var d = new Date(s * 1000); // timestamp is served in seconds, date takes ms - if (isNaN(d.getTime())) return null; - return d.toLocaleDateString(detected.locale, options); - } + issueComments.issue = function(val) { + if (!arguments.length) return _qaItem; + _qaItem = val; + return issueComments; + }; - errorComments.error = function(val) { - if (!arguments.length) return _error; - _error = val; - return errorComments; - }; - - return errorComments; + return issueComments; } diff --git a/modules/ui/improveOSM_details.js b/modules/ui/improveOSM_details.js index 435c09c51..d2b786d77 100644 --- a/modules/ui/improveOSM_details.js +++ b/modules/ui/improveOSM_details.js @@ -1,6 +1,6 @@ import { - event as d3_event, - select as d3_select + event as d3_event, + select as d3_select } from 'd3-selection'; import { dataEn } from '../../data'; @@ -8,130 +8,125 @@ import { modeSelect } from '../modes/select'; import { t } from '../util/locale'; import { utilDisplayName, utilEntityOrMemberSelector, utilEntityRoot } from '../util'; - export function uiImproveOsmDetails(context) { - var _error; + let _qaItem; + + function issueDetail(d) { + const unknown = t('inspector.unknown'); + + if (!d) return unknown; + + if (d.desc) return d.desc; + + const itemType = d.issueKey; + const et = dataEn.QA.improveOSM.error_types[itemType]; + + let detail; + if (et && et.description) { + detail = t(`QA.improveOSM.error_types.${itemType}.description`, d.replacements); + } else { + detail = unknown; + } + + return detail; + } + + function improveOsmDetails(selection) { + const details = selection.selectAll('.error-details') + .data( + (_qaItem ? [_qaItem] : []), + d => `${d.id}-${d.status || 0}` + ); + + details.exit() + .remove(); + + const detailsEnter = details.enter() + .append('div') + .attr('class', 'error-details qa-details-container'); - function errorDetail(d) { - var unknown = t('inspector.unknown'); + // description + const descriptionEnter = detailsEnter + .append('div') + .attr('class', 'qa-details-description'); - if (!d) return unknown; + descriptionEnter + .append('h4') + .text(() => t('QA.keepRight.detail_description')); - if (d.desc) return d.desc; + descriptionEnter + .append('div') + .attr('class', 'qa-details-description-text') + .html(issueDetail); - var errorType = d.error_key; - var et = dataEn.QA.improveOSM.error_types[errorType]; + // If there are entity links in the error message.. + let relatedEntities = []; + descriptionEnter.selectAll('.error_entity_link, .error_object_link') + .each(function() { + const link = d3_select(this); + const isObjectLink = link.classed('error_object_link'); + const entityID = isObjectLink ? + (utilEntityRoot(_qaItem.objectType) + _qaItem.objectId) + : this.textContent; + const entity = context.hasEntity(entityID); - var detail; - if (et && et.description) { - detail = t('QA.improveOSM.error_types.' + errorType + '.description', d.replacements); - } else { - detail = unknown; + relatedEntities.push(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(_qaItem.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 && !isObjectLink) { + const preset = context.presets().match(entity, context.graph()); + name = preset && !preset.isFallback() && preset.name(); // fallback to preset name + } + + if (name) { + this.innerText = name; + } } + }); - return detail; - } - - - function improveOsmDetails(selection) { - var details = selection.selectAll('.error-details') - .data( - (_error ? [_error] : []), - function(d) { return d.id + '-' + (d.status || 0); } - ); - - details.exit() - .remove(); - - var detailsEnter = details.enter() - .append('div') - .attr('class', 'error-details error-details-container'); - - - // description - var descriptionEnter = detailsEnter - .append('div') - .attr('class', 'error-details-description'); - - descriptionEnter - .append('h4') - .text(function() { return t('QA.keepRight.detail_description'); }); - - descriptionEnter - .append('div') - .attr('class', 'error-details-description-text') - .html(errorDetail); - - // If there are entity links in the error message.. - var relatedEntities = []; - descriptionEnter.selectAll('.error_entity_link, .error_object_link') - .each(function() { - var link = d3_select(this); - var isObjectLink = link.classed('error_object_link'); - var entityID = isObjectLink ? - (utilEntityRoot(_error.object_type) + _error.object_id) - : this.textContent; - var entity = context.hasEntity(entityID); - - relatedEntities.push(entityID); - - // Add click handler - link - .on('mouseenter', function() { - context.surface().selectAll(utilEntityOrMemberSelector([entityID], context.graph())) - .classed('hover', true); - }) - .on('mouseleave', function() { - context.surface().selectAll('.hover') - .classed('hover', false); - }) - .on('click', function() { - d3_event.preventDefault(); - var osmlayer = context.layers().layer('osm'); - if (!osmlayer.enabled()) { - osmlayer.enabled(true); - } - - context.map().centerZoom(_error.loc, 20); - - if (entity) { - context.enter(modeSelect(context, [entityID])); - } else { - context.loadEntity(entityID, function() { - context.enter(modeSelect(context, [entityID])); - }); - } - }); - - // Replace with friendly name if possible - // (The entity may not yet be loaded into the graph) - if (entity) { - var name = utilDisplayName(entity); // try to use common name - - if (!name && !isObjectLink) { - var 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(relatedEntities); - context.map().pan([0,0]); // trigger a redraw - } - - - improveOsmDetails.error = function(val) { - if (!arguments.length) return _error; - _error = val; - return improveOsmDetails; - }; - + // Don't hide entities related to this error - #5880 + context.features().forceVisible(relatedEntities); + context.map().pan([0,0]); // trigger a redraw + } + improveOsmDetails.issue = function(val) { + if (!arguments.length) return _qaItem; + _qaItem = val; return improveOsmDetails; -} \ No newline at end of file + }; + + return improveOsmDetails; +} diff --git a/modules/ui/improveOSM_editor.js b/modules/ui/improveOSM_editor.js index dd046627d..a09e86141 100644 --- a/modules/ui/improveOSM_editor.js +++ b/modules/ui/improveOSM_editor.js @@ -14,217 +14,197 @@ import { uiTooltipHtml } from './tooltipHtml'; import { utilNoAuto, utilRebind } from '../util'; - export function uiImproveOsmEditor(context) { - var dispatch = d3_dispatch('change'); - var errorDetails = uiImproveOsmDetails(context); - var errorComments = uiImproveOsmComments(context); - var errorHeader = uiImproveOsmHeader(context); - var quickLinks = uiQuickLinks(); + const dispatch = d3_dispatch('change'); + const qaDetails = uiImproveOsmDetails(context); + const qaComments = uiImproveOsmComments(context); + const qaHeader = uiImproveOsmHeader(context); + const quickLinks = uiQuickLinks(); - var _error; + let _qaItem; + function improveOsmEditor(selection) { + // quick links + const choices = [{ + id: 'zoom_to', + label: 'inspector.zoom_to.title', + tooltip: () => uiTooltipHtml(t('inspector.zoom_to.tooltip_qaItem'), t('inspector.zoom_to.key')), + click: () => context.mode().zoomToSelected() + }]; - function improveOsmEditor(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(); - } - }]; + const headerEnter = selection.selectAll('.header') + .data([0]) + .enter() + .append('div') + .attr('class', 'header fillL'); + headerEnter + .append('button') + .attr('class', 'fr qa-editor-close') + .on('click', () => context.enter(modeBrowse(context))) + .call(svgIcon('#iD-icon-close')); - var header = selection.selectAll('.header') - .data([0]); + headerEnter + .append('h3') + .text(t('QA.improveOSM.title')); - var headerEnter = header.enter() - .append('div') - .attr('class', 'header fillL'); + let body = selection.selectAll('.body') + .data([0]); - headerEnter - .append('button') - .attr('class', 'fr error-editor-close') - .on('click', function() { - context.enter(modeBrowse(context)); - }) - .call(svgIcon('#iD-icon-close')); + body = body.enter() + .append('div') + .attr('class', 'body') + .merge(body); - headerEnter - .append('h3') - .text(t('QA.improveOSM.title')); + const editor = body.selectAll('.qa-editor') + .data([0]); + editor.enter() + .append('div') + .attr('class', 'modal-section qa-editor') + .merge(editor) + .call(qaHeader.issue(_qaItem)) + .call(quickLinks.choices(choices)) + .call(qaDetails.issue(_qaItem)) + .call(qaComments.issue(_qaItem)) + .call(improveOsmSaveSection); + } - var body = selection.selectAll('.body') - .data([0]); + function improveOsmSaveSection(selection) { + const isSelected = (_qaItem && _qaItem.id === context.selectedErrorID()); + const isShown = (_qaItem && (isSelected || _qaItem.newComment || _qaItem.comment)); + let saveSection = selection.selectAll('.qa-save') + .data( + (isShown ? [_qaItem] : []), + d => `${d.id}-${d.status || 0}` + ); - body = body.enter() - .append('div') - .attr('class', 'body') - .merge(body); + // exit + saveSection.exit() + .remove(); - var editor = body.selectAll('.error-editor') - .data([0]); + // enter + const saveSectionEnter = saveSection.enter() + .append('div') + .attr('class', 'qa-save save-section cf'); - 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(errorComments.error(_error)) - .call(improveOsmSaveSection); + saveSectionEnter + .append('h4') + .attr('class', '.qa-save-header') + .text(t('note.newComment')); + + saveSectionEnter + .append('textarea') + .attr('class', 'new-comment-input') + .attr('placeholder', t('QA.keepRight.comment_placeholder')) + .attr('maxlength', 1000) + .property('value', d => d.newComment) + .call(utilNoAuto) + .on('input', changeInput) + .on('blur', changeInput); + + // update + saveSection = saveSectionEnter + .merge(saveSection) + .call(qaSaveButtons); + + function changeInput() { + const input = d3_select(this); + let val = input.property('value').trim(); + + if (val === '') { + val = undefined; + } + + // store the unsaved comment with the issue itself + _qaItem = _qaItem.update({ newComment: val }); + + const qaService = services.improveOSM; + if (qaService) { + qaService.replaceItem(_qaItem); + } + + saveSection + .call(qaSaveButtons); } + } - function improveOsmSaveSection(selection) { - var isSelected = (_error && _error.id === context.selectedErrorID()); - var isShown = (_error && (isSelected || _error.newComment || _error.comment)); - var saveSection = selection.selectAll('.error-save') - .data( - (isShown ? [_error] : []), - function(d) { return d.id + '-' + (d.status || 0); } - ); + function qaSaveButtons(selection) { + const isSelected = (_qaItem && _qaItem.id === context.selectedErrorID()); + let buttonSection = selection.selectAll('.buttons') + .data((isSelected ? [_qaItem] : []), d => d.status + d.id); - // exit - saveSection.exit() - .remove(); + // exit + buttonSection.exit() + .remove(); - // enter - var saveSectionEnter = saveSection.enter() - .append('div') - .attr('class', 'error-save save-section cf'); + // enter + const buttonEnter = buttonSection.enter() + .append('div') + .attr('class', 'buttons'); - saveSectionEnter - .append('h4') - .attr('class', '.error-save-header') - .text(t('note.newComment')); + buttonEnter + .append('button') + .attr('class', 'button comment-button action') + .text(t('QA.keepRight.save_comment')); - saveSectionEnter - .append('textarea') - .attr('class', 'new-comment-input') - .attr('placeholder', t('QA.keepRight.comment_placeholder')) - .attr('maxlength', 1000) - .property('value', function(d) { return d.newComment; }) - .call(utilNoAuto) - .on('input', changeInput) - .on('blur', changeInput); + buttonEnter + .append('button') + .attr('class', 'button close-button action'); - // update - saveSection = saveSectionEnter - .merge(saveSection) - .call(errorSaveButtons); + buttonEnter + .append('button') + .attr('class', 'button ignore-button action'); - function changeInput() { - var input = d3_select(this); - var val = input.property('value').trim(); + // update + buttonSection = buttonSection + .merge(buttonEnter); - if (val === '') { - val = undefined; - } - - // store the unsaved comment with the error itself - _error = _error.update({ newComment: val }); - - var errorService = services.improveOSM; - if (errorService) { - errorService.replaceError(_error); - } - - saveSection - .call(errorSaveButtons); + buttonSection.select('.comment-button') + .attr('disabled', d => d.newComment ? null : true) + .on('click.comment', function(d) { + this.blur(); // avoid keeping focus on the button - #4641 + const qaService = services.improveOSM; + if (qaService) { + qaService.postUpdate(d, (err, item) => dispatch.call('change', item)); } - } + }); - 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; }); + buttonSection.select('.close-button') + .text(d => { + const andComment = (d.newComment ? '_comment' : ''); + return t(`QA.keepRight.close${andComment}`); + }) + .on('click.close', function(d) { + this.blur(); // avoid keeping focus on the button - #4641 + const qaService = services.improveOSM; + if (qaService) { + d.newStatus = 'SOLVED'; + qaService.postUpdate(d, (err, item) => dispatch.call('change', item)); + } + }); - // exit - buttonSection.exit() - .remove(); + buttonSection.select('.ignore-button') + .text(d => { + const andComment = (d.newComment ? '_comment' : ''); + return t(`QA.keepRight.ignore${andComment}`); + }) + .on('click.ignore', function(d) { + this.blur(); // avoid keeping focus on the button - #4641 + const qaService = services.improveOSM; + if (qaService) { + d.newStatus = 'INVALID'; + qaService.postUpdate(d, (err, item) => dispatch.call('change', item)); + } + }); + } - // enter - var buttonEnter = buttonSection.enter() - .append('div') - .attr('class', 'buttons'); + // NOTE: Don't change method name until UI v3 is merged + improveOsmEditor.error = function(val) { + if (!arguments.length) return _qaItem; + _qaItem = val; + return improveOsmEditor; + }; - buttonEnter - .append('button') - .attr('class', 'button comment-button action') - .text(t('QA.keepRight.save_comment')); - - 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('.comment-button') - .attr('disabled', function(d) { - return d.newComment === undefined ? true : null; - }) - .on('click.comment', function(d) { - this.blur(); // avoid keeping focus on the button - #4641 - var errorService = services.improveOSM; - if (errorService) { - errorService.postUpdate(d, function(err, error) { - dispatch.call('change', error); - }); - } - }); - - buttonSection.select('.close-button') - .text(function(d) { - var andComment = (d.newComment !== undefined ? '_comment' : ''); - return t('QA.keepRight.close' + andComment); - }) - .on('click.close', function(d) { - this.blur(); // avoid keeping focus on the button - #4641 - var errorService = services.improveOSM; - if (errorService) { - d.newStatus = 'SOLVED'; - errorService.postUpdate(d, function(err, error) { - dispatch.call('change', error); - }); - } - }); - - buttonSection.select('.ignore-button') - .text(function(d) { - var andComment = (d.newComment !== undefined ? '_comment' : ''); - return t('QA.keepRight.ignore' + andComment); - }) - .on('click.ignore', function(d) { - this.blur(); // avoid keeping focus on the button - #4641 - var errorService = services.improveOSM; - if (errorService) { - d.newStatus = 'INVALID'; - errorService.postUpdate(d, function(err, error) { - dispatch.call('change', error); - }); - } - }); - } - - improveOsmEditor.error = function(val) { - if (!arguments.length) return _error; - _error = val; - return improveOsmEditor; - }; - - - return utilRebind(improveOsmEditor, dispatch, 'on'); + return utilRebind(improveOsmEditor, dispatch, 'on'); } diff --git a/modules/ui/improveOSM_header.js b/modules/ui/improveOSM_header.js index bc0fb28b3..5fffbe2af 100644 --- a/modules/ui/improveOSM_header.js +++ b/modules/ui/improveOSM_header.js @@ -3,94 +3,80 @@ import { t } from '../util/locale'; export function uiImproveOsmHeader() { - var _error; + let _qaItem; + function issueTitle(d) { + const unknown = t('inspector.unknown'); - function errorTitle(d) { - var unknown = t('inspector.unknown'); + if (!d) return unknown; + const { issueKey } = d; + const et = dataEn.QA.improveOSM.error_types[issueKey]; - if (!d) return unknown; - var errorType = d.error_key; - var et = dataEn.QA.improveOSM.error_types[errorType]; - - if (et && et.title) { - return t('QA.improveOSM.error_types.' + errorType + '.title'); - } else { - return unknown; - } + if (et && et.title) { + return t(`QA.improveOSM.error_types.${issueKey}.title`); + } else { + return unknown; } + } + function improveOsmHeader(selection) { + const header = selection.selectAll('.qa-header') + .data( + (_qaItem ? [_qaItem] : []), + d => `${d.id}-${d.status || 0}` + ); - function improveOsmHeader(selection) { - var header = selection.selectAll('.error-header') - .data( - (_error ? [_error] : []), - function(d) { return d.id + '-' + (d.status || 0); } - ); + header.exit() + .remove(); - header.exit() - .remove(); + const headerEnter = header.enter() + .append('div') + .attr('class', 'qa-header'); - var headerEnter = header.enter() - .append('div') - .attr('class', 'error-header'); + const svgEnter = headerEnter + .append('div') + .attr('class', 'qa-header-icon') + .classed('new', d => d.id < 0) + .append('svg') + .attr('width', '20px') + .attr('height', '30px') + .attr('viewbox', '0 0 20 30') + .attr('class', d => `preset-icon-28 qaItem ${d.service} itemId-${d.id} itemType-${d.itemType}`); - var iconEnter = headerEnter - .append('div') - .attr('class', 'error-header-icon') - .classed('new', function(d) { return d.id < 0; }); + svgEnter + .append('polygon') + .attr('fill', 'currentColor') + .attr('class', 'qaItem-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'); - 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 - ].join(' '); - }); + svgEnter + .append('use') + .attr('class', 'icon-annotation') + .attr('width', '11px') + .attr('height', '11px') + .attr('transform', 'translate(4.5, 7)') + .attr('xlink:href', d => { + const picon = d.icon; - svgEnter - .append('polygon') - .attr('fill', 'currentColor') - .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); - } - - - improveOsmHeader.error = function(val) { - if (!arguments.length) return _error; - _error = val; - return improveOsmHeader; - }; + if (!picon) { + return ''; + } else { + const isMaki = /^maki-/.test(picon); + return `#${picon}${isMaki ? '-11' : ''}`; + } + }); + headerEnter + .append('div') + .attr('class', 'qa-header-label') + .text(issueTitle); + } + improveOsmHeader.issue = function(val) { + if (!arguments.length) return _qaItem; + _qaItem = val; return improveOsmHeader; -} \ No newline at end of file + }; + + return improveOsmHeader; +} diff --git a/modules/ui/keepRight_details.js b/modules/ui/keepRight_details.js index 966e75f0e..624e7cdfe 100644 --- a/modules/ui/keepRight_details.js +++ b/modules/ui/keepRight_details.js @@ -1,6 +1,6 @@ import { - event as d3_event, - select as d3_select + event as d3_event, + select as d3_select } from 'd3-selection'; import { dataEn } from '../../data'; @@ -8,132 +8,124 @@ import { modeSelect } from '../modes/select'; import { t } from '../util/locale'; import { utilDisplayName, utilEntityOrMemberSelector, utilEntityRoot } from '../util'; - export function uiKeepRightDetails(context) { - var _error; + let _qaItem; + function issueDetail(d) { + const unknown = t('inspector.unknown'); + if (!d) return unknown; - function errorDetail(d) { - var unknown = t('inspector.unknown'); + const { itemType, parentIssueType, replacements } = d; + const et = dataEn.QA.keepRight.errorTypes[itemType]; + const pt = dataEn.QA.keepRight.errorTypes[parentIssueType]; - if (!d) return unknown; - var errorType = d.error_type; - var parentErrorType = d.parent_error_type; + let detail; + if (et && et.description) { + detail = t(`QA.keepRight.errorTypes.${itemType}.description`, replacements); + } else if (pt && pt.description) { + detail = t(`QA.keepRight.errorTypes.${parentIssueType}.description`, replacements); + } else { + detail = unknown; + } - var et = dataEn.QA.keepRight.errorTypes[errorType]; - var pt = dataEn.QA.keepRight.errorTypes[parentErrorType]; + return detail; + } - var detail; - if (et && et.description) { - detail = t('QA.keepRight.errorTypes.' + errorType + '.description', d.replacements); - } else if (pt && pt.description) { - detail = t('QA.keepRight.errorTypes.' + parentErrorType + '.description', d.replacements); - } else { - detail = unknown; + function keepRightDetails(selection) { + const details = selection.selectAll('.error-details') + .data( + (_qaItem ? [_qaItem] : []), + d => `${d.id}-${d.status || 0}` + ); + + details.exit() + .remove(); + + const detailsEnter = details.enter() + .append('div') + .attr('class', 'error-details qa-details-container'); + + // description + const descriptionEnter = detailsEnter + .append('div') + .attr('class', 'qa-details-description'); + + descriptionEnter + .append('h4') + .text(() => t('QA.keepRight.detail_description')); + + descriptionEnter + .append('div') + .attr('class', 'qa-details-description-text') + .html(issueDetail); + + // If there are entity links in the error message.. + let relatedEntities = []; + descriptionEnter.selectAll('.error_entity_link, .error_object_link') + .each(function() { + const link = d3_select(this); + const isObjectLink = link.classed('error_object_link'); + const entityID = isObjectLink ? + (utilEntityRoot(_qaItem.objectType) + _qaItem.objectId) + : this.textContent; + const entity = context.hasEntity(entityID); + + relatedEntities.push(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().centerZoomEase(_qaItem.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 && !isObjectLink) { + const preset = context.presets().match(entity, context.graph()); + name = preset && !preset.isFallback() && preset.name(); // fallback to preset name + } + + if (name) { + this.innerText = name; + } } + }); - return detail; - } - - - function keepRightDetails(selection) { - var details = selection.selectAll('.error-details') - .data( - (_error ? [_error] : []), - function(d) { return d.id + '-' + (d.status || 0); } - ); - - details.exit() - .remove(); - - var detailsEnter = details.enter() - .append('div') - .attr('class', 'error-details error-details-container'); - - - // description - var descriptionEnter = detailsEnter - .append('div') - .attr('class', 'error-details-description'); - - descriptionEnter - .append('h4') - .text(function() { return t('QA.keepRight.detail_description'); }); - - descriptionEnter - .append('div') - .attr('class', 'error-details-description-text') - .html(errorDetail); - - // If there are entity links in the error message.. - var relatedEntities = []; - descriptionEnter.selectAll('.error_entity_link, .error_object_link') - .each(function() { - var link = d3_select(this); - var isObjectLink = link.classed('error_object_link'); - var entityID = isObjectLink ? - (utilEntityRoot(_error.object_type) + _error.object_id) - : this.textContent; - var entity = context.hasEntity(entityID); - - relatedEntities.push(entityID); - - // Add click handler - link - .on('mouseenter', function() { - context.surface().selectAll(utilEntityOrMemberSelector([entityID], context.graph())) - .classed('hover', true); - }) - .on('mouseleave', function() { - context.surface().selectAll('.hover') - .classed('hover', false); - }) - .on('click', function() { - d3_event.preventDefault(); - var osmlayer = context.layers().layer('osm'); - if (!osmlayer.enabled()) { - osmlayer.enabled(true); - } - - context.map().centerZoomEase(_error.loc, 20); - - if (entity) { - context.enter(modeSelect(context, [entityID])); - } else { - context.loadEntity(entityID, function() { - context.enter(modeSelect(context, [entityID])); - }); - } - }); - - // Replace with friendly name if possible - // (The entity may not yet be loaded into the graph) - if (entity) { - var name = utilDisplayName(entity); // try to use common name - - if (!name && !isObjectLink) { - var 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(relatedEntities); - context.map().pan([0,0]); // trigger a redraw - } - - - keepRightDetails.error = function(val) { - if (!arguments.length) return _error; - _error = val; - return keepRightDetails; - }; - + // Don't hide entities related to this issue - #5880 + context.features().forceVisible(relatedEntities); + context.map().pan([0,0]); // trigger a redraw + } + keepRightDetails.issue = function(val) { + if (!arguments.length) return _qaItem; + _qaItem = val; return keepRightDetails; -} \ No newline at end of file + }; + + return keepRightDetails; +} diff --git a/modules/ui/keepRight_editor.js b/modules/ui/keepRight_editor.js index f866094aa..18fb2306f 100644 --- a/modules/ui/keepRight_editor.js +++ b/modules/ui/keepRight_editor.js @@ -14,229 +14,208 @@ import { uiViewOnKeepRight } from './view_on_keepRight'; import { utilNoAuto, utilRebind } from '../util'; - export function uiKeepRightEditor(context) { - var dispatch = d3_dispatch('change'); - var keepRightDetails = uiKeepRightDetails(context); - var keepRightHeader = uiKeepRightHeader(context); - var quickLinks = uiQuickLinks(); + const dispatch = d3_dispatch('change'); + const qaDetails = uiKeepRightDetails(context); + const qaHeader = uiKeepRightHeader(context); + const quickLinks = uiQuickLinks(); - var _error; + let _qaItem; + + function keepRightEditor(selection) { + // quick links + const choices = [{ + id: 'zoom_to', + label: 'inspector.zoom_to.title', + tooltip: () => uiTooltipHtml(t('inspector.zoom_to.tooltip_qaItem'), t('inspector.zoom_to.key')), + click: () => context.mode().zoomToSelected() + }]; + + const headerEnter = selection.selectAll('.header') + .data([0]) + .enter() + .append('div') + .attr('class', 'header fillL'); + + headerEnter + .append('button') + .attr('class', 'fr qa-editor-close') + .on('click', () => context.enter(modeBrowse(context))) + .call(svgIcon('#iD-icon-close')); + + headerEnter + .append('h3') + .text(t('QA.keepRight.title')); - function keepRightEditor(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(); - } - }]; + let body = selection.selectAll('.body') + .data([0]); + + body = body.enter() + .append('div') + .attr('class', 'body') + .merge(body); + + const editor = body.selectAll('.qa-editor') + .data([0]); + + editor.enter() + .append('div') + .attr('class', 'modal-section qa-editor') + .merge(editor) + .call(qaHeader.issue(_qaItem)) + .call(quickLinks.choices(choices)) + .call(qaDetails.issue(_qaItem)) + .call(keepRightSaveSection); - var header = selection.selectAll('.header') - .data([0]); + const footer = selection.selectAll('.footer') + .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.keepRight.title')); + footer.enter() + .append('div') + .attr('class', 'footer') + .merge(footer) + .call(uiViewOnKeepRight(context).what(_qaItem)); + } - var body = selection.selectAll('.body') - .data([0]); + function keepRightSaveSection(selection) { + const isSelected = (_qaItem && _qaItem.id === context.selectedErrorID()); + const isShown = (_qaItem && (isSelected || _qaItem.newComment || _qaItem.comment)); + let saveSection = selection.selectAll('.qa-save') + .data( + (isShown ? [_qaItem] : []), + d => `${d.id}-${d.status || 0}` + ); - body = body.enter() - .append('div') - .attr('class', 'body') - .merge(body); + // exit + saveSection.exit() + .remove(); - var editor = body.selectAll('.error-editor') - .data([0]); + // enter + const saveSectionEnter = saveSection.enter() + .append('div') + .attr('class', 'qa-save save-section cf'); - editor.enter() - .append('div') - .attr('class', 'modal-section error-editor') - .merge(editor) - .call(keepRightHeader.error(_error)) - .call(quickLinks.choices(choices)) - .call(keepRightDetails.error(_error)) - .call(keepRightSaveSection); + saveSectionEnter + .append('h4') + .attr('class', '.qa-save-header') + .text(t('QA.keepRight.comment')); + saveSectionEnter + .append('textarea') + .attr('class', 'new-comment-input') + .attr('placeholder', t('QA.keepRight.comment_placeholder')) + .attr('maxlength', 1000) + .property('value', d => d.newComment || d.comment) + .call(utilNoAuto) + .on('input', changeInput) + .on('blur', changeInput); - var footer = selection.selectAll('.footer') - .data([0]); + // update + saveSection = saveSectionEnter + .merge(saveSection) + .call(qaSaveButtons); - footer.enter() - .append('div') - .attr('class', 'footer') - .merge(footer) - .call(uiViewOnKeepRight(context).what(_error)); + function changeInput() { + const input = d3_select(this); + let val = input.property('value').trim(); + + if (val === _qaItem.comment) { + val = undefined; + } + + // store the unsaved comment with the issue itself + _qaItem = _qaItem.update({ newComment: val }); + + const qaService = services.keepRight; + if (qaService) { + qaService.replaceItem(_qaItem); // update keepright cache + } + + saveSection + .call(qaSaveButtons); } + } - function keepRightSaveSection(selection) { - var isSelected = (_error && _error.id === context.selectedErrorID()); - var isShown = (_error && (isSelected || _error.newComment || _error.comment)); - var saveSection = selection.selectAll('.error-save') - .data( - (isShown ? [_error] : []), - function(d) { return d.id + '-' + (d.status || 0); } - ); + function qaSaveButtons(selection) { + const isSelected = (_qaItem && _qaItem.id === context.selectedErrorID()); + let buttonSection = selection.selectAll('.buttons') + .data((isSelected ? [_qaItem] : []), d => d.status + d.id); - // exit - saveSection.exit() - .remove(); + // exit + buttonSection.exit() + .remove(); - // enter - var saveSectionEnter = saveSection.enter() - .append('div') - .attr('class', 'error-save save-section cf'); + // enter + const buttonEnter = buttonSection.enter() + .append('div') + .attr('class', 'buttons'); - saveSectionEnter - .append('h4') - .attr('class', '.error-save-header') - .text(t('QA.keepRight.comment')); + buttonEnter + .append('button') + .attr('class', 'button comment-button action') + .text(t('QA.keepRight.save_comment')); - saveSectionEnter - .append('textarea') - .attr('class', 'new-comment-input') - .attr('placeholder', t('QA.keepRight.comment_placeholder')) - .attr('maxlength', 1000) - .property('value', function(d) { return d.newComment || d.comment; }) - .call(utilNoAuto) - .on('input', changeInput) - .on('blur', changeInput); + buttonEnter + .append('button') + .attr('class', 'button close-button action'); - // update - saveSection = saveSectionEnter - .merge(saveSection) - .call(keepRightSaveButtons); + buttonEnter + .append('button') + .attr('class', 'button ignore-button action'); + // update + buttonSection = buttonSection + .merge(buttonEnter); - function changeInput() { - var input = d3_select(this); - var val = input.property('value').trim(); - - if (val === _error.comment) { - val = undefined; - } - - // store the unsaved comment with the error itself - _error = _error.update({ newComment: val }); - - var keepRight = services.keepRight; - if (keepRight) { - keepRight.replaceError(_error); // update keepright cache - } - - saveSection - .call(keepRightSaveButtons); + buttonSection.select('.comment-button') // select and propagate data + .attr('disabled', d => d.newComment ? null : true) + .on('click.comment', function(d) { + this.blur(); // avoid keeping focus on the button - #4641 + const qaService = services.keepRight; + if (qaService) { + qaService.postUpdate(d, (err, item) => dispatch.call('change', item)); } - } + }); + buttonSection.select('.close-button') // select and propagate data + .text(d => { + const andComment = (d.newComment ? '_comment' : ''); + return t(`QA.keepRight.close${andComment}`); + }) + .on('click.close', function(d) { + this.blur(); // avoid keeping focus on the button - #4641 + const qaService = services.keepRight; + if (qaService) { + d.newStatus = 'ignore_t'; // ignore temporarily (item fixed) + qaService.postUpdate(d, (err, item) => dispatch.call('change', item)); + } + }); - function keepRightSaveButtons(selection) { - var isSelected = (_error && _error.id === context.selectedErrorID()); - var buttonSection = selection.selectAll('.buttons') - .data((isSelected ? [_error] : []), function(d) { return d.status + d.id; }); + buttonSection.select('.ignore-button') // select and propagate data + .text(d => { + const andComment = (d.newComment ? '_comment' : ''); + return t(`QA.keepRight.ignore${andComment}`); + }) + .on('click.ignore', function(d) { + this.blur(); // avoid keeping focus on the button - #4641 + const qaService = services.keepRight; + if (qaService) { + d.newStatus = 'ignore'; // ignore permanently (false positive) + qaService.postUpdate(d, (err, item) => dispatch.call('change', item)); + } + }); + } - // exit - buttonSection.exit() - .remove(); + // NOTE: Don't change method name until UI v3 is merged + keepRightEditor.error = function(val) { + if (!arguments.length) return _qaItem; + _qaItem = val; + return keepRightEditor; + }; - // enter - var buttonEnter = buttonSection.enter() - .append('div') - .attr('class', 'buttons'); - - buttonEnter - .append('button') - .attr('class', 'button comment-button action') - .text(t('QA.keepRight.save_comment')); - - 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('.comment-button') // select and propagate data - .attr('disabled', function(d) { - return d.newComment === undefined ? true : null; - }) - .on('click.comment', function(d) { - this.blur(); // avoid keeping focus on the button - #4641 - var keepRight = services.keepRight; - if (keepRight) { - keepRight.postKeepRightUpdate(d, function(err, error) { - dispatch.call('change', error); - }); - } - }); - - buttonSection.select('.close-button') // select and propagate data - .text(function(d) { - var andComment = (d.newComment !== undefined ? '_comment' : ''); - return t('QA.keepRight.close' + andComment); - }) - .on('click.close', function(d) { - this.blur(); // avoid keeping focus on the button - #4641 - var keepRight = services.keepRight; - if (keepRight) { - d.state = 'ignore_t'; // ignore temporarily (error fixed) - keepRight.postKeepRightUpdate(d, function(err, error) { - dispatch.call('change', error); - }); - } - }); - - buttonSection.select('.ignore-button') // select and propagate data - .text(function(d) { - var andComment = (d.newComment !== undefined ? '_comment' : ''); - return t('QA.keepRight.ignore' + andComment); - }) - .on('click.ignore', function(d) { - this.blur(); // avoid keeping focus on the button - #4641 - var keepRight = services.keepRight; - if (keepRight) { - d.state = 'ignore'; // ignore permanently (false positive) - keepRight.postKeepRightUpdate(d, function(err, error) { - dispatch.call('change', error); - }); - } - }); - } - - - keepRightEditor.error = function(val) { - if (!arguments.length) return _error; - _error = val; - return keepRightEditor; - }; - - - return utilRebind(keepRightEditor, dispatch, 'on'); + return utilRebind(keepRightEditor, dispatch, 'on'); } diff --git a/modules/ui/keepRight_header.js b/modules/ui/keepRight_header.js index b2d4130a1..9fdce6399 100644 --- a/modules/ui/keepRight_header.js +++ b/modules/ui/keepRight_header.js @@ -4,68 +4,61 @@ import { t } from '../util/locale'; export function uiKeepRightHeader() { - var _error; + let _qaItem; + function issueTitle(d) { + const unknown = t('inspector.unknown'); - function errorTitle(d) { - var unknown = t('inspector.unknown'); + if (!d) return unknown; + const { itemType, parentIssueType } = d; - if (!d) return unknown; - var errorType = d.error_type; - var parentErrorType = d.parent_error_type; + const et = dataEn.QA.keepRight.errorTypes[itemType]; + const pt = dataEn.QA.keepRight.errorTypes[parentIssueType]; - var et = dataEn.QA.keepRight.errorTypes[errorType]; - var pt = dataEn.QA.keepRight.errorTypes[parentErrorType]; - - if (et && et.title) { - return t('QA.keepRight.errorTypes.' + errorType + '.title'); - } else if (pt && pt.title) { - return t('QA.keepRight.errorTypes.' + parentErrorType + '.title'); - } else { - return unknown; - } + if (et && et.title) { + return t(`QA.keepRight.errorTypes.${itemType}.title`); + } else if (pt && pt.title) { + return t(`QA.keepRight.errorTypes.${parentIssueType}.title`); + } else { + return unknown; } + } + function keepRightHeader(selection) { + const header = selection.selectAll('.qa-header') + .data( + (_qaItem ? [_qaItem] : []), + d => `${d.id}-${d.status || 0}` + ); - function keepRightHeader(selection) { - var header = selection.selectAll('.error-header') - .data( - (_error ? [_error] : []), - function(d) { return d.id + '-' + (d.status || 0); } - ); + header.exit() + .remove(); - header.exit() - .remove(); + const headerEnter = header.enter() + .append('div') + .attr('class', 'qa-header'); - var headerEnter = header.enter() - .append('div') - .attr('class', 'error-header'); + const iconEnter = headerEnter + .append('div') + .attr('class', 'qa-header-icon') + .classed('new', d => d.id < 0); - var iconEnter = headerEnter - .append('div') - .attr('class', 'error-header-icon') - .classed('new', function(d) { return d.id < 0; }); - - iconEnter - .append('div') - .attr('class', function(d) { - return 'preset-icon-28 qa_error ' + d.service + ' error_id-' + d.id + ' error_type-' + d.parent_error_type; - }) - .call(svgIcon('#iD-icon-bolt', 'qa_error-fill')); - - headerEnter - .append('div') - .attr('class', 'error-header-label') - .text(errorTitle); - } - - - keepRightHeader.error = function(val) { - if (!arguments.length) return _error; - _error = val; - return keepRightHeader; - }; + iconEnter + .append('div') + .attr('class', d => `preset-icon-28 qaItem ${d.service} itemId-${d.id} itemType-${d.parentIssueType}`) + .call(svgIcon('#iD-icon-bolt', 'qaItem-fill')); + headerEnter + .append('div') + .attr('class', 'qa-header-label') + .text(issueTitle); + } + keepRightHeader.issue = val => { + if (!arguments.length) return _qaItem; + _qaItem = val; return keepRightHeader; + }; + + return keepRightHeader; } diff --git a/modules/ui/osmose_details.js b/modules/ui/osmose_details.js index 174009900..22f43bce5 100644 --- a/modules/ui/osmose_details.js +++ b/modules/ui/osmose_details.js @@ -10,13 +10,13 @@ import { utilDisplayName, utilEntityOrMemberSelector } from '../util'; export function uiOsmoseDetails(context) { - let _error; + let _qaItem; function issueString(d, type) { if (!d) return ''; // Issue strings are cached from Osmose API - const s = services.osmose.getStrings(d.error_type); + const s = services.osmose.getStrings(d.itemType); return (type in s) ? s[type] : ''; } @@ -24,7 +24,7 @@ export function uiOsmoseDetails(context) { function osmoseDetails(selection) { const details = selection.selectAll('.error-details') .data( - _error ? [_error] : [], + _qaItem ? [_qaItem] : [], d => `${d.id}-${d.status || 0}` ); @@ -33,14 +33,14 @@ export function uiOsmoseDetails(context) { const detailsEnter = details.enter() .append('div') - .attr('class', 'error-details error-details-container'); + .attr('class', 'error-details qa-details-container'); // Description - if (issueString(_error, 'detail')) { + if (issueString(_qaItem, 'detail')) { const div = detailsEnter .append('div') - .attr('class', 'error-details-subsection'); + .attr('class', 'qa-details-subsection'); div .append('h4') @@ -48,24 +48,24 @@ export function uiOsmoseDetails(context) { div .append('p') - .attr('class', 'error-details-description-text') + .attr('class', 'qa-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'); + .attr('class', 'qa-details-subsection'); const elemsDiv = detailsEnter .append('div') - .attr('class', 'error-details-subsection'); + .attr('class', 'qa-details-subsection'); // Suggested Fix (musn't exist for every issue type) - if (issueString(_error, 'fix')) { + if (issueString(_qaItem, 'fix')) { const div = detailsEnter .append('div') - .attr('class', 'error-details-subsection'); + .attr('class', 'qa-details-subsection'); div .append('h4') @@ -77,10 +77,10 @@ export function uiOsmoseDetails(context) { } // Common Pitfalls (musn't exist for every issue type) - if (issueString(_error, 'trap')) { + if (issueString(_qaItem, 'trap')) { const div = detailsEnter .append('div') - .attr('class', 'error-details-subsection'); + .attr('class', 'qa-details-subsection'); div .append('h4') @@ -105,7 +105,7 @@ export function uiOsmoseDetails(context) { .append('use') .attr('href', '#iD-icon-out-link'); - services.osmose.loadErrorDetail(_error) + services.osmose.loadIssueDetail(_qaItem) .then(d => { // No details to add if there are no associated issue elements if (!d.elems || d.elems.length === 0) return; @@ -116,7 +116,6 @@ export function uiOsmoseDetails(context) { if (d.detail) { detailsDiv .append('h4') - .attr('class', 'error-details-subtitle') .text(() => t('QA.osmose.detail_title')); detailsDiv @@ -127,13 +126,10 @@ export function uiOsmoseDetails(context) { // 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') + .append('ul').selectAll('li') .data(d.elems) .enter() .append('li') @@ -189,7 +185,7 @@ export function uiOsmoseDetails(context) { } }); - // Don't hide entities related to this error - #5880 + // Don't hide entities related to this issue - #5880 context.features().forceVisible(d.elems); context.map().pan([0,0]); // trigger a redraw }) @@ -197,9 +193,9 @@ export function uiOsmoseDetails(context) { } - osmoseDetails.error = val => { - if (!arguments.length) return _error; - _error = val; + osmoseDetails.issue = function(val) { + if (!arguments.length) return _qaItem; + _qaItem = val; return osmoseDetails; }; diff --git a/modules/ui/osmose_editor.js b/modules/ui/osmose_editor.js index 4571eeb56..93df17386 100644 --- a/modules/ui/osmose_editor.js +++ b/modules/ui/osmose_editor.js @@ -12,159 +12,140 @@ 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(); + const dispatch = d3_dispatch('change'); + const qaDetails = uiOsmoseDetails(context); + const qaHeader = uiOsmoseHeader(context); + const quickLinks = uiQuickLinks(); - var _error; + let _qaItem; + function osmoseEditor(selection) { + // quick links + const choices = [{ + id: 'zoom_to', + label: 'inspector.zoom_to.title', + tooltip: () => uiTooltipHtml(t('inspector.zoom_to.tooltip_qaItem'), t('inspector.zoom_to.key')), + click: () => context.mode().zoomToSelected() + }]; - 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(); - } - }]; + const header = selection.selectAll('.header') + .data([0]); + const headerEnter = header.enter() + .append('div') + .attr('class', 'header fillL'); - var header = selection.selectAll('.header') - .data([0]); + headerEnter + .append('button') + .attr('class', 'fr qa-editor-close') + .on('click', () => context.enter(modeBrowse(context))) + .call(svgIcon('#iD-icon-close')); - var headerEnter = header.enter() - .append('div') - .attr('class', 'header fillL'); + headerEnter + .append('h3') + .text(t('QA.osmose.title')); - headerEnter - .append('button') - .attr('class', 'fr error-editor-close') - .on('click', function() { - context.enter(modeBrowse(context)); - }) - .call(svgIcon('#iD-icon-close')); + let body = selection.selectAll('.body') + .data([0]); - headerEnter - .append('h3') - .text(t('QA.osmose.title')); + body = body.enter() + .append('div') + .attr('class', 'body') + .merge(body); + let editor = body.selectAll('.qa-editor') + .data([0]); - var body = selection.selectAll('.body') - .data([0]); + editor.enter() + .append('div') + .attr('class', 'modal-section qa-editor') + .merge(editor) + .call(qaHeader.issue(_qaItem)) + .call(quickLinks.choices(choices)) + .call(qaDetails.issue(_qaItem)) + .call(osmoseSaveSection); + } - body = body.enter() - .append('div') - .attr('class', 'body') - .merge(body); + function osmoseSaveSection(selection) { + const isSelected = (_qaItem && _qaItem.id === context.selectedErrorID()); + const isShown = (_qaItem && isSelected); + let saveSection = selection.selectAll('.qa-save') + .data( + (isShown ? [_qaItem] : []), + d => `${d.id}-${d.status || 0}` + ); - var editor = body.selectAll('.error-editor') - .data([0]); + // exit + saveSection.exit() + .remove(); - 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); - } + // enter + const saveSectionEnter = saveSection.enter() + .append('div') + .attr('class', 'qa-save save-section cf'); - 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); } - ); + // update + saveSection = saveSectionEnter + .merge(saveSection) + .call(qaSaveButtons); + } - // exit - saveSection.exit() - .remove(); + function qaSaveButtons(selection) { + const isSelected = (_qaItem && _qaItem.id === context.selectedErrorID()); + let buttonSection = selection.selectAll('.buttons') + .data((isSelected ? [_qaItem] : []), d => d.status + d.id); - // enter - var saveSectionEnter = saveSection.enter() - .append('div') - .attr('class', 'error-save save-section cf'); + // exit + buttonSection.exit() + .remove(); - // update - saveSection = saveSectionEnter - .merge(saveSection) - .call(errorSaveButtons); - } + // enter + const buttonEnter = buttonSection.enter() + .append('div') + .attr('class', 'buttons'); - 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; }); + buttonEnter + .append('button') + .attr('class', 'button close-button action'); - // exit - buttonSection.exit() - .remove(); + buttonEnter + .append('button') + .attr('class', 'button ignore-button action'); - // enter - var buttonEnter = buttonSection.enter() - .append('div') - .attr('class', 'buttons'); + // update + buttonSection = buttonSection + .merge(buttonEnter); - buttonEnter - .append('button') - .attr('class', 'button close-button action'); + buttonSection.select('.close-button') + .text(() => t('QA.keepRight.close')) + .on('click.close', function(d) { + this.blur(); // avoid keeping focus on the button - #4641 + const qaService = services.osmose; + if (qaService) { + d.newStatus = 'done'; + qaService.postUpdate(d, (err, item) => dispatch.call('change', item)); + } + }); - buttonEnter - .append('button') - .attr('class', 'button ignore-button action'); + buttonSection.select('.ignore-button') + .text(() => t('QA.keepRight.ignore')) + .on('click.ignore', function(d) { + this.blur(); // avoid keeping focus on the button - #4641 + const qaService = services.osmose; + if (qaService) { + d.newStatus = 'false'; + qaService.postUpdate(d, (err, item) => dispatch.call('change', item)); + } + }); + } + // NOTE: Don't change method name until UI v3 is merged + osmoseEditor.error = function(val) { + if (!arguments.length) return _qaItem; + _qaItem = val; + return osmoseEditor; + }; - // 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 + return utilRebind(osmoseEditor, dispatch, 'on'); +} diff --git a/modules/ui/osmose_header.js b/modules/ui/osmose_header.js index 4016b4b71..83dac6213 100644 --- a/modules/ui/osmose_header.js +++ b/modules/ui/osmose_header.js @@ -3,91 +3,76 @@ import { t } from '../util/locale'; export function uiOsmoseHeader() { - var _error; + let _qaItem; + function issueTitle(d) { + const unknown = t('inspector.unknown'); - function errorTitle(d) { - var unknown = t('inspector.unknown'); + if (!d) return unknown; - if (!d) return unknown; + // Issue titles supplied by Osmose + const s = services.osmose.getStrings(d.itemType); + return ('title' in s) ? s.title : unknown; + } - // Issue titles supplied by Osmose - var s = services.osmose.getStrings(d.error_type); - return ('title' in s) ? s.title : unknown; - } + function osmoseHeader(selection) { + const header = selection.selectAll('.qa-header') + .data( + (_qaItem ? [_qaItem] : []), + d => `${d.id}-${d.status || 0}` + ); + header.exit() + .remove(); - function osmoseHeader(selection) { - var header = selection.selectAll('.error-header') - .data( - (_error ? [_error] : []), - function(d) { return d.id + '-' + (d.status || 0); } - ); + const headerEnter = header.enter() + .append('div') + .attr('class', 'qa-header'); - header.exit() - .remove(); + const svgEnter = headerEnter + .append('div') + .attr('class', 'qa-header-icon') + .classed('new', d => d.id < 0) + .append('svg') + .attr('width', '20px') + .attr('height', '30px') + .attr('viewbox', '0 0 20 30') + .attr('class', d => `preset-icon-28 qaItem ${d.service} itemId-${d.id} itemType-${d.itemType}`); - var headerEnter = header.enter() - .append('div') - .attr('class', 'error-header'); + svgEnter + .append('polygon') + .attr('fill', d => services.osmose.getColor(d.item)) + .attr('class', 'qaItem-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'); - var iconEnter = headerEnter - .append('div') - .attr('class', 'error-header-icon') - .classed('new', function(d) { return d.id < 0; }); + svgEnter + .append('use') + .attr('class', 'icon-annotation') + .attr('width', '11px') + .attr('height', '11px') + .attr('transform', 'translate(4.5, 7)') + .attr('xlink:href', d => { + const picon = d.icon; - 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; - }; + if (!picon) { + return ''; + } else { + const isMaki = /^maki-/.test(picon); + return `#${picon}${isMaki ? '-11' : ''}`; + } + }); + headerEnter + .append('div') + .attr('class', 'qa-header-label') + .text(issueTitle); + } + osmoseHeader.issue = function(val) { + if (!arguments.length) return _qaItem; + _qaItem = val; return osmoseHeader; + }; + + return osmoseHeader; } diff --git a/modules/ui/sidebar.js b/modules/ui/sidebar.js index ebf9dd6a8..a730d5aea 100644 --- a/modules/ui/sidebar.js +++ b/modules/ui/sidebar.js @@ -9,7 +9,7 @@ import { selectAll as d3_selectAll } from 'd3-selection'; import { utilArrayIdentical } from '../util/array'; -import { osmEntity, osmNote, qaError } from '../osm'; +import { osmEntity, osmNote, QAItem } from '../osm'; import { services } from '../services'; import { uiDataEditor } from './data_editor'; import { uiFeatureList } from './feature_list'; @@ -31,7 +31,7 @@ export function uiSidebar(context) { var _current; var _wasData = false; var _wasNote = false; - var _wasQAError = false; + var _wasQaItem = false; function sidebar(selection) { @@ -140,8 +140,8 @@ export function uiSidebar(context) { selection.selectAll('.sidebar-component') .classed('inspector-hover', true); - } else if (datum instanceof qaError) { - _wasQAError = true; + } else if (datum instanceof QAItem) { + _wasQaItem = true; var errService = services[datum.service]; if (errService) { @@ -159,7 +159,7 @@ export function uiSidebar(context) { errEditor = improveOsmEditor; } - d3_selectAll('.qa_error.' + datum.service) + d3_selectAll('.qaItem.' + datum.service) .classed('hover', function(d) { return d.id === datum.id; }); sidebar @@ -193,12 +193,12 @@ export function uiSidebar(context) { inspector .state('hide'); - } else if (_wasData || _wasNote || _wasQAError) { + } else if (_wasData || _wasNote || _wasQaItem) { _wasNote = false; _wasData = false; - _wasQAError = false; + _wasQaItem = false; d3_selectAll('.note').classed('hover', false); - d3_selectAll('.qa_error').classed('hover', false); + d3_selectAll('.qaItem').classed('hover', false); sidebar.hide(); } } @@ -366,4 +366,4 @@ export function uiSidebar(context) { sidebar.toggle = function() {}; return sidebar; -} \ No newline at end of file +} diff --git a/modules/ui/view_on_keepRight.js b/modules/ui/view_on_keepRight.js index 62944db5d..1a33b7d9a 100644 --- a/modules/ui/view_on_keepRight.js +++ b/modules/ui/view_on_keepRight.js @@ -1,45 +1,42 @@ import { t } from '../util/locale'; import { services } from '../services'; import { svgIcon } from '../svg/icon'; -import { qaError } from '../osm'; - +import { QAItem } from '../osm'; export function uiViewOnKeepRight() { - var _error; // a keepright error + let _qaItem; - - function viewOnKeepRight(selection) { - var url; - if (services.keepRight && (_error instanceof qaError)) { - url = services.keepRight.errorURL(_error); - } - - var link = selection.selectAll('.view-on-keepRight') - .data(url ? [url] : []); - - // exit - link.exit() - .remove(); - - // enter - var linkEnter = link.enter() - .append('a') - .attr('class', 'view-on-keepRight') - .attr('target', '_blank') - .attr('href', function(d) { return d; }) - .call(svgIcon('#iD-icon-out-link', 'inline')); - - linkEnter - .append('span') - .text(t('inspector.view_on_keepRight')); + function viewOnKeepRight(selection) { + let url; + if (services.keepRight && (_qaItem instanceof QAItem)) { + url = services.keepRight.issueURL(_qaItem); } + const link = selection.selectAll('.view-on-keepRight') + .data(url ? [url] : []); - viewOnKeepRight.what = function(val) { - if (!arguments.length) return _error; - _error = val; - return viewOnKeepRight; - }; + // exit + link.exit() + .remove(); + // enter + const linkEnter = link.enter() + .append('a') + .attr('class', 'view-on-keepRight') + .attr('target', '_blank') + .attr('href', function(d) { return d; }) + .call(svgIcon('#iD-icon-out-link', 'inline')); + + linkEnter + .append('span') + .text(t('inspector.view_on_keepRight')); + } + + viewOnKeepRight.what = function(val) { + if (!arguments.length) return _qaItem; + _qaItem = val; return viewOnKeepRight; -} \ No newline at end of file + }; + + return viewOnKeepRight; +} diff --git a/scripts/build_data.js b/scripts/build_data.js index d57ca72d5..4d31be886 100644 --- a/scripts/build_data.js +++ b/scripts/build_data.js @@ -90,7 +90,7 @@ function buildData() { 'dist/data/*', 'svg/fontawesome/*.svg', ]); - readQAErrorIcons(faIcons, tnpIcons); + readQAIssueIcons(faIcons, tnpIcons); let categories = generateCategories(tstrings, faIcons, tnpIcons); let fields = generateFields(tstrings, faIcons, tnpIcons, searchableFieldIDs); let presets = generatePresets(tstrings, faIcons, tnpIcons, searchableFieldIDs); @@ -128,7 +128,7 @@ function buildData() { minifyJSON('data/languages.json', 'dist/data/languages.min.json'), minifyJSON('data/locales.json', 'dist/data/locales.min.json'), minifyJSON('data/phone_formats.json', 'dist/data/phone_formats.min.json'), - minifyJSON('data/qa_errors.json', 'dist/data/qa_errors.min.json'), + minifyJSON('data/qa_data.json', 'dist/data/qa_data.min.json'), minifyJSON('data/shortcuts.json', 'dist/data/shortcuts.min.json'), minifyJSON('data/taginfo.json', 'dist/data/taginfo.min.json'), minifyJSON('data/territory_languages.json', 'dist/data/territory_languages.min.json') @@ -173,13 +173,12 @@ function validate(file, instance, schema) { } -function readQAErrorIcons(faIcons, tnpIcons) { - const qa = read('data/qa_errors.json'); +function readQAIssueIcons(faIcons, tnpIcons) { + const qa = read('data/qa_data.json'); - for (const service in qa.services) { - for (const error in qa.services[service].errorIcons) { - const icon = qa.services[service] - .errorIcons[error]; + for (const service in qa) { + for (const item in qa[service].icons) { + const icon = qa[service].icons[item]; // fontawesome icon, remember for later if (/^fa[srb]-/.test(icon)) {