diff --git a/css/20_map.css b/css/20_map.css index 9ded521e02..6c9ed5c4b2 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 466e21ae23..f473301c4a 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 24af906c48..71aafab9cd 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 */ -.note-header-icon .note-shadow, -.layer-notes .note .note-shadow { - color: #000; +.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-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 296359eb28..a249a85a4e 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; @@ -2429,9 +2431,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; @@ -2440,7 +2443,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; @@ -2450,18 +2454,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%; @@ -2469,7 +2476,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; } @@ -2535,17 +2543,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; } @@ -2554,6 +2567,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 c74f1a9777..619f13b30b 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -408,6 +408,7 @@ en: documentation_redirect: This documentation has been redirected to a new page 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 0000000000..a5ef35c710 --- /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 da3671d750..7ffd79d991 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -502,6 +502,7 @@ "documentation_redirect": "This documentation has been redirected to a new page", "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", @@ -7568,9 +7881,6 @@ "SPW_PICC": { "name": "SPW(allonie) PICC numerical imagery" }, - "US-TIGER-Roads-2012": { - "name": "TIGER Roads 2012" - }, "US-TIGER-Roads-2014": { "description": "At zoom level 16+, public domain map data from the US Census. At lower zooms, only changes since 2006 minus changes already incorporated into OpenStreetMap", "name": "TIGER Roads 2014" @@ -7579,6 +7889,10 @@ "description": "Yellow = Public domain map data from the US Census. Red = Data not found in OpenStreetMap", "name": "TIGER Roads 2017" }, + "US-TIGER-Roads-2018": { + "description": "Yellow = Public domain map data from the US Census. Red = Data not found in OpenStreetMap", + "name": "TIGER Roads 2018" + }, "US_Forest_Service_roads_overlay": { "description": "Highway: Green casing = unclassified. Brown casing = track. Surface: gravel = light brown fill, Asphalt = black, paved = gray, ground =white, concrete = blue, grass = green. Seasonal = white bars", "name": "U.S. Forest Roads Overlay" @@ -7595,6 +7909,12 @@ }, "name": "UrbIS-Ortho 2017" }, + "UrbISOrtho2018": { + "attribution": { + "text": "Realized by means of Brussels UrbIS®© - Distribution & Copyright CIRB" + }, + "name": "UrbIS-Ortho 2018" + }, "UrbisAdmFR": { "attribution": { "text": "Realized by means of Brussels UrbIS®© - Distribution & Copyright CIRB" @@ -7679,11 +7999,33 @@ "description": "Japan GSI Standard Map. Widely covered.", "name": "Japan GSI Standard Map" }, - "hike_n_bike": { + "helsingborg-orto": { "attribution": { - "text": "© OpenStreetMap contributors" + "text": "© Helsingborg municipality" + }, + "description": "Orthophotos from the municipality of Helsingborg 2016, public domain", + "name": "Helsingborg Orthophoto" + }, + "kalmar-orto-2014": { + "attribution": { + "text": "© Kalmar municipality" + }, + "description": "Orthophotos for the north coast of the municipality of Kalmar 2014", + "name": "Kalmar North Orthophoto 2014" + }, + "kalmar-orto-2016": { + "attribution": { + "text": "© Kalmar municipality" + }, + "description": "Orthophotos for the south coast of the municipality of Kalmar 2016", + "name": "Kalmar South Orthophoto 2016" + }, + "kalmar-orto-2018": { + "attribution": { + "text": "© Kalmar municipality" }, - "name": "Hike & Bike" + "description": "Orthophotos for urban areas of the municipality of Kalmar 2018", + "name": "Kalmar Urban Orthophoto 2018" }, "kelkkareitit": { "attribution": { @@ -7706,6 +8048,20 @@ "description": "Mosaic of Swedish orthophotos from the period 1970–1980. Is under construction.", "name": "Lantmäteriet Historic Orthophoto 1975" }, + "lantmateriet-topowebb": { + "attribution": { + "text": "© Lantmäteriet, CC0" + }, + "description": "Topographic map of Sweden 1:50 000", + "name": "Lantmäteriet Topographic Map" + }, + "linkoping-orto": { + "attribution": { + "text": "© Linköping municipality" + }, + "description": "Orthophotos from the municipality of Linköping 2010, open data", + "name": "Linköping Orthophoto" + }, "mapbox_locator_overlay": { "attribution": { "text": "Terms & Feedback" @@ -7770,6 +8126,13 @@ }, "name": "Stamen Terrain" }, + "stockholm-orto": { + "attribution": { + "text": "© Stockholm municipality, CC0" + }, + "description": "Orthophotos from the municipality of Stockholm 2015, CC0 license", + "name": "Stockholm Orthophoto" + }, "tf-cycle": { "attribution": { "text": "Maps © Thunderforest, Data © OpenStreetMap contributors" diff --git a/modules/behavior/drag.js b/modules/behavior/drag.js index 0221149ec5..5dd4329759 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 b7bd503de4..58b9187a94 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 bbb6df526f..8c445930d2 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 53385070d6..341c249019 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 7f834cb103..5c9714fc69 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 af440c4c21..f7f4d985c4 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 de9b6513e8..ad257b8e60 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 0000000000..abd6ea8bf6 --- /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 4e5fd5d158..dd24ffd786 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 bbbb4835ac..f8d7addc1a 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 0000000000..1ea2d4b256 --- /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 ce737f891f..3c4c0c3f89 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 59c9d9524a..d2d2cf96b7 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'; @@ -12,6 +13,7 @@ import serviceWikipedia from './wikipedia'; export var services = { geocoder: serviceNominatim, + keepRight: serviceKeepRight, mapillary: serviceMapillary, openstreetcam: serviceOpenstreetcam, osm: serviceOsm, @@ -24,6 +26,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 0000000000..3eb853e021 --- /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 9e4f355a8f..d3e52cda52 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 f5ca10f97c..9b025e5868 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 b444bd7a05..82d6bfe9a4 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 0000000000..a4ddd9e554 --- /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 4d0b9f7d22..59c9ab22c4 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 bc97adb551..d49fb3223a 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'); + // Loosely-coupled osm service for fetching notes. + function getService() { + if (services.osm && !_osmService) { + _osmService = services.osm; + _osmService.on('loadedNotes', throttledRedraw); + } else if (!services.osm && _osmService) { + _osmService = null; + } + + return _osmService; } - function editOff() { - layer.selectAll('.note').remove(); - layer.style('display', 'none'); + // Show the notes + function editOn() { + if (!_notesVisible) { + _notesVisible = true; + drawLayer + .style('display', 'block'); + } } - function getService() { - if (services.osm && !_notes) { - _notes = services.osm; - _notes.on('loadedNotes', throttledRedraw); - } else if (!services.osm && _notes) { - _notes = null; + // 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(); } - - return _notes; } - function showLayer() { + // 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') + var surface = context.surface(); + if (surface && !surface.empty()) { + touchLayer = surface.selectAll('.data-layer.touch .layer-touch.markers'); + } + + drawLayer = selection.selectAll('.layer-notes') .data(service ? [0] : []); - layer.exit() + drawLayer.exit() .remove(); - layer.enter() + drawLayer = drawLayer.enter() .append('g') .attr('class', 'layer-notes') - .style('display', enabled ? 'block' : 'none') - .merge(layer); - - function dimensions() { - return [window.innerWidth, window.innerHeight]; - } + .style('display', _notesEnabled ? 'block' : 'none') + .merge(drawLayer); - if (enabled) { + if (_notesEnabled) { if (service && ~~context.map().zoom() >= minZoom) { editOn(); - service.loadNotes(projection, dimensions()); - update(); + service.loadNotes(projection); + updateMarkers(); } else { editOff(); } } } + + // Toggles the layer on and off drawNotes.enabled = function(val) { - if (!arguments.length) return svgNotes.enabled; + if (!arguments.length) return _notesEnabled; - svgNotes.enabled = val; - if (svgNotes.enabled) { - showLayer(); + _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 96bb1c8710..860f95bd1b 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 195c348194..0f930058b1 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 3f65819cc1..d92bd0a7d7 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 f4785ddc39..7e634b602f 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 0000000000..01702365dc --- /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 0000000000..532c8c3e2e --- /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 0000000000..1c8d9533cd --- /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 e127370250..a6cee9fd78 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 4534ea10b1..cbb004095c 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 4ebc6e89d8..5de11ff5d2 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 8fc2a0e95d..c19338c49e 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 759f9ba135..09b78a4e4c 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 ddc92bd8d1..f9a669f5e4 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 0000000000..d0b28a4cbc --- /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 b013f158b9..7bf265275b 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 0517ce8740..98755addd9 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 7396b75498..1ed2c2b7ee 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 d9c613263f..605378ecee 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 0000000000..8078987b21 --- /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 f2277229cd..5a40332d61 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; }); });