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 @@
+
+
+
+
+
+
+
+