diff --git a/css/20_map.css b/css/20_map.css index 9ded521e0..6c9ed5c4b 100644 --- a/css/20_map.css +++ b/css/20_map.css @@ -31,7 +31,9 @@ /* No interactivity except what we specifically allow */ -.layer-osm * { +.data-layer.osm *, +.data-layer.notes *, +.data-layer.keepRight * { pointer-events: none; } @@ -42,6 +44,8 @@ /* `.target` objects are interactive */ /* They can be picked up, clicked, hovered, or things can connect to them */ +.kr_error.target, +.note.target, .node.target, .turn .target { pointer-events: fill; @@ -75,8 +79,11 @@ pointer-events: none !important; } +/* NOTE: when more QA layers are added, replace kr_error with generic QA layer selector */ +/* points, notes & QA */ -/* points & notes */ +/* points, notes, markers */ +g.kr_error .stroke, g.note .stroke { stroke: #222; stroke-width: 1; @@ -84,6 +91,7 @@ g.note .stroke { opacity: 0.6; } +g.kr_error.active .stroke, g.note.active .stroke { stroke: #222; stroke-width: 1; @@ -97,6 +105,7 @@ g.point .stroke { fill: #fff; } +g.kr_error .shadow, g.point .shadow, g.note .shadow { fill: none; @@ -105,13 +114,14 @@ g.note .shadow { stroke-opacity: 0; } -g.note.related:not(.selected) .shadow, +g.kr_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.note.selected .shadow, g.point.selected .shadow { stroke-opacity: 0.7; diff --git a/css/55_cursors.css b/css/55_cursors.css index 466e21ae2..f473301c4 100644 --- a/css/55_cursors.css +++ b/css/55_cursors.css @@ -96,7 +96,12 @@ cursor: url(img/cursor-draw.png) 9 9, crosshair; /* FF */ } +.mode-browse .note, +.mode-browse .kr_error, +.mode-select .note, +.mode-select .kr_error, .turn rect, .turn circle { cursor: pointer; } + diff --git a/css/65_data.css b/css/65_data.css index 24af906c4..71aafab9c 100644 --- a/css/65_data.css +++ b/css/65_data.css @@ -1,39 +1,27 @@ -/* OSM Notes Layer */ -.layer-notes { - pointer-events: none; -} -.layer-notes .note * { - pointer-events: none; -} -.mode-browse .layer-notes .note .note-fill, -.mode-select .layer-notes .note .note-fill, -.mode-select-data .layer-notes .note .note-fill, -.mode-select-note .layer-notes .note .note-fill { - pointer-events: visible; - cursor: pointer; /* Opera */ - cursor: url(img/cursor-select-point.png), pointer; /* FF */ +/* OSM Notes and KeepRight Layers */ + +.kr_error-header-icon .kr_error-fill, +.layer-keepRight .kr_error .kr_error-fill { + stroke: #333; + stroke-width: 1.3px; /* NOTE: likely a better way to scale the icon stroke */ } -.note-header-icon .note-shadow, -.layer-notes .note .note-shadow { - color: #000; -} .note-header-icon .note-fill, .layer-notes .note .note-fill { - color: #ff3300; + color: #f30; stroke: #333; stroke-width: 40px; } .note-header-icon.new .note-fill, .layer-notes .note.new .note-fill { - color: #ffee00; + color: #fe0; stroke: #333; stroke-width: 40px; } .note-header-icon.closed .note-fill, .layer-notes .note.closed .note-fill { - color: #55dd00; + color: #5d0; stroke: #333; stroke-width: 40px; } @@ -54,8 +42,81 @@ } -/* Custom Map Data (geojson, gpx, kml, vector tile) */ +/* 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 */ + color: #ff9; +} +.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 */ + color: #5d0; +} + +.kr_error_type_130 { /* disconnected ways */ + color: #fa3; +} + +.kr_error_type_170 { /* FIXME tag */ + color: #ff0; +} + +.kr_error_type_190 { /* intersection without junction */ + color: #f33; +} + +.kr_error_type_200 { /* overlapping ways */ + color: #fdbf6f; +} + +.kr_error_type_160, /* railway layer conflict */ +.kr_error_type_230 { /* layer conflict */ + color: #b60; +} + +.kr_error_type_280 { /* boundary issues */ + color: #5f47a0; +} + +.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 */ + color: #090; +} + +.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 */ + color: #c35; +} + + +/* Custom Map Data (geojson, gpx, kml, vector tile) */ .layer-mapdata { pointer-events: none; } diff --git a/css/80_app.css b/css/80_app.css index 0f6cc4411..5fcf099b2 100644 --- a/css/80_app.css +++ b/css/80_app.css @@ -586,6 +586,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.keepRight-editor-close, .entity-editor-pane .header button.preset-close, .preset-list-pane .header button.preset-choose { position: absolute; @@ -595,6 +596,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.keepRight-editor-close, [dir='rtl'] .entity-editor-pane .header button.preset-close, [dir='rtl'] .preset-list-pane .header button.preset-choose { left: 0; @@ -2430,9 +2432,10 @@ input.key-trap { } -/* OSM Note Editor +/* OSM Note / KeepRight Editors ------------------------------------------------------- */ -.note-header { +.note-header, +.kr_error-header { background-color: #f6f6f6; border-radius: 5px; border: 1px solid #ccc; @@ -2441,7 +2444,8 @@ input.key-trap { align-items: center; } -.note-header-icon { +.note-header-icon, +.kr_error-header-icon { background-color: #fff; padding: 10px; flex: 0 0 62px; @@ -2451,18 +2455,21 @@ input.key-trap { border-right: 1px solid #ccc; border-radius: 5px 0 0 5px; } -[dir='rtl'] .note-header-icon { +[dir='rtl'] .note-header-icon, +[dir='rtl'] .kr_error-header-icon { border-right: unset; border-left: 1px solid #ccc; border-radius: 0 5px 5px 0; } -.note-header-icon .icon-wrap { +.note-header-icon .icon-wrap, +.kr_error-header-icon .icon-wrap { position: absolute; top: 0px; } -.note-header-label { +.note-header-label, +.kr_error-header-label { background-color: #f6f6f6; padding: 0 15px; flex: 1 1 100%; @@ -2470,7 +2477,8 @@ input.key-trap { font-weight: bold; border-radius: 0 5px 5px 0; } -[dir='rtl'] .note-header-label { +[dir='rtl'] .note-header-label, +[dir='rtl'] .kr_error-header-label { border-radius: 5px 0 0 5px; } @@ -2536,17 +2544,22 @@ input.key-trap { border-left: none; } -.note-save { +.note-save, +.keepRight-save, +.kr_error-details, +.kr_error-comment-container { padding: 10px; } -.note-save #new-comment-input { +.keepRight-save .new-comment-input, +.note-save .new-comment-input { width: 100%; height: 100px; max-height: 300px; min-height: 100px; } +.keepRight-save .detail-section, .note-save .detail-section { margin: 10px 0; } @@ -2555,6 +2568,18 @@ input.key-trap { float: right; } +.kr_error-details-container { + background: #ececec; + padding: 10px; + margin-top: 20px; + border-radius: 4px; + border: 1px solid #ccc; +} + +.kr_error-details-description { + margin-bottom: 10px; +} + /* Custom Data Editor ------------------------------------------------------- */ diff --git a/data/core.yaml b/data/core.yaml index a06894368..4e224cbfb 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -407,6 +407,7 @@ en: no_documentation_key: There is no documentation available for this key show_more: Show More view_on_osm: View on openstreetmap.org + view_on_keepRight: View on keepright.at all_fields: All fields all_tags: All tags all_members: All members @@ -479,6 +480,9 @@ en: notes: tooltip: Note data from OpenStreetMap title: OpenStreetMap notes + keepRight: + tooltip: Automatically detected map issues from keepright.at + title: KeepRight Issues custom: tooltip: "Drag and drop a data file onto the page, or click the button to setup" title: Custom Map Data @@ -643,6 +647,235 @@ en: out: Zoom out cannot_zoom: "Cannot zoom out further in current mode." full_screen: Toggle Full Screen + QA: + keepRight: + title: KeepRight Error + detail_title: Error + detail_description: Description + comment: Comment + comment_placeholder: Enter a comment to share with other users. + close: Close (Error Fixed) + ignore: Ignore (Not an Error) + save_comment: Save Comment + close_comment: Close and Comment + ignore_comment: Ignore and Comment + error_parts: + node: node + way: way + relation: relation + highway: highway + railway: railway + waterway: waterway + cycleway: cycleway + cycleway_footpath: 'cycleway/footpath' + riverbank: riverbank + bridge: bridge + tunnel: tunnel + place_of_worship: 'place of worship' + pub: pub + restaurant: restaurant + school: school + university: university + hospital: hospital + library: library + theatre: theatre + courthouse: courthouse + bank: bank + cinema: cinema + pharmacy: pharmacy + cafe: cafe + fast_food: 'fast food' + fuel: fuel + from: from + to: to + errorTypes: + 20: + title: 'Multiple nodes on the same spot' + description: 'There is more than one node in this spot. Node IDs: {var1}.' + 30: + title: 'Non-closed area' + description: 'This way is tagged with "{var1}" and should be a closed loop.' + 40: + title: 'Impossible oneway' + description: 'The first node {var1} of this oneway is not connected to any other way.' + 41: + description: 'The last node {var1} of this oneway is not connected to any other way.' + 42: + description: 'You cannot reach this node because all ways leading from it are oneway.' + 43: + description: 'You cannot escape from this node because all ways leading to it are oneway.' + 50: + title: 'Almost junction' + description: 'This node is very close but not connected to way {var1}.' + 60: + title: 'Deprecated tag' + description: 'This {var1} uses deprecated tag "{var2}". Please use "{var3}" instead.' + 70: + title: 'Missing tag' + description: 'This {var1} has an empty tag: "{var2}".' + 71: + description: 'This way has no tags.' + 72: + description: 'This node is not member of any way and doesn''t have any tags.' + 73: + description: 'This way has a "{var1}" tag but no "highway" tag.' + 74: + description: 'This {var1} has an empty tag: "{var2}".' + 75: + description: 'This {var1} has a name "{var2}" but no other tags.' + 90: + title: 'Motorway without ref tag' + description: 'This way is tagged as a motorway and therefore needs a "ref", "nat_ref", or "int_ref" tag.' + 100: + title: 'Place of worship without religion' + description: 'This {var1} is tagged as a place of worship and therefore needs a religion tag.' + 110: + title: 'Point of interest without name' + description: 'This node is tagged as a "{var1}" and therefore needs a name tag.' + 120: + title: 'Way without nodes' + description: 'This way has just one single node.' + 130: + title: 'Disconnected way' + description: 'This way is not connected to the rest of the map.' + 150: + title: 'Railway crossing without tag' + description: 'This crossing of a highway and a railway needs to be tagged as "railway=crossing" or "railway=level_crossing".' + 160: + title: 'Railway layer conflict' + description: 'There are ways in different layers (e.g. tunnel or bridge) meeting at this railway crossing.' + 170: + title: 'FIXME tagged item' + description: '{var1}' + 180: + title: 'Relation without type' + description: 'This relation is missing a "type" tag.' + 190: + title: 'Intersection without junction' + description: 'This {var1} intersects the {var2} {var3} but there is no junction node, bridge, or tunnel.' + 200: + title: 'Overlapping ways' + description: 'This {var1} overlaps the {var2} {var3}.' + 210: + title: 'Self-intersecting way' + description: 'There is an unspecified issue with self intersecting ways.' + 211: + description: 'This way contains more than one node multiple times. Nodes are {var1}. This may or may not be an error.' + 212: + description: 'This way has only two different nodes and contains one of them more than once.' + 220: + title: 'Misspelled tag' + description: 'This {var1} is tagged "{var2}" where "{var3}" looks like "{var4}".' + 221: + description: 'This {var1} has a suspicious tag "{var2}".' + 230: + title: 'Layer conflict' + description: 'This node is a junction of ways on different layers.' + 231: + description: 'This node is a junction of ways on different layers: {var1}.' + layer: '(layer: {layer})' + 232: + description: 'This {var1} is tagged with "layer={var2}". This need not be an error but it looks strange.' + 270: + title: 'Unusual motorway connection' + description: 'This node is a junction of a motorway and a highway other than "motorway", "motorway_link", "trunk", "rest_area", or "construction". Connection to "service" or "unclassified" is only valid if it has "access=no/private", or it leads to a motorway service area, or if it is a "service=parking_aisle".' + 280: + title: 'Boundary issue' + description: 'There is an unspecified issue with this boundary.' + 281: + title: 'Boundary missing name' + description: 'This boundary has no name.' + 282: + title: 'Boundary missing admin level' + description: 'The boundary of {var1} has no valid numeric admin_level. Please do not mix admin levels (e.g. "6;7"). Always tag the lowest admin_level of all boundaries.' + 283: + title: 'Boundary not a closed loop' + description: 'The boundary of {var1} is not a closed loop.' + 284: + title: 'Boundary is split' + description: 'The boundary of {var1} splits here.' + 285: + title: 'Boundary admin_level too high' + description: 'This boundary way has "admin_level={var1}" but belongs to a relation with lower "admin_level" (e.g. higher priority); it should have the lowest "admin_level" of all relations.' + 290: + title: 'Restriction issue' + description: 'There is an unspecified issue with this restriction.' + 291: + title: 'Restriction missing type' + description: 'This turn restriction has an unrecognized restriction type.' + 292: + title: 'Restriction missing "from" way' + description: 'A turn restriction needs exactly one "from" member. This one has {var1}.' + 293: + title: 'Restriction missing "to" way' + description: 'A turn restriction needs exactly one "to" member. This one has {var1}.' + 294: + title: 'Restriction "from" or "to" is not a way' + description: '"from" and "to" members of turn restrictions need to be ways. {var1}.' + 295: + title: 'Restriction "via" is not an endpoint' + description: '"via" (node {var1}) is not the first or the last member of "{var2}" (way {var3}).' + 296: + title: 'Unusual restriction angle' + description: 'Restriction type is "{var1}" but angle is {var2} degrees. Maybe the restriction type is not appropriate?' + 297: + title: 'Wrong direction of to member' + description: 'Wrong direction of "to" way {var1}.' + 298: + title: 'Redundant restriction - oneway' + description: 'Entry already prohibited by "oneway" tag on {var1}.' + 300: + title: 'Missing maxspeed' + description: 'This road is missing a "maxspeed" tag and is tagged as motorway, trunk, primary, or secondary.' + 310: + title: 'Roundabout issue' + description: 'There is an unspecified issue with this roundabout.' + 311: + title: 'Roundabout not closed loop' + description: 'This way is part of a roundabout but is not closed-loop. (Split carriageways approaching a roundabout should not be tagged as roundabout).' + 312: + title: 'Roundabout wrong direction' + description: 'If this {var1} is in a country with {var2}-hand traffic then its orientation goes the wrong way around.' + 313: + title: 'Roundabout weakly connected' + description: 'This roundabout has only {var1} other road(s) connected. Roundabouts typically have 3 or more.' + 320: + title: 'Improper link connection' + description: 'This way is tagged as "{var1}" but doesn''t have a connection to any other "{var2}" or "{var3}".' + 350: + title: 'Improper bridge tag' + description: 'This bridge doesn''t have a tag in common with its surrounding ways that shows the purpose of this bridge. There should be one of these tags: {var1}.' + 360: + title: 'Missing local name tag' + description: 'It would be nice if this {var1} had a local name tag "name:XX={var2}" where XX shows the language of its common name "{var2}".' + 370: + title: 'Doubled places' + description: 'This node has tags in common with the surrounding way {var1} {var2} and seems to be redundant.' + including_the_name: "(including the name {name})" + 380: + title: 'Non-physical use of sport tag' + description: 'This way is tagged "{var1}" but has no physical tag (e.g. "leisure", "building", "amenity", or "highway".' + 390: + title: 'Missing tracktype' + description: This track doesn't have a "tracktype" tag. + 400: + title: 'Geometry issue' + description: 'There is an unspecified issue with the geometry here.' + 401: + title: 'Missing turn restriction' + description: 'Ways {var1} and {var2} join in a very sharp angle here and there is no oneway tag or turn restriction that prevents turning.' + 402: + title: 'Impossible angle' + description: 'This way bends in a very sharp angle here.' + 410: + title: 'Website issue' + description: 'There is an unspecified issue with a contact website or URL.' + 411: + description: 'The URL {var1} cannot be opened (HTTP status code {var2}).' + 412: + description: 'Possible domain squatting: The URL has suspicious text: "{var1}".' + 413: + description: 'Possible non-match. Content of the URL did not contain these keywords: ({var1}).' streetside: tooltip: "Streetside photos from Microsoft" title: "Photo Overlay (Bing Streetside)" @@ -850,6 +1083,13 @@ en: using: "To use a GPS trace for mapping, drag and drop the data file onto the map editor. If it's recognized, it will be drawn on the map as a bright purple line. Click the {data} **Map data** panel on the side of the map to enable, disable, or zoom to your GPS data." tracing: "The GPS track isn't sent to OpenStreetMap - the best way to use it is to draw on the map, using it as a guide for the new features that you add." upload: "You can also [upload your GPS data to OpenStreetMap](https://www.openstreetmap.org/trace/create) for other users to use." + qa: + title: Quality Assurance + intro: "*Quality Assurance* (Q/A) 3rd party tools help lead to better quality of OSM data. They list automatically deteted bugs, conflics, and issues with the data, which mappers can then go and 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." + issues_h: "Handling Issues" + issues: "Handling Q/A issues is similar to handling notes. Clicking an existing Q/A issue populates the sidebar with details on the issue type and related features. Each tool has its own capabilities, but generally you can comment and/or close an issue. Expect iD to support a 'fix me' button to automatically fix simple issues in the future." field: restrictions: title: Turn Restrictions Help diff --git a/data/keepRight.json b/data/keepRight.json new file mode 100644 index 000000000..a5ef35c71 --- /dev/null +++ b/data/keepRight.json @@ -0,0 +1,394 @@ +{ + "errorTypes": { + "20": { + "title": "multiple nodes on the same spot", + "severity": "warning", + "description": "There is more than one node in this spot. Offending node IDs: $1", + "IDs": ["20"], + "regex": "IDs: ((?:#\\d+,?)+)" + }, + "30": { + "title": "non-closed_areas", + "severity": "error", + "description": "This way is tagged with '$1' and should be closed-loop.", + "regex": "'(.+)'" + }, + "40": { + "title": "dead-ended one-ways", + "severity": "error", + "description": "The first node (id $1) of this one-way is not connected to any other way", + "IDs": ["n"], + "regex": "\\(id (\\d+)\\)" + }, + "41": { + "title": "", + "severity": "error", + "description": "The last node (id $1) of this one-way is not connected to any other way", + "IDs": ["n"], + "regex": "\\(id (\\d+)\\)" + }, + "42": { + "title": "", + "severity": "error", + "description": "This node cannot be reached because one-ways only lead away from here" + }, + "43": { + "title": "", + "severity": "error", + "description": "You cannot escape from this node because one-ways only lead to here" + }, + "50": { + "title": "almost-junctions", + "severity": "error", + "description": "This node is very close but not connected to way #$1", + "IDs": ["w"], + "regex": "way #(\\d+)" + }, + "60": { + "title": "deprecated tags", + "severity": "warning", + "description": "This $1 uses deprecated tag $2. Please use $3 instead!", + "regex": "This (node|way|relation) uses deprecated tag '(.+)'\\. Please use "(.+)"" + }, + "70": { + "title": "missing tags", + "severity": "error", + "description": "" + }, + "71": { + "title": "", + "severity": "error", + "description": "This way has no tags" + }, + "72": { + "title": "", + "severity": "error", + "description": "This node is not member of any way and does not have any tags" + }, + "73": { + "title": "", + "severity": "error", + "description": "This way has a $1 tag but no highway tag", + "regex": "has a (.+) tag" + }, + "74": { + "title": "missing tags", + "severity": "error", + "description": "This $1 has an empty tag: $2", + "regex": "This (node|way|relation) has an empty tag: "(.+)="" + }, + "75": { + "description": "This (node|way|relation) has a name \\((.+)\\) but no other tag", + "regex": "This (node|way|relation) has a name \\((.+)\\)" + }, + "90": { + "title": "motorways without ref", + "severity": "error", + "description": "This way is tagged as motorway and therefore needs a ref nat_ref or int_ref tag" + }, + "100": { + "title": "places of worship without religion", + "severity": "error", + "description": "This $1 is tagged as place of worship and therefore needs a religion tag", + "regex": "This (node|way|relation) is" + }, + "110": { + "title": "point of interest without name", + "severity": "error", + "description": "This node is tagged as $1 and therefore needs a name tag", + "regex": "as (.+) and" + }, + "120": { + "title": "ways without nodes", + "severity": "error", + "description": "This way has just one single node" + }, + "130": { + "title": "floating islands", + "severity": "error", + "description": "This way is not connected to the rest of the map" + }, + "150": { + "title": "railway crossing without tag", + "severity": "error", + "description": "This crossing of a highway and a railway needs to be tagged as railway=crossing or railway=level_crossing" + }, + "160": { + "title": "wrongly used railway tag", + "severity": "error", + "description": "There are ways in different layers coming together in this railway crossing. There are ways tagged as tunnel or bridge coming together in this railway crossing" + }, + "170": { + "title": "FIXME tagged items", + "severity": "error", + "description": "(.*)", + "regex": "(.*)" + }, + "180": { + "title": "relations without type", + "severity": "error", + "description": "This relation has no type tag which is mandatory for relations" + }, + "190": { + "title": "intersections without junctions", + "severity": "error", + "description": "This $1 intersects the $2 #$3 but there is no junction node", + "IDs": ["", "", "w"], + "regex": "This (.+) intersects the (.+) #(\\d+)" + }, + "200": { + "title": "overlapping ways", + "severity": "error", + "IDs": ["", "","w"], + "description": "This $1 overlaps the $2 #$3", + "regex": "This (.+) overlaps the (.+) #(\\d+)" + }, + "210": { + "title": "loopings", + "severity": "error", + "description": "These errors contain self intersecting ways" + }, + "211": { + "title": "", + "severity": "error", + "description": "This way contains more than one node at least twice. Nodes are $1.", + "IDs": ["211"], + "regex": "Nodes are ((?:#\\d+(?:, )?)+)\\." + }, + "212": { + "title": "", + "severity": "error", + "description": "This way has only two different nodes and contains one of them more than once" + }, + "220": { + "title": "misspelled tags", + "severity": "error", + "description": "This $1 is tagged '$2' where $3 looks like $4", + "regex": "This (node|way|relation) is tagged '(.+)' where "(.+)" looks like "(.+)"" + }, + "221": { + "title": "", + "severity": "error", + "description": "The key of this $1's tag is 'key': $2", + "regex": "this (node|way|relation)\\'s tag is \\'key\\': (.+)" + }, + "230": { + "title": "layer conflicts", + "severity": "error", + "description": "Connected ways should be on the same layer. Crossings on intermediate nodes of ways on different layers are obviously wrong. Junctions on end-nodes of ways on different layers are also deprecated, but common practice. So you may ignore this part of the check and switch them off separately. Please note that bridges are set to layer +1, and tunnels to -1, anything else to layer 0 implicitly if no layer tag is present." + }, + "231": { + "title": "mixed layers intersection", + "severity": "error", + "description": "This node is a junction of ways on different layers: $1", + "IDs": ["231"], + "regex": "layers: (.+)" + }, + "232": { + "title": "strange layers", + "severity": "error", + "description": "This $1 is tagged with layer $2. This need not be an error, but it looks strange", + "regex": "This (bridge|tunnel) is tagged with layer (-?\\d+)\\." + }, + "270": { + "title": "motorways connected directly", + "severity": "error", + "description": "This node is a junction of a motorway and a highway other than motorway, motorway_link, trunk, rest_area or construction. Service or unclassified is only valid if it has access=no/private or it leads to a motorway service area or if it is a service=parking_aisle." + }, + "280": { + "title": "boundaries", + "severity": "error", + "description": "Administrative Boundaries can be expressed either by tagging ways or by adding them to a relation. They should be closed-loop sequences of ways, they must not self-intersect or split and they must have a name and an admin_level." + }, + "281": { + "title": "missing name", + "severity": "error", + "description": "This boundary has no name" + }, + "282": { + "title": "missing admin level", + "severity": "error", + "description": "The boundary of $1 has no (?:valid numeric)?admin_level", + "regex": "of (.+) has" + }, + "283": { + "title": "no closed loop", + "severity": "error", + "description": "The boundary of $1 is not closed-loop", + "regex": "boundary of (.+) is" + }, + "284": { + "title": "splitting boundary", + "severity": "error", + "description": "The boundary of $1 splits here", + "regex": "boundary of (.+) splits" + }, + "285": { + "title": "admin_level too high", + "severity": "error", + "description": "This boundary-way has admin_level $1 but belongs to a relation with lower admin_level (higher priority); it should have the lowest admin_level of all relations", + "regex": "admin_level (-?\\d+) but" + }, + "290": { + "title": "restrictions", + "severity": "error", + "description": "Analyses all relations tagged type=restriction or following variations type=restriction:hgv type=restriction:caravan type=restriction:motorcar type=restriction:bus type=restriction:agricultural type=restriction:motorcycle type=restriction:bicycle and type=restriction:hazmat" + }, + "291": { + "title": "missing type", + "severity": "error", + "description": "This turn-restriction has no (?:known )?restriction type", + "regex": "This turn-restriction has no (?:known )?restriction type" + }, + "292": { + "title": "missing from way", + "severity": "error", + "description": "A turn-restriction needs exactly one from member. This one has $1", + "regex": "has (\\d+)" + }, + "293": { + "title": "missing to way", + "severity": "error", + "description": "A turn-restriction needs exactly one to member. This one has $1", + "regex": "has (\\d+)" + }, + "294": { + "title": "from or to not a way", + "severity": "error", + "description": "From- and To-members of turn restrictions need to be ways. $1", + "IDs": ["294"], + "regex": "ways\\. ((?:(?:from|to) (?:node|relation) #\\d+,?)+)" + }, + "295": { + "title": "via is not on the way ends", + "severity": "error", + "description": "via (node #$1) is not the first or the last member of (from|to) (way #$3)", + "IDs": ["n", "", "w"], + "regex": "via \\(node #(\\d+)\\) is not the first or the last member of (from|to) \\(way #(\\d+)\\)" + }, + "296": { + "title": "wrong restriction angle", + "severity": "error", + "description": "restriction type is $1, but angle is $2 degrees. Maybe the restriction type is not appropriate?", + "regex": "is (\\w+), but angle is (-?\\d+) degrees" + }, + "297": { + "title": "wrong direction of to member", + "severity": "error", + "description": "wrong direction of to way $1", + "IDs": ["w"], + "regex": "way (\\d+)" + }, + "298": { + "title": "already restricted by oneway", + "severity": "error", + "description": "entry already prohibited by oneway tag on $1", + "IDs": ["w"], + "regex": "on (\\d+)" + }, + "300": { + "title": "missing maxspeed", + "severity": "warning", + "description": "missing maxspeed tag" + }, + "310": { + "title": "roundabouts", + "severity": "error", + "description": "Analyses ways with tag junction=roundabout. More then one way can form a roundabout. It supports tag oneway=-1" + }, + "311": { + "title": "not closed loop", + "severity": "error", + "description": "This way is part of a roundabout but is not closed-loop. (split carriageways approaching a roundabout should not be tagged as roundabout)" + }, + "312": { + "title": "wrong direction", + "severity": "error", + "description": "If this ((?:mini_)?roundabout) is in a country with (left|right)-hand traffic then its orientation goes the wrong way around", + "regex": "this ((?:mini_)?roundabout) is in a country with (left|right)-hand" + }, + "313": { + "title": "faintly connected", + "severity": "error", + "description": "This roundabout has only $1 other roads connected. Roundabouts typically have three", + "regex": "only (\\d) other" + }, + "320": { + "title": "*_link connections", + "severity": "error", + "description": "This way is tagged as highway=$1_link but doesn't have a connection to any other $1 or $1_link", + "regex": "(highway=.+) but doesn't have a connection to any other (.+) or (.+)" + }, + "350": { + "title": "bridge-tags", + "severity": "error", + "description": "This bridge does not have a tag in common with its surrounding ways that shows the purpose of this bridge. There should be one of these tags: (.+)", + "NOTE": "Group can be arbitrary list of form: key=value,key=value,key=value...", + "regex": "these tags: (.+)" + }, + "360": { + "title": "language unknown", + "severity": "warning", + "description": "It would be nice if this (node|way|relation) had an additional tag 'name:XX=(.+)' where XX shows the language of its name '\\2'", + "regex": "this (node|way|relation) had an additional tag 'name:XX=(.+)' where XX shows the language of its name '\\2'" + }, + "370": { + "title": "doubled places", + "severity": "error", + "description": "This node has tags in common with the surrounding way #$1 ((?:\\(including the name '.+'\\) )?)and seems to be redundand", + "IDs": ["w","370"], + "regex": "way #(\\d+) ((?:\\(including the name '.+'\\) )?)and" + }, + "380": { + "title": "non-physical use of sport-tag", + "severity": "error", + "description": "This way is tagged sport=$1 but has no physical tag like e.g. leisure, building, amenity or highway", + "regex": "(sport=.+) but" + }, + "390": { + "title": "missing tracktype", + "severity": "warning", + "description": "This track doesn''t have a tracktype" + }, + "400": { + "title": "geometry glitches", + "severity": "error", + "description": "" + }, + "401": { + "title": "missing turn restriction", + "severity": "error", + "description": "ways $1 and $2 join in a very sharp angle here and there is no oneway tag or turn restriction that prevents turning( from way (\\1|\\2) to (\\1|\\2))?", + "IDs": ["w", "w"], + "regex": "ways (\\d+) and (\\d+) join" + }, + "402": { + "title": "impossible angles", + "severity": "error", + "description": "this way bends in a very sharp angle here" + }, + "410": { + "title": "website", + "severity": "error", + "description": "Web pages are analyzed. Web page is defined by any of the following tags website=* url=* website:mobile=* contact:website=* contact:url=* image=* source:website=* or source:url=*" + }, + "411": { + "title": "http error", + "severity": "error", + "description": "The URL ($1) cannot be opened (HTTP status code $2)", + "regex": "href=(.+)>\\1\\) cannot be opened \\(HTTP status code (\\d+)\\)" + }, + "412": { + "title": "domain hijacking", + "severity": "error", + "description": "Possible domain squatting: $1. Suspicious text is: \"$2\"", + "regex": "Possible domain squatting: \\1\\. Suspicious text is: "(.+)"" + }, + "413": { + "title": "non-match", + "severity": "error", + "description": "Content of the URL ($1) did not contain these keywords: ($2)", + "regex": "Content of the URL (\\1) did not contain these keywords: \\((.+)\\)" + } + } +} diff --git a/dist/locales/en.json b/dist/locales/en.json index 7f0522eab..3fc87f37b 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -501,6 +501,7 @@ "no_documentation_key": "There is no documentation available for this key", "show_more": "Show More", "view_on_osm": "View on openstreetmap.org", + "view_on_keepRight": "View on keepright.at", "all_fields": "All fields", "all_tags": "All tags", "all_members": "All members", @@ -582,6 +583,10 @@ "tooltip": "Note data from OpenStreetMap", "title": "OpenStreetMap notes" }, + "keepRight": { + "tooltip": "Automatically detected map issues from keepright.at", + "title": "KeepRight Issues" + }, "custom": { "tooltip": "Drag and drop a data file onto the page, or click the button to setup", "title": "Custom Map Data", @@ -782,6 +787,306 @@ }, "cannot_zoom": "Cannot zoom out further in current mode.", "full_screen": "Toggle Full Screen", + "QA": { + "keepRight": { + "title": "KeepRight Error", + "detail_title": "Error", + "detail_description": "Description", + "comment": "Comment", + "comment_placeholder": "Enter a comment to share with other users.", + "close": "Close (Error Fixed)", + "ignore": "Ignore (Not an Error)", + "save_comment": "Save Comment", + "close_comment": "Close and Comment", + "ignore_comment": "Ignore and Comment", + "error_parts": { + "node": "node", + "way": "way", + "relation": "relation", + "highway": "highway", + "railway": "railway", + "waterway": "waterway", + "cycleway": "cycleway", + "cycleway_footpath": "cycleway/footpath", + "riverbank": "riverbank", + "bridge": "bridge", + "tunnel": "tunnel", + "place_of_worship": "place of worship", + "pub": "pub", + "restaurant": "restaurant", + "school": "school", + "university": "university", + "hospital": "hospital", + "library": "library", + "theatre": "theatre", + "courthouse": "courthouse", + "bank": "bank", + "cinema": "cinema", + "pharmacy": "pharmacy", + "cafe": "cafe", + "fast_food": "fast food", + "fuel": "fuel", + "from": "from", + "to": "to" + }, + "errorTypes": { + "20": { + "title": "Multiple nodes on the same spot", + "description": "There is more than one node in this spot. Node IDs: {var1}." + }, + "30": { + "title": "Non-closed area", + "description": "This way is tagged with \"{var1}\" and should be a closed loop." + }, + "40": { + "title": "Impossible oneway", + "description": "The first node {var1} of this oneway is not connected to any other way." + }, + "41": { + "description": "The last node {var1} of this oneway is not connected to any other way." + }, + "42": { + "description": "You cannot reach this node because all ways leading from it are oneway." + }, + "43": { + "description": "You cannot escape from this node because all ways leading to it are oneway." + }, + "50": { + "title": "Almost junction", + "description": "This node is very close but not connected to way {var1}." + }, + "60": { + "title": "Deprecated tag", + "description": "This {var1} uses deprecated tag \"{var2}\". Please use \"{var3}\" instead." + }, + "70": { + "title": "Missing tag", + "description": "This {var1} has an empty tag: \"{var2}\"." + }, + "71": { + "description": "This way has no tags." + }, + "72": { + "description": "This node is not member of any way and doesn't have any tags." + }, + "73": { + "description": "This way has a \"{var1}\" tag but no \"highway\" tag." + }, + "74": { + "description": "This {var1} has an empty tag: \"{var2}\"." + }, + "75": { + "description": "This {var1} has a name \"{var2}\" but no other tags." + }, + "90": { + "title": "Motorway without ref tag", + "description": "This way is tagged as a motorway and therefore needs a \"ref\", \"nat_ref\", or \"int_ref\" tag." + }, + "100": { + "title": "Place of worship without religion", + "description": "This {var1} is tagged as a place of worship and therefore needs a religion tag." + }, + "110": { + "title": "Point of interest without name", + "description": "This node is tagged as a \"{var1}\" and therefore needs a name tag." + }, + "120": { + "title": "Way without nodes", + "description": "This way has just one single node." + }, + "130": { + "title": "Disconnected way", + "description": "This way is not connected to the rest of the map." + }, + "150": { + "title": "Railway crossing without tag", + "description": "This crossing of a highway and a railway needs to be tagged as \"railway=crossing\" or \"railway=level_crossing\"." + }, + "160": { + "title": "Railway layer conflict", + "description": "There are ways in different layers (e.g. tunnel or bridge) meeting at this railway crossing." + }, + "170": { + "title": "FIXME tagged item", + "description": "{var1}" + }, + "180": { + "title": "Relation without type", + "description": "This relation is missing a \"type\" tag." + }, + "190": { + "title": "Intersection without junction", + "description": "This {var1} intersects the {var2} {var3} but there is no junction node, bridge, or tunnel." + }, + "200": { + "title": "Overlapping ways", + "description": "This {var1} overlaps the {var2} {var3}." + }, + "210": { + "title": "Self-intersecting way", + "description": "There is an unspecified issue with self intersecting ways." + }, + "211": { + "description": "This way contains more than one node multiple times. Nodes are {var1}. This may or may not be an error." + }, + "212": { + "description": "This way has only two different nodes and contains one of them more than once." + }, + "220": { + "title": "Misspelled tag", + "description": "This {var1} is tagged \"{var2}\" where \"{var3}\" looks like \"{var4}\"." + }, + "221": { + "description": "This {var1} has a suspicious tag \"{var2}\"." + }, + "230": { + "title": "Layer conflict", + "description": "This node is a junction of ways on different layers." + }, + "231": { + "description": "This node is a junction of ways on different layers: {var1}.", + "layer": "(layer: {layer})" + }, + "232": { + "description": "This {var1} is tagged with \"layer={var2}\". This need not be an error but it looks strange." + }, + "270": { + "title": "Unusual motorway connection", + "description": "This node is a junction of a motorway and a highway other than \"motorway\", \"motorway_link\", \"trunk\", \"rest_area\", or \"construction\". Connection to \"service\" or \"unclassified\" is only valid if it has \"access=no/private\", or it leads to a motorway service area, or if it is a \"service=parking_aisle\"." + }, + "280": { + "title": "Boundary issue", + "description": "There is an unspecified issue with this boundary." + }, + "281": { + "title": "Boundary missing name", + "description": "This boundary has no name." + }, + "282": { + "title": "Boundary missing admin level", + "description": "The boundary of {var1} has no valid numeric admin_level. Please do not mix admin levels (e.g. \"6;7\"). Always tag the lowest admin_level of all boundaries." + }, + "283": { + "title": "Boundary not a closed loop", + "description": "The boundary of {var1} is not a closed loop." + }, + "284": { + "title": "Boundary is split", + "description": "The boundary of {var1} splits here." + }, + "285": { + "title": "Boundary admin_level too high", + "description": "This boundary way has \"admin_level={var1}\" but belongs to a relation with lower \"admin_level\" (e.g. higher priority); it should have the lowest \"admin_level\" of all relations." + }, + "290": { + "title": "Restriction issue", + "description": "There is an unspecified issue with this restriction." + }, + "291": { + "title": "Restriction missing type", + "description": "This turn restriction has an unrecognized restriction type." + }, + "292": { + "title": "Restriction missing \"from\" way", + "description": "A turn restriction needs exactly one \"from\" member. This one has {var1}." + }, + "293": { + "title": "Restriction missing \"to\" way", + "description": "A turn restriction needs exactly one \"to\" member. This one has {var1}." + }, + "294": { + "title": "Restriction \"from\" or \"to\" is not a way", + "description": "\"from\" and \"to\" members of turn restrictions need to be ways. {var1}." + }, + "295": { + "title": "Restriction \"via\" is not an endpoint", + "description": "\"via\" (node {var1}) is not the first or the last member of \"{var2}\" (way {var3})." + }, + "296": { + "title": "Unusual restriction angle", + "description": "Restriction type is \"{var1}\" but angle is {var2} degrees. Maybe the restriction type is not appropriate?" + }, + "297": { + "title": "Wrong direction of to member", + "description": "Wrong direction of \"to\" way {var1}." + }, + "298": { + "title": "Redundant restriction - oneway", + "description": "Entry already prohibited by \"oneway\" tag on {var1}." + }, + "300": { + "title": "Missing maxspeed", + "description": "This road is missing a \"maxspeed\" tag and is tagged as motorway, trunk, primary, or secondary." + }, + "310": { + "title": "Roundabout issue", + "description": "There is an unspecified issue with this roundabout." + }, + "311": { + "title": "Roundabout not closed loop", + "description": "This way is part of a roundabout but is not closed-loop. (Split carriageways approaching a roundabout should not be tagged as roundabout)." + }, + "312": { + "title": "Roundabout wrong direction", + "description": "If this {var1} is in a country with {var2}-hand traffic then its orientation goes the wrong way around." + }, + "313": { + "title": "Roundabout weakly connected", + "description": "This roundabout has only {var1} other road(s) connected. Roundabouts typically have 3 or more." + }, + "320": { + "title": "Improper link connection", + "description": "This way is tagged as \"{var1}\" but doesn't have a connection to any other \"{var2}\" or \"{var3}\"." + }, + "350": { + "title": "Improper bridge tag", + "description": "This bridge doesn't have a tag in common with its surrounding ways that shows the purpose of this bridge. There should be one of these tags: {var1}." + }, + "360": { + "title": "Missing local name tag", + "description": "It would be nice if this {var1} had a local name tag \"name:XX={var2}\" where XX shows the language of its common name \"{var2}\"." + }, + "370": { + "title": "Doubled places", + "description": "This node has tags in common with the surrounding way {var1} {var2} and seems to be redundant.", + "including_the_name": "(including the name {name})" + }, + "380": { + "title": "Non-physical use of sport tag", + "description": "This way is tagged \"{var1}\" but has no physical tag (e.g. \"leisure\", \"building\", \"amenity\", or \"highway\"." + }, + "390": { + "title": "Missing tracktype", + "description": "This track doesn't have a \"tracktype\" tag." + }, + "400": { + "title": "Geometry issue", + "description": "There is an unspecified issue with the geometry here." + }, + "401": { + "title": "Missing turn restriction", + "description": "Ways {var1} and {var2} join in a very sharp angle here and there is no oneway tag or turn restriction that prevents turning." + }, + "402": { + "title": "Impossible angle", + "description": "This way bends in a very sharp angle here." + }, + "410": { + "title": "Website issue", + "description": "There is an unspecified issue with a contact website or URL." + }, + "411": { + "description": "The URL {var1} cannot be opened (HTTP status code {var2})." + }, + "412": { + "description": "Possible domain squatting: The URL has suspicious text: \"{var1}\"." + }, + "413": { + "description": "Possible non-match. Content of the URL did not contain these keywords: ({var1})." + } + } + } + }, "streetside": { "tooltip": "Streetside photos from Microsoft", "title": "Photo Overlay (Bing Streetside)", @@ -1009,6 +1314,14 @@ "tracing": "The GPS track isn't sent to OpenStreetMap - the best way to use it is to draw on the map, using it as a guide for the new features that you add.", "upload": "You can also [upload your GPS data to OpenStreetMap](https://www.openstreetmap.org/trace/create) for other users to use." }, + "qa": { + "title": "Quality Assurance", + "intro": "*Quality Assurance* (Q/A) 3rd party tools help lead to better quality of OSM data. They list automatically deteted bugs, conflics, and issues with the data, which mappers can then go and 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.", + "issues_h": "Handling Issues", + "issues": "Handling Q/A issues is similar to handling notes. Clicking an existing Q/A issue populates the sidebar with details on the issue type and related features. Each tool has its own capabilities, but generally you can comment and/or close an issue. Expect iD to support a 'fix me' button to automatically fix simple issues in the future." + }, "field": { "restrictions": { "title": "Turn Restrictions Help", diff --git a/modules/behavior/drag.js b/modules/behavior/drag.js index 0221149ec..5dd432975 100644 --- a/modules/behavior/drag.js +++ b/modules/behavior/drag.js @@ -160,8 +160,8 @@ export function behaviorDrag() { for (; target && target !== root; target = target.parentNode) { var datum = target.__data__; - var entity = datum instanceof osmNote ? - datum : datum && datum.properties && datum.properties.entity; + var entity = datum instanceof osmNote ? datum + : datum && datum.properties && datum.properties.entity; if (entity && target[matchesSelector](_selector)) { return dragstart.call(target, entity); diff --git a/modules/behavior/hover.js b/modules/behavior/hover.js index b7bd503de..58b9187a9 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 } from '../osm'; +import { osmEntity, osmNote, krError } from '../osm'; import { utilKeybinding, utilRebind } from '../util'; @@ -112,6 +112,10 @@ export function behaviorHover(context) { entity = datum; selector = '.data' + datum.__featurehash__; + } else if (datum instanceof krError) { + entity = datum; + selector = '.kr_error-' + datum.id; + } else if (datum instanceof osmNote) { entity = datum; selector = '.note-' + datum.id; diff --git a/modules/behavior/select.js b/modules/behavior/select.js index bbb6df526..8c445930d 100644 --- a/modules/behavior/select.js +++ b/modules/behavior/select.js @@ -12,12 +12,14 @@ import { modeBrowse, modeSelect, modeSelectData, - modeSelectNote + modeSelectNote, + modeSelectError } from '../modes'; import { osmEntity, - osmNote + osmNote, + krError } from '../osm'; @@ -130,6 +132,7 @@ export function behaviorSelect(context) { if (datum instanceof osmEntity) { // clicked an entity.. var selectedIDs = context.selectedIDs(); context.selectedNoteID(null); + context.selectedErrorID(null); if (!isMultiselect) { if (selectedIDs.length > 1 && (!suppressMenu && !isShowAlways)) { @@ -167,9 +170,13 @@ export function behaviorSelect(context) { context .selectedNoteID(datum.id) .enter(modeSelectNote(context, datum.id)); - + } else if (datum instanceof krError & !isMultiselect) { // clicked a krError error + context + .selectedErrorID(datum.id) + .enter(modeSelectError(context, datum.id)); } else { // clicked nothing.. context.selectedNoteID(null); + context.selectedErrorID(null); if (!isMultiselect && mode.id !== 'browse') { context.enter(modeBrowse(context)); } diff --git a/modules/core/context.js b/modules/core/context.js index 53385070d..341c24901 100644 --- a/modules/core/context.js +++ b/modules/core/context.js @@ -263,6 +263,13 @@ export function coreContext() { return context; }; + var _selectedErrorID; + context.selectedErrorID = function(errorID) { + if (!arguments.length) return _selectedErrorID; + _selectedErrorID = errorID; + return context; + }; + /* Behaviors */ context.install = function(behavior) { diff --git a/modules/modes/drag_note.js b/modules/modes/drag_note.js index 7f834cb10..5c9714fc6 100644 --- a/modules/modes/drag_note.js +++ b/modules/modes/drag_note.js @@ -20,13 +20,14 @@ export function modeDragNote(context) { var _nudgeInterval; var _lastLoc; + var _note; // most current note.. dragged note may have stale datum. - function startNudge(note, nudge) { + function startNudge(nudge) { if (_nudgeInterval) window.clearInterval(_nudgeInterval); _nudgeInterval = window.setInterval(function() { context.pan(nudge); - doMove(note, nudge); + doMove(nudge); }, 50); } @@ -45,58 +46,66 @@ export function modeDragNote(context) { function start(note) { - context.surface().selectAll('.note-' + note.id) + _note = note; + var osm = services.osm; + if (osm) { + // Get latest note from cache.. The marker may have a stale datum bound to it + // and dragging it around can sometimes delete the users note comment. + _note = osm.getNote(_note.id); + } + + context.surface().selectAll('.note-' + _note.id) .classed('active', true); context.perform(actionNoop()); context.enter(mode); - context.selectedNoteID(note.id); + context.selectedNoteID(_note.id); } - function move(note) { + function move() { d3_event.sourceEvent.stopPropagation(); _lastLoc = context.projection.invert(d3_event.point); - doMove(note); + doMove(); var nudge = geoViewportEdge(d3_event.point, context.map().dimensions()); if (nudge) { - startNudge(note, nudge); + startNudge(nudge); } else { stopNudge(); } } - function doMove(note, nudge) { + function doMove(nudge) { nudge = nudge || [0, 0]; var currPoint = (d3_event && d3_event.point) || context.projection(_lastLoc); var currMouse = geoVecSubtract(currPoint, nudge); var loc = context.projection.invert(currMouse); - note = note.move(loc); + _note = _note.move(loc); var osm = services.osm; if (osm) { - osm.replaceNote(note); // update note cache + osm.replaceNote(_note); // update note cache } context.replace(actionNoop()); // trigger redraw } - function end(note) { + function end() { context.replace(actionNoop()); // trigger redraw context - .selectedNoteID(note.id) - .enter(modeSelectNote(context, note.id)); + .selectedNoteID(_note.id) + .enter(modeSelectNote(context, _note.id)); } var drag = behaviorDrag() - .selector('.layer-notes .new') + .selector('.layer-touch.markers .target.note.new') .surface(d3_select('#map').node()) .origin(origin) .on('start', start) diff --git a/modules/modes/index.js b/modules/modes/index.js index af440c4c2..f7f4d985c 100644 --- a/modules/modes/index.js +++ b/modules/modes/index.js @@ -12,4 +12,5 @@ export { modeRotate } from './rotate'; export { modeSave } from './save'; export { modeSelect } from './select'; export { modeSelectData } from './select_data'; +export { modeSelectError} from './select_error'; export { modeSelectNote } from './select_note'; diff --git a/modules/modes/select_data.js b/modules/modes/select_data.js index de9b6513e..ad257b8e6 100644 --- a/modules/modes/select_data.js +++ b/modules/modes/select_data.js @@ -1,4 +1,3 @@ - import { geoBounds as d3_geoBounds } from 'd3-geo'; import { diff --git a/modules/modes/select_error.js b/modules/modes/select_error.js new file mode 100644 index 000000000..abd6ea8bf --- /dev/null +++ b/modules/modes/select_error.js @@ -0,0 +1,126 @@ +import { + event as d3_event, + select as d3_select +} from 'd3-selection'; + +import { + behaviorBreathe, + behaviorHover, + behaviorLasso, + behaviorSelect +} from '../behavior'; + +import { services } from '../services'; +import { modeBrowse, modeDragNode, modeDragNote } from '../modes'; +import { uiKeepRightEditor } from '../ui'; +import { utilKeybinding } from '../util'; + + +export function modeSelectError(context, selectedErrorID) { + 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 behaviors = [ + behaviorBreathe(context), + behaviorHover(context), + behaviorSelect(context), + behaviorLasso(context), + modeDragNode(context).behavior, + modeDragNote(context).behavior + ]; + + + function checkSelectedID() { + if (!keepRight) return; + var error = keepRight.getError(selectedErrorID); + if (!error) { + context.enter(modeBrowse(context)); + } + return error; + } + + + mode.enter = function() { + + // class the error as selected, or return to browse mode if the error is gone + function selectError(drawn) { + if (!checkSelectedID()) return; + + var selection = context.surface() + .selectAll('.kr_error-' + selectedErrorID); + + if (selection.empty()) { + // Return to browse mode if selected DOM elements have + // disappeared because the user moved them out of view.. + var source = d3_event && d3_event.type === 'zoom' && d3_event.sourceEvent; + if (drawn && source && (source.type === 'mousemove' || source.type === 'touchmove')) { + context.enter(modeBrowse(context)); + } + + } else { + selection + .classed('selected', true); + + context.selectedErrorID(selectedErrorID); + } + } + + function esc() { + if (d3_select('.combobox').size()) return; + context.enter(modeBrowse(context)); + } + + var error = checkSelectedID(); + if (!error) return; + + behaviors.forEach(context.install); + keybinding.on('⎋', esc, true); + + d3_select(document) + .call(keybinding); + + selectError(); + + var sidebar = context.ui().sidebar; + sidebar.show(keepRightEditor.error(error)); + + context.map() + .on('drawn.select-error', selectError); + }; + + + mode.exit = function() { + behaviors.forEach(context.uninstall); + + d3_select(document) + .call(keybinding.unbind); + + context.surface() + .selectAll('.kr_error.selected') + .classed('selected hover', false); + + context.map() + .on('drawn.select-error', null); + + context.ui().sidebar + .hide(); + + context.selectedErrorID(null); + }; + + + return mode; +} diff --git a/modules/modes/select_note.js b/modules/modes/select_note.js index 4e5fd5d15..dd24ffd78 100644 --- a/modules/modes/select_note.js +++ b/modules/modes/select_note.js @@ -72,6 +72,7 @@ export function modeSelectNote(context, selectedNoteID) { } else { selection .classed('selected', true); + context.selectedNoteID(selectedNoteID); } } diff --git a/modules/osm/index.js b/modules/osm/index.js index bbbb4835a..f8d7addc1 100644 --- a/modules/osm/index.js +++ b/modules/osm/index.js @@ -1,5 +1,6 @@ export { osmChangeset } from './changeset'; export { osmEntity } from './entity'; +export { krError } from './keepRight'; export { osmNode } from './node'; export { osmNote } from './note'; export { osmRelation } from './relation'; diff --git a/modules/osm/keepRight.js b/modules/osm/keepRight.js new file mode 100644 index 000000000..1ea2d4b25 --- /dev/null +++ b/modules/osm/keepRight.js @@ -0,0 +1,49 @@ +import _extend from 'lodash-es/extend'; + + +export function krError() { + if (!(this instanceof krError)) { + return (new krError()).initialize(arguments); + } else if (arguments.length) { + this.initialize(arguments); + } +} + + +krError.id = function() { + return krError.id.next--; +}; + + +krError.id.next = -1; + + +_extend(krError.prototype, { + + type: 'krError', + + 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 = krError.id() + ''; // as string + } + + return this; + }, + + update: function(attrs) { + return krError(this, attrs); // {v: 1 + (this.v || 0)} + } +}); \ No newline at end of file diff --git a/modules/renderer/map.js b/modules/renderer/map.js index ce737f891..3c4c0c3f8 100644 --- a/modules/renderer/map.js +++ b/modules/renderer/map.js @@ -351,10 +351,11 @@ export function rendererMap(context) { function editOff() { context.features().resetStats(); surface.selectAll('.layer-osm *').remove(); - surface.selectAll('.layer-touch *').remove(); + surface.selectAll('.layer-touch:not(.markers) *').remove(); var mode = context.mode(); - if (mode && mode.id !== 'save' && mode.id !== 'select-note' && mode.id !== 'select-data') { + if (mode && mode.id !== 'save' && mode.id !== 'select-note' && + mode.id !== 'select-data' && mode.id !== 'select-error') { context.enter(modeBrowse(context)); } diff --git a/modules/services/index.js b/modules/services/index.js index cc2a3d729..8b75d7418 100644 --- a/modules/services/index.js +++ b/modules/services/index.js @@ -1,3 +1,4 @@ +import serviceKeepRight from './keepRight'; import serviceMapillary from './mapillary'; import serviceMapRules from './maprules'; import serviceNominatim from './nominatim'; @@ -13,6 +14,7 @@ import serviceWikipedia from './wikipedia'; export var services = { geocoder: serviceNominatim, + keepRight: serviceKeepRight, mapillary: serviceMapillary, openstreetcam: serviceOpenstreetcam, osm: serviceOsm, @@ -26,6 +28,7 @@ export var services = { }; export { + serviceKeepRight, serviceMapillary, serviceMapRules, serviceNominatim, diff --git a/modules/services/keepRight.js b/modules/services/keepRight.js new file mode 100644 index 000000000..3eb853e02 --- /dev/null +++ b/modules/services/keepRight.js @@ -0,0 +1,454 @@ +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 { krError } from '../osm'; +import { t } from '../util/locale'; +import { utilRebind, utilTiler, utilQsString } from '../util'; + +import { errorTypes } from '../../data/keepRight.json'; + + +var tiler = utilTiler(); +var dispatch = d3_dispatch('loaded'); + +var _krCache; +var _krZoom = 14; +var _krUrlRoot = 'https://www.keepright.at/'; +var _krLocalize = { + node: 'node', + way: 'way', + relation: 'relation', + highway: 'highway', + railway: 'railway', + waterway: 'waterway', + cycleway: 'cycleway', + footpath: 'footpath', + 'cycleway/footpath': 'cycleway_footpath', + riverbank: 'riverbank', + bridge: 'bridge', + tunnel: 'tunnel', + place_of_worship: 'place_of_worship', + pub: 'pub', + restaurant: 'restaurant', + school: 'school', + university: 'university', + hospital: 'hospital', + library: 'library', + theatre: 'theatre', + courthouse: 'courthouse', + bank: 'bank', + cinema: 'cinema', + pharmacy: 'pharmacy', + cafe: 'cafe', + fast_food: 'fast_food', + fuel: 'fuel', + from: 'from', + to: 'to' +}; + +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 +]; + + +function abortRequest(i) { + if (i) { + i.abort(); + } +} + +function abortUnwantedRequests(cache, tiles) { + _forEach(cache.inflight, function(v, k) { + var wanted = _find(tiles, function(tile) { + return k === tile.id; + }); + if (!wanted) { + abortRequest(v); + delete cache.inflight[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); + } +} + + +function tokenReplacements(d) { + if (!(d instanceof krError)) return; + + var htmlRegex = new RegExp(/<\/[a-z][\s\S]*>/); + var 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; + } + + // some descriptions are just fixed text + if (!errorTemplate.regex) return; + + // regex pattern should match description with variable details captured as groups + 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 group = errorMatch[i]; + var idType; + + idType = 'IDs' in errorTemplate ? errorTemplate.IDs[i-1] : ''; + if (idType && group) { // link IDs if present in the group + group = parseError(group, idType); + } else if (htmlRegex.test(group)) { // escape any html in non-IDs + group = '\\' + group + '\\'; + } else if (_krLocalize[group]) { // some replacement strings can be localized + group = t('QA.keepRight.error_parts.' + _krLocalize[group]); + } + + replacements['var' + i] = group; + } + + return replacements; +} + + +function parseError(group, idType) { + + function linkEntity(d) { + return '' + d + ''; + } + + // arbitrary node list of form: #ID, #ID, #ID... + function parseError211(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 parseError231(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 parseError294(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 parseError370(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 parseWarning20(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(', '); + } + + switch (idType) { + // simple case just needs a linking span + case 'n': + case 'w': + case 'r': + group = linkEntity(idType + group); + break; + // some errors have more complex ID lists/variance + case '211': + group = parseError211(group); + break; + case '231': + group = parseError231(group); + break; + case '294': + group = parseError294(group); + break; + case '370': + group = parseError370(group); + break; + case '20': + group = parseWarning20(group); + } + + return group; +} + + +export default { + init: function() { + if (!_krCache) { + this.reset(); + } + + this.event = utilRebind(this, dispatch, 'on'); + }, + + reset: function() { + if (_krCache) { + _forEach(_krCache.inflight, abortRequest); + } + _krCache = { loaded: {}, inflight: {}, keepRight: {}, rtree: 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.loaded[tile.id] || _krCache.inflight[tile.id]) return; + + var rect = tile.extent.rectangle(); + var params = _extend({}, options, { left: rect[0], bottom: rect[3], right: rect[2], top: rect[1] }); + var url = _krUrlRoot + 'export.php?' + utilQsString(params) + '&ch=' + rules; + + _krCache.inflight[tile.id] = d3_json(url, + function(err, data) { + delete _krCache.inflight[tile.id]; + + if (err) return; + _krCache.loaded[tile.id] = true; + + if (!data.features || !data.features.length) return; + + 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; + + // - 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.00002, 0] : [0, 0.00002]; + loc = geoVecAdd(loc, delta); + var bbox = geoExtent(loc).bbox(); + coincident = _krCache.rtree.search(bbox).length; + } while (coincident); + + var d = new krError({ + loc: loc, + id: props.error_id, + comment: props.comment || null, + description: props.description || '', + error_id: props.error_id, + which_type: whichType, + error_type: errorType, + parent_error_type: parentErrorType, + object_id: props.object_id, + object_type: props.object_type, + schema: props.schema, + title: props.title + }); + + d.replacements = tokenReplacements(d); + + _krCache.keepRight[d.id] = d; + _krCache.rtree.insert(encodeErrorRtree(d)); + }); + + dispatch.call('loaded'); + } + ); + }); + }, + + + postKeepRightUpdate: function(d, callback) { + if (_krCache.inflight[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); + _krCache.inflight[d.id] = d3_request(url) + .post(function(err) { + delete _krCache.inflight[d.id]; + if (d.state === 'ignore' || d.state === 'ignore_t') { + that.removeError(d); + } else { + d = that.replaceError(d.update({ + comment: d.newComment, + newComment: undefined, + state: undefined + })); + } + + 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 _krCache.rtree.search(bbox).map(function(d) { + return d.data; + }); + }, + + + // get a single error from the cache + getError: function(id) { + return _krCache.keepRight[id]; + }, + + + // replace a single error in the cache + replaceError: function(error) { + if (!(error instanceof krError) || !error.id) return; + + _krCache.keepRight[error.id] = error; + updateRtree(encodeErrorRtree(error), true); // true = replace + return error; + }, + + + // remove a single error from the cache + removeError: function(error) { + if (!(error instanceof krError) || !error.id) return; + + delete _krCache.keepRight[error.id]; + updateRtree(encodeErrorRtree(error), false); // false = remove + }, + + + errorURL: function(error) { + return _krUrlRoot + 'report_map.php?schema=' + error.schema + '&error=' + error.id; + } + +}; diff --git a/modules/services/osm.js b/modules/services/osm.js index 9e4f355a8..d3e52cda5 100644 --- a/modules/services/osm.js +++ b/modules/services/osm.js @@ -432,6 +432,11 @@ export default { }, + noteReportURL: function(note) { + return urlroot + '/reports/new?reportable_type=Note&reportable_id=' + note.id; + }, + + // Generic method to load data from the OSM API // Can handle either auth or unauth calls. loadFromAPI: function(path, callback, options) { diff --git a/modules/svg/geolocate.js b/modules/svg/geolocate.js index f5ca10f97..9b025e586 100644 --- a/modules/svg/geolocate.js +++ b/modules/svg/geolocate.js @@ -2,9 +2,9 @@ import { select as d3_select } from 'd3-selection'; import { svgPointTransform } from './helpers'; import { geoMetersToLat } from '../geo'; -import _throttle from 'lodash-es/throttle'; -export function svgGeolocate(projection, context, dispatch) { + +export function svgGeolocate(projection) { var layer = d3_select(null); var _position; diff --git a/modules/svg/index.js b/modules/svg/index.js index b444bd7a0..82d6bfe9a 100644 --- a/modules/svg/index.js +++ b/modules/svg/index.js @@ -2,6 +2,7 @@ export { svgAreas } from './areas.js'; export { svgData } from './data.js'; export { svgDebug } from './debug.js'; export { svgDefs } from './defs.js'; +export { svgKeepRight } from './keepRight'; export { svgIcon } from './icon.js'; export { svgGeolocate } from './geolocate'; export { svgLabels } from './labels.js'; diff --git a/modules/svg/keepRight.js b/modules/svg/keepRight.js new file mode 100644 index 000000000..a4ddd9e55 --- /dev/null +++ b/modules/svg/keepRight.js @@ -0,0 +1,241 @@ +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 _keepRightEnabled = false; +var _keepRightService; + + +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; + + + 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 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 errors + function editOn() { + if (!_keepRightVisible) { + _keepRightVisible = true; + drawLayer + .style('display', 'block'); + } + } + + + // Immediately remove the errors and their touch targets + function editOff() { + if (_keepRightVisible) { + _keepRightVisible = false; + drawLayer + .style('display', 'none'); + drawLayer.selectAll('.kr_error') + .remove(); + touchLayer.selectAll('.kr_error') + .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('.kr_error') + .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('.kr_error') + .data(data, function(d) { return d.id; }); + + // exit + markers.exit() + .remove(); + + // enter + var markersEnter = markers.enter() + .append('g') + .attr('class', function(d) { + return 'kr_error kr_error-' + d.id + ' kr_error_type_' + d.parent_error_type; } + ); + + 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', 'kr_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('.kr_error') + .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 'kr_error target kr_error-' + 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 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)); + } + } + + dispatch.call('change'); + return this; + }; + + + drawKeepRight.supported = function() { + return !!getService(); + }; + + + return drawKeepRight; +} diff --git a/modules/svg/layers.js b/modules/svg/layers.js index 4d0b9f7d2..59c9ab22c 100644 --- a/modules/svg/layers.js +++ b/modules/svg/layers.js @@ -10,6 +10,7 @@ import { select as d3_select } from 'd3-selection'; import { svgData } from './data'; import { svgDebug } from './debug'; import { svgGeolocate } from './geolocate'; +import { svgKeepRight } from './keepRight'; import { svgStreetside } from './streetside'; import { svgMapillaryImages } from './mapillary_images'; import { svgMapillarySigns } from './mapillary_signs'; @@ -28,6 +29,7 @@ export function svgLayers(projection, context) { { id: 'osm', layer: svgOsm(projection, context, dispatch) }, { id: 'notes', layer: svgNotes(projection, context, dispatch) }, { id: 'data', layer: svgData(projection, context, dispatch) }, + { id: 'keepRight', layer: svgKeepRight(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/notes.js b/modules/svg/notes.js index bc97adb55..d49fb3223 100644 --- a/modules/svg/notes.js +++ b/modules/svg/notes.js @@ -8,12 +8,18 @@ import { svgPointTransform } from './index'; import { services } from '../services'; +var _notesEnabled = false; +var _osmService; + + export function svgNotes(projection, context, dispatch) { if (!dispatch) { dispatch = d3_dispatch('change'); } var throttledRedraw = _throttle(function () { dispatch.call('change'); }, 1000); var minZoom = 12; - var layer = d3_select(null); - var _notes; + var touchLayer = d3_select(null); + var drawLayer = d3_select(null); + var _notesVisible = false; + function markerPath(selection, klass) { selection @@ -22,40 +28,49 @@ export function svgNotes(projection, context, dispatch) { .attr('d', 'm17.5,0l-15,0c-1.37,0 -2.5,1.12 -2.5,2.5l0,11.25c0,1.37 1.12,2.5 2.5,2.5l3.75,0l0,3.28c0,0.38 0.43,0.6 0.75,0.37l4.87,-3.65l5.62,0c1.37,0 2.5,-1.12 2.5,-2.5l0,-11.25c0,-1.37 -1.12,-2.5 -2.5,-2.5z'); } - function init() { - if (svgNotes.initialized) return; // run once - svgNotes.enabled = false; - svgNotes.initialized = true; - } - - function editOn() { - layer.style('display', 'block'); - } - - - function editOff() { - layer.selectAll('.note').remove(); - layer.style('display', 'none'); - } - + // Loosely-coupled osm service for fetching notes. function getService() { - if (services.osm && !_notes) { - _notes = services.osm; - _notes.on('loadedNotes', throttledRedraw); - } else if (!services.osm && _notes) { - _notes = null; + if (services.osm && !_osmService) { + _osmService = services.osm; + _osmService.on('loadedNotes', throttledRedraw); + } else if (!services.osm && _osmService) { + _osmService = null; } - return _notes; + return _osmService; } - function showLayer() { + // Show the notes + function editOn() { + if (!_notesVisible) { + _notesVisible = true; + drawLayer + .style('display', 'block'); + } + } + + + // Immediately remove the notes and their touch targets + function editOff() { + if (_notesVisible) { + _notesVisible = false; + drawLayer + .style('display', 'none'); + drawLayer.selectAll('.note') + .remove(); + touchLayer.selectAll('.note') + .remove(); + } + } + + + // Enable the layer. This shows the notes and transitions them to visible. + function layerOn() { editOn(); - layer - .classed('disabled', false) + drawLayer .style('opacity', 0) .transition() .duration(250) @@ -66,30 +81,35 @@ export function svgNotes(projection, context, dispatch) { } - function hideLayer() { - editOff(); - + // Disable the layer. This transitions the layer invisible and then hides the notes. + function layerOff() { throttledRedraw.cancel(); - layer.interrupt(); + drawLayer.interrupt(); + touchLayer.selectAll('.note') + .remove(); - layer + drawLayer .transition() .duration(250) .style('opacity', 0) .on('end interrupt', function () { - layer.classed('disabled', true); + editOff(); dispatch.call('change'); }); - } - function update() { + // Update the note markers + function updateMarkers() { + if (!_notesVisible || !_notesEnabled) return; + var service = getService(); var selectedID = context.selectedNoteID(); var data = (service ? service.notes(projection) : []); - var transform = svgPointTransform(projection); - var notes = layer.selectAll('.note') + var getTransform = svgPointTransform(projection); + + // Draw markers.. + var notes = drawLayer.selectAll('.note') .data(data, function(d) { return d.status + d.id; }); // exit @@ -139,55 +159,90 @@ export function svgNotes(projection, context, dispatch) { // update notes .merge(notesEnter) - .sort(function(a, b) { - return (a.id === selectedID) ? 1 - : (b.id === selectedID) ? -1 - : b.loc[1] - a.loc[1]; // sort Y + .sort(sortY) + .classed('selected', function(d) { + var mode = context.mode(); + var isMoving = mode && mode.id === 'drag-note'; // no shadows when dragging + return !isMoving && d.id === selectedID; }) - .classed('selected', function(d) { return d.id === selectedID; }) - .attr('transform', transform); + .attr('transform', getTransform); + + + // Draw targets.. + if (touchLayer.empty()) return; + var fillClass = context.getDebug('target') ? 'pink ' : 'nocolor '; + + var targets = touchLayer.selectAll('.note') + .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) { + var newClass = (d.id < 0 ? 'new' : ''); + return 'note target note-' + d.id + ' ' + fillClass + newClass; + }) + .attr('transform', getTransform); + + + function sortY(a, b) { + return (a.id === selectedID) ? 1 : (b.id === selectedID) ? -1 : b.loc[1] - a.loc[1]; + } } + // Draw the notes layer and schedule loading notes and updating markers. function drawNotes(selection) { - var enabled = svgNotes.enabled; var service = getService(); - layer = selection.selectAll('.layer-notes') - .data(service ? [0] : []); - - layer.exit() - .remove(); - - layer.enter() - .append('g') - .attr('class', 'layer-notes') - .style('display', enabled ? 'block' : 'none') - .merge(layer); - - function dimensions() { - return [window.innerWidth, window.innerHeight]; + var surface = context.surface(); + if (surface && !surface.empty()) { + touchLayer = surface.selectAll('.data-layer.touch .layer-touch.markers'); } - if (enabled) { + drawLayer = selection.selectAll('.layer-notes') + .data(service ? [0] : []); + + drawLayer.exit() + .remove(); + + drawLayer = drawLayer.enter() + .append('g') + .attr('class', 'layer-notes') + .style('display', _notesEnabled ? 'block' : 'none') + .merge(drawLayer); + + if (_notesEnabled) { if (service && ~~context.map().zoom() >= minZoom) { editOn(); - service.loadNotes(projection, dimensions()); - update(); + service.loadNotes(projection); + updateMarkers(); } else { editOff(); } } } - drawNotes.enabled = function(val) { - if (!arguments.length) return svgNotes.enabled; - svgNotes.enabled = val; - if (svgNotes.enabled) { - showLayer(); + // Toggles the layer on and off + drawNotes.enabled = function(val) { + if (!arguments.length) return _notesEnabled; + + _notesEnabled = val; + if (_notesEnabled) { + layerOn(); } else { - hideLayer(); + layerOff(); if (context.selectedNoteID()) { context.enter(modeBrowse(context)); } @@ -197,6 +252,6 @@ export function svgNotes(projection, context, dispatch) { return this; }; - init(); + return drawNotes; } diff --git a/modules/svg/touch.js b/modules/svg/touch.js index 96bb1c871..860f95bd1 100644 --- a/modules/svg/touch.js +++ b/modules/svg/touch.js @@ -2,7 +2,7 @@ export function svgTouch() { function drawTouch(selection) { selection.selectAll('.layer-touch') - .data(['areas', 'lines', 'points', 'turns', 'notes']) + .data(['areas', 'lines', 'points', 'turns', 'markers']) .enter() .append('g') .attr('class', function(d) { return 'layer-touch ' + d; }); diff --git a/modules/ui/help.js b/modules/ui/help.js index 195c34819..0f930058b 100644 --- a/modules/ui/help.js +++ b/modules/ui/help.js @@ -180,6 +180,13 @@ export function uiHelp(context) { 'using', 'tracing', 'upload' + ]], + ['qa', [ + 'intro', + 'tools_h', + 'tools', + 'issues_h', + 'issues' ]] ]; @@ -227,6 +234,8 @@ export function uiHelp(context) { 'help.imagery.offsets_h': 3, 'help.streetlevel.using_h': 3, 'help.gps.using_h': 3, + 'help.qa.tools_h': 3, + 'help.qa.issues_h': 3 }; var replacements = { diff --git a/modules/ui/index.js b/modules/ui/index.js index 3f65819cc..d92bd0a7d 100644 --- a/modules/ui/index.js +++ b/modules/ui/index.js @@ -30,6 +30,9 @@ export { uiGeolocate } from './geolocate'; export { uiHelp } from './help'; export { uiInfo } from './info'; export { uiInspector } from './inspector'; +export { uiKeepRightDetails } from './keepRight_details'; +export { uiKeepRightEditor } from './keepRight_editor'; +export { uiKeepRightHeader } from './keepRight_header'; export { uiLasso } from './lasso'; export { uiLoading } from './loading'; export { uiMapData } from './map_data'; @@ -64,4 +67,5 @@ export { uiTooltipHtml } from './tooltipHtml'; export { uiUndoRedo } from './undo_redo'; export { uiVersion } from './version'; export { uiViewOnOSM } from './view_on_osm'; +export { uiViewOnKeepRight } from './view_on_keepRight'; export { uiZoom } from './zoom'; diff --git a/modules/ui/inspector.js b/modules/ui/inspector.js index ccb415077..e11f8ee7c 100644 --- a/modules/ui/inspector.js +++ b/modules/ui/inspector.js @@ -92,9 +92,9 @@ export function uiInspector(context) { } - inspector.state = function(_) { + inspector.state = function(val) { if (!arguments.length) return _state; - _state = _; + _state = val; entityEditor.state(_state); // remove any old field help overlay that might have gotten attached to the inspector @@ -104,16 +104,16 @@ export function uiInspector(context) { }; - inspector.entityID = function(_) { + inspector.entityID = function(val) { if (!arguments.length) return _entityID; - _entityID = _; + _entityID = val; return inspector; }; - inspector.newFeature = function(_) { + inspector.newFeature = function(val) { if (!arguments.length) return _newFeature; - _newFeature = _; + _newFeature = val; return inspector; }; diff --git a/modules/ui/keepRight_details.js b/modules/ui/keepRight_details.js new file mode 100644 index 000000000..01702365d --- /dev/null +++ b/modules/ui/keepRight_details.js @@ -0,0 +1,80 @@ +import { event as d3_event } from 'd3-selection'; + +import { dataEn } from '../../data'; +import { t } from '../util/locale'; + + +export function uiKeepRightDetails(context) { + var _error; + + + function errorDetail(d) { + var unknown = t('inspector.unknown'); + + if (!d) return unknown; + var errorType = d.error_type; + var parentErrorType = d.parent_error_type; + + var et = dataEn.QA.keepRight.errorTypes[errorType]; + var pt = dataEn.QA.keepRight.errorTypes[parentErrorType]; + + if (et && et.description) { + return t('QA.keepRight.errorTypes.' + errorType + '.description', d.replacements); + } else if (pt && pt.description) { + return t('QA.keepRight.errorTypes.' + parentErrorType + '.description', d.replacements); + } else { + return unknown; + } + } + + + function keepRightDetails(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 description = detailsEnter + .append('div') + .attr('class', 'kr_error-details-description'); + + description + .append('h4') + .text(function() { return t('QA.keepRight.detail_description'); }); + + description + .append('div') + .attr('class', 'kr_error-details-description-text') + .html(errorDetail); + + description.selectAll('.kr_error_description-id') + .on('click', function() { clickLink(context, this.text); }); + + + function clickLink(context, entityID) { + d3_event.preventDefault(); + context.layers().layer('osm').enabled(true); + context.zoomToEntity(entityID); + } + } + + + keepRightDetails.error = function(val) { + if (!arguments.length) return _error; + _error = val; + return keepRightDetails; + }; + + + return keepRightDetails; +} diff --git a/modules/ui/keepRight_editor.js b/modules/ui/keepRight_editor.js new file mode 100644 index 000000000..532c8c3e2 --- /dev/null +++ b/modules/ui/keepRight_editor.js @@ -0,0 +1,221 @@ +import { dispatch as d3_dispatch } from 'd3-dispatch'; +import { select as d3_select } from 'd3-selection'; + +import { t } from '../util/locale'; +import { services } from '../services'; +import { modeBrowse } from '../modes'; +import { svgIcon } from '../svg'; +import { uiKeepRightDetails, uiKeepRightHeader, uiViewOnKeepRight } from './index'; +import { utilNoAuto, utilRebind } from '../util'; + + +export function uiKeepRightEditor(context) { + var dispatch = d3_dispatch('change'); + var keepRightDetails = uiKeepRightDetails(context); + var keepRightHeader = uiKeepRightHeader(context); + + var _error; + + + function keepRightEditor(selection) { + 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.keepRight.title')); + + + var body = selection.selectAll('.body') + .data([0]); + + body = body.enter() + .append('div') + .attr('class', 'body') + .merge(body); + + var editor = body.selectAll('.error-editor') + .data([0]); + + editor.enter() + .append('div') + .attr('class', 'modal-section keepRight-editor') + .merge(editor) + .call(keepRightHeader.error(_error)) + .call(keepRightDetails.error(_error)) + .call(keepRightSaveSection); + + + var footer = selection.selectAll('.footer') + .data([0]); + + footer.enter() + .append('div') + .attr('class', 'footer') + .merge(footer) + .call(uiViewOnKeepRight(context).what(_error)); + } + + + 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); } + ); + + // exit + saveSection.exit() + .remove(); + + // enter + var saveSectionEnter = saveSection.enter() + .append('div') + .attr('class', 'keepRight-save save-section cf'); + + saveSectionEnter + .append('h4') + .attr('class', '.error-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', function(d) { return d.newComment || d.comment; }) + .call(utilNoAuto) + .on('input', changeInput) + .on('blur', changeInput); + + // update + saveSection = saveSectionEnter + .merge(saveSection) + .call(keepRightSaveButtons); + + + 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); + } + } + + + 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; }); + + // exit + buttonSection.exit() + .remove(); + + // 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'); +} diff --git a/modules/ui/keepRight_header.js b/modules/ui/keepRight_header.js new file mode 100644 index 000000000..1c8d9533c --- /dev/null +++ b/modules/ui/keepRight_header.js @@ -0,0 +1,71 @@ +import { dataEn } from '../../data'; +import { svgIcon } from '../svg'; +import { t } from '../util/locale'; + + +export function uiKeepRightHeader() { + var _error; + + + function errorTitle(d) { + var unknown = t('inspector.unknown'); + + if (!d) return unknown; + var errorType = d.error_type; + var parentErrorType = d.parent_error_type; + + 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; + } + } + + + function keepRightHeader(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; }); + + iconEnter + .append('div') + .attr('class', function(d) { + return 'preset-icon-28 kr_error kr_error-' + d.id + ' kr_error_type_' + d.parent_error_type; + }) + .call(svgIcon('#iD-icon-bolt', 'kr_error-fill')); + + headerEnter + .append('div') + .attr('class', 'kr_error-header-label') + .text(errorTitle); + } + + + keepRightHeader.error = function(val) { + if (!arguments.length) return _error; + _error = val; + return keepRightHeader; + }; + + + return keepRightHeader; +} diff --git a/modules/ui/map_data.js b/modules/ui/map_data.js index e12737025..a6cee9fd7 100644 --- a/modules/ui/map_data.js +++ b/modules/ui/map_data.js @@ -29,6 +29,7 @@ export function uiMapData(context) { var _dataLayerContainer = d3_select(null); var _fillList = d3_select(null); var _featureList = d3_select(null); + var _QAList = d3_select(null); function showsFeature(d) { @@ -37,6 +38,7 @@ export function uiMapData(context) { function autoHiddenFeature(d) { + if (d.type === 'kr_error') return context.errors().autoHidden(d); return context.features().autoHidden(d); } @@ -47,6 +49,22 @@ export function uiMapData(context) { } + function showsQA(d) { + var QAKeys = [d]; + var QALayers = layers.all().filter(function(obj) { return QAKeys.indexOf(obj.id) !== -1; }); + var data = QALayers.filter(function(obj) { return obj.layer.supported(); }); + + function layerSupported(d) { + return d.layer && d.layer.supported(); + } + function layerEnabled(d) { + return layerSupported(d) && d.layer.enabled(); + } + + return layerEnabled(data[0]); + } + + function showsFill(d) { return _fillSelected === d; } @@ -206,6 +224,58 @@ export function uiMapData(context) { } + function drawQAItems(selection) { + var qaKeys = ['keepRight']; + var qaLayers = layers.all().filter(function(obj) { return qaKeys.indexOf(obj.id) !== -1; }); + + var ul = selection + .selectAll('.layer-list-qa') + .data([0]); + + ul = ul.enter() + .append('ul') + .attr('class', 'layer-list layer-list-qa') + .merge(ul); + + var li = ul.selectAll('.list-item') + .data(qaLayers); + + li.exit() + .remove(); + + var liEnter = li.enter() + .append('li') + .attr('class', function(d) { return 'list-item list-item-' + d.id; }); + + var labelEnter = liEnter + .append('label') + .each(function(d) { + d3_select(this) + .call(tooltip() + .title(t('map_data.layers.' + d.id + '.tooltip')) + .placement('bottom') + ); + }); + + labelEnter + .append('input') + .attr('type', 'checkbox') + .on('change', function(d) { toggleLayer(d.id); }); + + labelEnter + .append('span') + .text(function(d) { return t('map_data.layers.' + d.id + '.title'); }); + + + // Update + li + .merge(liEnter) + .classed('active', function (d) { return d.layer.enabled(); }) + .selectAll('input') + .property('checked', function (d) { return d.layer.enabled(); }); + } + + // Beta feature - sample vector layers to support Detroit Mapping Challenge // https://github.com/osmus/detroit-mapping-challenge function drawVectorItems(selection) { @@ -429,7 +499,8 @@ export function uiMapData(context) { var tip = t(name + '.' + d + '.tooltip'), key = (d === 'wireframe' ? t('area_fill.wireframe.key') : null); - if (name === 'feature' && autoHiddenFeature(d)) { + + if ((name === 'feature' || name === 'keepRight') && autoHiddenFeature(d)) { var msg = showsLayer('osm') ? t('map_data.autohidden') : t('map_data.osmhidden'); tip += '
' + msg + '
'; } @@ -460,7 +531,7 @@ export function uiMapData(context) { .selectAll('input') .property('checked', active) .property('indeterminate', function(d) { - return (name === 'feature' && autoHiddenFeature(d)); + return ((name === 'feature' || name === 'keepRight') && autoHiddenFeature(d)); }); } @@ -501,6 +572,7 @@ export function uiMapData(context) { function update() { _dataLayerContainer .call(drawOsmItems) + .call(drawQAItems) .call(drawPhotoItems) .call(drawCustomDataItems) .call(drawVectorItems); // Beta - Detroit mapping challenge @@ -510,6 +582,9 @@ export function uiMapData(context) { _featureList .call(drawListItems, features, 'checkbox', 'feature', clickFeature, showsFeature); + + _QAList + .call(drawListItems, ['keep-right'], 'checkbox', 'QA', function(d) { toggleLayer(d); }, showsQA); } @@ -609,6 +684,7 @@ export function uiMapData(context) { .append('div') .attr('class', 'pane-content'); + // data layers content .append('div') diff --git a/modules/ui/note_comments.js b/modules/ui/note_comments.js index 4534ea10b..cbb004095 100644 --- a/modules/ui/note_comments.js +++ b/modules/ui/note_comments.js @@ -110,9 +110,9 @@ export function uiNoteComments() { } - noteComments.note = function(_) { + noteComments.note = function(val) { if (!arguments.length) return _note; - _note = _; + _note = val; return noteComments; }; diff --git a/modules/ui/note_editor.js b/modules/ui/note_editor.js index 4ebc6e89d..5de11ff5d 100644 --- a/modules/ui/note_editor.js +++ b/modules/ui/note_editor.js @@ -155,7 +155,7 @@ export function uiNoteEditor(context) { noteSaveEnter .append('textarea') - .attr('id', 'new-comment-input') + .attr('class', 'new-comment-input') .attr('placeholder', t('note.inputPlaceholder')) .attr('maxlength', 1000) .property('value', function(d) { return d.newComment; }) @@ -425,9 +425,9 @@ export function uiNoteEditor(context) { } - noteEditor.note = function(_) { + noteEditor.note = function(val) { if (!arguments.length) return _note; - _note = _; + _note = val; return noteEditor; }; diff --git a/modules/ui/note_header.js b/modules/ui/note_header.js index 8fc2a0e95..c19338c49 100644 --- a/modules/ui/note_header.js +++ b/modules/ui/note_header.js @@ -49,9 +49,9 @@ export function uiNoteHeader() { } - noteHeader.note = function(_) { + noteHeader.note = function(val) { if (!arguments.length) return _note; - _note = _; + _note = val; return noteHeader; }; diff --git a/modules/ui/note_report.js b/modules/ui/note_report.js index 759f9ba13..09b78a4e4 100644 --- a/modules/ui/note_report.js +++ b/modules/ui/note_report.js @@ -1,23 +1,20 @@ import { t } from '../util/locale'; +import { osmNote } from '../osm'; +import { services } from '../services'; import { svgIcon } from '../svg'; -import { - osmNote -} from '../osm'; export function uiNoteReport() { var _note; - var url = 'https://www.openstreetmap.org/reports/new?reportable_id='; function noteReport(selection) { + var url; + if (services.osm && (_note instanceof osmNote) && (!_note.isNew())) { + url = services.osm.noteReportURL(_note); + } - if (!(_note instanceof osmNote)) return; - - url += _note.id + '&reportable_type=Note'; - - var data = ((!_note || _note.isNew()) ? [] : [_note]); var link = selection.selectAll('.note-report') - .data(data, function(d) { return d.id; }); + .data(url ? [url] : []); // exit link.exit() @@ -28,7 +25,7 @@ export function uiNoteReport() { .append('a') .attr('class', 'note-report') .attr('target', '_blank') - .attr('href', url) + .attr('href', function(d) { return d; }) .call(svgIcon('#iD-icon-out-link', 'inline')); linkEnter @@ -37,9 +34,9 @@ export function uiNoteReport() { } - noteReport.note = function(_) { + noteReport.note = function(val) { if (!arguments.length) return _note; - _note = _; + _note = val; return noteReport; }; diff --git a/modules/ui/sidebar.js b/modules/ui/sidebar.js index 9316a6695..0085b0f0c 100644 --- a/modules/ui/sidebar.js +++ b/modules/ui/sidebar.js @@ -9,18 +9,9 @@ import { selectAll as d3_selectAll } from 'd3-selection'; -import { - osmEntity, - osmNote -} from '../osm'; - -import { - uiDataEditor, - uiFeatureList, - uiInspector, - uiNoteEditor -} from './index'; - +import { osmEntity, osmNote, krError } from '../osm'; +import { services } from '../services'; +import { uiDataEditor, uiFeatureList, uiInspector, uiNoteEditor, uiKeepRightEditor } from './index'; import { textDirection } from '../util/locale'; @@ -28,9 +19,11 @@ export function uiSidebar(context) { var inspector = uiInspector(context); var dataEditor = uiDataEditor(context); var noteEditor = uiNoteEditor(context); + var keepRightEditor = uiKeepRightEditor(context); var _current; var _wasData = false; var _wasNote = false; + var _wasKRError = false; function sidebar(selection) { @@ -127,12 +120,34 @@ export function uiSidebar(context) { if (context.mode().id === 'drag-note') return; _wasNote = true; + var osm = services.osm; + if (osm) { + datum = osm.getNote(datum.id); // marker may contain stale data - get latest + } + sidebar .show(noteEditor.note(datum)); selection.selectAll('.sidebar-component') .classed('inspector-hover', true); + } else if (datum instanceof krError) { + _wasKRError = true; + + var keepRight = services.keepRight; + if (keepRight) { + datum = keepRight.getError(datum.id); // marker may contain stale data - get latest + } + + d3_selectAll('.kr_error') + .classed('hover', function(d) { return d.id === datum.id; }); + + sidebar + .show(keepRightEditor.error(datum)); + + selection.selectAll('.sidebar-component') + .classed('inspector-hover', true); + } else if (!_current && (datum instanceof osmEntity)) { featureListWrap .classed('inspector-hidden', true); @@ -158,10 +173,12 @@ export function uiSidebar(context) { inspector .state('hide'); - } else if (_wasData || _wasNote) { + } else if (_wasData || _wasNote || _wasKRError) { _wasNote = false; _wasData = false; + _wasKRError = false; d3_selectAll('.note').classed('hover', false); + d3_selectAll('.kr_error').classed('hover', false); sidebar.hide(); } } diff --git a/modules/ui/view_on_keepRight.js b/modules/ui/view_on_keepRight.js new file mode 100644 index 000000000..d0b28a4cb --- /dev/null +++ b/modules/ui/view_on_keepRight.js @@ -0,0 +1,45 @@ +import { t } from '../util/locale'; +import { services } from '../services'; +import { svgIcon } from '../svg'; +import { krError } from '../osm'; + + +export function uiViewOnKeepRight() { + var _error; // a keepright error + + + function viewOnKeepRight(selection) { + var url; + if (services.keepRight && (_error instanceof krError)) { + 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')); + } + + + viewOnKeepRight.what = function(val) { + if (!arguments.length) return _error; + _error = val; + return viewOnKeepRight; + }; + + return viewOnKeepRight; +} diff --git a/modules/ui/view_on_osm.js b/modules/ui/view_on_osm.js index b013f158b..7bf265275 100644 --- a/modules/ui/view_on_osm.js +++ b/modules/ui/view_on_osm.js @@ -1,9 +1,6 @@ import { t } from '../util/locale'; +import { osmEntity, osmNote } from '../osm'; import { svgIcon } from '../svg'; -import { - osmEntity, - osmNote -} from '../osm'; export function uiViewOnOSM(context) { diff --git a/modules/util/index.js b/modules/util/index.js index 0517ce874..98755addd 100644 --- a/modules/util/index.js +++ b/modules/util/index.js @@ -4,6 +4,7 @@ export { utilCleanTags } from './clean_tags'; export { utilDisplayName } from './util'; export { utilDisplayNameForPath } from './util'; export { utilDisplayType } from './util'; +export { utilEntityRoot } from './util'; export { utilEditDistance } from './util'; export { utilEntitySelector } from './util'; export { utilEntityOrMemberSelector } from './util'; @@ -27,7 +28,6 @@ export { utilRebind } from './rebind'; export { utilSetTransform } from './util'; export { utilSessionMutex } from './session_mutex'; export { utilStringQs } from './util'; -// export { utilSuggestNames } from './suggest_names'; export { utilTagText } from './util'; export { utilTiler } from './tiler'; export { utilTriggerEvent } from './trigger_event'; diff --git a/modules/util/locale.js b/modules/util/locale.js index 7396b7549..1ed2c2b7e 100644 --- a/modules/util/locale.js +++ b/modules/util/locale.js @@ -42,7 +42,9 @@ export function t(s, o, loc) { if (rep !== undefined) { if (o) { for (var k in o) { - rep = rep.replace('{' + k + '}', o[k]); + var variable = '{' + k + '}'; + var re = new RegExp(variable, 'g'); // check globally for variables + rep = rep.replace(re, o[k]); } } return rep; diff --git a/modules/util/util.js b/modules/util/util.js index d9c613263..605378ece 100644 --- a/modules/util/util.js +++ b/modules/util/util.js @@ -121,6 +121,15 @@ export function utilDisplayType(id) { } +export function utilEntityRoot(entityType) { + return { + node: 'n', + way: 'w', + relation: 'r' + }[entityType]; +} + + export function utilStringQs(str) { return str.split('&').reduce(function(obj, pair){ var parts = pair.split('='); diff --git a/svg/iD-sprite/icons/icon-bolt.svg b/svg/iD-sprite/icons/icon-bolt.svg new file mode 100644 index 000000000..8078987b2 --- /dev/null +++ b/svg/iD-sprite/icons/icon-bolt.svg @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/test/spec/svg/layers.js b/test/spec/svg/layers.js index f2277229c..5a40332d6 100644 --- a/test/spec/svg/layers.js +++ b/test/spec/svg/layers.js @@ -26,17 +26,18 @@ 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(10); + expect(nodes.length).to.eql(11); 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('streetside')).to.be.true; - expect(d3.select(nodes[4]).classed('mapillary-images')).to.be.true; - expect(d3.select(nodes[5]).classed('mapillary-signs')).to.be.true; - expect(d3.select(nodes[6]).classed('openstreetcam-images')).to.be.true; - expect(d3.select(nodes[7]).classed('debug')).to.be.true; - expect(d3.select(nodes[8]).classed('geolocate')).to.be.true; - expect(d3.select(nodes[9]).classed('touch')).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; }); });