diff --git a/build_data.js b/build_data.js index deb949b89..2d72591d8 100644 --- a/build_data.js +++ b/build_data.js @@ -59,7 +59,9 @@ module.exports = function buildData() { }; // Font Awesome icons used - var faIcons = {}; + var faIcons = { + 'fas-long-arrow-alt-right': {} + }; // Start clean shell.rm('-f', [ diff --git a/css/20_map.css b/css/20_map.css index 6c9ed5c4b..559b3ae72 100644 --- a/css/20_map.css +++ b/css/20_map.css @@ -33,7 +33,8 @@ /* No interactivity except what we specifically allow */ .data-layer.osm *, .data-layer.notes *, -.data-layer.keepRight * { +.data-layer.keepRight *, +.data-layer.improveOSM * { pointer-events: none; } @@ -44,7 +45,7 @@ /* `.target` objects are interactive */ /* They can be picked up, clicked, hovered, or things can connect to them */ -.kr_error.target, +.qa_error.target, .note.target, .node.target, .turn .target { @@ -79,11 +80,10 @@ pointer-events: none !important; } -/* NOTE: when more QA layers are added, replace kr_error with generic QA layer selector */ /* points, notes & QA */ /* points, notes, markers */ -g.kr_error .stroke, +g.qa_error .stroke, g.note .stroke { stroke: #222; stroke-width: 1; @@ -91,7 +91,7 @@ g.note .stroke { opacity: 0.6; } -g.kr_error.active .stroke, +g.qa_error.active .stroke, g.note.active .stroke { stroke: #222; stroke-width: 1; @@ -105,7 +105,7 @@ g.point .stroke { fill: #fff; } -g.kr_error .shadow, +g.qa_error .shadow, g.point .shadow, g.note .shadow { fill: none; @@ -114,14 +114,14 @@ g.note .shadow { stroke-opacity: 0; } -g.kr_error.hover:not(.selected) .shadow, +g.qa_error.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.kr_error.selected .shadow, +g.qa_error.selected .shadow, g.note.selected .shadow, g.point.selected .shadow { stroke-opacity: 0.7; diff --git a/css/25_areas.css b/css/25_areas.css index ca964e54e..7e6d9f41a 100644 --- a/css/25_areas.css +++ b/css/25_areas.css @@ -31,7 +31,6 @@ path.stroke.tag-leisure-track, path.stroke.tag-leisure-golf_course, path.stroke.tag-leisure-garden, path.stroke.tag-leisure-park, -path.stroke.tag-barrier-hedge, path.stroke.tag-landuse-forest, path.stroke.tag-landuse-wood, path.stroke.tag-landuse-grass { diff --git a/css/50_misc.css b/css/50_misc.css index 0e3e726e2..265d641bb 100644 --- a/css/50_misc.css +++ b/css/50_misc.css @@ -1,4 +1,56 @@ +/* narrow width miscellanous things */ +path.line.shadow.tag-aerialway, +path.line.shadow.tag-attraction-summer_toboggan, +path.line.shadow.tag-attraction-water_slide, +path.line.shadow.tag-man_made-pipeline, +path.line.shadow.tag-natural-tree_row, +path.line.shadow.tag-piste { + stroke-width: 16; +} +path.line.casing.tag-aerialway, +path.line.casing.tag-attraction-summer_toboggan, +path.line.casing.tag-attraction-water_slide, +path.line.casing.tag-man_made-pipeline, +path.line.casing.tag-natural-tree_row, +path.line.casing.tag-piste { + stroke-width: 7; +} +path.line.stroke.tag-aerialway, +path.line.stroke.tag-attraction-summer_toboggan, +path.line.stroke.tag-attraction-water_slide, +path.line.stroke.tag-man_made-pipeline, +path.line.stroke.tag-natural-tree_row, +path.line.stroke.tag-piste { + stroke-width: 5; +} + +.low-zoom path.line.shadow.tag-aerialway, +.low-zoom path.line.shadow.tag-attraction-summer_toboggan, +.low-zoom path.line.shadow.tag-attraction-water_slide, +.low-zoom path.line.shadow.tag-man_made-pipeline, +.low-zoom path.line.shadow.tag-natural-tree_row, +.low-zoom path.line.shadow.tag-piste { + stroke-width: 12; +} +.low-zoom path.line.casing.tag-aerialway, +.low-zoom path.line.casing.tag-attraction-summer_toboggan, +.low-zoom path.line.casing.tag-attraction-water_slide, +.low-zoom path.line.casing.tag-man_made-pipeline, +.low-zoom path.line.casing.tag-natural-tree_row, +.low-zoom path.line.casing.tag-piste { + stroke-width: 5; +} +.low-zoom path.line.stroke.tag-aerialway, +.low-zoom path.line.stroke.tag-attraction-summer_toboggan, +.low-zoom path.line.stroke.tag-attraction-water_slide, +.low-zoom path.line.stroke.tag-man_made-pipeline, +.low-zoom path.line.stroke.tag-natural-tree_row, +.low-zoom path.line.stroke.tag-piste { + stroke-width: 3; +} + + /* ferry routes */ .preset-icon .icon.tag-route-ferry { color: #58a9ed; @@ -24,6 +76,22 @@ path.line.stroke.tag-route-ferry { } +/* aerialways */ +path.line.stroke.tag-aerialway { + stroke: #c55; +} +path.line.casing.tag-aerialway { + stroke: #444; +} + +/* pistes */ +path.line.stroke.tag-piste { + stroke: #9ac; +} +path.line.casing.tag-piste { + stroke: #444; +} + /* power and pipeline */ .preset-icon .icon.tag-man_made-pipeline, .preset-icon .icon.tag-power { @@ -31,6 +99,7 @@ path.line.stroke.tag-route-ferry { fill: #939393; } + /* power */ path.line.stroke.tag-power { stroke: #939393; @@ -40,21 +109,21 @@ path.line.casing.tag-power { stroke: none; } + /* pipeline */ path.line.stroke.tag-man_made-pipeline { stroke: #cbd0d8; stroke-linecap: butt; - stroke-width: 3; stroke-dasharray: 80, 1.25; } path.line.casing.tag-man_made-pipeline { stroke: #666; - stroke-width: 4.5; } .low-zoom path.line.stroke.tag-man_made-pipeline { stroke-dasharray: 40, 1; } + /* boundaries */ path.line.stroke.tag-boundary { stroke: #fff; @@ -73,31 +142,14 @@ path.line.casing.tag-boundary-national_park { } -/* Tree Rows */ -path.line.shadow.tag-natural-tree_row { - stroke-width: 16; -} -path.line.casing.tag-natural-tree_row { - stroke-width: 7; -} -path.line.stroke.tag-natural-tree_row { - stroke-width: 5; -} -.low-zoom path.line.shadow.tag-natural-tree_row { - stroke-width: 12; -} -.low-zoom path.line.casing.tag-natural-tree_row { - stroke-width: 5; -} -.low-zoom path.line.stroke.tag-natural-tree_row { - stroke-width: 3; -} - - /* barriers and similar */ -path.line.stroke.tag-barrier:not(.tag-barrier-hedge) { +path.line.stroke.tag-barrier { stroke: #ddd; } +path.line.stroke.tag-barrier-hedge { + stroke: rgb(140, 208, 95); +} + path.line.stroke.tag-barrier, path.line.stroke.tag-man_made-groyne, path.line.stroke.tag-man_made-breakwater { @@ -412,30 +464,6 @@ path.line.stroke.tag-crossing.tag-crossing-zebra { } /* Attractions */ -path.line.shadow.tag-attraction-summer_toboggan, -path.line.shadow.tag-attraction-water_slide { - stroke-width: 16; -} -path.line.casing.tag-attraction-summer_toboggan, -path.line.casing.tag-attraction-water_slide { - stroke-width: 7; -} -path.line.stroke.tag-attraction-summer_toboggan, -path.line.stroke.tag-attraction-water_slide { - stroke-width: 5; -} -.low-zoom path.line.shadow.tag-attraction-summer_toboggan, -.low-zoom path.line.shadow.tag-attraction-water_slide { - stroke-width: 12; -} -.low-zoom path.line.casing.tag-attraction-summer_toboggan, -.low-zoom path.line.casing.tag-attraction-water_slide { - stroke-width: 5; -} -.low-zoom path.line.stroke.tag-attraction-summer_toboggan, -.low-zoom path.line.stroke.tag-attraction-water_slide { - stroke-width: 3; -} path.line.stroke.tag-attraction-summer_toboggan { stroke: #9e9e9e; } diff --git a/css/55_cursors.css b/css/55_cursors.css index f473301c4..1a4c8cf7f 100644 --- a/css/55_cursors.css +++ b/css/55_cursors.css @@ -97,9 +97,9 @@ } .mode-browse .note, -.mode-browse .kr_error, +.mode-browse .qa_error, .mode-select .note, -.mode-select .kr_error, +.mode-select .qa_error, .turn rect, .turn circle { cursor: pointer; diff --git a/css/65_data.css b/css/65_data.css index 71aafab9c..2686d940d 100644 --- a/css/65_data.css +++ b/css/65_data.css @@ -1,8 +1,9 @@ /* OSM Notes and KeepRight Layers */ -.kr_error-header-icon .kr_error-fill, -.layer-keepRight .kr_error .kr_error-fill { +.kr_error-header-icon .qa_error-fill, +.layer-keepRight .qa_error .qa_error-fill, +.layer-improveOSM .qa_error .qa_error-fill { stroke: #333; stroke-width: 1.3px; /* NOTE: likely a better way to scale the icon stroke */ } @@ -41,80 +42,115 @@ height: 15px; } +/* adjustment for error icon */ + +.kr_error-header-icon .preset-icon-28 { + top: auto; + left: auto; +} + +.kr_error-header-icon { + display: flex; + align-items: center; + justify-content: center; +} /* Keep Right Errors ------------------------------------------------------- */ -.kr_error_type_20, /* multiple nodes on same spot */ -.kr_error_type_40, /* impossible oneways */ -.kr_error_type_210, /* self intersecting ways */ -.kr_error_type_270, /* unusual motorway connection */ -.kr_error_type_310, /* roundabout issues */ -.kr_error_type_320, /* improper _link */ -.kr_error_type_350 { /* improper bridge tag */ +.kr.error_type-20, /* multiple nodes on same spot */ +.kr.error_type-40, /* impossible oneways */ +.kr.error_type-210, /* self intersecting ways */ +.kr.error_type-270, /* unusual motorway connection */ +.kr.error_type-310, /* roundabout issues */ +.kr.error_type-320, /* improper _link */ +.kr.error_type-350 { /* improper bridge tag */ color: #ff9; } -.kr_error_type_50 { /* almost junctions */ +.kr.error_type-50 { /* almost junctions */ color: #88f; } -.kr_error_type_60, /* deprecated tags */ -.kr_error_type_70, /* tagging issues */ -.kr_error_type_90, /* motorway without ref */ -.kr_error_type_100, /* place of worship without religion */ -.kr_error_type_110, /* poi without name */ -.kr_error_type_150, /* railway crossing without tag */ -.kr_error_type_220, /* misspelled tag */ -.kr_error_type_380 { /* non-physical sport tag */ +.kr.error_type-60, /* deprecated tags */ +.kr.error_type-70, /* tagging issues */ +.kr.error_type-90, /* motorway without ref */ +.kr.error_type-100, /* place of worship without religion */ +.kr.error_type-110, /* poi without name */ +.kr.error_type-150, /* railway crossing without tag */ +.kr.error_type-220, /* misspelled tag */ +.kr.error_type-380 { /* non-physical sport tag */ color: #5d0; } -.kr_error_type_130 { /* disconnected ways */ +.kr.error_type-130 { /* disconnected ways */ color: #fa3; } -.kr_error_type_170 { /* FIXME tag */ +.kr.error_type-170 { /* FIXME tag */ color: #ff0; } -.kr_error_type_190 { /* intersection without junction */ +.kr.error_type-190 { /* intersection without junction */ color: #f33; } -.kr_error_type_200 { /* overlapping ways */ +.kr.error_type-200 { /* overlapping ways */ color: #fdbf6f; } -.kr_error_type_160, /* railway layer conflict */ -.kr_error_type_230 { /* layer conflict */ +.kr.error_type-160, /* railway layer conflict */ +.kr.error_type-230 { /* layer conflict */ color: #b60; } -.kr_error_type_280 { /* boundary issues */ +.kr.error_type-280 { /* boundary issues */ color: #5f47a0; } -.kr_error_type_180, /* relation without type */ -.kr_error_type_290 { /* turn restriction issues */ +.kr.error_type-180, /* relation without type */ +.kr.error_type-290 { /* turn restriction issues */ color: #ace; } -.kr_error_type_300, /* missing maxspeed */ -.kr_error_type_390 { /* missing tracktype */ +.kr.error_type-300, /* missing maxspeed */ +.kr.error_type-390 { /* missing tracktype */ color: #090; } -.kr_error_type_360, /* language unknown */ -.kr_error_type_370, /* doubled places */ -.kr_error_type_410 { /* website issues */ +.kr.error_type-360, /* language unknown */ +.kr.error_type-370, /* doubled places */ +.kr.error_type-410 { /* website issues */ color: #f9b; } -.kr_error_type_120, /* way without nodes */ -.kr_error_type_400 { /* geometry / turn angles */ +.kr.error_type-120, /* way without nodes */ +.kr.error_type-400 { /* geometry / turn angles */ color: #c35; } +/* ImproveOSM Errors +------------------------------------------------------- */ + +.iOSM.error_type-ow- { /* missing one way */ + color: #1E90FF; +} + +.iOSM.error_type-mr-road { /* missing road */ + color: #B452CD; +} +.iOSM.error_type-mr-path { /* missing path */ + color: #A0522D; +} +.iOSM.error_type-mr-parking { /* missing parking */ + color: #EEEE00; +} +.iOSM.error_type-mr-both { /* missing road+parking */ + color: #FFA500; +} + +.iOSM.error_type-tr- { /* missing turn restriction */ + color: #EC1C24; +} /* Custom Map Data (geojson, gpx, kml, vector tile) */ .layer-mapdata { diff --git a/data/core.yaml b/data/core.yaml index 14d8c70a7..b0b05c196 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -493,6 +493,9 @@ en: keepRight: tooltip: Automatically detected map issues from keepright.at title: KeepRight Issues + improveOSM: + tooltip: Missing data automatically detected by improveosm.org + title: ImproveOSM Issues custom: tooltip: "Drag and drop a data file onto the page, or click the button to setup" title: Custom Map Data @@ -642,6 +645,32 @@ en: cannot_zoom: "Cannot zoom out further in current mode." full_screen: Toggle Full Screen QA: + improveOSM: + title: ImproveOSM Detection + geometry_types: + path: paths + parking: parking + road: roads + both: roads and parking + directions: + east: east + north: north + northeast: northeast + northwest: northwest + south: south + southeast: southeast + southwest: southwest + west: west + error_types: + ow: + title: Missing One-way + description: 'Along this section of {highway}, {percentage}% of {num_trips} recorded trips travel from {from_node} to {to_node}. There may be missing a "oneway" tag.' + mr: + title: Missing Geometry + description: '{num_trips} recorded trips in this area suggest there may be unmapped {geometry_type} here.' + tr: + title: Missing Turn Restriction + description: '{num_passed} of {num_trips} recorded trips (travelling {travel_direction}) make a turn from {from_way} to {to_way} at {junction}. There may be a missing "{turn_restriction}" restriction.' keepRight: title: KeepRight Error detail_title: Error @@ -1098,7 +1127,7 @@ en: title: Quality Assurance intro: "*Quality Assurance* (Q/A) tools can find improper tags, disconnected roads, and other issues with OpenStreetMap, which mappers can then fix. To view existing Q/A issues, click the {data} **Map data** panel to enable a specific Q/A layer." tools_h: "Tools" - tools: "The following tools are currently supported: [KeepRight](https://www.keepright.at/). Expect iD to support [Osmose](https://osmose.openstreetmap.fr/), [ImproveOSM](https://improveosm.org/en/), and more Q/A tools in the future." + tools: "The following tools are currently supported: [KeepRight](https://www.keepright.at/) and [ImproveOSM](https://improveosm.org/en/). Expect iD to support [Osmose](https://osmose.openstreetmap.fr/) and more Q/A tools in the future." issues_h: "Handling Issues" issues: "Handling Q/A issues is similar to handling notes. Click on a marker to view the issue details in the sidebar. Each tool has its own capabilities, but generally you can comment and/or close an issue." field: diff --git a/data/presets/fields.json b/data/presets/fields.json index 4080e533d..c578d98a1 100644 --- a/data/presets/fields.json +++ b/data/presets/fields.json @@ -84,8 +84,8 @@ "denotation": {"key": "denotation", "type": "combo", "label": "Denotation"}, "description": {"key": "description", "type": "textarea", "label": "Description", "universal": true}, "design": {"key": "design", "type": "combo", "label": "Design"}, - "destination_oneway": {"key": "destination", "type": "semiCombo", "label": "Destinations", "prerequisiteTag": {"key": "oneway", "value": "yes"}}, - "destination/ref_oneway": {"key": "destination:ref", "type": "semiCombo", "label": "Destination Road Numbers", "prerequisiteTag": {"key": "oneway", "value": "yes"}}, + "destination_oneway": {"key": "destination", "type": "semiCombo", "label": "Destinations", "prerequisiteTag": {"key": "oneway", "value": "yes"}, "snake_case": false}, + "destination/ref_oneway": {"key": "destination:ref", "type": "semiCombo", "label": "Destination Road Numbers", "prerequisiteTag": {"key": "oneway", "value": "yes"}, "snake_case": false}, "destination/symbol_oneway": {"key": "destination:symbol", "type": "semiCombo", "label": "Destination Symbols", "prerequisiteTag": {"key": "oneway", "value": "yes"}}, "devices": {"key": "devices", "type": "number", "minValue": 0, "label": "Devices", "placeholder": "1, 2, 3..."}, "diaper": {"key": "diaper", "type": "combo", "label": "Diaper Changing Available", "options": ["yes", "no", "room", "1", "2", "3", "4", "5"]}, diff --git a/data/presets/fields/destination/ref_oneway.json b/data/presets/fields/destination/ref_oneway.json index fc360f211..71fe8dec1 100644 --- a/data/presets/fields/destination/ref_oneway.json +++ b/data/presets/fields/destination/ref_oneway.json @@ -5,5 +5,6 @@ "prerequisiteTag": { "key": "oneway", "value": "yes" - } + }, + "snake_case": false } diff --git a/data/presets/fields/destination_oneway.json b/data/presets/fields/destination_oneway.json index 2f71c079b..1d5bc9db8 100644 --- a/data/presets/fields/destination_oneway.json +++ b/data/presets/fields/destination_oneway.json @@ -5,5 +5,6 @@ "prerequisiteTag": { "key": "oneway", "value": "yes" - } + }, + "snake_case": false } diff --git a/dist/locales/en.json b/dist/locales/en.json index e11a3b639..300183fcb 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -598,6 +598,10 @@ "tooltip": "Automatically detected map issues from keepright.at", "title": "KeepRight Issues" }, + "improveOSM": { + "tooltip": "Missing data automatically detected by improveosm.org", + "title": "ImproveOSM Issues" + }, "custom": { "tooltip": "Drag and drop a data file onto the page, or click the button to setup", "title": "Custom Map Data", @@ -782,6 +786,39 @@ "cannot_zoom": "Cannot zoom out further in current mode.", "full_screen": "Toggle Full Screen", "QA": { + "improveOSM": { + "title": "ImproveOSM Detection", + "geometry_types": { + "path": "paths", + "parking": "parking", + "road": "roads", + "both": "roads and parking" + }, + "directions": { + "east": "east", + "north": "north", + "northeast": "northeast", + "northwest": "northwest", + "south": "south", + "southeast": "southeast", + "southwest": "southwest", + "west": "west" + }, + "error_types": { + "ow": { + "title": "Missing One-way", + "description": "Along this section of {highway}, {percentage}% of {num_trips} recorded trips travel from {from_node} to {to_node}. There may be missing a \"oneway\" tag." + }, + "mr": { + "title": "Missing Geometry", + "description": "{num_trips} recorded trips in this area suggest there may be unmapped {geometry_type} here." + }, + "tr": { + "title": "Missing Turn Restriction", + "description": "{num_passed} of {num_trips} recorded trips (travelling {travel_direction}) make a turn from {from_way} to {to_way} at {junction}. There may be a missing \"{turn_restriction}\" restriction." + } + } + }, "keepRight": { "title": "KeepRight Error", "detail_title": "Error", @@ -1329,7 +1366,7 @@ "title": "Quality Assurance", "intro": "*Quality Assurance* (Q/A) tools can find improper tags, disconnected roads, and other issues with OpenStreetMap, which mappers can then fix. To view existing Q/A issues, click the {data} **Map data** panel to enable a specific Q/A layer.", "tools_h": "Tools", - "tools": "The following tools are currently supported: [KeepRight](https://www.keepright.at/). Expect iD to support [Osmose](https://osmose.openstreetmap.fr/), [ImproveOSM](https://improveosm.org/en/), and more Q/A tools in the future.", + "tools": "The following tools are currently supported: [KeepRight](https://www.keepright.at/) and [ImproveOSM](https://improveosm.org/en/). Expect iD to support [Osmose](https://osmose.openstreetmap.fr/) and more Q/A tools in the future.", "issues_h": "Handling Issues", "issues": "Handling Q/A issues is similar to handling notes. Click on a marker to view the issue details in the sidebar. Each tool has its own capabilities, but generally you can comment and/or close an issue." }, diff --git a/modules/behavior/hover.js b/modules/behavior/hover.js index 58b9187a9..9645b83e2 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, krError } from '../osm'; +import { osmEntity, osmNote, krError, iOsmError } from '../osm'; import { utilKeybinding, utilRebind } from '../util'; @@ -112,9 +112,12 @@ export function behaviorHover(context) { entity = datum; selector = '.data' + datum.__featurehash__; - } else if (datum instanceof krError) { + } else if ( + datum instanceof iOsmError || + datum instanceof krError + ) { entity = datum; - selector = '.kr_error-' + datum.id; + selector = '.' + datum.source + '.error_id-' + datum.id; } else if (datum instanceof osmNote) { entity = datum; diff --git a/modules/behavior/select.js b/modules/behavior/select.js index 8c445930d..8043648d3 100644 --- a/modules/behavior/select.js +++ b/modules/behavior/select.js @@ -19,6 +19,7 @@ import { import { osmEntity, osmNote, + iOsmError, krError } from '../osm'; @@ -170,10 +171,14 @@ export function behaviorSelect(context) { context .selectedNoteID(datum.id) .enter(modeSelectNote(context, datum.id)); + } else if (datum instanceof iOsmError & !isMultiselect) { // clicked an improveOSM error + context + .selectedErrorID(datum.id) + .enter(modeSelectError(context, datum.id, datum.source)); } else if (datum instanceof krError & !isMultiselect) { // clicked a krError error context .selectedErrorID(datum.id) - .enter(modeSelectError(context, datum.id)); + .enter(modeSelectError(context, datum.id, datum.source)); } else { // clicked nothing.. context.selectedNoteID(null); context.selectedErrorID(null); diff --git a/modules/modes/select_error.js b/modules/modes/select_error.js index dbdae8aff..4f7645644 100644 --- a/modules/modes/select_error.js +++ b/modules/modes/select_error.js @@ -13,26 +13,44 @@ import { import { t } from '../util/locale'; import { services } from '../services'; import { modeBrowse, modeDragNode, modeDragNote } from '../modes'; -import { uiKeepRightEditor } from '../ui'; +import { uiImproveOsmEditor, uiKeepRightEditor } from '../ui'; import { utilKeybinding } from '../util'; -export function modeSelectError(context, selectedErrorID) { +export function modeSelectError(context, selectedErrorID, selectedErrorSource) { var mode = { id: 'select-error', button: 'browse' }; - var keepRight = services.keepRight; var keybinding = utilKeybinding('select-error'); - var keepRightEditor = uiKeepRightEditor(context) - .on('change', function() { - context.map().pan([0,0]); // trigger a redraw - var error = checkSelectedID(); - if (!error) return; - context.ui().sidebar - .show(keepRightEditor.error(error)); - }); + + var errorService, errorEditor; + switch (selectedErrorSource) { + case 'iOSM': + errorService = services.improveOSM; + errorEditor = uiImproveOsmEditor(context) + .on('change', function() { + context.map().pan([0,0]); // trigger a redraw + var error = checkSelectedID(); + if (!error) return; + context.ui().sidebar + .show(errorEditor.error(error)); + }); + break; + case 'kr': + errorService = services.keepRight; + errorEditor = uiKeepRightEditor(context) + .on('change', function() { + context.map().pan([0,0]); // trigger a redraw + var error = checkSelectedID(); + if (!error) return; + context.ui().sidebar + .show(errorEditor.error(error)); + }); + break; + } + var behaviors = [ behaviorBreathe(context), @@ -45,8 +63,8 @@ export function modeSelectError(context, selectedErrorID) { function checkSelectedID() { - if (!keepRight) return; - var error = keepRight.getError(selectedErrorID); + if (!errorService) return; + var error = errorService.getError(selectedErrorID); if (!error) { context.enter(modeBrowse(context)); } @@ -55,8 +73,8 @@ export function modeSelectError(context, selectedErrorID) { mode.zoomToSelected = function() { - if (!keepRight) return; - var error = keepRight.getError(selectedErrorID); + if (!errorService) return; + var error = errorService.getError(selectedErrorID); if (error) { context.map().centerZoomEase(error.loc, 20); } @@ -78,7 +96,7 @@ export function modeSelectError(context, selectedErrorID) { selectError(); var sidebar = context.ui().sidebar; - sidebar.show(keepRightEditor.error(error)); + sidebar.show(errorEditor.error(error)); context.map() .on('drawn.select-error', selectError); @@ -89,7 +107,7 @@ export function modeSelectError(context, selectedErrorID) { if (!checkSelectedID()) return; var selection = context.surface() - .selectAll('.kr_error-' + selectedErrorID); + .selectAll('.error_id-' + selectedErrorID + '.' + selectedErrorSource); if (selection.empty()) { // Return to browse mode if selected DOM elements have @@ -121,7 +139,7 @@ export function modeSelectError(context, selectedErrorID) { .call(keybinding.unbind); context.surface() - .selectAll('.kr_error.selected') + .selectAll('.qa_error.selected') .classed('selected hover', false); context.map() diff --git a/modules/osm/improveOSM.js b/modules/osm/improveOSM.js new file mode 100644 index 000000000..fc438ee53 --- /dev/null +++ b/modules/osm/improveOSM.js @@ -0,0 +1,51 @@ +import _extend from 'lodash-es/extend'; + + +export function iOsmError() { + if (!(this instanceof iOsmError)) { + return (new iOsmError()).initialize(arguments); + } else if (arguments.length) { + this.initialize(arguments); + } +} + +// ImproveOSM has no error IDs unfortunately +// So no way to explicitly refer to each error in their DB +iOsmError.id = function() { + return iOsmError.id.next--; +}; + + +iOsmError.id.next = -1; + + +_extend(iOsmError.prototype, { + + type: 'iOsmError', + source: 'iOSM', + + 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]; + } + } + } + } + + if (!this.id) { + this.id = iOsmError.id() + ''; // as string + } + + return this; + }, + + update: function(attrs) { + return iOsmError(this, attrs); // {v: 1 + (this.v || 0)} + } +}); diff --git a/modules/osm/index.js b/modules/osm/index.js index 3a9783b1e..bc19af0f9 100644 --- a/modules/osm/index.js +++ b/modules/osm/index.js @@ -1,6 +1,7 @@ export { osmChangeset } from './changeset'; export { osmEntity } from './entity'; export { krError } from './keepRight'; +export { iOsmError } from './improveOSM'; export { osmNode } from './node'; export { osmNote } from './note'; export { osmRelation } from './relation'; diff --git a/modules/osm/keepRight.js b/modules/osm/keepRight.js index 1ea2d4b25..838382e65 100644 --- a/modules/osm/keepRight.js +++ b/modules/osm/keepRight.js @@ -21,6 +21,7 @@ krError.id.next = -1; _extend(krError.prototype, { type: 'krError', + source: 'kr', initialize: function(sources) { for (var i = 0; i < sources.length; ++i) { @@ -46,4 +47,4 @@ _extend(krError.prototype, { update: function(attrs) { return krError(this, attrs); // {v: 1 + (this.v || 0)} } -}); \ No newline at end of file +}); diff --git a/modules/osm/tags.js b/modules/osm/tags.js index 3b7824ec4..0e08dfa19 100644 --- a/modules/osm/tags.js +++ b/modules/osm/tags.js @@ -10,13 +10,14 @@ export function osmIsInterestingTag(key) { export var osmOneWayTags = { 'aerialway': { 'chair_lift': true, - 'mixed_lift': true, - 't-bar': true, + 'drag_lift': true, 'j-bar': true, + 'magic_carpet': true, + 'mixed_lift': true, 'platter': true, 'rope_tow': true, - 'magic_carpet': true, - 'yes': true + 't-bar': true, + 'zipline': true }, 'highway': { 'motorway': true diff --git a/modules/services/improveOSM.js b/modules/services/improveOSM.js new file mode 100644 index 000000000..2031c4720 --- /dev/null +++ b/modules/services/improveOSM.js @@ -0,0 +1,439 @@ +import _extend from 'lodash-es/extend'; +import _find from 'lodash-es/find'; +import _forEach from 'lodash-es/forEach'; + +import rbush from 'rbush'; + +import { dispatch as d3_dispatch } from 'd3-dispatch'; +import { json as d3_json } from 'd3-request'; +import { request as d3_request } from 'd3-request'; + +import { geoExtent, geoVecAdd } from '../geo'; +import { iOsmError } from '../osm'; +import { services } 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: 'http://directionofflow.skobbler.net/directionOfFlowService', + mr: 'http://missingroads.skobbler.net/missingGeoService', + tr: 'http://turnrestrictionservice.skobbler.net/turnRestrictionService' +}; + +function abortRequest(i) { + _forEach(i, function(v) { + if (v) { + v.abort(); + } + }); +} + +function abortUnwantedRequests(cache, tiles) { + _forEach(cache.inflightTile, function(v, k) { + var wanted = _find(tiles, function(tile) { + return k === tile.id; + }); + if (!wanted) { + abortRequest(v); + delete cache.inflightTile[k]; + } + }); +} + + +function encodeErrorRtree(d) { + return { minX: d.loc[0], minY: d.loc[1], maxX: d.loc[0], maxY: d.loc[1], data: d }; +} + + +// replace or remove error from rtree +function updateRtree(item, replace) { + _erCache.rtree.remove(item, function isEql(a, b) { + return a.data.id === b.data.id; + }); + + if (replace) { + _erCache.rtree.insert(item); + } +} + +function linkErrorObject(d) { + return '' + d + ''; +} + +function linkEntity(d) { + return '' + d + ''; +} + +function pointAverage(points) { + var x = 0; + var y = 0; + + _forEach(points, function(v) { + x += v.lon; + y += v.lat; + }); + + x /= points.length; + y /= points.length; + + return [x, y]; +} + +function relativeBearing(p1, p2) { + var 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; +} + +// 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' + }; + + 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); + + return loc; +} + +export default { + init: function() { + if (!_erCache) { + this.reset(); + } + + this.event = utilRebind(this, dispatch, 'on'); + }, + + reset: function() { + if (_erCache) { + _forEach(_erCache.inflightTile, abortRequest); + } + _erCache = { + data: {}, + loadedTile: {}, + inflightTile: {}, + inflightPost: {}, + closed: {}, + rtree: 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 = _extend({}, options, { east: rect[0], south: rect[3], west: rect[2], north: rect[1] }); + + // 3 separate requests to store for each tile + var requests = {}; + + _forEach(_impOsmUrls, function(v, 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 = _extend({}, params, (k === 'mr') ? { type: 'PARKING,ROAD,BOTH,PATH' } : { confidenceLevel: 'C1' }); + var url = v + '/search?' + utilQsString(kParams); + + requests[k] = d3_json(url, + function(err, data) { + delete _erCache.inflightTile[tile.id]; + + if (err) return; + _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 iOsmError({ + loc: loc, + comments: null, + error_subtype: '', + error_type: k, + icon: 'fas-long-arrow-alt-right', + 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) { + // Average of recorded points should land on the missing geometry + var loc = pointAverage(feature.points); + + // Missing geometry could happen to land on another error + loc = preventCoincident(loc, false); + + + var geoType = feature.type.toLowerCase(); + var geoIcons = { + road: 'maki-car', + parking: 'maki-parking', + both: 'maki-car', + path: 'maki-shoe' + }; + + var d = new iOsmError({ + loc: loc, + comments: null, + error_subtype: geoType, + error_type: k, + icon: geoIcons[geoType], + identifier: { x: feature.x, y: feature.y }, + status: feature.status + }); + + d.replacements = { + num_trips: feature.numberOfTrips, + geometry_type: t('QA.improveOSM.geometry_types.' + geoType) + }; + + _erCache.data[d.id] = d; + _erCache.rtree.insert(encodeErrorRtree(d)); + }); + } + + // Entities at high zoom == turn restrictions + if (data.entities) { + data.entities.forEach(function(feature) { + var loc = feature.point; + + // Turn restrictions could be missing at same junction + // We also want to bump the error up so node is accessible + 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]; + + // 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)); + + var d = new iOsmError({ + loc: loc, + comments: null, + error_subtype: '', + error_type: k, + icon: 'temaki-junction', + identifier: feature.id, + object_id: via_node, + object_type: 'node', + status: feature.status + }); + + // 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)); + }); + } + } + ); + }); + + _erCache.inflightTile[tile.id] = requests; + dispatch.call('loaded'); + }); + }, + + postUpdate: function(d, callback) { + if (!services.osm.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 + services.osm.userDetails(sendPayload); + + function sendPayload(err, user) { + if (err) { return callback(err, d); } + + var type = d.error_type; + var url = _impOsmUrls[type] + '/comment'; + var payload = { + username: user.display_name + }; + + // Each error type has different data for identification + if (type === 'ow') { + payload.roadSegments = [ d.identifier ]; + } else if (type === 'mr') { + payload.tiles = [ d.identifier ]; + } else if (type === 'tr') { + payload.targetIds = [ d.identifier ]; + } + + // Comments don't currently work, if they ever do in future + // it looks as though they require a separate post + // if (d.newComment !== undefined) { + // payload.text = d.newComment; + // } + + if (d.newStatus !== d.status) { + payload.status = d.newStatus; + payload.text = 'status changed'; + } + + _erCache.inflightPost[d.id] = d3_request(url) + .header('Content-Type', 'application/json') + .post(JSON.stringify(payload), function(err) { + delete _erCache.inflightPost[d.id]; + + // Unsuccessful response status, keep issue open + if (err.status !== 200) { return callback(err, d); } + + that.removeError(d); + + // No pretty identifier, so we just use coordinates + if (d.newStatus === 'SOLVED') { + var closedID = d.loc[1].toFixed(5) + '/' + d.loc[0].toFixed(5); + _erCache.closed[d.error_type + ':' + closedID] = true; + } + + return callback(err, d); + }); + } + }, + + // 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 iOsmError) || !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 iOsmError) || !error.id) return; + + delete _erCache.data[error.id]; + updateRtree(encodeErrorRtree(error), false); // false = remove + }, + + // Used to populate `closed:improveosm` changeset tag + getClosedIDs: function() { + return Object.keys(_erCache.closed).sort(); + } +}; diff --git a/modules/services/index.js b/modules/services/index.js index 8b75d7418..838ed435f 100644 --- a/modules/services/index.js +++ b/modules/services/index.js @@ -1,4 +1,5 @@ import serviceKeepRight from './keepRight'; +import serviceImproveOSM from './improveOSM'; import serviceMapillary from './mapillary'; import serviceMapRules from './maprules'; import serviceNominatim from './nominatim'; @@ -15,6 +16,7 @@ import serviceWikipedia from './wikipedia'; export var services = { geocoder: serviceNominatim, keepRight: serviceKeepRight, + improveOSM: serviceImproveOSM, mapillary: serviceMapillary, openstreetcam: serviceOpenstreetcam, osm: serviceOsm, @@ -29,6 +31,7 @@ export var services = { export { serviceKeepRight, + serviceImproveOSM, serviceMapillary, serviceMapRules, serviceNominatim, diff --git a/modules/svg/improveOSM.js b/modules/svg/improveOSM.js new file mode 100644 index 000000000..b9881ff29 --- /dev/null +++ b/modules/svg/improveOSM.js @@ -0,0 +1,262 @@ +import _throttle from 'lodash-es/throttle'; +import { select as d3_select } from 'd3-selection'; + +import { modeBrowse } from '../modes'; +import { svgPointTransform } from './index'; +import { services } from '../services'; + +var _improveOsmEnabled = false; +var _errorService; + + +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; + + 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 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 errors + function editOn() { + if (!_improveOsmVisible) { + _improveOsmVisible = true; + drawLayer + .style('display', 'block'); + } + } + + + // Immediately remove the errors and their touch targets + function editOff() { + if (_improveOsmVisible) { + _improveOsmVisible = false; + drawLayer + .style('display', 'none'); + drawLayer.selectAll('.qa_error.iOSM') + .remove(); + touchLayer.selectAll('.qa_error.iOSM') + .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.iOSM') + .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.iOSM') + .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.source, + 'error_id-' + d.id, + 'error_type-' + d.error_type + '-' + d.error_subtype + ].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') + .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.iOSM') + .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.source + ' 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 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)); + } + } + + dispatch.call('change'); + return this; + }; + + + drawImproveOSM.supported = function() { + return !!getService(); + }; + + + return drawImproveOSM; +} diff --git a/modules/svg/keepRight.js b/modules/svg/keepRight.js index c4b8807ab..126ffd320 100644 --- a/modules/svg/keepRight.js +++ b/modules/svg/keepRight.js @@ -54,9 +54,9 @@ export function svgKeepRight(projection, context, dispatch) { _keepRightVisible = false; drawLayer .style('display', 'none'); - drawLayer.selectAll('.kr_error') + drawLayer.selectAll('.qa_error.kr') .remove(); - touchLayer.selectAll('.kr_error') + touchLayer.selectAll('.qa_error.kr') .remove(); } } @@ -81,7 +81,7 @@ export function svgKeepRight(projection, context, dispatch) { function layerOff() { throttledRedraw.cancel(); drawLayer.interrupt(); - touchLayer.selectAll('.kr_error') + touchLayer.selectAll('.qa_error.kr') .remove(); drawLayer @@ -105,7 +105,7 @@ export function svgKeepRight(projection, context, dispatch) { var getTransform = svgPointTransform(projection); // Draw markers.. - var markers = drawLayer.selectAll('.kr_error') + var markers = drawLayer.selectAll('.qa_error.kr') .data(data, function(d) { return d.id; }); // exit @@ -116,8 +116,13 @@ export function svgKeepRight(projection, context, dispatch) { var markersEnter = markers.enter() .append('g') .attr('class', function(d) { - return 'kr_error kr_error-' + d.id + ' kr_error_type_' + d.parent_error_type; } - ); + return [ + 'qa_error', + d.source, + 'error_id-' + d.id, + 'error_type-' + d.parent_error_type + ].join(' '); + }); markersEnter .append('ellipse') @@ -133,7 +138,7 @@ export function svgKeepRight(projection, context, dispatch) { markersEnter .append('use') - .attr('class', 'kr_error-fill') + .attr('class', 'qa_error-fill') .attr('width', '20px') .attr('height', '20px') .attr('x', '-8px') @@ -152,7 +157,7 @@ export function svgKeepRight(projection, context, dispatch) { if (touchLayer.empty()) return; var fillClass = context.getDebug('target') ? 'pink ' : 'nocolor '; - var targets = touchLayer.selectAll('.kr_error') + var targets = touchLayer.selectAll('.qa_error.kr') .data(data, function(d) { return d.id; }); // exit @@ -169,7 +174,7 @@ export function svgKeepRight(projection, context, dispatch) { .merge(targets) .sort(sortY) .attr('class', function(d) { - return 'kr_error target kr_error-' + d.id + ' ' + fillClass; + return 'qa_error ' + d.source + ' target error_id-' + d.id + ' ' + fillClass; }) .attr('transform', getTransform); diff --git a/modules/svg/layers.js b/modules/svg/layers.js index 59c9ab22c..ffe380c89 100644 --- a/modules/svg/layers.js +++ b/modules/svg/layers.js @@ -11,6 +11,7 @@ import { svgData } from './data'; import { svgDebug } from './debug'; import { svgGeolocate } from './geolocate'; import { svgKeepRight } from './keepRight'; +import { svgImproveOSM } from './improveOSM'; import { svgStreetside } from './streetside'; import { svgMapillaryImages } from './mapillary_images'; import { svgMapillarySigns } from './mapillary_signs'; @@ -30,6 +31,7 @@ export function svgLayers(projection, context) { { id: 'notes', layer: svgNotes(projection, context, dispatch) }, { id: 'data', layer: svgData(projection, context, dispatch) }, { id: 'keepRight', layer: svgKeepRight(projection, context, dispatch) }, + { id: 'improveOSM', layer: svgImproveOSM(projection, context, dispatch) }, { id: 'streetside', layer: svgStreetside(projection, context, dispatch)}, { id: 'mapillary-images', layer: svgMapillaryImages(projection, context, dispatch) }, { id: 'mapillary-signs', layer: svgMapillarySigns(projection, context, dispatch) }, diff --git a/modules/svg/tag_classes.js b/modules/svg/tag_classes.js index 588fad521..0e0a08479 100644 --- a/modules/svg/tag_classes.js +++ b/modules/svg/tag_classes.js @@ -4,8 +4,8 @@ import { osmPavedTags } from '../osm/tags'; export function svgTagClasses() { var primaries = [ - 'building', 'highway', 'railway', 'waterway', 'aeroway', - 'motorway', 'boundary', 'power', 'amenity', 'natural', 'landuse', + 'building', 'highway', 'railway', 'waterway', 'aeroway', 'aerialway', + 'piste:type', 'boundary', 'power', 'amenity', 'natural', 'landuse', 'leisure', 'military', 'place', 'man_made', 'route', 'attraction' ]; var statuses = [ @@ -30,27 +30,39 @@ export function svgTagClasses() { } var t = _tags(entity); - var isMultiPolygon = (t.type === 'multipolygon'); - var shouldRenderLineAsArea = isMultiPolygon && !entity.hasInterestingTags(); - var i, k, v; + // in some situations we want to render perimeter strokes a certain way + var overrideGeometry; + if (/\bstroke\b/.test(value)) { + if (!!t.barrier && t.barrier !== 'no') { + overrideGeometry = 'line'; + } else if (t.type === 'multipolygon' && !entity.hasInterestingTags()) { + overrideGeometry = 'area'; + } + } + // preserve base classes (nothing with `tag-`) var classes = value.trim().split(/\s+/) .filter(function(klass) { return klass.length && !/^tag-/.test(klass); }) - .map(function(klass) { // style multipolygon inner/outers as areas not lines - return (klass === 'line' && shouldRenderLineAsArea) ? 'area' : klass; + .map(function(klass) { // special overrides for some perimeter strokes + return (klass === 'line' || klass === 'area') ? (overrideGeometry || klass) : klass; }); + // pick at most one primary classification tag.. for (i = 0; i < primaries.length; i++) { k = primaries[i]; v = t[k]; if (!v || v === 'no') continue; + if (k === 'piste:type') { // avoid a ':' in the class name + k = 'piste'; + } + primary = k; if (statuses.indexOf(v) !== -1) { // e.g. `railway=abandoned` status = v; diff --git a/modules/ui/commit.js b/modules/ui/commit.js index 01ece7291..084bc6462 100644 --- a/modules/ui/commit.js +++ b/modules/ui/commit.js @@ -110,6 +110,12 @@ export function uiCommit(context) { tags['closed:keepright'] = krClosed.join(';').substr(0, 255); } } + if (services.improveOSM) { + var iOsmClosed = services.improveOSM.getClosedIDs(); + if (iOsmClosed.length) { + tags['closed:improveosm'] = iOsmClosed.join(';').substr(0, 255); + } + } _changeset = _changeset.update({ tags: tags }); @@ -487,4 +493,4 @@ export function uiCommit(context) { return utilRebind(commit, dispatch, 'on'); -} +} \ No newline at end of file diff --git a/modules/ui/improveOSM_details.js b/modules/ui/improveOSM_details.js new file mode 100644 index 000000000..be513ccb0 --- /dev/null +++ b/modules/ui/improveOSM_details.js @@ -0,0 +1,127 @@ +import { + event as d3_event, + select as d3_select +} from 'd3-selection'; + +import { dataEn } from '../../data'; +import { modeSelect } from '../modes'; +import { t } from '../util/locale'; +import { utilDisplayName, utilEntityOrMemberSelector, utilEntityRoot } from '../util'; + + +export function uiImproveOsmDetails(context) { + var _error; + + + function errorDetail(d) { + var unknown = t('inspector.unknown'); + + if (!d) return unknown; + var errorType = d.error_type; + var et = dataEn.QA.improveOSM.error_types[errorType]; + + var detail; + if (et && et.description) { + detail = t('QA.improveOSM.error_types.' + errorType + '.description', d.replacements); + } else { + detail = unknown; + } + + return detail; + } + + + function improveOsmDetails(selection) { + var details = selection.selectAll('.kr_error-details') + .data( + (_error ? [_error] : []), + function(d) { return d.id + '-' + (d.status || 0); } + ); + + details.exit() + .remove(); + + var detailsEnter = details.enter() + .append('div') + .attr('class', 'kr_error-details kr_error-details-container'); + + + // description + var descriptionEnter = detailsEnter + .append('div') + .attr('class', 'kr_error-details-description'); + + descriptionEnter + .append('h4') + .text(function() { return t('QA.keepRight.detail_description'); }); + + descriptionEnter + .append('div') + .attr('class', 'kr_error-details-description-text') + .html(errorDetail); + + // If there are entity links in the error message.. + descriptionEnter.selectAll('.kr_error_entity_link, .kr_error_object_link') + .each(function() { + var link = d3_select(this); + var isObjectLink = link.classed('kr_error_object_link'); + var entityID = isObjectLink ? + (utilEntityRoot(_error.object_type) + _error.object_id) + : this.textContent; + var entity = context.hasEntity(entityID); + + // Add click handler + link + .on('mouseover', function() { + context.surface().selectAll(utilEntityOrMemberSelector([entityID], context.graph())) + .classed('hover', true); + }) + .on('mouseout', 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; + } + } + }); + } + + + improveOsmDetails.error = function(val) { + if (!arguments.length) return _error; + _error = val; + return improveOsmDetails; + }; + + + return improveOsmDetails; +} diff --git a/modules/ui/improveOSM_editor.js b/modules/ui/improveOSM_editor.js new file mode 100644 index 000000000..c1c29c1b5 --- /dev/null +++ b/modules/ui/improveOSM_editor.js @@ -0,0 +1,195 @@ +import { dispatch as d3_dispatch } from 'd3-dispatch'; + +import { t } from '../util/locale'; +import { services } from '../services'; +import { modeBrowse } from '../modes'; +import { svgIcon } from '../svg'; + +import { + uiImproveOsmDetails, + uiImproveOsmHeader, + uiQuickLinks, + uiTooltipHtml +} from './index'; + +import { utilRebind } from '../util'; + + +export function uiImproveOsmEditor(context) { + var dispatch = d3_dispatch('change'); + var errorDetails = uiImproveOsmDetails(context); + var errorHeader = uiImproveOsmHeader(context); + var quickLinks = uiQuickLinks(); + + var _error; + + + 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(); + } + }]; + + + var header = selection.selectAll('.header') + .data([0]); + + var headerEnter = header.enter() + .append('div') + .attr('class', 'header fillL'); + + headerEnter + .append('button') + .attr('class', 'fr keepRight-editor-close') + .on('click', function() { + context.enter(modeBrowse(context)); + }) + .call(svgIcon('#iD-icon-close')); + + headerEnter + .append('h3') + .text(t('QA.improveOSM.title')); + + + var body = selection.selectAll('.body') + .data([0]); + + body = body.enter() + .append('div') + .attr('class', 'body') + .merge(body); + + var editor = body.selectAll('.keepRight-editor') + .data([0]); + + editor.enter() + .append('div') + .attr('class', 'modal-section keepRight-editor') + .merge(editor) + .call(errorHeader.error(_error)) + .call(quickLinks.choices(choices)) + .call(errorDetails.error(_error)) + .call(improveOsmSaveSection); + } + + 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); } + ); + + // exit + saveSection.exit() + .remove(); + + // enter + var saveSectionEnter = saveSection.enter() + .append('div') + .attr('class', 'keepRight-save save-section cf'); + + // update + saveSection = saveSectionEnter + .merge(saveSection) + .call(errorSaveButtons); + } + + function errorSaveButtons(selection) { + var isSelected = (_error && _error.id === context.selectedErrorID()); + var buttonSection = selection.selectAll('.buttons') + .data((isSelected ? [_error] : []), function(d) { return d.status + d.id; }); + + // exit + buttonSection.exit() + .remove(); + + // enter + var buttonEnter = buttonSection.enter() + .append('div') + .attr('class', 'buttons'); + + // Comments don't currently work + // 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); + + // Comments don't currently work + // 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'); +} diff --git a/modules/ui/improveOSM_header.js b/modules/ui/improveOSM_header.js new file mode 100644 index 000000000..aa393b22b --- /dev/null +++ b/modules/ui/improveOSM_header.js @@ -0,0 +1,89 @@ +import { dataEn } from '../../data'; +import { t } from '../util/locale'; + + +export function uiImproveOsmHeader() { + var _error; + + + function errorTitle(d) { + var unknown = t('inspector.unknown'); + + if (!d) return unknown; + var errorType = d.error_type; + var et = dataEn.QA.improveOSM.error_types[errorType]; + + if (et && et.title) { + return t('QA.improveOSM.error_types.' + errorType + '.title'); + } else { + return unknown; + } + } + + + function improveOsmHeader(selection) { + var header = selection.selectAll('.kr_error-header') + .data( + (_error ? [_error] : []), + function(d) { return d.id + '-' + (d.status || 0); } + ); + + header.exit() + .remove(); + + var headerEnter = header.enter() + .append('div') + .attr('class', 'kr_error-header'); + + var iconEnter = headerEnter + .append('div') + .attr('class', 'kr_error-header-icon') + .classed('new', function(d) { return d.id < 0; }); + + var svgEnter = iconEnter + .append('svg') + .attr('width', '20px') + .attr('height', '30px') + .attr('viewbox', '0 0 20 30') + .attr('class', function(d) { + return 'preset-icon-28 qa_error ' + d.source + ' error_id-' + d.id + ' error_type-' + d.error_type + '-' + d.error_subtype; + }); + + 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('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', 'kr_error-header-label') + .text(errorTitle); + } + + + improveOsmHeader.error = function(val) { + if (!arguments.length) return _error; + _error = val; + return improveOsmHeader; + }; + + + return improveOsmHeader; +} diff --git a/modules/ui/index.js b/modules/ui/index.js index de4ff07c7..c1361064e 100644 --- a/modules/ui/index.js +++ b/modules/ui/index.js @@ -28,6 +28,9 @@ export { uiFormFields } from './form_fields'; export { uiFullScreen } from './full_screen'; export { uiGeolocate } from './geolocate'; export { uiHelp } from './help'; +export { uiImproveOsmDetails } from './improveOSM_details'; +export { uiImproveOsmEditor } from './improveOSM_editor'; +export { uiImproveOsmHeader } from './improveOSM_header'; export { uiInfo } from './info'; export { uiInspector } from './inspector'; export { uiKeepRightDetails } from './keepRight_details'; diff --git a/modules/ui/keepRight_header.js b/modules/ui/keepRight_header.js index 1c8d9533c..534ecaf73 100644 --- a/modules/ui/keepRight_header.js +++ b/modules/ui/keepRight_header.js @@ -49,9 +49,9 @@ export function uiKeepRightHeader() { iconEnter .append('div') .attr('class', function(d) { - return 'preset-icon-28 kr_error kr_error-' + d.id + ' kr_error_type_' + d.parent_error_type; + return 'preset-icon-28 qa_error ' + d.source + ' error_id-' + d.id + ' error_type-' + d.parent_error_type; }) - .call(svgIcon('#iD-icon-bolt', 'kr_error-fill')); + .call(svgIcon('#iD-icon-bolt', 'qa_error-fill')); headerEnter .append('div') diff --git a/modules/ui/map_data.js b/modules/ui/map_data.js index 0d4793224..99236e246 100644 --- a/modules/ui/map_data.js +++ b/modules/ui/map_data.js @@ -228,7 +228,7 @@ export function uiMapData(context) { function drawQAItems(selection) { - var qaKeys = ['keepRight']; + var qaKeys = ['keepRight', 'improveOSM']; var qaLayers = layers.all().filter(function(obj) { return qaKeys.indexOf(obj.id) !== -1; }); var ul = selection diff --git a/modules/ui/sidebar.js b/modules/ui/sidebar.js index 278c466b0..498baa666 100644 --- a/modules/ui/sidebar.js +++ b/modules/ui/sidebar.js @@ -9,9 +9,16 @@ import { selectAll as d3_selectAll } from 'd3-selection'; -import { osmEntity, osmNote, krError } from '../osm'; +import { osmEntity, osmNote, iOsmError, krError } from '../osm'; import { services } from '../services'; -import { uiDataEditor, uiFeatureList, uiInspector, uiNoteEditor, uiKeepRightEditor } from './index'; +import { + uiDataEditor, + uiFeatureList, + uiInspector, + uiNoteEditor, + uiImproveOsmEditor, + uiKeepRightEditor +} from './index'; import { textDirection } from '../util/locale'; @@ -19,10 +26,12 @@ export function uiSidebar(context) { var inspector = uiInspector(context); var dataEditor = uiDataEditor(context); var noteEditor = uiNoteEditor(context); + var improveOsmEditor = uiImproveOsmEditor(context); var keepRightEditor = uiKeepRightEditor(context); var _current; var _wasData = false; var _wasNote = false; + var _wasIOsmError = false; var _wasKRError = false; @@ -131,6 +140,23 @@ export function uiSidebar(context) { selection.selectAll('.sidebar-component') .classed('inspector-hover', true); + } else if (datum instanceof iOsmError) { + _wasIOsmError = true; + + var improveOSM = services.improveOSM; + if (improveOSM) { + datum = improveOSM.getError(datum.id); + } + + d3_selectAll('.iOSM.qa_error') + .classed('hover', function(d) { return d.id === datum.id; }); + + sidebar + .show(improveOsmEditor.error(datum)); + + selection.selectAll('.sidebar-component') + .classed('inspector-hover', true); + } else if (datum instanceof krError) { _wasKRError = true; @@ -139,7 +165,7 @@ export function uiSidebar(context) { datum = keepRight.getError(datum.id); // marker may contain stale data - get latest } - d3_selectAll('.kr_error') + d3_selectAll('.kr.qa_error') .classed('hover', function(d) { return d.id === datum.id; }); sidebar @@ -173,9 +199,10 @@ export function uiSidebar(context) { inspector .state('hide'); - } else if (_wasData || _wasNote || _wasKRError) { + } else if (_wasData || _wasNote || _wasIOsmError || _wasKRError) { _wasNote = false; _wasData = false; + _wasIOsmError = false; _wasKRError = false; d3_selectAll('.note').classed('hover', false); d3_selectAll('.kr_error').classed('hover', false); diff --git a/svg/fontawesome/fas-long-arrow-alt-right.svg b/svg/fontawesome/fas-long-arrow-alt-right.svg new file mode 100644 index 000000000..a2b44d34b --- /dev/null +++ b/svg/fontawesome/fas-long-arrow-alt-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svg/iD-sprite/icons/icon-bolt.svg b/svg/iD-sprite/icons/icon-bolt.svg index 8078987b2..aaa264a45 100644 --- a/svg/iD-sprite/icons/icon-bolt.svg +++ b/svg/iD-sprite/icons/icon-bolt.svg @@ -1,8 +1,7 @@ - + diff --git a/test/spec/svg/layers.js b/test/spec/svg/layers.js index 5a40332d6..9009dcc2d 100644 --- a/test/spec/svg/layers.js +++ b/test/spec/svg/layers.js @@ -26,18 +26,19 @@ describe('iD.svgLayers', function () { it('creates default data layers', function () { container.call(iD.svgLayers(projection, context)); var nodes = container.selectAll('svg .data-layer').nodes(); - expect(nodes.length).to.eql(11); + expect(nodes.length).to.eql(12); expect(d3.select(nodes[0]).classed('osm')).to.be.true; expect(d3.select(nodes[1]).classed('notes')).to.be.true; expect(d3.select(nodes[2]).classed('data')).to.be.true; expect(d3.select(nodes[3]).classed('keepRight')).to.be.true; - expect(d3.select(nodes[4]).classed('streetside')).to.be.true; - expect(d3.select(nodes[5]).classed('mapillary-images')).to.be.true; - expect(d3.select(nodes[6]).classed('mapillary-signs')).to.be.true; - expect(d3.select(nodes[7]).classed('openstreetcam-images')).to.be.true; - expect(d3.select(nodes[8]).classed('debug')).to.be.true; - expect(d3.select(nodes[9]).classed('geolocate')).to.be.true; - expect(d3.select(nodes[10]).classed('touch')).to.be.true; + expect(d3.select(nodes[4]).classed('improveOSM')).to.be.true; + expect(d3.select(nodes[5]).classed('streetside')).to.be.true; + expect(d3.select(nodes[6]).classed('mapillary-images')).to.be.true; + expect(d3.select(nodes[7]).classed('mapillary-signs')).to.be.true; + expect(d3.select(nodes[8]).classed('openstreetcam-images')).to.be.true; + expect(d3.select(nodes[9]).classed('debug')).to.be.true; + expect(d3.select(nodes[10]).classed('geolocate')).to.be.true; + expect(d3.select(nodes[11]).classed('touch')).to.be.true; }); }); diff --git a/test/spec/svg/tag_classes.js b/test/spec/svg/tag_classes.js index 8baa177dd..2df7caeb1 100644 --- a/test/spec/svg/tag_classes.js +++ b/test/spec/svg/tag_classes.js @@ -209,6 +209,25 @@ describe('iD.svgTagClasses', function () { expect(selection.attr('class')).to.equal('selected'); }); + it('stroke overrides: renders areas with barriers as lines', function() { + selection + .attr('class', 'way area stroke') + .datum(iD.osmEntity({tags: {landuse: 'residential', barrier: 'hedge'}})) + .call(iD.svgTagClasses()); + expect(selection.classed('area')).to.be.false; + expect(selection.classed('line')).to.be.true; + }); + + it('stroke overrides: renders simple multipolygon lines as areas', function() { + var multipolygon = function () { return { type: 'multipolygon' }; }; + selection + .attr('class', 'way line stroke') + .datum(iD.osmEntity({tags: {}})) + .call(iD.svgTagClasses().tags(multipolygon)); + expect(selection.classed('area')).to.be.true; + expect(selection.classed('line')).to.be.false; + }); + it('works on SVG elements', function() { selection = d3.select(document.createElementNS('http://www.w3.org/2000/svg', 'g')); selection