diff --git a/css/80_app.css b/css/80_app.css index 839ebe71c..8baacd462 100644 --- a/css/80_app.css +++ b/css/80_app.css @@ -352,6 +352,8 @@ button.secondary-action:hover { background: #cccccc; } +button.action.disabled, +button.action.disabled:hover, button[disabled].action, button[disabled].action:hover { background: #cccccc; @@ -413,6 +415,19 @@ button[disabled].action:hover { color: #333; } +.icon-badge { + display: block; + position: absolute; + width: 10px; + height: 10px; + right: 7px; + top: 9px; +} + +.icon-badge.hide { + display: none; +} + /* Toolbar / Persistent UI Elements ------------------------------------------------------- */ @@ -1234,18 +1249,23 @@ img.tag-reference-wiki-image { /* Entity/Preset Editor ------------------------------------------------------- */ +.entity-issues, .preset-editor { overflow: hidden; padding: 10px 0px 5px 0px; } +.entity-issues a.hide-toggle, .preset-editor a.hide-toggle { margin: 0 20px 5px 20px; } +.entity-issues .disclosure-wrap-entity_issues, .preset-editor .form-fields-container { padding: 10px; margin: 0 10px 10px 10px; border-radius: 8px; + background: #ececec; } +.entity-issues .disclosure-wrap-entity_issues:empty, .preset-editor .form-fields-container:empty { display: none; } @@ -2418,7 +2438,8 @@ input.key-trap { .inspector-hover button, .inspector-hover input, .inspector-hover textarea, -.inspector-hover label { +.inspector-hover label, +.inspector-hover .entity-issues .issue button { background: #ececec; } .inspector-hover .preset-list-button, @@ -2426,6 +2447,14 @@ input.key-trap { background: #f6f6f6; } +.inspector-hover .entity-issues .issue, +.inspector-hover .entity-issues .issue li { + border: 1px solid #ccc; +} +.inspector-hover .entity-issues .issue .icon { + color: #666; +} + .inspector-hover a, .inspector-hover .form-field-input-multicombo .chips, .inspector-hover .form-field-input-check span { @@ -2452,7 +2481,8 @@ input.key-trap { .inspector-hover .form-field-input-radio label, .inspector-hover .form-field-input-radio label span, .inspector-hover .form-field-input-radio label.remove .icon, -.inspector-hover .inspector-inner .add-row { +.inspector-hover .inspector-inner .add-row, +.inspector-hover .entity-issues .issue ul.issue-fix-list { display: none; } @@ -2760,6 +2790,7 @@ input.key-trap { } .map-control > button { + position: relative; width: 40px; background: rgba(0,0,0,.5); border-radius: 0; @@ -2861,11 +2892,11 @@ div.full-screen > button:hover { border-radius: 4px; } -.layer-list li { - position: relative; +.layer-list > li { height: 30px; background-color: #fff; color: #7092ff; + position: relative; } .layer-list:empty { @@ -2961,6 +2992,157 @@ div.full-screen > button:hover { } +/* Issues +------------------------------------------------------- */ +.issue { + overflow: hidden; +} +.issue button { + padding: 5px 10px 5px 5px; + height: auto; + width: 100%; + font-weight: inherit; + border-radius: 0; + text-align: inherit; + display: flex; + color: inherit; +} +[dir='rtl'] .issue button { + padding: 5px 5px 5px 10px; +} +.warnings-list, +.issue.severity-warning, + li.issue.severity-warning { + border-color: #FFDF5C; +} +.icon-badge.warning { + color: #FFDF5C; +} +.errors-list, +.issue.severity-error, + li.issue.severity-error { + border-color: #f5b0ab; +} +.icon-badge.error { + color: #ff0c05; +} + +.issue.severity-warning, +.issue.severity-warning button, +.mode-save .warning-section { + background: #ffb; +} +.issue.severity-warning:not(.expanded) button:hover, +.issue.severity-warning:not(.expanded) button:focus { + background: #FFFF99; +} +.issue.severity-warning .issue-icon, +.issue.severity-warning .issue-fix-item.actionable { + color: #ff9205; + fill: #ff9205; +} +.issue.severity-warning .issue-fix-item.actionable:hover, +.issue.severity-warning .issue-fix-item.actionable button:focus { + color: #f07504; + fill: #f07504; +} + +.issue.severity-error, +.issue.severity-error button, +.mode-save .error-section { + background: #FFD5D4; +} +.issue.severity-error:not(.expanded) button:hover, +.issue.severity-error:not(.expanded) button:focus { + background: #ffc9c7; +} +.issue.severity-error .issue-icon, +.issue.severity-error .issue-fix-item.actionable { + color: #DD1400; + fill: #DD1400; +} +.issue.severity-error .issue-fix-item.actionable:hover, +.issue.severity-error .issue-fix-item.actionable button:focus { + color: #ab0f00; + fill: #ab0f00; +} + +/* Issues Pane */ + +.issues-list label { + padding: 5px; +} +.issues-list label > span { + display: inline; + white-space: normal; +} +.issues-list li { + height: auto; + color: inherit; + position: static; +} + +.issues-none { + border-radius: 4px; + border: 1px solid #72D979; + background: #C6FFCA; + padding: 5px !important; + display: flex; + margin-top: 5px; +} +.issues-none .icon { + color: #05AC10; +} + +/* Entity Issues List */ +.entity-issues .issue { + border-radius: 4px; + border-width: 1px; + border-style: solid; +} +.entity-issues .issue:not(:last-of-type) { + margin-bottom: 10px; +} +.issue.expanded button.message { + cursor: auto; + padding-bottom: 0px; +} +ul.issue-fix-list button { + padding: 2px 10px 2px 26px; +} +.issue-fix-item:first-of-type button { + padding-top:4px; +} +.issue-fix-item:last-of-type button { + padding-bottom:7px; +} +.issue-fix-item:not(.actionable) button { + cursor: auto; + +} +.issue-fix-item:not(.actionable) .fix-icon { + color: #555; + fill: #555; +} + +.issue:not(.expanded) ul.issue-fix-list { + display: none; +} +/* don't animate right now +.issue ul.issue-fix-list { + max-height: 0; + transition: max-height 200ms linear; + -moz-transition: max-height 200ms linear; + -webkit-transition: max-height 200ms linear; +} +.issue.expanded ul.issue-fix-list { + max-height: 180px; + transition: max-height 200ms linear; + -moz-transition: max-height 200ms linear; + -webkit-transition: max-height 200ms linear; +}*/ + + /* Background - Display Options Sliders ------------------------------------------------------- */ .display-options-container { @@ -4297,14 +4479,6 @@ svg.mouseclick use.right { margin-bottom: 10px; } -.mode-save .warning-section { - background: #ffb; -} - -.mode-save .error-section { - background: #ffa5a5; -} - .mode-save .warning-section .changeset-list button { border-left: 1px solid #ccc; } @@ -4432,6 +4606,7 @@ svg.mouseclick use.right { color: #333; font-size: 12px; white-space: initial; + pointer-events: none; } .tooltip.in { opacity: 0.9; diff --git a/data/core.yaml b/data/core.yaml index 172cdb1ba..8259e838c 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -330,6 +330,8 @@ en: modified: Modified deleted: Deleted created: Created + outstanding_errors_message: "Please resolve all errors first. {count} remaining." + comment_needed_message: Please add a changeset comment first. about_changeset_comments: About changeset comments about_changeset_comments_link: //wiki.openstreetmap.org/wiki/Good_changeset_comments google_warning: "You mentioned Google in this comment: remember that copying from Google Maps is strictly forbidden." @@ -640,24 +642,6 @@ en: description: Description on_wiki: "{tag} on wiki.osm.org" used_with: "used with {type}" - validations: - disconnected_highway: Disconnected highway - disconnected_highway_tooltip: "Roads should be connected to other roads or building entrances." - generic_name: Possible generic name - generic_name_tooltip: 'This feature seems to have a generic name "{name}". Please only use the name field to record the official name of a feature.' - old_multipolygon: Multipolygon tags on outer way - old_multipolygon_tooltip: "This style of multipolygon is deprecated. Please assign the tags to the parent multipolygon instead of the outer way." - untagged_point: Untagged point - untagged_point_tooltip: "Select a feature type that describes what this point is." - untagged_line: Untagged line - untagged_line_tooltip: "Select a feature type that describes what this line is." - untagged_area: Untagged area - untagged_area_tooltip: "Select a feature type that describes what this area is." - untagged_relation: Untagged relation - untagged_relation_tooltip: "Select a feature type that describes what this relation is." - many_deletions: "You're deleting {n} features: {p} nodes, {l} lines, {a} areas, {r} relations. Are you sure you want to do this? This will delete them from the map that everyone else sees on openstreetmap.org." - tag_suggests_area: "The tag {tag} suggests line should be area, but it is not an area" - deprecated_tags: "Deprecated tags: {tags}" zoom: in: Zoom in out: Zoom out @@ -1182,6 +1166,122 @@ en: indirect: "**Some restrictions display the text \"(indirect)\" and are drawn lighter.**" indirect_example: "These restrictions exist because of another nearby restriction. For example, an \"Only Straight On\" restriction will indirectly create \"No Turn\" restrictions for all other paths through the intersection." indirect_noedit: "You may not edit indirect restrictions. Instead, edit the nearby direct restriction." + issues: + title: Issues + key: I + list_title: "Issues ({count})" + errors: + list_title: "Errors ({count})" + warnings: + list_title: "Warnings ({count})" + no_issues: + message: Everything looks fine + info: Any issues will show up here as you edit + almost_junction: + message: "{feature} is very close but not connected to {feature2}" + highway-highway: + tip: Intersecting highways should share a junction vertex. + crossing_ways: + message: "{feature} crosses {feature2}" + building-building: + tip: "Buildings should not intersect except on different layers." + building-highway: + tip: "Highways crossing buildings should use bridges, tunnels, coverings, or entrances." + building-railway: + tip: "Railways crossing buildings should use bridges or tunnels." + building-waterway: + tip: "Waterways crossing buildings should use tunnels or different layers." + highway-highway: + tip: "Crossing highways should use bridges, tunnels, or intersections." + highway-railway: + tip: "Highways crossing railways should use bridges, tunnels, or level crossings." + highway-waterway: + tip: "Highways crossing waterways should use bridges, tunnels, or fords." + railway-railway: + tip: "Crossing railways should be connected or use bridges or tunnels." + railway-waterway: + tip: "Railways crossing waterways should use bridges or tunnels." + waterway-waterway: + tip: "Crossing waterways should be connected or use tunnels." + tunnel-tunnel: + tip: "Crossing tunnels should use different layers." + tunnel-tunnel_connectable: + tip: "Crossing tunnels should be connected or use different layers." + bridge-bridge: + tip: "Crossing bridges should use different layers." + bridge-bridge_connectable: + tip: "Crossing bridges should be connected or use different layers." + deprecated_tag: + single: + message: '{feature} has the outdated tag "{tag}"' + combination: + message: '{feature} has an outdated tag combination: {tags}' + tip: "Some tags become deprecated over time and should be replaced." + disconnected_way: + highway: + message: "{highway} is disconnected from other roads and paths" + tip: "Highways should connect to other highways or building entrances." + generic_name: + message: '{feature} has the generic name "{name}"' + tip: "Names should be the actual, on-the-ground names of features." + many_deletions: + points-lines-areas: + message: "Deleting {n} features: {p} points, {l} lines, and {a} areas" + points-lines-areas-relations: + message: "Deleting {n} features: {p} points, {l} lines, {a} areas, and {r} relations" + tip: "Only redundant or nonexistent features should be deleted." + missing_tag: + any: + message: "{feature} has no tags" + descriptive: + message: "{feature} has no descriptive tags" + specific: + message: '{feature} has no "{tag}" tag' + tip: "Features must have tags that define what they are." + old_multipolygon: + message: "{multipolygon} has misplaced tags" + tip: "Multipolygons should be tagged on their relation, not their outer way." + tag_suggests_area: + message: '{feature} should be a closed area based on the tag "{tag}"' + tip: "Areas must have connected endpoints." + fix: + connect_almost_junction: + annotation: Connected very close features. + connect_crossing_features: + annotation: Connected crossing features. + connect_endpoints: + title: Connect the ends + annotation: Connected the endpoints of a way. + connect_features: + title: Connect the features + continue_from_start: + title: Continue drawing from start + continue_from_end: + title: Continue drawing from end + delete_feature: + title: Delete this feature + move_tags: + title: Move the tags + annotation: Moved tags. + remove_generic_name: + title: Remove the name + annotation: Removed a generic name. + remove_tag: + title: Remove the tag + annotation: Removed tag. + reposition_features: + title: Reposition the features + select_preset: + title: Select a feature type + tag_as_disconnected: + title: Tag as disconnected + annotation: Tagged very close features as disconnected. + upgrade_tag: + title: Upgrade this tag + annotation: Upgraded an old tag. + upgrade_tag_combo: + title: Upgrade these tags + annotation: Upgraded an old tag combination. intro: done: done ok: OK @@ -1551,4 +1651,4 @@ en: wikidata: identifier: "Identifier" label: "Label" - description: "Description" \ No newline at end of file + description: "Description" diff --git a/data/deprecated.json b/data/deprecated.json index bd05fe8c2..f2ef63a68 100644 --- a/data/deprecated.json +++ b/data/deprecated.json @@ -1,9 +1,77 @@ { "dataDeprecated": [ + { + "old": {"amenity": "advertising"}, + "replace": {"advertising": "*"} + }, + { + "old": {"amenity": "artwork"}, + "replace": {"tourism": "artwork"} + }, + { + "old": {"amenity": "car_repair"}, + "replace": {"shop": "car_repair"} + }, + { + "old": {"amenity": "citymap_post"}, + "replace": {"tourism": "information"} + }, + { + "old": {"amenity": "community_center"}, + "replace": {"amenity": "community_centre"} + }, + { + "old": {"amenity": "ev_charging"}, + "replace": {"amenity": "charging_station"} + }, { "old": {"amenity": "firepit"}, "replace": {"leisure": "firepit"} }, + { + "old": {"amenity": "garage"}, + "replace": {"landuse": "garages"} + }, + { + "old": {"amenity": "garages"}, + "replace": {"landuse": "garages"} + }, + { + "old": {"amenity": "real_estate"}, + "replace": {"office": "estate_agent"} + }, + { + "old": {"amenity": "register_office"}, + "replace": {"office": "government", "government": "register_office"} + }, + { + "old": {"amenity": "sauna"}, + "replace": {"leisure": "sauna"} + }, + { + "old": {"amenity": "scrapyard"}, + "replace": {"landuse": "industrial", "industrial": "scrap_yard"} + }, + { + "old": {"amenity": "shop"}, + "replace": {"shop": "*"} + }, + { + "old": {"amenity": "swimming_pool"}, + "replace": {"leisure": "swimming_pool"} + }, + { + "old": {"amenity": "vending_machine", "vending": "news_papers"}, + "replace": {"amenity": "vending_machine", "vending": "newspapers"} + }, + { + "old": {"amenity": "youth_center"}, + "replace": {"amenity": "community_centre", "community_centre:for": "juvenile"} + }, + { + "old": {"amenity": "youth_centre"}, + "replace": {"amenity": "community_centre", "community_centre:for": "juvenile"} + }, { "old": {"barrier": "wire_fence"}, "replace": {"barrier": "fence", "fence_type": "chain"} @@ -12,9 +80,48 @@ "old": {"barrier": "wood_fence"}, "replace": {"barrier": "fence", "fence_type": "wood"} }, + { + "old": {"building": "entrance"}, + "replace": {"entrance": "*"} + }, + { + "old": {"building:roof:colour": "*"}, + "replace": {"roof:colour": "$1"} + }, + { + "old": {"building:type": "*"}, + "replace": {"building": "$1"} + }, + { + "old": {"color": "*"}, + "replace": {"colour": "$1"} + }, + { + "old": {"craft": "jeweler"}, + "replace": {"shop": "jewelery"} + }, + { + "old": {"craft": "optician"}, + "replace": {"shop": "optician"} + }, + { + "old": {"drinkable": "*"}, + "replace": {"drinking_water": "$1"} + }, + { + "old": {"escalator": "*"}, + "replace": {"highway": "steps", "conveying": "$1"} + }, + { + "old": {"fenced": "yes"}, + "replace": {"barrier": "fence"} + }, { "old": {"highway": "ford"}, - "replace": {"ford": "yes"} + "replace": {"ford": "*"} + }, + { + "old": {"highway": "no"} }, { "old": {"highway": "stile"}, @@ -32,14 +139,74 @@ "old": {"highway": "unsurfaced"}, "replace": {"highway": "road", "incline": "unpaved"} }, + { + "old": {"landuse": "farm"}, + "replace": {"landuse": "farmland"} + }, + { + "old": {"landuse": "field"}, + "replace": {"landuse": "farmland"} + }, + { + "old": {"landuse": "pond"}, + "replace": {"natural": "water", "water": "pond"} + }, { "old": {"landuse": "wood"}, - "replace": {"landuse": "forest", "natural": "wood"} + "replace": {"natural": "wood"} + }, + { + "old": {"leisure": "beach"}, + "replace": {"natural": "beach"} + }, + { + "old": {"leisure": "club"}, + "replace": {"club": "*"} + }, + { + "old": {"man_made": "jetty"}, + "replace": {"man_made": "pier"} + }, + { + "old": {"man_made": "mdf"}, + "replace": {"telecom": "exchange"} + }, + { + "old": {"man_made": "MDF"}, + "replace": {"telecom": "exchange"} + }, + { + "old": {"man_made": "water_tank"}, + "replace": {"man_made": "storage_tank", "content": "water"} + }, + { + "old": {"man_made": "well"}, + "replace": {"man_made": "water_well"} }, { "old": {"natural": "marsh"}, "replace": {"natural": "wetland", "wetland": "marsh"} }, + { + "old": {"office": "administrative"}, + "replace": {"office": "government"} + }, + { + "old": {"office": "real_estate"}, + "replace": {"office": "estate_agent"} + }, + { + "old": {"place_name": "*"}, + "replace": {"name": "$1"} + }, + { + "old": {"pole": "transition"}, + "replace": {"location:transition": "yes"} + }, + { + "old": {"power": "sub_station"}, + "replace": {"power": "substation"} + }, { "old": {"power_source": "*"}, "replace": {"generator:source": "$1"} @@ -48,9 +215,61 @@ "old": {"power_rating": "*"}, "replace": {"generator:output": "$1"} }, + { + "old": {"roof:color": "*"}, + "replace": {"roof:colour": "$1"} + }, + { + "old": {"route": "ncn"}, + "replace": {"route": "bicycle", "network": "ncn"} + }, { "old": {"shop": "organic"}, "replace": {"shop": "supermarket", "organic": "only"} + }, + { + "old": {"shop": "fish"}, + "replace": {"shop": "seafood"} + }, + { + "old": {"shop": "fishmonger"}, + "replace": {"shop": "seafood"} + }, + { + "old": {"shop": "furnace"}, + "replace": {"shop": "fireplace"} + }, + { + "old": {"shop": "gallery"}, + "replace": {"shop": "art"} + }, + { + "old": {"shop": "perfume"}, + "replace": {"shop": "perfumery"} + }, + { + "old": {"shop": "real_estate"}, + "replace": {"office": "estate_agent"} + }, + { + "old": {"tourism": "bed_and_breakfast"}, + "replace": {"tourism": "guest_house"} + }, + { + "old": {"water": "intermittent"}, + "replace": {"natural": "water", "intermittent": "yes"} + }, + { + "old": {"water": "riverbank"}, + "replace": {"natural": "water", "water": "river"} + }, + { + "old": {"water": "salt"}, + "replace": {"natural": "water", "salt": "yes"} + }, + { + "old": {"water": "tidal"}, + "replace": {"natural": "water", "tidal": "yes"} } ] } diff --git a/dist/locales/en.json b/dist/locales/en.json index edbe6208c..23e80acc9 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -414,6 +414,8 @@ "modified": "Modified", "deleted": "Deleted", "created": "Created", + "outstanding_errors_message": "Please resolve all errors first. {count} remaining.", + "comment_needed_message": "Please add a changeset comment first.", "about_changeset_comments": "About changeset comments", "about_changeset_comments_link": "//wiki.openstreetmap.org/wiki/Good_changeset_comments", "google_warning": "You mentioned Google in this comment: remember that copying from Google Maps is strictly forbidden.", @@ -781,25 +783,6 @@ "on_wiki": "{tag} on wiki.osm.org", "used_with": "used with {type}" }, - "validations": { - "disconnected_highway": "Disconnected highway", - "disconnected_highway_tooltip": "Roads should be connected to other roads or building entrances.", - "generic_name": "Possible generic name", - "generic_name_tooltip": "This feature seems to have a generic name \"{name}\". Please only use the name field to record the official name of a feature.", - "old_multipolygon": "Multipolygon tags on outer way", - "old_multipolygon_tooltip": "This style of multipolygon is deprecated. Please assign the tags to the parent multipolygon instead of the outer way.", - "untagged_point": "Untagged point", - "untagged_point_tooltip": "Select a feature type that describes what this point is.", - "untagged_line": "Untagged line", - "untagged_line_tooltip": "Select a feature type that describes what this line is.", - "untagged_area": "Untagged area", - "untagged_area_tooltip": "Select a feature type that describes what this area is.", - "untagged_relation": "Untagged relation", - "untagged_relation_tooltip": "Select a feature type that describes what this relation is.", - "many_deletions": "You're deleting {n} features: {p} nodes, {l} lines, {a} areas, {r} relations. Are you sure you want to do this? This will delete them from the map that everyone else sees on openstreetmap.org.", - "tag_suggests_area": "The tag {tag} suggests line should be area, but it is not an area", - "deprecated_tags": "Deprecated tags: {tags}" - }, "zoom": { "in": "Zoom in", "out": "Zoom out" @@ -1431,6 +1414,174 @@ } } }, + "issues": { + "title": "Issues", + "key": "I", + "list_title": "Issues ({count})", + "errors": { + "list_title": "Errors ({count})" + }, + "warnings": { + "list_title": "Warnings ({count})" + }, + "no_issues": { + "message": "Everything looks fine", + "info": "Any issues will show up here as you edit" + }, + "almost_junction": { + "message": "{feature} is very close but not connected to {feature2}", + "highway-highway": { + "tip": "Intersecting highways should share a junction vertex." + } + }, + "crossing_ways": { + "message": "{feature} crosses {feature2}", + "building-building": { + "tip": "Buildings should not intersect except on different layers." + }, + "building-highway": { + "tip": "Highways crossing buildings should use bridges, tunnels, coverings, or entrances." + }, + "building-railway": { + "tip": "Railways crossing buildings should use bridges or tunnels." + }, + "building-waterway": { + "tip": "Waterways crossing buildings should use tunnels or different layers." + }, + "highway-highway": { + "tip": "Crossing highways should use bridges, tunnels, or intersections." + }, + "highway-railway": { + "tip": "Highways crossing railways should use bridges, tunnels, or level crossings." + }, + "highway-waterway": { + "tip": "Highways crossing waterways should use bridges, tunnels, or fords." + }, + "railway-railway": { + "tip": "Crossing railways should be connected or use bridges or tunnels." + }, + "railway-waterway": { + "tip": "Railways crossing waterways should use bridges or tunnels." + }, + "waterway-waterway": { + "tip": "Crossing waterways should be connected or use tunnels." + }, + "tunnel-tunnel": { + "tip": "Crossing tunnels should use different layers." + }, + "tunnel-tunnel_connectable": { + "tip": "Crossing tunnels should be connected or use different layers." + }, + "bridge-bridge": { + "tip": "Crossing bridges should use different layers." + }, + "bridge-bridge_connectable": { + "tip": "Crossing bridges should be connected or use different layers." + } + }, + "deprecated_tag": { + "single": { + "message": "{feature} has the outdated tag \"{tag}\"" + }, + "combination": { + "message": "{feature} has an outdated tag combination: {tags}" + }, + "tip": "Some tags become deprecated over time and should be replaced." + }, + "disconnected_way": { + "highway": { + "message": "{highway} is disconnected from other roads and paths", + "tip": "Highways should connect to other highways or building entrances." + } + }, + "generic_name": { + "message": "{feature} has the generic name \"{name}\"", + "tip": "Names should be the actual, on-the-ground names of features." + }, + "many_deletions": { + "points-lines-areas": { + "message": "Deleting {n} features: {p} points, {l} lines, and {a} areas" + }, + "points-lines-areas-relations": { + "message": "Deleting {n} features: {p} points, {l} lines, {a} areas, and {r} relations" + }, + "tip": "Only redundant or nonexistent features should be deleted." + }, + "missing_tag": { + "any": { + "message": "{feature} has no tags" + }, + "descriptive": { + "message": "{feature} has no descriptive tags" + }, + "specific": { + "message": "{feature} has no \"{tag}\" tag" + }, + "tip": "Features must have tags that define what they are." + }, + "old_multipolygon": { + "message": "{multipolygon} has misplaced tags", + "tip": "Multipolygons should be tagged on their relation, not their outer way." + }, + "tag_suggests_area": { + "message": "{feature} should be a closed area based on the tag \"{tag}\"", + "tip": "Areas must have connected endpoints." + }, + "fix": { + "connect_almost_junction": { + "annotation": "Connected very close features." + }, + "connect_crossing_features": { + "annotation": "Connected crossing features." + }, + "connect_endpoints": { + "title": "Connect the ends", + "annotation": "Connected the endpoints of a way." + }, + "connect_features": { + "title": "Connect the features" + }, + "continue_from_start": { + "title": "Continue drawing from start" + }, + "continue_from_end": { + "title": "Continue drawing from end" + }, + "delete_feature": { + "title": "Delete this feature" + }, + "move_tags": { + "title": "Move the tags", + "annotation": "Moved tags." + }, + "remove_generic_name": { + "title": "Remove the name", + "annotation": "Removed a generic name." + }, + "remove_tag": { + "title": "Remove the tag", + "annotation": "Removed tag." + }, + "reposition_features": { + "title": "Reposition the features" + }, + "select_preset": { + "title": "Select a feature type" + }, + "tag_as_disconnected": { + "title": "Tag as disconnected", + "annotation": "Tagged very close features as disconnected." + }, + "upgrade_tag": { + "title": "Upgrade this tag", + "annotation": "Upgraded an old tag." + }, + "upgrade_tag_combo": { + "title": "Upgrade these tags", + "annotation": "Upgraded an old tag combination." + } + } + }, "intro": { "done": "done", "ok": "OK", diff --git a/modules/actions/merge_nodes.js b/modules/actions/merge_nodes.js index 123d3b641..26f73eb00 100644 --- a/modules/actions/merge_nodes.js +++ b/modules/actions/merge_nodes.js @@ -7,7 +7,7 @@ import { geoVecAdd, geoVecScale } from '../geo'; // 1. move all the nodes to a common location // 2. `actionConnect` them -export function actionMergeNodes(nodeIDs) { +export function actionMergeNodes(nodeIDs, loc) { // If there is a single "interesting" node, use that as the location. // Otherwise return the average location of all the nodes. @@ -31,11 +31,16 @@ export function actionMergeNodes(nodeIDs) { var action = function(graph) { if (nodeIDs.length < 2) return graph; - var toLoc = chooseLoc(graph); + var toLoc = loc; + if (!toLoc) { + toLoc = chooseLoc(graph); + } for (var i = 0; i < nodeIDs.length; i++) { var node = graph.entity(nodeIDs[i]); - graph = graph.replace(node.move(toLoc)); + if (node.loc !== toLoc) { + graph = graph.replace(node.move(toLoc)); + } } return actionConnect(nodeIDs)(graph); diff --git a/modules/actions/split.js b/modules/actions/split.js index 54c41f380..ad19e2457 100644 --- a/modules/actions/split.js +++ b/modules/actions/split.js @@ -7,7 +7,7 @@ import { actionAddMember } from './add_member'; import { geoSphericalDistance } from '../geo'; import { - osmIsSimpleMultipolygonOuterMember, + osmIsOldMultipolygonOuterMember, osmRelation, osmWay } from '../osm'; @@ -93,7 +93,7 @@ export function actionSplit(nodeId, newWayIds) { var nodesA; var nodesB; var isArea = wayA.isArea(); - var isOuter = osmIsSimpleMultipolygonOuterMember(wayA, graph); + var isOuter = osmIsOldMultipolygonOuterMember(wayA, graph); if (wayA.isClosed()) { var nodes = wayA.nodes.slice(0, -1); diff --git a/modules/behavior/draw_way.js b/modules/behavior/draw_way.js index f40938821..d95ac8480 100644 --- a/modules/behavior/draw_way.js +++ b/modules/behavior/draw_way.js @@ -1,27 +1,22 @@ -import { t } from '../util/locale'; - import { event as d3_event, select as d3_select } from 'd3-selection'; -import { - actionAddMidpoint, - actionMoveNode, - actionNoop -} from '../actions'; - +import { t } from '../util/locale'; +import { actionAddMidpoint, actionMoveNode, actionNoop } from '../actions'; import { behaviorDraw } from './draw'; import { geoChooseEdge, geoHasSelfIntersections } from '../geo'; import { modeBrowse, modeSelect } from '../modes'; import { osmNode } from '../osm'; import { utilKeybinding } from '../util'; -export function behaviorDrawWay(context, wayId, index, mode, startGraph) { - var origWay = context.entity(wayId); +export function behaviorDrawWay(context, wayID, index, mode, startGraph) { + var origWay = context.entity(wayID); + var annotation = t((origWay.isDegenerate() ? 'operations.start.annotation.' : - 'operations.continue.annotation.') + context.geometry(wayId) + 'operations.continue.annotation.') + context.geometry(wayID) ); var behavior = behaviorDraw(context); @@ -31,6 +26,10 @@ export function behaviorDrawWay(context, wayId, index, mode, startGraph) { var end = osmNode({ loc: context.map().mouseCoordinates() }); + if (context.graph() === startGraph) { + context.history().checkpoint('drawWay-initial'); + } + // Push an annotated state for undo to return back to. // We must make sure to remove this edit later. context.perform(actionNoop(), annotation); @@ -67,10 +66,12 @@ export function behaviorDrawWay(context, wayId, index, mode, startGraph) { } } + function allowsVertex(d) { return context.presets().allowsVertex(d, context.graph()); } + // related code // - `mode/drag_node.js` `doMode()` // - `behavior/draw.js` `click()` @@ -122,7 +123,8 @@ export function behaviorDrawWay(context, wayId, index, mode, startGraph) { for (var i = 0; i < parents.length; i++) { var parent = parents[i]; - var nodes = parent.nodes.map(function(nodeID) { return graph.entity(nodeID); }); + + var nodes = graph.childNodes(parent); if (origWay.isClosed()) { // Check if Area if (finishDraw) { @@ -148,14 +150,16 @@ export function behaviorDrawWay(context, wayId, index, mode, startGraph) { function undone() { // Undo popped the history back to the initial annotated no-op edit. - // Remove initial no-op edit and whatever edit happened immediately before it. - context.pop(2); - _tempEdits = 0; + _tempEdits = 0; // We will deal with the temp edits here + context.pop(1); // Remove initial no-op edit - if (context.hasEntity(wayId)) { - context.enter(mode); + if (context.graph() === startGraph) { // We've undone back to the beginning + context.history().reset('drawWay-initial'); + context.enter(modeSelect(context, [wayID])); } else { - context.enter(modeBrowse(context)); + // Remove whatever segment was drawn previously and continue drawing + context.pop(1); + context.enter(mode); } } @@ -198,10 +202,7 @@ export function behaviorDrawWay(context, wayId, index, mode, startGraph) { // This can happen if the user changes modes, // clicks geolocate button, a hashchange event occurs, etc. if (_tempEdits) { - context.pop(_tempEdits); - while (context.graph() !== startGraph) { - context.pop(); - } + context.history().reset('drawWay-initial'); } context.map() @@ -313,7 +314,7 @@ export function behaviorDrawWay(context, wayId, index, mode, startGraph) { context.pop(_tempEdits); _tempEdits = 0; - var way = context.hasEntity(wayId); + var way = context.hasEntity(wayID); if (!way || way.isDegenerate()) { drawWay.cancel(); return; @@ -323,7 +324,7 @@ export function behaviorDrawWay(context, wayId, index, mode, startGraph) { context.map().dblclickEnable(true); }, 1000); var isNewFeature = !mode.isContinuing; - context.enter(modeSelect(context, [wayId]).newFeature(isNewFeature)); + context.enter(modeSelect(context, [wayID]).newFeature(isNewFeature)); }; diff --git a/modules/core/context.js b/modules/core/context.js index de64f62eb..6bc3ae849 100644 --- a/modules/core/context.js +++ b/modules/core/context.js @@ -13,6 +13,7 @@ import { select as d3_select } from 'd3-selection'; import { t, currentLocale, addTranslation, setLocale } from '../util/locale'; import { coreHistory } from './history'; +import { coreValidator } from './validator'; import { dataLocales, dataEn } from '../../data'; import { geoRawMercator } from '../geo/raw_mercator'; import { modeSelect } from '../modes/select'; @@ -95,10 +96,10 @@ export function coreContext() { /* Straight accessors. Avoid using these if you can. */ - var connection, history; + var connection, history, validator; context.connection = function() { return connection; }; context.history = function() { return history; }; - + context.validator = function() { return validator; }; /* Connection */ context.preauth = function(options) { @@ -451,10 +452,30 @@ export function coreContext() { } history = coreHistory(context); + context.graph = history.graph; context.changes = history.changes; context.intersects = history.intersects; + validator = coreValidator(context); + + // run validation upon restoring from page reload + history.on('restore', function() { + validator.validate(); + }); + // re-run validation upon a significant graph change + history.on('annotatedChange', function(difference) { + if (difference) { + validator.validate(); + } + }); + // re-run validation upon merging fetched data + history.on('merge', function(entities) { + if (entities && entities.length > 0) { + validator.validate(); + } + }); + // Debounce save, since it's a synchronous localStorage write, // and history changes can happen frequently (e.g. when dragging). context.debouncedSave = _debounce(context.save, 350); diff --git a/modules/core/history.js b/modules/core/history.js index 50bf09f11..97f730579 100644 --- a/modules/core/history.js +++ b/modules/core/history.js @@ -18,7 +18,6 @@ import { dispatch as d3_dispatch } from 'd3-dispatch'; import { easeLinear as d3_easeLinear } from 'd3-ease'; import { select as d3_select } from 'd3-selection'; -import * as Validations from '../validations/index'; import { coreDifference } from './difference'; import { coreGraph } from './graph'; import { coreTree } from './tree'; @@ -32,7 +31,7 @@ import { export function coreHistory(context) { - var dispatch = d3_dispatch('change', 'undone', 'redone'); + var dispatch = d3_dispatch('change', 'annotatedChange', 'merge', 'restore', 'undone', 'redone'); var lock = utilSessionMutex('lock'); var duration = 150; var _imageryUsed = []; @@ -70,9 +69,10 @@ export function coreHistory(context) { function _perform(args, t) { var previous = _stack[_index].graph; _stack = _stack.slice(0, _index + 1); - _stack.push(_act(args, t)); + var actionResult = _act(args, t); + _stack.push(actionResult); _index++; - return change(previous); + return change(previous, actionResult.annotation); } @@ -80,8 +80,9 @@ export function coreHistory(context) { function _replace(args, t) { var previous = _stack[_index].graph; // assert(_index == _stack.length - 1) - _stack[_index] = _act(args, t); - return change(previous); + var actionResult = _act(args, t); + _stack[_index] = actionResult; + return change(previous, actionResult.annotation); } @@ -93,16 +94,22 @@ export function coreHistory(context) { _stack.pop(); } _stack = _stack.slice(0, _index + 1); - _stack.push(_act(args, t)); + var actionResult = _act(args, t); + _stack.push(actionResult); _index++; - return change(previous); + return change(previous, actionResult.annotation); } // determine difference and dispatch a change event - function change(previous) { + function change(previous, isAnnotated) { var difference = coreDifference(previous, history.graph()); dispatch.call('change', this, difference); + if (isAnnotated) { + // actions like dragging a node can fire lots of changes, + // so use 'annotatedChange' to listen for grouped undo/redo changes + dispatch.call('annotatedChange', this, difference); + } return difference; } @@ -120,6 +127,11 @@ export function coreHistory(context) { }, + tree: function() { + return _tree; + }, + + base: function() { return _stack[0].graph; }, @@ -130,6 +142,7 @@ export function coreHistory(context) { _tree.rebase(entities, false); dispatch.call('change', this, undefined, extent); + dispatch.call('merge', this, entities); }, @@ -208,7 +221,7 @@ export function coreHistory(context) { } dispatch.call('undone', this, _stack[_index]); - return change(previous); + return change(previous, true); }, @@ -227,7 +240,7 @@ export function coreHistory(context) { } } - return change(previous); + return change(previous, true); }, @@ -279,13 +292,6 @@ export function coreHistory(context) { }, - validate: function(changes) { - return _flatten(_map(Validations, function(fn) { - return fn()(changes, _stack[_index].graph); - })); - }, - - hasChanges: function() { return this.difference().length() > 0; }, @@ -511,6 +517,7 @@ export function coreHistory(context) { loading.close(); context.redrawEnable(true); dispatch.call('change'); + dispatch.call('restore', this); } }; @@ -565,6 +572,7 @@ export function coreHistory(context) { if (loadComplete) { dispatch.call('change'); + dispatch.call('restore', this); } return history; diff --git a/modules/core/index.js b/modules/core/index.js index da7f872d8..cc0061242 100644 --- a/modules/core/index.js +++ b/modules/core/index.js @@ -3,3 +3,4 @@ export { coreDifference } from './difference'; export { coreGraph } from './graph'; export { coreHistory } from './history'; export { coreTree } from './tree'; +export { coreValidator } from './validator'; diff --git a/modules/core/validator.js b/modules/core/validator.js new file mode 100644 index 000000000..09726117d --- /dev/null +++ b/modules/core/validator.js @@ -0,0 +1,230 @@ +import _isFunction from 'lodash-es/isFunction'; +import _map from 'lodash-es/map'; +import _filter from 'lodash-es/filter'; +import _flatten from 'lodash-es/flatten'; +import _flattenDeep from 'lodash-es/flattenDeep'; +import _uniq from 'lodash-es/uniq'; +import _uniqWith from 'lodash-es/uniqWith'; + +import { dispatch as d3_dispatch } from 'd3-dispatch'; + +import { geoExtent } from '../geo'; +import { osmEntity } from '../osm'; +import { utilRebind } from '../util/rebind'; +import * as Validations from '../validations/index'; + + +export function coreValidator(context) { + var dispatch = d3_dispatch('reload'); + var self = {}; + var _issues = []; + var _issuesByEntityID = {}; + + var validations = _filter(Validations, _isFunction).reduce(function(obj, validation) { + var func = validation(); + obj[func.type] = func; + return obj; + }, {}); + + var entityValidationIDs = []; + var changesValidationIDs = []; + + for (var key in validations) { + var validation = validations[key]; + if (validation.inputType && validation.inputType === 'changes') { + changesValidationIDs.push(key); + } else { + entityValidationIDs.push(key); + } + } + + //self.featureApplicabilityOptions = ['edited', 'all']; + + /*var featureApplicability = context.storage('issue-features') || 'edited'; + + self.getFeatureApplicability = function() { + return featureApplicability; + }; + + self.setFeatureApplicability = function(applicability) { + featureApplicability = applicability; + context.storage('issue-features', applicability); + };*/ + + self.getIssues = function() { + return _issues; + }; + + self.getWarnings = function() { + return _issues.filter(function(d) { return d.severity === 'warning'; }); + }; + + self.getErrors = function() { + return _issues.filter(function(d) { return d.severity === 'error'; }); + }; + + self.getIssuesForEntityWithID = function(entityID) { + if (!context.hasEntity(entityID)) return []; + var entity = context.entity(entityID); + var key = osmEntity.key(entity); + + if (!_issuesByEntityID[key]) { + _issuesByEntityID[key] = validateEntity(entity); + } + return _issuesByEntityID[key]; + }; + + + function validateEntity(entity) { + var _issues = []; + var ran = {}; + + // runs validation and appends resulting issues, returning true if validation passed + function runValidation(which) { + if (ran[which]) return true; + + var fn = validations[which]; + var typeIssues = fn(entity, context); + _issues = _issues.concat(typeIssues); + ran[which] = true; // mark this validation as having run + return !typeIssues.length; + } + + if (entity.type === 'relation') { + if (!runValidation('old_multipolygon')) { + // don't flag missing tags if they are on the outer way + ran.missing_tag = true; + } + } + + // other validations require feature to be tagged + if (!runValidation('missing_tag')) return _issues; + + if (entity.type === 'way') { + runValidation('crossing_ways'); + + // only check for disconnected way if no almost junctions + if (runValidation('almost_junction')) { + runValidation('disconnected_way'); + } else { + ran.disconnected_way = true; + } + + runValidation('tag_suggests_area'); + } + + // run all validations not yet run manually + entityValidationIDs.forEach(runValidation); + + return _issues; + } + + + self.validate = function() { + _issuesByEntityID = {}; // clear cached + _issues = []; + + var history = context.history(); + var changes = history.changes(); + var entitiesToCheck = changes.created.concat(changes.modified); + var graph = history.graph(); + + _issues = _flatten(_map(changesValidationIDs, function(ruleID) { + var validation = validations[ruleID]; + return validation(changes, context); + })); + + entitiesToCheck = _uniq(_flattenDeep(_map(entitiesToCheck, function(entity) { + var entities = [entity]; + if (entity.type === 'node') { // validate ways if their nodes have changed + entities = entities.concat(graph.parentWays(entity)); + } + entities = _map(entities, function(entity) { + if (entity.type !== 'relation') { // validate relations if their geometries have changed + return [entity].concat(graph.parentRelations(entity)); + } + return entity; + }); + return entities; + }))); + + for (var entityIndex in entitiesToCheck) { + var entity = entitiesToCheck[entityIndex]; + var entityIssues = validateEntity(entity); + _issuesByEntityID[entity.id] = entityIssues; + _issues = _issues.concat(entityIssues); + } + + _issues = _uniqWith(_issues, function(issue1, issue2) { + return issue1.id() === issue2.id(); + }); + + dispatch.call('reload', self, _issues); + }; + + return utilRebind(self, dispatch, 'on'); +} + + +export function validationIssue(attrs) { + this.type = attrs.type; // required + this.severity = attrs.severity; // required - 'warning' or 'error' + this.message = attrs.message; // required - localized string + this.tooltip = attrs.tooltip; // required - localized string + this.entities = attrs.entities; // optional - array of entities + this.loc = attrs.loc; // optional - expect a [lon, lat] array + this.info = attrs.info; // optional - object containing arbitrary extra information + this.fixes = attrs.fixes; // optional - array of validationIssueFix objects + this.hash = attrs.hash; // optional - string to further differentiate the issue + + // A unique, deterministic string hash. + // Issues with identical id values are considered identical. + this.id = function() { + var id = this.type; + + if (this.hash) { // subclasses can pass in their own differentiator + id += this.hash; + } + + // factor in the entities this issue is for + // (sort them so the id is deterministic) + var entityKeys = this.entities.map(osmEntity.key); + id += entityKeys.sort().join(); + + // factor in loc since two separate issues can have an + // idential type and entities, e.g. in crossing_ways + if (this.loc) { + id += this.loc.join(); + } + return id; + }; + + + this.extent = function(resolver) { + if (this.loc) { + return geoExtent(this.loc); + } + if (this.entities && this.entities.length) { + return this.entities.reduce(function(extent, entity) { + return extent.extend(entity.extent(resolver)); + }, geoExtent()); + } + return null; + }; + + + if (this.fixes) { // add a reference in the fixes to the issue for use in fix actions + for (var i = 0; i < this.fixes.length; i++) { + this.fixes[i].issue = this; + } + } +} + + +export function validationIssueFix(attrs) { + this.icon = attrs.icon; + this.title = attrs.title; + this.onClick = attrs.onClick; + this.entityIds = attrs.entityIds || []; // Used for hover-higlighting. + this.issue = null; // the issue this fix is for +} diff --git a/modules/geo/geo.js b/modules/geo/geo.js index 1e9bee80d..ce239eafe 100644 --- a/modules/geo/geo.js +++ b/modules/geo/geo.js @@ -66,3 +66,23 @@ export function geoZoomToScale(z, tileSize) { return tileSize * Math.pow(2, z) / TAU; } + +// returns info about the node from `nodes` closest to the given `point` +export function geoSphericalClosestNode(nodes, point) { + var minDistance = Infinity, distance; + var indexOfMin; + + for (var i in nodes) { + distance = geoSphericalDistance(nodes[i].loc, point); + if (distance < minDistance) { + minDistance = distance; + indexOfMin = i; + } + } + + if (indexOfMin !== undefined) { + return { index: indexOfMin, distance: minDistance, node: nodes[indexOfMin] }; + } else { + return null; + } +} diff --git a/modules/geo/index.js b/modules/geo/index.js index 5480eb1fd..e27f00cfc 100644 --- a/modules/geo/index.js +++ b/modules/geo/index.js @@ -7,6 +7,7 @@ export { geoMetersToLon } from './geo.js'; export { geoMetersToOffset } from './geo.js'; export { geoOffsetToMeters } from './geo.js'; export { geoScaleToZoom } from './geo.js'; +export { geoSphericalClosestNode } from './geo.js'; export { geoSphericalDistance } from './geo.js'; export { geoZoomToScale } from './geo.js'; diff --git a/modules/index.js b/modules/index.js index 3c5abe18b..1d775abfc 100644 --- a/modules/index.js +++ b/modules/index.js @@ -19,6 +19,7 @@ export * from './ui/settings/index'; export * from './ui/index'; export * from './util/index'; export * from './validations/index'; +export { coreValidator } from './core/validator'; /* export some legacy symbols: */ import { services } from './services/index'; diff --git a/modules/modes/drag_node.js b/modules/modes/drag_node.js index 66dd2dfa0..d83be0bc5 100644 --- a/modules/modes/drag_node.js +++ b/modules/modes/drag_node.js @@ -220,8 +220,7 @@ export function modeDragNode(context) { } context.replace( - actionMoveNode(entity.id, loc), - moveAnnotation(entity) + actionMoveNode(entity.id, loc) ); // Below here: validations diff --git a/modules/modes/move.js b/modules/modes/move.js index 3da82f224..1d9c3ee7c 100644 --- a/modules/modes/move.js +++ b/modules/modes/move.js @@ -5,7 +5,10 @@ import { import { t } from '../util/locale'; -import { actionMove } from '../actions'; +import { + actionMove, + actionNoop +} from '../actions'; import { behaviorEdit } from '../behavior'; import { geoViewportEdge, geoVecSubtract } from '../geo'; import { modeBrowse, modeSelect } from './index'; @@ -63,7 +66,7 @@ export function modeMove(context, entityIDs, baseGraph) { var origMouse = context.projection(_origin); var delta = geoVecSubtract(geoVecSubtract(currMouse, origMouse), nudge); - fn(actionMove(entityIDs, delta, context.projection, _cache), annotation); + fn(actionMove(entityIDs, delta, context.projection, _cache)); _prevGraph = context.graph(); } @@ -98,6 +101,7 @@ export function modeMove(context, entityIDs, baseGraph) { function finish() { d3_event.stopPropagation(); + context.replace(actionNoop(), annotation); context.enter(modeSelect(context, entityIDs)); stopNudge(); } diff --git a/modules/modes/rotate.js b/modules/modes/rotate.js index 6ad041dd4..060b1c25a 100644 --- a/modules/modes/rotate.js +++ b/modules/modes/rotate.js @@ -9,7 +9,10 @@ import { } from 'd3-polygon'; import { t } from '../util/locale'; -import { actionRotate } from '../actions'; +import { + actionRotate, + actionNoop +} from '../actions'; import { behaviorEdit } from '../behavior'; import { geoVecInterp } from '../geo'; import { modeBrowse, modeSelect } from './index'; @@ -88,7 +91,7 @@ export function modeRotate(context, entityIDs) { if (typeof _prevAngle === 'undefined') _prevAngle = currAngle; var delta = currAngle - _prevAngle; - fn(actionRotate(entityIDs, _pivot, delta, projection), annotation); + fn(actionRotate(entityIDs, _pivot, delta, projection)); _prevTransform = currTransform; _prevAngle = currAngle; @@ -98,6 +101,7 @@ export function modeRotate(context, entityIDs) { function finish() { d3_event.stopPropagation(); + context.replace(actionNoop(), annotation); context.enter(modeSelect(context, entityIDs)); } diff --git a/modules/osm/entity.js b/modules/osm/entity.js index aa7d417c1..517540af5 100644 --- a/modules/osm/entity.js +++ b/modules/osm/entity.js @@ -1,6 +1,6 @@ import _clone from 'lodash-es/clone'; import _keys from 'lodash-es/keys'; -import _toPairs from 'lodash-es/toPairs'; +import _every from 'lodash-es/every'; import _union from 'lodash-es/union'; import _without from 'lodash-es/without'; @@ -165,17 +165,19 @@ osmEntity.prototype = { }, deprecatedTags: function() { - var tags = _toPairs(this.tags); - var deprecated = {}; + var tags = this.tags; + // if there are no tags, none can be deprecated + if (Object.keys(tags).length === 0) return []; + + var deprecated = []; dataDeprecated.forEach(function(d) { - var match = _toPairs(d.old)[0]; - tags.forEach(function(t) { - if (t[0] === match[0] && - (t[1] === match[1] || match[1] === '*')) { - deprecated[t[0]] = t[1]; - } + var matchesDeprecatedTags = _every(Object.keys(d.old), function(key) { + return tags[key] && (d.old[key] === tags[key] || d.old[key] === '*'); }); + if (matchesDeprecatedTags) { + deprecated.push(d); + } }); return deprecated; diff --git a/modules/osm/index.js b/modules/osm/index.js index c8e467001..4b74c912b 100644 --- a/modules/osm/index.js +++ b/modules/osm/index.js @@ -17,8 +17,9 @@ export { } from './lanes'; export { - osmIsSimpleMultipolygonOuterMember, - osmSimpleMultipolygonOuterMember, + osmOldMultipolygonOuterMemberOfRelation, + osmIsOldMultipolygonOuterMember, + osmOldMultipolygonOuterMember, osmJoinWays } from './multipolygon'; diff --git a/modules/osm/multipolygon.js b/modules/osm/multipolygon.js index 966e0b774..38bb37353 100644 --- a/modules/osm/multipolygon.js +++ b/modules/osm/multipolygon.js @@ -3,9 +3,44 @@ import { osmIsInterestingTag } from './tags'; import { osmWay } from './way'; +// "Old" multipolyons, previously known as "simple" multipolygons, are as follows: +// +// 1. Relation tagged with `type=multipolygon` and no interesting tags. +// 2. One and only one member with the `outer` role. Must be a way with interesting tags. +// 3. No members without a role. +// +// Old multipolygons are no longer recommended but are still rendered as areas by iD. + +export function osmOldMultipolygonOuterMemberOfRelation(entity, graph) { + if (entity.type !== 'relation' || + !entity.isMultipolygon() + || Object.keys(entity.tags).filter(osmIsInterestingTag).length > 1) { + return false; + } + + var outerMember; + for (var memberIndex in entity.members) { + var member = entity.members[memberIndex]; + if (!member.role) return false; + if (member.role === 'outer') { + if (outerMember) return false; + if (member.type !== 'way') return false; + if (!graph.hasEntity(member.id)) return false; + + outerMember = graph.entity(member.id); + + if (Object.keys(outerMember.tags).filter(osmIsInterestingTag).length === 0) { + return false; + } + } + } + + return outerMember; +} + // For fixing up rendering of multipolygons with tags on the outer member. // https://github.com/openstreetmap/iD/issues/613 -export function osmIsSimpleMultipolygonOuterMember(entity, graph) { +export function osmIsOldMultipolygonOuterMember(entity, graph) { if (entity.type !== 'way' || Object.keys(entity.tags).filter(osmIsInterestingTag).length === 0) return false; @@ -30,7 +65,7 @@ export function osmIsSimpleMultipolygonOuterMember(entity, graph) { } -export function osmSimpleMultipolygonOuterMember(entity, graph) { +export function osmOldMultipolygonOuterMember(entity, graph) { if (entity.type !== 'way') return false; diff --git a/modules/osm/way.js b/modules/osm/way.js index 3c98c8833..f53a69b89 100644 --- a/modules/osm/way.js +++ b/modules/osm/way.js @@ -193,8 +193,12 @@ _extend(osmWay.prototype, { return true; }, + // returns an objects with the tag that implies this is an area, if any + tagSuggestingArea: function() { + + if (this.tags.area === 'yes') return { area: 'yes' }; + if (this.tags.area === 'no') return null; - isArea: function() { // `highway` and `railway` are typically linear features, but there // are a few exceptions that should be treated as areas, even in the // absence of a proper `area=yes` or `areaKeys` tag.. see #4194 @@ -211,20 +215,27 @@ _extend(osmWay.prototype, { wash: true } }; + var returnTags = {}; + for (var key in this.tags) { + if (key in areaKeys && !(this.tags[key] in areaKeys[key])) { + returnTags[key] = this.tags[key]; + return returnTags; + } + if (key in lineKeys && this.tags[key] in lineKeys[key]) { + returnTags[key] = this.tags[key]; + return returnTags; + } + } + return null; + }, + + isArea: function() { if (this.tags.area === 'yes') return true; if (!this.isClosed() || this.tags.area === 'no') return false; - for (var key in this.tags) { - if (key in areaKeys && !(this.tags[key] in areaKeys[key])) { - return true; - } - if (key in lineKeys && this.tags[key] in lineKeys[key]) { - return true; - } - } - return false; + return this.tagSuggestingArea() !== null; }, diff --git a/modules/services/maprules.js b/modules/services/maprules.js index 172823adc..dd8bb0961 100644 --- a/modules/services/maprules.js +++ b/modules/services/maprules.js @@ -4,6 +4,9 @@ import _reduce from 'lodash-es/reduce'; import _every from 'lodash-es/every'; import { areaKeys } from '../core/context'; +import { + validationIssue +} from '../core/validator'; var buildRuleChecks = function() { return { @@ -212,14 +215,17 @@ export default { } }, // when geometries match and tag matches are present, return a warning... - findWarnings: function (entity, graph, warnings) { + findIssues: function (entity, graph, issues) { if (this.geometryMatches(entity, graph) && this.matches(entity)) { - var type = Object.keys(selector).indexOf('error') > -1 ? 'error' : 'warning'; - warnings.push({ - severity: type, - message: selector[type], - entity: entity - }); + var severity = Object.keys(selector).indexOf('error') > -1 + ? 'error' + : 'warning'; + issues.push(new validationIssue({ + type: 'maprules', + severity: severity, + message: selector[severity], + entities: [entity], + })); } } }; diff --git a/modules/svg/areas.js b/modules/svg/areas.js index 8a0ef97e1..1cfbae252 100644 --- a/modules/svg/areas.js +++ b/modules/svg/areas.js @@ -3,7 +3,7 @@ import _values from 'lodash-es/values'; import { bisector as d3_bisector } from 'd3-array'; -import { osmEntity, osmIsSimpleMultipolygonOuterMember } from '../osm'; +import { osmEntity, osmIsOldMultipolygonOuterMember } from '../osm'; import { svgPath, svgSegmentWay, svgTagClasses } from './index'; @@ -200,7 +200,7 @@ export function svgAreas(projection, context) { var entity = entities[i]; if (entity.geometry(graph) !== 'area') continue; - multipolygon = osmIsSimpleMultipolygonOuterMember(entity, graph); + multipolygon = osmIsOldMultipolygonOuterMember(entity, graph); if (multipolygon) { areas[multipolygon.id] = { entity: multipolygon.mergeTags(entity.tags), diff --git a/modules/svg/icon.js b/modules/svg/icon.js index 8d287ad3a..d2c43fe28 100644 --- a/modules/svg/icon.js +++ b/modules/svg/icon.js @@ -1,6 +1,6 @@ export function svgIcon(name, svgklass, useklass) { return function drawIcon(selection) { - selection.selectAll('svg') + selection.selectAll('svg.icon') .data([0]) .enter() .append('svg') diff --git a/modules/svg/lines.js b/modules/svg/lines.js index 6300f23ca..7834f276b 100644 --- a/modules/svg/lines.js +++ b/modules/svg/lines.js @@ -14,7 +14,7 @@ import { svgTagClasses } from './index'; -import { osmEntity, osmSimpleMultipolygonOuterMember } from '../osm'; +import { osmEntity, osmOldMultipolygonOuterMember } from '../osm'; import { utilDetect } from '../util/detect'; @@ -191,7 +191,7 @@ export function svgLines(projection, context) { for (var i = 0; i < entities.length; i++) { var entity = entities[i]; - var outer = osmSimpleMultipolygonOuterMember(entity, graph); + var outer = osmOldMultipolygonOuterMember(entity, graph); if (outer) { ways.push(entity.mergeTags(outer.tags)); oldMultiPolygonOuters[outer.id] = true; diff --git a/modules/ui/background.js b/modules/ui/background.js index bafba8d20..0f1f784be 100644 --- a/modules/ui/background.js +++ b/modules/ui/background.js @@ -17,6 +17,7 @@ import { uiBackgroundOffset } from './background_offset'; import { uiCmd } from './cmd'; import { uiDisclosure } from './disclosure'; import { uiHelp } from './help'; +import { uiIssues } from './issues'; import { uiMapData } from './map_data'; import { uiMapInMap } from './map_in_map'; import { uiSettingsCustomBackground } from './settings/custom_background'; @@ -80,7 +81,7 @@ export function uiBackground(context) { return context.background().showsLayer(d); } - selection.selectAll('.layer') + selection.selectAll('li') .classed('active', active) .classed('switch', function(d) { return d === _previousBackground; }) .call(setTooltips) @@ -135,7 +136,7 @@ export function uiBackground(context) { .sources(context.map().extent()) .filter(filter); - var layerLinks = layerList.selectAll('li.layer') + var layerLinks = layerList.selectAll('li') .data(sources, function(d) { return d.name(); }); layerLinks.exit() @@ -143,7 +144,6 @@ export function uiBackground(context) { var enter = layerLinks.enter() .append('li') - .attr('class', 'layer') .classed('layer-custom', function(d) { return d.id === 'custom'; }) .classed('best', function(d) { return d.best(); }); @@ -181,9 +181,9 @@ export function uiBackground(context) { .text(function(d) { return d.name(); }); - layerList.selectAll('li.layer') + layerList.selectAll('li') .sort(sortSources) - .style('display', layerList.selectAll('li.layer').data().length > 0 ? 'block' : 'none'); + .style('display', layerList.selectAll('li').data().length > 0 ? 'block' : 'none'); layerList .call(updateLayerSelections); @@ -217,7 +217,7 @@ export function uiBackground(context) { .append('ul') .attr('class', 'layer-list minimap-toggle-list') .append('li') - .attr('class', 'layer minimap-toggle-item'); + .attr('class', 'minimap-toggle-item'); var minimapLabelEnter = minimapEnter .append('label') @@ -329,8 +329,9 @@ export function uiBackground(context) { _shown = show; if (show) { - uiMapData.hidePane(); uiHelp.hidePane(); + uiIssues.hidePane(); + uiMapData.hidePane(); update(); pane diff --git a/modules/ui/commit.js b/modules/ui/commit.js index f1706fdc7..d28eab79d 100644 --- a/modules/ui/commit.js +++ b/modules/ui/commit.js @@ -14,6 +14,7 @@ import { uiCommitChanges } from './commit_changes'; import { uiCommitWarnings } from './commit_warnings'; import { uiRawTagEditor } from './raw_tag_editor'; import { utilDetect } from '../util/detect'; +import { tooltip } from '../util/tooltip'; import { utilRebind } from '../util'; import { modeBrowse } from '../modes'; import { svgIcon } from '../svg'; @@ -264,13 +265,16 @@ export function uiCommit(context) { .attr('class', 'label') .text(t('commit.cancel')); - buttonEnter + var uploadButton = buttonEnter .append('button') - .attr('class', 'action button save-button') - .append('span') + .attr('class', 'action button save-button'); + + uploadButton.append('span') .attr('class', 'label') .text(t('commit.save')); + var uploadBlockerTooltipText = getUploadBlockerMessage(); + // update buttonSection = buttonSection .merge(buttonEnter); @@ -282,15 +286,21 @@ export function uiCommit(context) { }); buttonSection.selectAll('.save-button') - .attr('disabled', function() { - var n = d3_select('#preset-input-comment').node(); - return (n && n.value.length) ? null : true; - }) + .classed('disabled', uploadBlockerTooltipText !== null) .on('click.save', function() { - this.blur(); // avoid keeping focus on the button - #4641 - dispatch.call('save', this, _changeset); + if (!d3_select(this).classed('disabled')) { + this.blur(); // avoid keeping focus on the button - #4641 + dispatch.call('save', this, _changeset); + } }); + // remove any existing tooltip + tooltip().destroyAny(buttonSection.selectAll('.save-button')); + + if (uploadBlockerTooltipText) { + buttonSection.selectAll('.save-button') + .call(tooltip().title(uploadBlockerTooltipText).placement('top')); + } // Raw Tag Editor var tagSection = body.selectAll('.tag-section.raw-tag-editor') @@ -329,6 +339,22 @@ export function uiCommit(context) { } + function getUploadBlockerMessage() { + var errorCount = context.validator().getErrors().length; + if (errorCount > 0) { + return t('commit.outstanding_errors_message', { count: errorCount }); + + } else { + var n = d3_select('#preset-input-comment').node(); + var hasChangesetComment = n && n.value.length > 0; + if (!hasChangesetComment) { + return t('commit.comment_needed_message'); + } + } + return null; + } + + function changeTags(changed, onInput) { if (changed.hasOwnProperty('comment')) { if (changed.comment === undefined) { @@ -467,4 +493,4 @@ export function uiCommit(context) { return utilRebind(commit, dispatch, 'on'); -} \ No newline at end of file +} diff --git a/modules/ui/commit_warnings.js b/modules/ui/commit_warnings.js index afaf01860..d7ac748a2 100644 --- a/modules/ui/commit_warnings.js +++ b/modules/ui/commit_warnings.js @@ -1,3 +1,5 @@ +import _map from 'lodash-es/map'; + import { t } from '../util/locale'; import { modeSelect } from '../modes'; import { svgIcon } from '../svg'; @@ -11,26 +13,24 @@ export function uiCommitWarnings(context) { function commitWarnings(selection) { - var changes = context.history().changes(); - var validations = context.history().validate(changes); + var issues = context.validator().getIssues(); - validations = _reduce(validations, function(validations, val) { + issues = _reduce(issues, function(issues, val) { var severity = val.severity; - if (validations.hasOwnProperty(severity)) { - validations[severity].push(val); + if (issues.hasOwnProperty(severity)) { + issues[severity].push(val); } else { - validations[severity] = [val]; + issues[severity] = [val]; } - return validations; + return issues; }, {}); - _forEach(validations, function(instances, type) { + _forEach(issues, function(instances, severity) { instances = _uniqBy(instances, function(val) { return val.entity || (val.id + '_' + val.message.replace(/\s+/g,'')); }); - - var section = type + '-section'; - var instanceItem = type + '-item'; + var section = severity + '-section'; + var instanceItem = severity + '-item'; var container = selection.selectAll('.' + section) .data(instances.length ? [0] : []); @@ -44,7 +44,7 @@ export function uiCommitWarnings(context) { containerEnter .append('h3') - .text(type === 'warning' ? t('commit.warnings') : t('commit.errors')); + .text(severity === 'warning' ? t('commit.warnings') : t('commit.errors')); containerEnter .append('ul') @@ -80,31 +80,35 @@ export function uiCommitWarnings(context) { items = itemsEnter .merge(items); + items .on('mouseover', mouseover) .on('mouseout', mouseout) .on('click', warningClick); - function mouseover(d) { - if (d.entity) { + if (d.entities) { context.surface().selectAll( - utilEntityOrMemberSelector([d.entity.id], context.graph()) + utilEntityOrMemberSelector( + _map(d.entities, function(e) { return e.id; }), + context.graph() + ) ).classed('hover', true); } } - function mouseout() { context.surface().selectAll('.hover') .classed('hover', false); } - function warningClick(d) { - if (d.entity) { - context.map().zoomTo(d.entity); - context.enter(modeSelect(context, [d.entity.id])); + if (d.entities && d.entities.length > 0) { + context.map().zoomTo(d.entities[0]); + context.enter(modeSelect( + context, + _map(d.entities, function(e) { return e.id; }) + )); } } }); diff --git a/modules/ui/entity_editor.js b/modules/ui/entity_editor.js index 269972b72..845ad37e9 100644 --- a/modules/ui/entity_editor.js +++ b/modules/ui/entity_editor.js @@ -14,13 +14,14 @@ import { tooltip } from '../util/tooltip'; import { actionChangeTags } from '../actions'; import { modeBrowse } from '../modes'; import { svgIcon } from '../svg'; -import { uiPresetEditor } from './preset_editor'; import { uiPresetIcon } from './preset_icon'; import { uiQuickLinks } from './quick_links'; import { uiRawMemberEditor } from './raw_member_editor'; import { uiRawMembershipEditor } from './raw_membership_editor'; import { uiRawTagEditor } from './raw_tag_editor'; import { uiTagReference } from './tag_reference'; +import { uiPresetEditor } from './preset_editor'; +import { uiEntityIssues } from './entity_issues'; import { uiTooltipHtml } from './tooltipHtml'; import { utilCleanTags, utilRebind } from '../util'; @@ -35,6 +36,7 @@ export function uiEntityEditor(context) { var _activePreset; var _tagReference; + var entityIssues = uiEntityIssues(context); var quickLinks = uiQuickLinks(); var presetEditor = uiPresetEditor(context).on('change', changeTags); var rawTagEditor = uiRawTagEditor(context).on('change', changeTags); @@ -105,6 +107,10 @@ export function uiEntityEditor(context) { .append('div') .attr('class', 'preset-quick-links'); + bodyEnter + .append('div') + .attr('class', 'entity-issues'); + bodyEnter .append('div') .attr('class', 'preset-editor'); @@ -165,7 +171,6 @@ export function uiEntityEditor(context) { .attr('class', 'namepart') .text(function(d) { return d; }); - // update quick links var choices = [{ id: 'zoom_to', @@ -183,6 +188,11 @@ export function uiEntityEditor(context) { // update editor sections + body.select('.entity-issues') + .call(entityIssues + .entityID(_entityID) + ); + body.select('.preset-editor') .call(presetEditor .preset(_activePreset) diff --git a/modules/ui/entity_issues.js b/modules/ui/entity_issues.js new file mode 100644 index 000000000..ef53ea2e9 --- /dev/null +++ b/modules/ui/entity_issues.js @@ -0,0 +1,180 @@ +import { select as d3_select } from 'd3-selection'; + +import { svgIcon } from '../svg'; +import { t } from '../util/locale'; +import { tooltip } from '../util/tooltip'; +import { uiDisclosure } from './disclosure'; +import { uiTooltipHtml } from './tooltipHtml'; +import { utilHighlightEntities } from '../util'; + + +export function uiEntityIssues(context) { + var _selection = d3_select(null); + var _expandedIssueID; + var _entityID; + + // Listen for validation reload even though the entity editor is reloaded on + // every graph change since the graph change event may happen before the issue + // cache is refreshed + context.validator().on('reload.entity_issues', function() { + + _selection.selectAll('.disclosure-wrap-entity_issues') + .call(render); + + update(); + }); + + + function entityIssues(selection) { + _selection = selection; + + selection + .call(uiDisclosure(context, 'entity_issues', true) + .content(render) + ); + + update(); + } + + + function update() { + var issues = context.validator().getIssuesForEntityWithID(_entityID); + + _selection + .classed('hide', issues.length === 0); + + _selection.selectAll('.hide-toggle-entity_issues span') + .text(t('issues.list_title', { count: issues.length })); + } + + + function render(selection) { + var issues = context.validator().getIssuesForEntityWithID(_entityID); + _expandedIssueID = issues.length > 0 ? issues[0].id() : null; + + var items = selection.selectAll('.issue') + .data(issues, function(d) { return d.id(); }); + + // Exit + items.exit() + .remove(); + + // Enter + var itemsEnter = items.enter() + .append('div') + .attr('class', function(d) { return 'issue severity-' + d.severity; }) + .call(tooltip() + .html(true) + .title(function(d) { return uiTooltipHtml(d.tooltip); }) + .placement('top') + ) + .on('mouseover.highlight', function(d) { + // don't hover-highlight the selected entity + var ids = d.entities.filter(function(e) { return e.id !== _entityID; }) + .map(function(e) { return e.id; }); + utilHighlightEntities(ids, true, context); + }) + .on('mouseout.highlight', function(d) { + var ids = d.entities.filter(function(e) { return e.id !== _entityID; }) + .map(function(e) { return e.id; }); + utilHighlightEntities(ids, false, context); + }); + + var messagesEnter = itemsEnter + .append('button') + .attr('class', 'message') + .on('click', function(d) { + + _expandedIssueID = d.id(); // expand only the clicked item + selection.selectAll('.issue') + .classed('expanded', function(d) { return d.id() === _expandedIssueID; }); + + var extent = d.extent(context.graph()); + if (extent) { + var view = context.map().trimmedExtent(); + var zoom = context.map().zoom(); + if (!view.contains(extent) || zoom < 19) { + context.map().centerZoomEase(extent.center(), Math.max(zoom, 19)); + } + } + }); + + messagesEnter + .append('span') + .attr('class', 'issue-icon') + .call(svgIcon('', 'pre-text')); + + messagesEnter + .append('strong') + .attr('class', 'issue-text'); + + itemsEnter + .append('ul') + .attr('class', 'issue-fix-list'); + + + // Update + items = items + .merge(itemsEnter) + .classed('expanded', function(d) { return d.id() === _expandedIssueID; }); + + items.select('.issue-icon svg use') // propagate bound data + .attr('href', function(d) { + return '#iD-icon-' + (d.severity === 'warning' ? 'alert' : 'error'); + }); + + items.select('.issue-text') // propagate bound data + .text(function(d) { return d.message; }); + + + // fixes + var fixLists = items.selectAll('.issue-fix-list'); + + var fixes = fixLists.selectAll('.issue-fix-item') + .data(function(d) { return d.fixes; }) + .enter() + .append('li') + .attr('class', function(d) { + return 'issue-fix-item ' + (d.onClick ? 'actionable' : ''); + }) + .append('button') + .on('click', function(d) { + if (d.onClick) { + utilHighlightEntities(d.entityIds, false, context); + d.onClick(); + } + }) + .on('mouseover.highlight', function(d) { + utilHighlightEntities(d.entityIds, true, context); + }) + .on('mouseout.highlight', function(d) { + utilHighlightEntities(d.entityIds, false, context); + }); + + fixes.append('span') + .attr('class', 'fix-icon') + .each(function(d) { + var iconName = d.icon || 'iD-icon-wrench'; + if (iconName.startsWith('maki')) { + iconName += '-15'; + } + d3_select(this).call(svgIcon('#' + iconName, 'pre-text')); + }); + + fixes.append('span') + .text(function(d) { return d.title; }); + } + + + entityIssues.entityID = function(val) { + if (!arguments.length) return _entityID; + if (_entityID !== val) { + _entityID = val; + _expandedIssueID = null; + } + return entityIssues; + }; + + + return entityIssues; +} diff --git a/modules/ui/help.js b/modules/ui/help.js index 0f930058b..973ed96cc 100644 --- a/modules/ui/help.js +++ b/modules/ui/help.js @@ -9,6 +9,7 @@ import { uiCmd } from './cmd'; import { uiBackground } from './background'; import { uiIntro } from './intro'; import { uiMapData } from './map_data'; +import { uiIssues } from './issues'; import { uiShortcuts } from './shortcuts'; import { uiTooltipHtml } from './tooltipHtml'; @@ -303,6 +304,7 @@ export function uiHelp(context) { if (show) { uiBackground.hidePane(); + uiIssues.hidePane(); uiMapData.hidePane(); pane.style('display', 'block') diff --git a/modules/ui/init.js b/modules/ui/init.js index ac45e1b44..366530afc 100644 --- a/modules/ui/init.js +++ b/modules/ui/init.js @@ -21,6 +21,7 @@ import { uiGeolocate } from './geolocate'; import { uiHelp } from './help'; import { uiInfo } from './info'; import { uiIntro } from './intro'; +import { uiIssues } from './issues'; import { uiLoading } from './loading'; import { uiMapData } from './map_data'; import { uiMapInMap } from './map_in_map'; @@ -165,6 +166,11 @@ export function uiInit(context) { .attr('class', 'map-control map-data-control') .call(uiMapData(context)); + controls + .append('div') + .attr('class', 'map-control map-issues-control') + .call(uiIssues(context)); + controls .append('div') .attr('class', 'map-control help-control') diff --git a/modules/ui/inspector.js b/modules/ui/inspector.js index e11f8ee7c..6d4bca2a1 100644 --- a/modules/ui/inspector.js +++ b/modules/ui/inspector.js @@ -1,5 +1,5 @@ import { interpolate as d3_interpolate } from 'd3-interpolate'; -import { selectAll as d3_selectAll } from 'd3-selection'; +import { select as d3_select, selectAll as d3_selectAll } from 'd3-selection'; import { uiEntityEditor } from './entity_editor'; import { uiPresetList } from './preset_list'; @@ -9,6 +9,9 @@ import { uiViewOnOSM } from './view_on_osm'; export function uiInspector(context) { var presetList = uiPresetList(context); var entityEditor = uiEntityEditor(context); + var wrap = d3_select(null), + presetPane = d3_select(null), + editorPane = d3_select(null); var _state = 'select'; var _entityID; var _newFeature = false; @@ -18,14 +21,14 @@ export function uiInspector(context) { presetList .entityID(_entityID) .autofocus(_newFeature) - .on('choose', setPreset); + .on('choose', inspector.setPreset); entityEditor .state(_state) .entityID(_entityID) - .on('choose', showList); + .on('choose', inspector.showList); - var wrap = selection.selectAll('.panewrap') + wrap = selection.selectAll('.panewrap') .data([0]); var enter = wrap.enter() @@ -41,8 +44,8 @@ export function uiInspector(context) { .attr('class', 'entity-editor-pane pane'); wrap = wrap.merge(enter); - var presetPane = wrap.selectAll('.preset-list-pane'); - var editorPane = wrap.selectAll('.entity-editor-pane'); + presetPane = wrap.selectAll('.preset-list-pane'); + editorPane = wrap.selectAll('.entity-editor-pane'); var entity = context.entity(_entityID); @@ -71,26 +74,32 @@ export function uiInspector(context) { .call(uiViewOnOSM(context) .what(context.hasEntity(_entityID)) ); + } + inspector.showList = function(preset) { + wrap.transition() + .styleTween('right', function() { return d3_interpolate('0%', '-100%'); }); - function showList(preset) { - wrap.transition() - .styleTween('right', function() { return d3_interpolate('0%', '-100%'); }); + presetPane + .call(presetList.preset(preset).autofocus(true)); + }; + inspector.setPreset = function(preset) { + + // upon setting multipolygon, go to the area preset list instead of the editor + if (preset.id === 'type/multipolygon') { presetPane .call(presetList.preset(preset).autofocus(true)); - } - - function setPreset(preset) { + } else { wrap.transition() .styleTween('right', function() { return d3_interpolate('-100%', '0%'); }); editorPane .call(entityEditor.preset(preset)); } - } + }; inspector.state = function(val) { if (!arguments.length) return _state; diff --git a/modules/ui/issues.js b/modules/ui/issues.js new file mode 100644 index 000000000..0bc430f9a --- /dev/null +++ b/modules/ui/issues.js @@ -0,0 +1,394 @@ +import { + event as d3_event, + select as d3_select +} from 'd3-selection'; + +import { svgIcon } from '../svg'; +import { t, textDirection } from '../util/locale'; +import { tooltip } from '../util/tooltip'; +import { modeSelect } from '../modes'; +import { uiBackground } from './background'; +import { uiDisclosure } from './disclosure'; +import { uiHelp } from './help'; +import { uiMapData } from './map_data'; +import { uiTooltipHtml } from './tooltipHtml'; +import { utilHighlightEntities } from '../util'; + + +export function uiIssues(context) { + var key = t('issues.key'); + //var _featureApplicabilityList = d3_select(null); + var _errorsList = d3_select(null); + var _warningsList = d3_select(null); + var _pane = d3_select(null); + var _button = d3_select(null); + var _shown = false; + + context.validator().on('reload.issues_pane', update); + + /*function renderIssuesOptions(selection) { + var container = selection.selectAll('.issues-options-container') + .data([0]); + + container = container.enter() + .append('div') + .attr('class', 'issues-options-container') + .merge(container); + + _featureApplicabilityList = container.selectAll('.feature-applicability-list') + .data([0]); + + _featureApplicabilityList = _featureApplicabilityList.enter() + .append('ul') + .attr('class', 'layer-list feature-applicability-list') + .merge(_featureApplicabilityList); + + updateFeatureApplicabilityList(); + }*/ + + function addIconBadge(selection) { + var d = 10; + selection.selectAll('svg.icon-badge') + .data([0]) + .enter() + .append('svg') + .attr('viewbox', '0 0 ' + d + ' ' + d) + .attr('class', 'icon-badge') + .append('circle') + .attr('cx', d / 2) + .attr('cy', d / 2) + .attr('r', (d / 2) - 1) + .attr('fill', 'currentColor'); + } + + function renderErrorsList(selection) { + _errorsList = selection.selectAll('.errors-list') + .data([0]); + + _errorsList = _errorsList.enter() + .append('ul') + .attr('class', 'layer-list errors-list issues-list') + .merge(_errorsList); + + updateErrorsList(); + } + + function renderWarningsList(selection) { + _warningsList = selection.selectAll('.warnings-list') + .data([0]); + + _warningsList = _warningsList.enter() + .append('ul') + .attr('class', 'layer-list warnings-list issues-list') + .merge(_warningsList); + + updateWarningsList(); + } + + + function drawIssuesList(selection, issues) { + var items = selection.selectAll('li') + .data(issues, function(d) { return d.id(); }); + + // Exit + items.exit() + .remove(); + + // Enter + var itemsEnter = items.enter() + .append('li') + .attr('class', function (d) { return 'issue severity-' + d.severity; }) + .on('click', function(d) { + var extent = d.extent(context.graph()); + if (extent) { + var msec = 0; + var view = context.map().trimmedExtent(); + var zoom = context.map().zoom(); + + // make sure user can see the issue + if (!view.contains(extent) || zoom < 19) { + msec = 250; + context.map().centerZoomEase(extent.center(), Math.max(zoom, 19), msec); + } + + // select the first entity + if (d.entities && d.entities.length) { + window.setTimeout(function() { + var ids = d.entities.map(function(e) { return e.id; }); + context.enter(modeSelect(context, [ids[0]])); + utilHighlightEntities(ids, true, context); + }, msec); + } + } + }) + .on('mouseover', function(d) { + var ids = d.entities.map(function(e) { return e.id; }); + utilHighlightEntities(ids, true, context); + }) + .on('mouseout', function(d) { + var ids = d.entities.map(function(e) { return e.id; }); + utilHighlightEntities(ids, false, context); + }); + + + var messagesEnter = itemsEnter + .append('button') + .attr('class', 'message'); + + messagesEnter + .call(tooltip() + .html(true) + .title(function(d) { return uiTooltipHtml(d.tooltip); }) + .placement('top') + ); + + messagesEnter + .append('span') + .attr('class', 'issue-icon') + .call(svgIcon('', 'pre-text')); + + messagesEnter + .append('span') + .attr('class', 'issue-text'); + + + // Update + items = items + .merge(itemsEnter); + + items.select('.issue-icon svg use') // propagate bound data + .attr('href', function(d) { + return '#iD-icon-' + (d.severity === 'warning' ? 'alert' : 'error'); + }); + + items.select('.issue-text') // propagate bound data + .text(function(d) { return d.message; }); + } + + + function renderNoIssuesBox(selection) { + selection + .append('div') + .call(svgIcon('#iD-icon-apply', 'pre-text')); + + var noIssuesMessage = selection + .append('span'); + + noIssuesMessage + .append('strong') + .text(t('issues.no_issues.message')); + + noIssuesMessage + .append('br'); + + noIssuesMessage + .append('span') + .text(t('issues.no_issues.info')); + } + + /* + function showsFeatureApplicability(d) { + return context.validator().getFeatureApplicability() === d; + } + + function setFeatureApplicability(d) { + context.validator().setFeatureApplicability(d); + update(); + } + + function updateFeatureApplicabilityList() { + _featureApplicabilityList + .call( + drawListItems, + context.validator().featureApplicabilityOptions, + 'radio', + 'features_to_validate', + setFeatureApplicability, + showsFeatureApplicability + ); + }*/ + + function updateErrorsList() { + var errors = context.validator().getErrors(); + _errorsList + .call(drawIssuesList, errors); + } + + + function updateWarningsList() { + var warnings = context.validator().getWarnings(); + _warningsList + .call(drawIssuesList, warnings); + } + + + function update() { + var errors = context.validator().getErrors(); + var warnings = context.validator().getWarnings(); + + _button.selectAll('.icon-badge') + .classed('error', (errors.length > 0)) + .classed('warning', (errors.length === 0 && warnings.length > 0)) + .classed('hide', (errors.length === 0 && warnings.length === 0)); + + _pane.select('.issues-errors') + .classed('hide', errors.length === 0); + + if (errors.length > 0) { + _pane.select('.hide-toggle-issues_errors .hide-toggle-text') + .text(t('issues.errors.list_title', { count: errors.length })); + if (!_pane.select('.disclosure-wrap-issues_errors').classed('hide')) { + updateErrorsList(); + } + } + + _pane.select('.issues-warnings') + .classed('hide', warnings.length === 0); + + if (warnings.length > 0) { + _pane.select('.hide-toggle-issues_warnings .hide-toggle-text') + .text(t('issues.warnings.list_title', { count: warnings.length })); + if (!_pane.select('.disclosure-wrap-issues_warnings').classed('hide')) { + updateWarningsList(); + } + } + + _pane.select('.issues-none') + .classed('hide', warnings.length > 0 || errors.length > 0); + + //if (!_pane.select('.disclosure-wrap-issues_options').classed('hide')) { + // updateFeatureApplicabilityList(); + //} + } + + function issues(selection) { + + function hidePane() { + setVisible(false); + } + + function togglePane() { + if (d3_event) d3_event.preventDefault(); + setVisible(!_button.classed('active')); + } + + function setVisible(show) { + if (show !== _shown) { + _button.classed('active', show); + _shown = show; + + if (show) { + uiBackground.hidePane(); + uiHelp.hidePane(); + uiMapData.hidePane(); + update(); + + _pane + .style('display', 'block') + .style('right', '-300px') + .transition() + .duration(200) + .style('right', '0px'); + + } else { + _pane + .style('display', 'block') + .style('right', '0px') + .transition() + .duration(200) + .style('right', '-300px') + .on('end', function() { + d3_select(this).style('display', 'none'); + }); + } + } + } + + _pane = selection + .append('div') + .attr('class', 'fillL map-pane hide'); + + var paneTooltip = tooltip() + .placement((textDirection === 'rtl') ? 'right' : 'left') + .html(true) + .title(uiTooltipHtml(t('issues.title'), key)); + + _button = selection + .append('button') + .attr('tabindex', -1) + .on('click', togglePane) + .call(svgIcon('#iD-icon-alert', 'light')) + .call(addIconBadge) + .call(paneTooltip); + + var heading = _pane + .append('div') + .attr('class', 'pane-heading'); + + heading + .append('h2') + .text(t('issues.title')); + + heading + .append('button') + .on('click', function() { uiIssues.hidePane(); }) + .call(svgIcon('#iD-icon-close')); + + var content = _pane + .append('div') + .attr('class', 'pane-content'); + + content + .append('div') + .attr('class', 'issues-none') + .call(renderNoIssuesBox); + + // errors + content + .append('div') + .attr('class', 'issues-errors') + .call(uiDisclosure(context, 'issues_errors', true) + .content(renderErrorsList) + ); + + // warnings + content + .append('div') + .attr('class', 'issues-warnings') + .call(uiDisclosure(context, 'issues_warnings', true) + .content(renderWarningsList) + ); + + // options + /* + // add this back to core.yaml when re-enabling the options + options: + title: Options + features_to_validate: + edited: + description: Edited features only + tooltip: Flag issues with features you create and modify + all: + description: All features + tooltip: Flag issues with all nearby features + + content + .append('div') + .attr('class', 'issues-options') + .call(uiDisclosure(context, 'issues_options', true) + .title(t('issues.options.title')) + .content(renderIssuesOptions) + ); + */ + update(); + + context.keybinding() + .on(key, togglePane); + + uiIssues.hidePane = hidePane; + uiIssues.togglePane = togglePane; + uiIssues.setVisible = setVisible; + } + + return issues; +} diff --git a/modules/ui/map_data.js b/modules/ui/map_data.js index d32b5d530..99236e246 100644 --- a/modules/ui/map_data.js +++ b/modules/ui/map_data.js @@ -11,6 +11,7 @@ import { modeBrowse } from '../modes'; import { uiBackground } from './background'; import { uiDisclosure } from './disclosure'; import { uiHelp } from './help'; +import { uiIssues } from './issues'; import { uiSettingsCustomData } from './settings/custom_data'; import { uiTooltipHtml } from './tooltipHtml'; @@ -494,7 +495,6 @@ export function uiMapData(context) { // Enter var enter = items.enter() .append('li') - .attr('class', 'layer') .call(tooltip() .html(true) .title(function(d) { @@ -647,6 +647,7 @@ export function uiMapData(context) { if (show) { uiBackground.hidePane(); uiHelp.hidePane(); + uiIssues.hidePane(); update(); pane diff --git a/modules/ui/raw_member_editor.js b/modules/ui/raw_member_editor.js index b45e3b18f..e60d5bbca 100644 --- a/modules/ui/raw_member_editor.js +++ b/modules/ui/raw_member_editor.js @@ -10,12 +10,7 @@ import { osmEntity } from '../osm'; import { svgIcon } from '../svg'; import { services } from '../services'; import { uiCombobox, uiDisclosure } from './index'; -import { - utilDisplayName, - utilDisplayType, - utilNoAuto, - utilHighlightEntity -} from '../util'; +import { utilDisplayName, utilDisplayType, utilHighlightEntities, utilNoAuto } from '../util'; export function uiRawMemberEditor(context) { @@ -37,7 +32,7 @@ export function uiRawMemberEditor(context) { context.map().zoomTo(entity); // highlight the feature in case it wasn't previously on-screen - utilHighlightEntity(d.id, true, context); + utilHighlightEntities([d.id], true, context); } @@ -45,7 +40,7 @@ export function uiRawMemberEditor(context) { d3_event.preventDefault(); // remove the hover-highlight styling - utilHighlightEntity(d.id, false, context); + utilHighlightEntities([d.id], false, context); var entity = context.entity(d.id); var mapExtent = context.map().extent(); @@ -82,7 +77,7 @@ export function uiRawMemberEditor(context) { context.enter(modeBrowse(context)); } - utilHighlightEntity(d.id, false, context); + utilHighlightEntities([d.id], false, context); } @@ -152,10 +147,10 @@ export function uiRawMemberEditor(context) { // highlight the member feature in the map while hovering on the list item item .on('mouseover', function() { - utilHighlightEntity(d.id, true, context); + utilHighlightEntities([d.id], true, context); }) .on('mouseout', function() { - utilHighlightEntity(d.id, false, context); + utilHighlightEntities([d.id], false, context); }); var labelLink = label diff --git a/modules/ui/raw_membership_editor.js b/modules/ui/raw_membership_editor.js index b8f19c1d9..5eb4fc828 100644 --- a/modules/ui/raw_membership_editor.js +++ b/modules/ui/raw_membership_editor.js @@ -21,7 +21,7 @@ import { osmEntity, osmRelation } from '../osm'; import { services } from '../services'; import { svgIcon } from '../svg'; import { uiCombobox, uiDisclosure } from './index'; -import { utilDisplayName, utilNoAuto, utilHighlightEntity } from '../util'; +import { utilDisplayName, utilNoAuto, utilHighlightEntities } from '../util'; export function uiRawMembershipEditor(context) { @@ -38,7 +38,7 @@ export function uiRawMembershipEditor(context) { d3_event.preventDefault(); // remove the hover-highlight styling - utilHighlightEntity(d.relation.id, false, context); + utilHighlightEntities([d.relation.id], false, context); context.enter(modeSelect(context, [d.relation.id])); } @@ -194,10 +194,10 @@ export function uiRawMembershipEditor(context) { // highlight the relation in the map while hovering on the list item d3_select(this) .on('mouseover', function() { - utilHighlightEntity(d.relation.id, true, context); + utilHighlightEntities([d.relation.id], true, context); }) .on('mouseout', function() { - utilHighlightEntity(d.relation.id, false, context); + utilHighlightEntities([d.relation.id], false, context); }); }); diff --git a/modules/ui/selection_list.js b/modules/ui/selection_list.js index f7f3a3ee7..0380f856b 100644 --- a/modules/ui/selection_list.js +++ b/modules/ui/selection_list.js @@ -7,7 +7,7 @@ import { t } from '../util/locale'; import { modeSelect } from '../modes'; import { osmEntity } from '../osm'; import { svgIcon } from '../svg'; -import { utilDisplayName, utilHighlightEntity } from '../util'; +import { utilDisplayName, utilHighlightEntities } from '../util'; export function uiSelectionList(context, selectedIDs) { @@ -69,14 +69,13 @@ export function uiSelectionList(context, selectedIDs) { enter .each(function(d) { - // highlight the feature in the map while hovering on the list item - d3_select(this).on('mouseover', function() { - utilHighlightEntity(d.id, true, context); + d3_select(this).on('mouseover', function() { + utilHighlightEntities([d.id], true, context); + }); + d3_select(this).on('mouseout', function() { + utilHighlightEntities([d.id], false, context); + }); }); - d3_select(this).on('mouseout', function() { - utilHighlightEntity(d.id, false, context); - }); - }); var label = enter .append('button') diff --git a/modules/ui/sidebar.js b/modules/ui/sidebar.js index dd1975cb6..25d8abd62 100644 --- a/modules/ui/sidebar.js +++ b/modules/ui/sidebar.js @@ -196,7 +196,6 @@ export function uiSidebar(context) { } } - sidebar.hover = _throttle(hover, 200); @@ -213,9 +212,9 @@ export function uiSidebar(context) { sidebar.hide(); if (id) { + var entity = context.entity(id); // uncollapse the sidebar if (selection.classed('collapsed')) { - var entity = context.entity(id); var extent = entity.extent(context.graph()); sidebar.expand(sidebar.intersects(extent)); } @@ -237,6 +236,10 @@ export function uiSidebar(context) { .call(inspector, newFeature); } + sidebar.showPresetList = function() { + inspector.showList(context.presets().match(entity, context.graph())); + }; + } else { inspector .state('hide'); @@ -342,7 +345,7 @@ export function uiSidebar(context) { resizer.on('dblclick', sidebar.toggle); } - + sidebar.showPresetList = function() {}; sidebar.hover = function() {}; sidebar.hover.cancel = function() {}; sidebar.intersects = function() {}; diff --git a/modules/util/index.js b/modules/util/index.js index 3d0fc6a2d..95cd35cc7 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 { utilDisplayLabel } from './util'; export { utilEntityRoot } from './util'; export { utilEditDistance } from './util'; export { utilEntitySelector } from './util'; @@ -15,12 +16,13 @@ export { utilGetAllNodes } from './util'; export { utilGetPrototypeOf } from './util'; export { utilGetSetValue } from './get_set_value'; export { utilHashcode } from './util'; -export { utilHighlightEntity } from './util'; +export { utilHighlightEntities } from './util'; export { utilIdleWorker } from './idle_worker'; export { utilKeybinding } from './keybinding'; export { utilNoAuto } from './util'; export { utilPrefixCSSProperty } from './util'; export { utilPrefixDOMProperty } from './util'; +export { utilPreset } from './util'; export { utilQsString } from './util'; export { utilRebind } from './rebind'; export { utilSetTransform } from './util'; diff --git a/modules/util/util.js b/modules/util/util.js index c7c8cb95c..68906e888 100644 --- a/modules/util/util.js +++ b/modules/util/util.js @@ -62,6 +62,14 @@ export function utilEntityOrDeepMemberSelector(ids, graph) { } +// Adds or removes highlight styling for the specified entities +export function utilHighlightEntities(ids, highlighted, context) { + context.surface() + .selectAll(utilEntityOrDeepMemberSelector(ids, context.graph())) + .classed('highlighted', highlighted); +} + + export function utilGetAllNodes(ids, graph) { var seen = {}; var nodes = []; @@ -123,6 +131,27 @@ export function utilDisplayType(id) { } +export function utilDisplayLabel(entity, context) { + var displayName = utilDisplayName(entity); + if (displayName) { + // use the display name if there is one + return displayName; + } + var preset = utilPreset(entity, context); + if (preset && preset.name()) { + // use the preset name if there is a match + return preset.name(); + } + // fallback to the display type (node/way/relation) + return utilDisplayType(entity.id); +} + + +export function utilPreset(entity, context) { + return context.presets().match(entity, context.graph()); +} + + export function utilEntityRoot(entityType) { return { node: 'n', @@ -324,10 +353,3 @@ export function utilHashcode(str) { } return hash; } - -// Adds or removes highlight styling for the specified entity's SVG elements in the map. -export function utilHighlightEntity(id, highlighted, context) { - context.surface() - .selectAll(utilEntityOrDeepMemberSelector([id], context.graph())) - .classed('highlighted', highlighted); -} diff --git a/modules/validations/almost_junction.js b/modules/validations/almost_junction.js new file mode 100644 index 000000000..3ac6f13ed --- /dev/null +++ b/modules/validations/almost_junction.js @@ -0,0 +1,202 @@ +import _cloneDeep from 'lodash-es/cloneDeep'; +import { + geoExtent, + geoLineIntersection, + geoMetersToLat, + geoMetersToLon, + geoSphericalDistance, + geoVecInterp, + geoHasSelfIntersections, + geoSphericalClosestNode +} from '../geo'; + +import { actionAddMidpoint, actionChangeTags, actionMergeNodes } from '../actions'; +import { t } from '../util/locale'; +import { utilDisplayLabel } from '../util'; +import { validationIssue, validationIssueFix } from '../core/validator'; + + +/** + * Look for roads that can be connected to other roads with a short extension + */ +export function validationAlmostJunction() { + var type = 'almost_junction'; + + + function isHighway(entity) { + return entity.type === 'way' && entity.tags.highway && entity.tags.highway !== 'no'; + } + + function isNoexit(node) { + return node.tags.noexit && node.tags.noexit === 'yes'; + } + + function findConnectableEndNodesByExtension(way, graph, tree) { + var results = []; + var nidFirst = way.nodes[0]; + var nidLast = way.nodes[way.nodes.length - 1]; + var nodeFirst = graph.entity(nidFirst); + var nodeLast = graph.entity(nidLast); + + if (nidFirst === nidLast) return results; + + var testNodes; + + if (!isNoexit(nodeFirst) && graph.parentWays(nodeFirst).length === 1) { + var connNearFirst = canConnectByExtend(way, 0, graph, tree); + if (connNearFirst !== null) { + testNodes = _cloneDeep(graph.childNodes(way)); + testNodes[0].loc = connNearFirst.cross_loc; + // don't flag issue if connecting the ways would cause self-intersection + if (!geoHasSelfIntersections(testNodes, nodeFirst.id)) { + results.push({ + node: nodeFirst, + wid: connNearFirst.wid, + edge: connNearFirst.edge, + cross_loc: connNearFirst.cross_loc + }); + } + } + } + + if (!isNoexit(nodeLast) && graph.parentWays(nodeLast).length === 1) { + var connNearLast = canConnectByExtend(way, way.nodes.length - 1, graph, tree); + if (connNearLast !== null) { + testNodes = _cloneDeep(graph.childNodes(way)); + testNodes[testNodes.length-1].loc = connNearLast.cross_loc; + // don't flag issue if connecting the ways would cause self-intersection + if (!geoHasSelfIntersections(testNodes, nodeLast.id)) { + results.push({ + node: nodeLast, + wid: connNearLast.wid, + edge: connNearLast.edge, + cross_loc: connNearLast.cross_loc + }); + } + } + } + return results; + } + + + function canConnectByExtend(way, endNodeIdx, graph, tree) { + var EXTEND_TH_METERS = 5; + var tipNid = way.nodes[endNodeIdx]; // the 'tip' node for extension point + var midNid = endNodeIdx === 0 ? way.nodes[1] : way.nodes[way.nodes.length - 2]; // the other node of the edge + var tipNode = graph.entity(tipNid); + var midNode = graph.entity(midNid); + var lon = tipNode.loc[0]; + var lat = tipNode.loc[1]; + var lon_range = geoMetersToLon(EXTEND_TH_METERS, lat) / 2; + var lat_range = geoMetersToLat(EXTEND_TH_METERS) / 2; + var queryExtent = geoExtent([ + [lon - lon_range, lat - lat_range], + [lon + lon_range, lat + lat_range] + ]); + + // first, extend the edge of [midNode -> tipNode] by EXTEND_TH_METERS and find the "extended tip" location + var edgeLen = geoSphericalDistance(midNode.loc, tipNode.loc); + var t = EXTEND_TH_METERS / edgeLen + 1.0; + var extTipLoc = geoVecInterp(midNode.loc, tipNode.loc, t); + + // then, check if the extension part [tipNode.loc -> extTipLoc] intersects any other ways + var intersected = tree.intersects(queryExtent, graph); + for (var i = 0; i < intersected.length; i++) { + if (!isHighway(intersected[i]) || intersected[i].id === way.id) continue; + + var way2 = intersected[i]; + for (var j = 0; j < way2.nodes.length - 1; j++) { + var nA = graph.entity(way2.nodes[j]); + var nB = graph.entity(way2.nodes[j + 1]); + var crossLoc = geoLineIntersection([tipNode.loc, extTipLoc], [nA.loc, nB.loc]); + if (crossLoc !== null) { + return { + wid: way2.id, + edge: [nA.id, nB.id], + cross_loc: crossLoc + }; + } + } + } + return null; + } + + + var validation = function(endHighway, context) { + if (!isHighway(endHighway)) return []; + + var graph = context.graph(); + var tree = context.history().tree(); + var issues = []; + + var extendableNodeInfos = findConnectableEndNodesByExtension(endHighway, graph, tree); + extendableNodeInfos.forEach(function(extendableNodeInfo) { + var node = extendableNodeInfo.node; + var edgeHighway = graph.entity(extendableNodeInfo.wid); + + var fixes = [new validationIssueFix({ + title: t('issues.fix.connect_features.title'), + onClick: function() { + var endNode = this.issue.entities[1]; + var targetEdge = this.issue.info.edge; + var crossLoc = this.issue.info.cross_loc; + var edgeNodes = [context.graph().entity(targetEdge[0]), context.graph().entity(targetEdge[1])]; + var closestNodeInfo = geoSphericalClosestNode(edgeNodes, crossLoc); + + var annotation = t('issues.fix.connect_almost_junction.annotation'); + // already a point nearby, just connect to that + if (closestNodeInfo.distance < 0.75) { + context.perform( + actionMergeNodes([closestNodeInfo.node.id, endNode.id], closestNodeInfo.node.loc), + annotation + ); + // else add the end node to the edge way + } else { + context.perform( + actionAddMidpoint({loc: crossLoc, edge: targetEdge}, endNode), + annotation + ); + } + } + })]; + + if (Object.keys(node.tags).length === 0) { + // node has no tags, suggest noexit fix + fixes.push(new validationIssueFix({ + icon: 'maki-barrier', + title: t('issues.fix.tag_as_disconnected.title'), + onClick: function() { + var nodeID = this.issue.entities[1].id; + context.perform( + actionChangeTags(nodeID, { noexit: 'yes' }), + t('issues.fix.tag_as_disconnected.annotation') + ); + } + })); + } + + issues.push(new validationIssue({ + type: type, + severity: 'warning', + message: t('issues.almost_junction.message', { + feature: utilDisplayLabel(endHighway, context), + feature2: utilDisplayLabel(edgeHighway, context) + }), + tooltip: t('issues.almost_junction.highway-highway.tip'), + entities: [endHighway, node, edgeHighway], + loc: extendableNodeInfo.node.loc, + info: { + edge: extendableNodeInfo.edge, + cross_loc: extendableNodeInfo.cross_loc + }, + fixes: fixes + })); + }); + + return issues; + }; + + validation.type = type; + + return validation; +} diff --git a/modules/validations/crossing_ways.js b/modules/validations/crossing_ways.js new file mode 100644 index 000000000..c4431e7d8 --- /dev/null +++ b/modules/validations/crossing_ways.js @@ -0,0 +1,420 @@ +import _clone from 'lodash-es/clone'; +import _map from 'lodash-es/map'; +import _flattenDeep from 'lodash-es/flatten'; + +import { actionAddMidpoint, actionMergeNodes } from '../actions'; +import { geoExtent, geoLineIntersection, geoSphericalClosestNode } from '../geo'; +import { osmNode } from '../osm'; +import { t } from '../util/locale'; +import { utilDisplayLabel } from '../util'; +import { validationIssue, validationIssueFix } from '../core/validator'; + + +export function validationCrossingWays() { + var type = 'crossing_ways'; + + + // Check if the edge going from n1 to n2 crosses (without a connection node) + // any edge on way. Return the cross point if so. + function findEdgeToWayCrossCoords(n1, n2, way, graph) { + var crossCoords = []; + var nA, nB; + var segment1 = [n1.loc, n2.loc]; + var segment2; + + var nodes = graph.childNodes(way); + for (var j = 0; j < nodes.length - 1; j++) { + nA = nodes[j]; + nB = nodes[j + 1]; + if (nA.id === n1.id || nA.id === n2.id || + nB.id === n1.id || nB.id === n2.id) { + // n1 or n2 is a connection node; skip + continue; + } + segment2 = [nA.loc, nB.loc]; + var point = geoLineIntersection(segment1, segment2); + if (point) { + crossCoords.push({ edge: [nA.id, nB.id], point: point }); + } + } + return crossCoords; + } + + + // returns the way or its parent relation, whichever has a useful feature type + function getFeatureWithFeatureTypeTagsForWay(way, graph) { + if (getFeatureTypeForTags(way.tags) === null) { + // if the way doesn't match a feature type, check is parent relations + var parentRels = graph.parentRelations(way); + for (var i = 0; i < parentRels.length; i++) { + var rel = parentRels[i]; + if (getFeatureTypeForTags(rel.tags) !== null) { + return rel; + } + } + } + return way; + } + + + function hasTag(tags, key) { + return tags[key] !== undefined && tags[key] !== 'no'; + } + + + function getFeatureTypeForCrossingCheck(way, graph) { + var tags = getFeatureWithFeatureTypeTagsForWay(way, graph).tags; + return getFeatureTypeForTags(tags); + } + + + // only validate certain waterway features + var waterways = ['canal', 'ditch', 'drain', 'river', 'stream']; + // ignore certain highway and railway features + var ignoredHighways = ['rest_area', 'services']; + var ignoredRailways = ['train_wash']; + + + function getFeatureTypeForTags(tags) { + if (hasTag(tags, 'building')) return 'building'; + + // don't check non-building areas + if (hasTag(tags, 'area')) return null; + + if (hasTag(tags, 'highway') && ignoredHighways.indexOf(tags.highway) === -1) return 'highway'; + if (hasTag(tags, 'railway') && ignoredRailways.indexOf(tags.railway) === -1) return 'railway'; + if (hasTag(tags, 'waterway') && waterways.indexOf(tags.waterway) !== -1) return 'waterway'; + + return null; + } + + + function extendTagsByInferredLayer(tags, way) { + if (!hasTag(tags, 'layer')) { + tags.layer = way.layer().toString(); + } + return tags; + } + + + function isLegitCrossing(way1, featureType1, way2, featureType2, graph) { + var tags1 = _clone(getFeatureWithFeatureTypeTagsForWay(way1, graph).tags); + var tags2 = _clone(getFeatureWithFeatureTypeTagsForWay(way2, graph).tags); + tags1 = extendTagsByInferredLayer(tags1, way1); + tags2 = extendTagsByInferredLayer(tags2, way2); + + // For better readability, not chaining all the true conditions into one if statement. + if ((featureType1 === 'highway' && featureType2 === 'highway') || + (featureType1 === 'highway' && featureType2 === 'railway') || + (featureType1 === 'railway' && featureType2 === 'railway')) { + // Legit cases: + // (1) they're on different layers + // (2) only one of the two ways is on a bridge + // (3) only one of the two ways is in a tunnel + if (tags1.layer !== tags2.layer) return true; + if (hasTag(tags1, 'bridge') && !hasTag(tags2, 'bridge')) return true; + if (!hasTag(tags1, 'bridge') && hasTag(tags2, 'bridge')) return true; + if (hasTag(tags1, 'tunnel') && !hasTag(tags2, 'tunnel')) return true; + if (!hasTag(tags1, 'tunnel') && hasTag(tags2, 'tunnel')) return true; + } + if ((featureType1 === 'highway' && featureType2 === 'waterway') || + (featureType1 === 'railway' && featureType2 === 'waterway')) { + // Legit cases: + // (1) highway/railway is on a bridge + // (2) only one of the two ways is in a tunnel + // (3) both are in tunnels but on different layers + if (hasTag(tags1, 'bridge')) return true; + if (hasTag(tags1, 'tunnel') && !hasTag(tags2, 'tunnel')) return true; + if (!hasTag(tags1, 'tunnel') && hasTag(tags2, 'tunnel')) return true; + if (hasTag(tags1, 'tunnel') && hasTag(tags2, 'tunnel') && tags1.layer !== tags2.layer) return true; + } + if ((featureType1 === 'highway' && featureType2 === 'building') || + (featureType1 === 'railway' && featureType2 === 'building')) { + // Legit cases: + // (1) highway/railway has a bridge or tunnel tag + // (2) highway/railway has a covered tag + if (hasTag(tags1, 'bridge') || hasTag(tags1, 'tunnel') || hasTag(tags1, 'covered')) return true; + } + if (featureType1 === 'waterway' && featureType2 === 'waterway') { + // Legit cases: + // (1) only one of the water is in a tunnel + // (2) both are in tunnels but on differnt layers + if (hasTag(tags1, 'tunnel') && !hasTag(tags2, 'tunnel')) return true; + if (!hasTag(tags1, 'tunnel') && hasTag(tags2, 'tunnel')) return true; + if (hasTag(tags1, 'tunnel') && hasTag(tags2, 'tunnel') && tags1.layer !== tags2.layer) return true; + } + if (featureType1 === 'waterway' && featureType2 === 'building') { + // Legit cases: + // (1) water is in a tunnel + // (2) water has a covered tag + if (hasTag(tags1, 'tunnel') || hasTag(tags1, 'covered')) return true; + } + if (featureType1 === 'building' && featureType2 === 'building') { + // Legit case: they're on different layers + if (tags1.layer !== tags2.layer) return true; + } + return false; + } + + + // highway values for which we shouldn't recommend connecting to waterways + var highwaysDisallowingFords = [ + 'motorway', 'motorway_link', 'trunk', 'trunk_link', + 'primary', 'primary_link', 'secondary', 'secondary_link' + ]; + var pathHighways = [ + 'path', 'footway', 'cycleway', 'bridleway', 'pedestrian', 'steps' + ]; + + function tagsForConnectionNodeIfAllowed(entity1, entity2) { + var featureType1 = getFeatureTypeForTags(entity1.tags); + var featureType2 = getFeatureTypeForTags(entity2.tags); + if (featureType1 === featureType2) { + if (featureType1 === 'highway') { + var entity1IsPath = pathHighways.indexOf(entity1.tags.highway) !== -1; + var entity2IsPath = pathHighways.indexOf(entity2.tags.highway) !== -1; + if ((entity1IsPath || entity2IsPath) && entity1IsPath !== entity2IsPath) { + // one feature is a path but not both, use a crossing + + var pathFeature = entity1IsPath ? entity1 : entity2; + if (pathFeature.tags.highway === 'footway' && + pathFeature.tags.footway === 'crossing' && + ['marked', 'unmarked'].indexOf(pathFeature.tags.crossing) !== -1) { + // if the path is a crossing, match the crossing type + return { highway: 'crossing', crossing: pathFeature.tags.crossing }; + } + return { highway: 'crossing' }; + } + return {}; + } + if (featureType1 === 'waterway') return {}; + if (featureType1 === 'railway') return {}; + + } else { + var featureTypes = [featureType1, featureType2]; + if (featureTypes.indexOf('highway') !== -1) { + if (featureTypes.indexOf('building') !== -1) return {}; + if (featureTypes.indexOf('railway') !== -1) { + if (pathHighways.indexOf(entity1.tags.highway) !== -1 || + pathHighways.indexOf(entity2.tags.highway) !== -1) { + // path-rail connections use this tag + return { railway: 'crossing' }; + } else { + // road-rail connections use this tag + return { railway: 'level_crossing' }; + } + } + + if (featureTypes.indexOf('waterway') !== -1) { + // do not allow fords on structures + if (hasTag(entity1.tags, 'tunnel') && hasTag(entity2.tags, 'tunnel')) return null; + if (hasTag(entity1.tags, 'bridge') && hasTag(entity2.tags, 'bridge')) return null; + + if (highwaysDisallowingFords.indexOf(entity1.tags.highway) !== -1 || + highwaysDisallowingFords.indexOf(entity2.tags.highway) !== -1) { + // do not allow fords on major highways + return null; + } + return { ford: 'yes' }; + } + } + } + return null; + } + + + function findCrossingsByWay(primaryWay, graph, tree) { + var edgeCrossInfos = []; + if (primaryWay.type !== 'way') return edgeCrossInfos; + + var primaryFeatureType = getFeatureTypeForCrossingCheck(primaryWay, graph); + if (primaryFeatureType === null) return edgeCrossInfos; + + for (var i = 0; i < primaryWay.nodes.length - 1; i++) { + var nid1 = primaryWay.nodes[i]; + var nid2 = primaryWay.nodes[i + 1]; + var n1 = graph.entity(nid1); + var n2 = graph.entity(nid2); + var extent = geoExtent([ + [ + Math.min(n1.loc[0], n2.loc[0]), + Math.min(n1.loc[1], n2.loc[1]) + ], + [ + Math.max(n1.loc[0], n2.loc[0]), + Math.max(n1.loc[1], n2.loc[1]) + ] + ]); + + var intersected = tree.intersects(extent, graph); + for (var j = 0; j < intersected.length; j++) { + if (intersected[j].type !== 'way') continue; + + // only check crossing highway, waterway, building, and railway + var way = intersected[j]; + var wayFeatureType = getFeatureTypeForCrossingCheck(way, graph); + if (wayFeatureType === null || + isLegitCrossing(primaryWay, primaryFeatureType, way, wayFeatureType, graph) || + isLegitCrossing(way, wayFeatureType, primaryWay, primaryFeatureType, graph)) { + continue; + } + + var crossCoords = findEdgeToWayCrossCoords(n1, n2, way, graph); + for (var k = 0; k < crossCoords.length; k++) { + var crossingInfo = crossCoords[k]; + edgeCrossInfos.push({ + ways: [primaryWay, way], + featureTypes: [primaryFeatureType, wayFeatureType], + edges: [[n1.id, n2.id], crossingInfo.edge], + crossPoint: crossingInfo.point + }); + } + } + } + return edgeCrossInfos; + } + + + var validation = function(entity, context) { + var graph = context.graph(); + var tree = context.history().tree(); + + var waysToCheck = _flattenDeep(_map([entity], function(entity) { + if (!getFeatureTypeForTags(entity.tags)) { + return []; + } + if (entity.type === 'way') { + return entity; + } else if (entity.type === 'relation' && + entity.tags.type === 'multipolygon' && + // only check multipolygons if they are buildings + hasTag(entity.tags, 'building')) { + return _map(entity.members, function(member) { + if (context.hasEntity(member.id)) { + var entity = context.entity(member.id); + if (entity.type === 'way') { + return entity; + } + } + return []; + }); + } + return []; + })); + + var crossings = waysToCheck.reduce(function(array, way) { + return array.concat(findCrossingsByWay(way, graph, tree)); + }, []); + + var issues = []; + crossings.forEach(function(crossing) { + issues.push(createIssue(crossing, context)); + }); + return issues; + }; + + + function createIssue(crossing, context) { + var graph = context.graph(); + + // use the entities with the tags that define the feature type + var entities = crossing.ways.sort(function(entity1, entity2) { + var type1 = getFeatureTypeForCrossingCheck(entity1, graph); + var type2 = getFeatureTypeForCrossingCheck(entity2, graph); + if (type1 === type2) { + return utilDisplayLabel(entity1, context) > utilDisplayLabel(entity2, context); + } else if (type1 === 'waterway') { + return true; + } else if (type2 === 'waterway') { + return false; + } + return type1 < type2; + }); + entities = _map(entities, function(way) { + return getFeatureWithFeatureTypeTagsForWay(way, graph); + }); + + var connectionTags = tagsForConnectionNodeIfAllowed(entities[0], entities[1]); + + var crossingTypeID; + if (hasTag(entities[0].tags, 'tunnel') && hasTag(entities[1].tags, 'tunnel')) { + crossingTypeID = 'tunnel-tunnel'; + if (connectionTags) { + crossingTypeID += '_connectable'; + } + } else if (hasTag(entities[0].tags, 'bridge') && hasTag(entities[1].tags, 'bridge')) { + crossingTypeID = 'bridge-bridge'; + if (connectionTags) { + crossingTypeID += '_connectable'; + } + } else { + crossingTypeID = crossing.featureTypes.sort().join('-'); + } + + var messageDict = { + feature: utilDisplayLabel(entities[0], context), + feature2: utilDisplayLabel(entities[1], context) + }; + + var fixes = []; + if (connectionTags) { + fixes.push(new validationIssueFix({ + title: t('issues.fix.connect_features.title'), + onClick: function() { + var loc = this.issue.loc; + var connectionTags = this.issue.info.connectionTags; + var edges = this.issue.info.edges; + + context.perform( + function actionConnectCrossingWays(graph) { + // create the new node for the points + var node = osmNode({ loc: loc, tags: connectionTags }); + graph = graph.replace(node); + + var nodesToMerge = [node.id]; + var mergeThresholdInMeters = 0.75; + + edges.forEach(function(edge) { + var edgeNodes = [graph.entity(edge[0]), graph.entity(edge[1])]; + var closestNodeInfo = geoSphericalClosestNode(edgeNodes, loc); + // if there is already a point nearby, use that + if (closestNodeInfo.distance < mergeThresholdInMeters) { + nodesToMerge.push(closestNodeInfo.node.id); + // else add the new node to the way + } else { + graph = actionAddMidpoint({loc: loc, edge: edge}, node)(graph); + } + }); + + if (nodesToMerge.length > 1) { + // if we're using nearby nodes, merge them with the new node + graph = actionMergeNodes(nodesToMerge, loc)(graph); + } + + return graph; + }, + t('issues.fix.connect_crossing_features.annotation') + ); + } + })); + } + fixes.push(new validationIssueFix({ + title: t('issues.fix.reposition_features.title') + })); + return new validationIssue({ + type: type, + severity: 'warning', + message: t('issues.crossing_ways.message', messageDict), + tooltip: t('issues.crossing_ways.'+crossingTypeID+'.tip'), + entities: entities, + info: { edges: crossing.edges, connectionTags: connectionTags }, + loc: crossing.crossPoint, + fixes: fixes + }); + } + + validation.type = type; + + + return validation; +} diff --git a/modules/validations/deprecated_tag.js b/modules/validations/deprecated_tag.js index 1ed3f1dcd..8452b32a5 100644 --- a/modules/validations/deprecated_tag.js +++ b/modules/validations/deprecated_tag.js @@ -1,30 +1,90 @@ -import _isEmpty from 'lodash-es/isEmpty'; +import _clone from 'lodash-es/clone'; import { t } from '../util/locale'; -import { utilTagText } from '../util/index'; - +import { actionChangeTags } from '../actions'; +import { utilDisplayLabel, utilTagText } from '../util'; +import { validationIssue, validationIssueFix } from '../core/validator'; export function validationDeprecatedTag() { + var type = 'deprecated_tag'; - var validation = function(changes) { - var warnings = []; - for (var i = 0; i < changes.created.length; i++) { - var change = changes.created[i]; - var deprecatedTags = change.deprecatedTags(); - if (!_isEmpty(deprecatedTags)) { - var tags = utilTagText({ tags: deprecatedTags }); - warnings.push({ - id: 'deprecated_tags', - message: t('validations.deprecated_tags', { tags: tags }), - entity: change - }); + var validation = function(entity, context) { + var issues = []; + var deprecatedTagsArray = entity.deprecatedTags(); + if (deprecatedTagsArray.length > 0) { + for (var deprecatedTagIndex in deprecatedTagsArray) { + var deprecatedTags = deprecatedTagsArray[deprecatedTagIndex]; + var tagsLabel = utilTagText({ tags: deprecatedTags.old }); + var featureLabel = utilDisplayLabel(entity, context); + var isCombo = Object.keys(deprecatedTags.old).length > 1; + var messageObj = { feature: featureLabel }; + if (isCombo) { + messageObj.tags = tagsLabel; + } else { + messageObj.tag = tagsLabel; + } + var tagMessageID = isCombo ? 'combination' : 'single'; + issues.push(new validationIssue({ + type: type, + severity: 'warning', + message: t('issues.deprecated_tag.' + tagMessageID + '.message', messageObj), + tooltip: t('issues.deprecated_tag.tip'), + entities: [entity], + hash: tagsLabel, + info: { + oldTags: deprecatedTags.old, + replaceTags: deprecatedTags.replace + }, + fixes: [ + new validationIssueFix({ + icon: 'iD-icon-up', + title: t('issues.fix.' + (isCombo ? 'upgrade_tag_combo' : 'upgrade_tag') + '.title'), + onClick: function() { + var entity = this.issue.entities[0]; + var tags = _clone(entity.tags); + var replaceTags = this.issue.info.replaceTags; + var oldTags = this.issue.info.oldTags; + var fixTextID = Object.keys(oldTags).length > 1 ? 'upgrade_tag_combo' : 'upgrade_tag'; + var transferValue; + for (var oldTagKey in oldTags) { + if (oldTags[oldTagKey] === '*') { + transferValue = tags[oldTagKey]; + } + delete tags[oldTagKey]; + } + for (var replaceKey in replaceTags) { + var replaceValue = replaceTags[replaceKey]; + if (replaceValue === '*') { + if (tags[replaceKey]) { + // any value is okay and there already + // is one, so don't update it + continue; + } else { + // otherwise assume `yes` is okay + tags[replaceKey] = 'yes'; + } + } else if (replaceValue === '$1') { + tags[replaceKey] = transferValue; + } else { + tags[replaceKey] = replaceValue; + } + } + context.perform( + actionChangeTags(entity.id, tags), + t('issues.fix.' + fixTextID + '.annotation') + ); + } + }) + ] + })); } } - return warnings; + return issues; }; + validation.type = type; return validation; } diff --git a/modules/validations/disconnected_highway.js b/modules/validations/disconnected_highway.js deleted file mode 100644 index ef2d8cf8c..000000000 --- a/modules/validations/disconnected_highway.js +++ /dev/null @@ -1,45 +0,0 @@ -import { t } from '../util/locale'; - - -export function validationDisconnectedHighway() { - - function isDisconnectedHighway(entity, graph) { - if (!entity.tags.highway) return false; - if (entity.geometry(graph) !== 'line') return false; - - return graph.childNodes(entity) - .every(function(vertex) { - var parents = graph.parentWays(vertex); - if (parents.length === 1) { // standalone vertex - return true; - } else { // shared vertex - return !vertex.tags.entrance && - parents.filter(function(parent) { - return parent.tags.highway && parent !== entity; - }).length === 0; - } - }); - } - - - var validation = function(changes, graph) { - var warnings = []; - for (var i = 0; i < changes.created.length; i++) { - var entity = changes.created[i]; - - if (isDisconnectedHighway(entity, graph)) { - warnings.push({ - id: 'disconnected_highway', - message: t('validations.disconnected_highway'), - tooltip: t('validations.disconnected_highway_tooltip'), - entity: entity - }); - } - } - - return warnings; - }; - - - return validation; -} diff --git a/modules/validations/disconnected_way.js b/modules/validations/disconnected_way.js new file mode 100644 index 000000000..55645960f --- /dev/null +++ b/modules/validations/disconnected_way.js @@ -0,0 +1,100 @@ +import { t } from '../util/locale'; +import { modeDrawLine } from '../modes'; +import { operationDelete } from '../operations/index'; +import { utilDisplayLabel } from '../util'; +import { validationIssue, validationIssueFix } from '../core/validator'; + + +export function validationDisconnectedWay() { + var type = 'disconnected_way'; + + + function isDisconnectedHighway(entity, graph) { + if (!entity.tags.highway) return false; + if (entity.geometry(graph) !== 'line') return false; + + return graph.childNodes(entity) + .every(function(vertex) { + var parents = graph.parentWays(vertex); + if (parents.length === 1) { // standalone vertex + return true; + } else { // shared vertex + return !vertex.tags.entrance && + parents.filter(function(parent) { + return parent.tags.highway && parent !== entity; + }).length === 0; + } + }); + } + + + var validation = function(entity, context) { + var issues = []; + var graph = context.graph(); + + if (isDisconnectedHighway(entity, graph)) { + var entityLabel = utilDisplayLabel(entity, context); + var fixes = []; + + if (!entity.isClosed()) { + fixes.push(new validationIssueFix({ + icon: 'iD-operation-continue-left', + title: t('issues.fix.continue_from_start.title'), + entityIds: [entity.first()], + onClick: function() { + var vertex = context.entity(entity.first()); + continueDrawing(entity, vertex, context); + } + })); + fixes.push(new validationIssueFix({ + icon: 'iD-operation-continue', + title: t('issues.fix.continue_from_end.title'), + entityIds: [entity.last()], + onClick: function() { + var vertex = context.entity(entity.last()); + continueDrawing(entity, vertex, context); + } + })); + } + + fixes.push(new validationIssueFix({ + icon: 'iD-operation-delete', + title: t('issues.fix.delete_feature.title'), + entityIds: [entity.id], + onClick: function() { + var id = this.issue.entities[0].id; + operationDelete([id], context)(); + } + })); + + issues.push(new validationIssue({ + type: type, + severity: 'warning', + message: t('issues.disconnected_way.highway.message', { highway: entityLabel }), + tooltip: t('issues.disconnected_way.highway.tip'), + entities: [entity], + fixes: fixes + })); + } + + return issues; + + + function continueDrawing(way, vertex) { + // make sure the vertex is actually visible and editable + var map = context.map(); + if (!map.editable() || !map.trimmedExtent().contains(vertex.loc)) { + map.zoomToEase(vertex); + } + + context.enter( + modeDrawLine(context, way.id, context.graph(), way.affix(vertex.id), true) + ); + } + }; + + + validation.type = type; + + return validation; +} diff --git a/modules/validations/generic_name.js b/modules/validations/generic_name.js index 7cc27834a..9ab314901 100644 --- a/modules/validations/generic_name.js +++ b/modules/validations/generic_name.js @@ -1,7 +1,14 @@ +import _clone from 'lodash-es/clone'; import { t } from '../util/locale'; +import { utilPreset } from '../util'; +import { validationIssue, validationIssueFix } from '../core/validator'; +import { actionChangeTags } from '../actions'; import { discardNames } from '../../node_modules/name-suggestion-index/config/filters.json'; + export function validationGenericName() { + var type = 'generic_name'; + function isGenericName(entity) { var name = entity.tags.name; @@ -30,22 +37,39 @@ export function validationGenericName() { } - return function validation(changes) { - var warnings = []; - - for (var i = 0; i < changes.created.length; i++) { - var change = changes.created[i]; - var generic = isGenericName(change); - if (generic) { - warnings.push({ - id: 'generic_name', - message: t('validations.generic_name'), - tooltip: t('validations.generic_name_tooltip', { name: generic }), - entity: change - }); - } + var validation = function(entity, context) { + var issues = []; + var generic = isGenericName(entity); + if (generic) { + var preset = utilPreset(entity, context); + issues.push(new validationIssue({ + type: type, + severity: 'warning', + message: t('issues.generic_name.message', {feature: preset.name(), name: generic}), + tooltip: t('issues.generic_name.tip'), + entities: [entity], + fixes: [ + new validationIssueFix({ + icon: 'iD-operation-delete', + title: t('issues.fix.remove_generic_name.title'), + onClick: function() { + var entity = this.issue.entities[0]; + var tags = _clone(entity.tags); + delete tags.name; + context.perform( + actionChangeTags(entity.id, tags), + t('issues.fix.remove_generic_name.annotation') + ); + } + }) + ] + })); } - return warnings; + return issues; }; + + validation.type = type; + + return validation; } diff --git a/modules/validations/index.js b/modules/validations/index.js index 0e562fc80..235f2e540 100644 --- a/modules/validations/index.js +++ b/modules/validations/index.js @@ -1,8 +1,10 @@ +export { validationAlmostJunction } from './almost_junction'; +export { validationCrossingWays } from './crossing_ways'; export { validationDeprecatedTag } from './deprecated_tag'; -export { validationDisconnectedHighway } from './disconnected_highway'; -export { validationGenericName } from './generic_name.js'; +export { validationDisconnectedWay } from './disconnected_way'; +export { validationGenericName } from './generic_name'; export { validationManyDeletions } from './many_deletions'; -export { validationMapCSSChecks } from './mapcss_checks'; +export { validationMaprules } from './maprules'; export { validationMissingTag } from './missing_tag'; export { validationOldMultipolygon } from './old_multipolygon'; export { validationTagSuggestsArea } from './tag_suggests_area'; diff --git a/modules/validations/many_deletions.js b/modules/validations/many_deletions.js index 08e01dc96..676ce73a5 100644 --- a/modules/validations/many_deletions.js +++ b/modules/validations/many_deletions.js @@ -1,31 +1,58 @@ import { t } from '../util/locale'; +import { validationIssue } from '../core/validator'; export function validationManyDeletions() { - var threshold = 100; + var totalOtherGeomThreshold = 50; + var relationThreshold = 10; // relations are less common so use a lower threshold - var validation = function(changes, graph) { - var warnings = []; - var nodes = 0, ways = 0, areas = 0, relations = 0; + var type = 'many_deletions'; - changes.deleted.forEach(function(c) { - if (c.type === 'node') { nodes++; } - else if (c.type === 'way' && c.geometry(graph) === 'line') { ways++; } - else if (c.type === 'way' && c.geometry(graph) === 'area') { areas++; } - else if (c.type === 'relation') { relations++; } + var validation = function(changes, context) { + var issues = []; + var points = 0, lines = 0, areas = 0, relations = 0; + var base = context.history().base(); + var geometry; + + changes.deleted.forEach(function(entity) { + if (entity.type === 'node' && entity.geometry(base) === 'point') { + points++; + } else if (entity.type === 'way') { + geometry = entity.geometry(base); + if (geometry === 'line') { + lines++; + } else if (geometry === 'area') { + areas++; + } + } else if (entity.type === 'relation') { + relations++; + } }); - if (changes.deleted.length > threshold) { - warnings.push({ - id: 'many_deletions', - message: t('validations.many_deletions', - { n: changes.deleted.length, p: nodes, l: ways, a:areas, r: relations } - ) - }); + + if (points + lines + areas >= totalOtherGeomThreshold || relations >= relationThreshold) { + var totalFeatures = points + lines + areas + relations; + + var messageType = 'points-lines-areas'; + if (relations > 0) { + messageType += '-relations'; + } + issues.push(new validationIssue({ + type: type, + severity: 'warning', + message: t( + 'issues.many_deletions.'+messageType+'.message', + { n: totalFeatures, p: points, l: lines, a:areas, r: relations } + ), + tooltip: t('issues.many_deletions.tip'), + hash: [points, lines, areas, relations].join() + })); } - return warnings; + return issues; }; + validation.type = type; + validation.inputType = 'changes'; return validation; } diff --git a/modules/validations/mapcss_checks.js b/modules/validations/mapcss_checks.js deleted file mode 100644 index bf87e9cf6..000000000 --- a/modules/validations/mapcss_checks.js +++ /dev/null @@ -1,27 +0,0 @@ -import { services } from '../services'; - -export function validationMapCSSChecks() { - var validation = function(changes, graph) { - if (!services.maprules) return []; - - var rules = services.maprules.validationRules(); - var warnings = []; - var createdModified = ['created', 'modified']; - - for (var i = 0; i < rules.length; i++) { - var rule = rules[i]; - for (var j = 0; j < createdModified.length; j++) { - var type = createdModified[j]; - var entities = changes[type]; - for (var k = 0; k < entities.length; k++) { - rule.findWarnings(entities[k], graph, warnings); - } - } - } - - return warnings; - }; - - - return validation; -} diff --git a/modules/validations/maprules.js b/modules/validations/maprules.js new file mode 100644 index 000000000..23f8d43f4 --- /dev/null +++ b/modules/validations/maprules.js @@ -0,0 +1,29 @@ +import { services } from '../services'; + + +export function validationMaprules() { + var type = 'maprules'; + + + var validation = function(entity, context) { + if (!services.maprules) return []; + + var graph = context.graph(); + + var rules = services.maprules.validationRules(); + var issues = []; + + for (var i = 0; i < rules.length; i++) { + var rule = rules[i]; + rule.findIssues(entity, graph, issues); + } + + return issues; + }; + + + validation.type = type; + + + return validation; +} diff --git a/modules/validations/missing_tag.js b/modules/validations/missing_tag.js index 0f07af39c..6d472c4ae 100644 --- a/modules/validations/missing_tag.js +++ b/modules/validations/missing_tag.js @@ -1,36 +1,84 @@ import _without from 'lodash-es/without'; +import _isEmpty from 'lodash-es/isEmpty'; + +import { operationDelete } from '../operations/index'; +import { osmIsInterestingTag } from '../osm/tags'; import { t } from '../util/locale'; +import { utilDisplayLabel } from '../util'; +import { validationIssue, validationIssueFix } from '../core/validator'; export function validationMissingTag() { + var type = 'missing_tag'; - // Slightly stricter check than Entity#isUsed (#3091) - function hasTags(entity, graph) { - return _without(Object.keys(entity.tags), 'area', 'name').length > 0 || - graph.parentRelations(entity).length > 0; + + function hasDescriptiveTags(entity) { + var keys = _without(Object.keys(entity.tags), 'area', 'name').filter(osmIsInterestingTag); + if (entity.type === 'relation' && keys.length === 1) { + return entity.tags.type !== 'multipolygon'; + } + return keys.length > 0; } - var validation = function(changes, graph) { - var types = ['point', 'line', 'area', 'relation']; - var warnings = []; - for (var i = 0; i < changes.created.length; i++) { - var change = changes.created[i]; - var geometry = change.geometry(graph); + var validation = function(entity, context) { + var graph = context.graph(); - if (types.indexOf(geometry) !== -1 && !hasTags(change, graph)) { - warnings.push({ - id: 'missing_tag', - message: t('validations.untagged_' + geometry), - tooltip: t('validations.untagged_' + geometry + '_tooltip'), - entity: change - }); - } + // ignore vertex features and relation members + if (entity.geometry(graph) === 'vertex' || entity.hasParentRelations(graph)) { + return []; } - return warnings; + var messageObj = {}; + var missingTagType; + + if (_isEmpty(entity.tags)) { + missingTagType = 'any'; + } else if (!hasDescriptiveTags(entity)) { + missingTagType = 'descriptive'; + } else if (entity.type === 'relation' && !entity.tags.type) { + missingTagType = 'specific'; + messageObj.tag = 'type'; + } + + if (!missingTagType) { + return []; + } + + messageObj.feature = utilDisplayLabel(entity, context); + + var issues = []; + + issues.push(new validationIssue({ + type: type, + // error if created or modified, else warning + severity: !entity.version || entity.v ? 'error' : 'warning', + message: t('issues.missing_tag.' + missingTagType + '.message', messageObj), + tooltip: t('issues.missing_tag.tip'), + entities: [entity], + fixes: [ + new validationIssueFix({ + icon: 'iD-icon-search', + title: t('issues.fix.select_preset.title'), + onClick: function() { + context.ui().sidebar.showPresetList(); + } + }), + new validationIssueFix({ + icon: 'iD-operation-delete', + title: t('issues.fix.delete_feature.title'), + onClick: function() { + var id = this.issue.entities[0].id; + operationDelete([id], context)(); + } + }) + ] + })); + + return issues; }; + validation.type = type; return validation; } diff --git a/modules/validations/old_multipolygon.js b/modules/validations/old_multipolygon.js index 6e6c640b8..67c690e59 100644 --- a/modules/validations/old_multipolygon.js +++ b/modules/validations/old_multipolygon.js @@ -1,23 +1,62 @@ import { t } from '../util/locale'; -import { osmIsSimpleMultipolygonOuterMember } from '../osm'; + +import { actionChangeTags } from '../actions'; +import { osmIsOldMultipolygonOuterMember, osmOldMultipolygonOuterMemberOfRelation } from '../osm'; +import { utilDisplayLabel } from '../util'; +import { validationIssue, validationIssueFix } from '../core/validator'; export function validationOldMultipolygon() { + var type = 'old_multipolygon'; - return function validation(changes, graph) { - var warnings = []; - for (var i = 0; i < changes.created.length; i++) { - var entity = changes.created[i]; - var parent = osmIsSimpleMultipolygonOuterMember(entity, graph); - if (parent) { - warnings.push({ - id: 'old_multipolygon', - message: t('validations.old_multipolygon'), - tooltip: t('validations.old_multipolygon_tooltip'), - entity: parent - }); - } + + var validation = function(entity, context) { + var issues = []; + var graph = context.graph(); + + var multipolygon, outerWay; + if (entity.type === 'relation') { + outerWay = osmOldMultipolygonOuterMemberOfRelation(entity, graph); + multipolygon = entity; + } else if (entity.type === 'way') { + multipolygon = osmIsOldMultipolygonOuterMember(entity, graph); + outerWay = entity; + } else { + return issues; } - return warnings; + + if (multipolygon && outerWay) { + var multipolygonLabel = utilDisplayLabel(multipolygon, context); + issues.push(new validationIssue({ + type: type, + severity: 'warning', + message: t('issues.old_multipolygon.message', { multipolygon: multipolygonLabel }), + tooltip: t('issues.old_multipolygon.tip'), + entities: [outerWay, multipolygon], + fixes: [ + new validationIssueFix({ + title: t('issues.fix.move_tags.title'), + onClick: function() { + var outerWay = this.issue.entities[0]; + var multipolygon = this.issue.entities[1]; + context.perform( + function(graph) { + multipolygon = multipolygon.mergeTags(outerWay.tags); + graph = graph.replace(multipolygon); + graph = actionChangeTags(outerWay.id, {})(graph); + return graph; + }, + t('issues.fix.move_tags.annotation') + ); + } + }) + ] + })); + } + return issues; }; + + validation.type = type; + + return validation; } diff --git a/modules/validations/tag_suggests_area.js b/modules/validations/tag_suggests_area.js index c6f3d7d6b..1918af33d 100644 --- a/modules/validations/tag_suggests_area.js +++ b/modules/validations/tag_suggests_area.js @@ -1,48 +1,104 @@ -import _isEmpty from 'lodash-es/isEmpty'; +import _clone from 'lodash-es/clone'; + +import { actionAddVertex, actionChangeTags, actionMergeNodes } from '../actions'; +import { geoHasSelfIntersections, geoSphericalDistance } from '../geo'; import { t } from '../util/locale'; +import { utilDisplayLabel, utilTagText } from '../util'; +import { validationIssue, validationIssueFix } from '../core/validator'; -// https://github.com/openstreetmap/josm/blob/mirror/src/org/ -// openstreetmap/josm/data/validation/tests/UnclosedWays.java#L80 export function validationTagSuggestsArea() { + var type = 'tag_suggests_area'; - function tagSuggestsArea(tags) { - if (_isEmpty(tags)) return false; - var presence = ['landuse', 'amenities', 'tourism', 'shop']; - for (var i = 0; i < presence.length; i++) { - if (tags[presence[i]] !== undefined) { - if (presence[i] === 'tourism' && tags[presence[i]] === 'artwork') { - continue; // exception for tourism=artwork - #5206 - } else { - return presence[i] + '=' + tags[presence[i]]; + var validation = function(entity, context) { + if (entity.type !== 'way') return []; + + var issues = []; + var graph = context.graph(); + var tagSuggestingArea = entity.tagSuggestingArea(); + var tagSuggestsArea = !entity.isClosed() && tagSuggestingArea; + + if (tagSuggestsArea) { + var tagText = utilTagText({ tags: tagSuggestingArea }); + var fixes = []; + var nodes = graph.childNodes(entity), testNodes; + var firstToLastDistanceMeters = geoSphericalDistance(nodes[0].loc, nodes[nodes.length-1].loc); + var connectEndpointsOnClick; + + // if the distance is very small, attempt to merge the endpoints + if (firstToLastDistanceMeters < 0.75) { + testNodes = _clone(nodes); + testNodes.pop(); + testNodes.push(testNodes[0]); + // make sure this will not create a self-intersection + if (!geoHasSelfIntersections(testNodes, testNodes[0].id)) { + connectEndpointsOnClick = function() { + var way = this.issue.entities[0]; + context.perform( + actionMergeNodes([way.nodes[0], way.nodes[way.nodes.length-1]], nodes[0].loc), + t('issues.fix.connect_endpoints.annotation') + ); + }; } } - } - if (tags.building && tags.building === 'yes') return 'building=yes'; - } - - - var validation = function(changes, graph) { - var warnings = []; - for (var i = 0; i < changes.created.length; i++) { - var change = changes.created[i]; - var geometry = change.geometry(graph); - var suggestion = (geometry === 'line' ? tagSuggestsArea(change.tags) : undefined); - - if (suggestion) { - warnings.push({ - id: 'tag_suggests_area', - message: t('validations.tag_suggests_area', { tag: suggestion }), - entity: change - }); + if (!connectEndpointsOnClick) { + // if the points were not merged, attempt to close the way + testNodes = _clone(nodes); + testNodes.push(testNodes[0]); + // make sure this will not create a self-intersection + if (!geoHasSelfIntersections(testNodes, testNodes[0].id)) { + connectEndpointsOnClick = function() { + var way = this.issue.entities[0]; + var nodeId = way.nodes[0]; + var index = way.nodes.length; + context.perform( + actionAddVertex(way.id, nodeId, index), + t('issues.fix.connect_endpoints.annotation') + ); + }; + } } + + if (connectEndpointsOnClick) { + fixes.push(new validationIssueFix({ + title: t('issues.fix.connect_endpoints.title'), + onClick: connectEndpointsOnClick + })); + } + + fixes.push(new validationIssueFix({ + icon: 'iD-operation-delete', + title: t('issues.fix.remove_tag.title'), + onClick: function() { + var entity = this.issue.entities[0]; + var tags = _clone(entity.tags); + for (var key in tagSuggestingArea) { + delete tags[key]; + } + context.perform( + actionChangeTags(entity.id, tags), + t('issues.fix.remove_tag.annotation') + ); + } + })); + + var featureLabel = utilDisplayLabel(entity, context); + issues.push(new validationIssue({ + type: type, + severity: 'warning', + message: t('issues.tag_suggests_area.message', { feature: featureLabel, tag: tagText }), + tooltip: t('issues.tag_suggests_area.tip'), + entities: [entity], + fixes: fixes + })); } - return warnings; + return issues; }; + validation.type = type; return validation; } diff --git a/svg/iD-sprite/icons/icon-error.svg b/svg/iD-sprite/icons/icon-error.svg new file mode 100644 index 000000000..d6d799b23 --- /dev/null +++ b/svg/iD-sprite/icons/icon-error.svg @@ -0,0 +1,4 @@ + + + + diff --git a/svg/iD-sprite/icons/icon-wrench.svg b/svg/iD-sprite/icons/icon-wrench.svg new file mode 100644 index 000000000..0eaef0b6b --- /dev/null +++ b/svg/iD-sprite/icons/icon-wrench.svg @@ -0,0 +1,4 @@ + + + + diff --git a/svg/iD-sprite/operations/operation-continue-left.svg b/svg/iD-sprite/operations/operation-continue-left.svg new file mode 100644 index 000000000..28e35f6b9 --- /dev/null +++ b/svg/iD-sprite/operations/operation-continue-left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/test/index.html b/test/index.html index 8e78ab96d..66bf09be9 100644 --- a/test/index.html +++ b/test/index.html @@ -144,6 +144,14 @@ + + + + + + + +