diff --git a/Docs/SpecialRenderings.md b/Docs/SpecialRenderings.md index f22383ee8..da2ff0dbb 100644 --- a/Docs/SpecialRenderings.md +++ b/Docs/SpecialRenderings.md @@ -349,11 +349,12 @@ snap_onto_layers | _undefined_ | If a way of the given layer(s) is closeby, will max_snap_distance | 5 | The maximum distance that the imported point will be moved to snap onto a way in an already existing layer (in meters). This is previewed to the contributor, similar to the 'add new point'-action of MapComplete note_id | _undefined_ | If given, this key will be read. The corresponding note on OSM will be closed, stating 'imported' location_picker | photo | Chooses the background for the precise location picker, options are 'map', 'photo' or 'osmbasedmap' or 'none' if the precise input picker should be disabled +maproulette_id | _undefined_ | If given, the maproulette challenge will be marked as fixed #### Example usage of import_button - `{import_button(,,Import this data into OpenStreetMap,./assets/svg/addSmall.svg,,5,,photo)}` + `{import_button(,,Import this data into OpenStreetMap,./assets/svg/addSmall.svg,,5,,photo,)}` diff --git a/Logic/FeatureSource/Sources/GeoJsonSource.ts b/Logic/FeatureSource/Sources/GeoJsonSource.ts index 1443760ca..3c4e9142d 100644 --- a/Logic/FeatureSource/Sources/GeoJsonSource.ts +++ b/Logic/FeatureSource/Sources/GeoJsonSource.ts @@ -82,6 +82,28 @@ export default class GeoJsonSource implements FeatureSourceForLayer, Tiled { Utils.downloadJsonCached(url, 60 * 60) .then(json => { self.state.setData("loaded") + // TODO: move somewhere else, just for testing + // Check for maproulette data + if (url.startsWith("https://maproulette.org/api/v2/tasks/box/")) { + console.log("MapRoulette data detected") + const data = json; + let maprouletteFeatures: any[] = []; + data.forEach(element => { + maprouletteFeatures.push({ + type: "Feature", + geometry: { + type: "Point", + coordinates: [element.point.lng, element.point.lat] + }, + properties: { + // Map all properties to the feature + ...element, + } + }); + }); + json.features = maprouletteFeatures; + } + if (json.features === undefined || json.features === null) { return; } diff --git a/Logic/Maproulette.ts b/Logic/Maproulette.ts new file mode 100644 index 000000000..470bb75d8 --- /dev/null +++ b/Logic/Maproulette.ts @@ -0,0 +1,39 @@ +import Constants from "../Models/Constants"; + +export default class Maproulette { + /** + * The API endpoint to use + */ + endpoint: string; + + /** + * The API key to use for all requests + */ + private apiKey: string; + + /** + * Creates a new Maproulette instance + * @param endpoint The API endpoint to use + */ + constructor(endpoint: string = "https://maproulette.org/api/v2") { + this.endpoint = endpoint; + this.apiKey = Constants.MaprouletteApiKey; + } + + /** + * Close a task + * @param taskId The task to close + */ + async closeTask(taskId: number): Promise { + const response = await fetch(`${this.endpoint}/task/${taskId}/1`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + "apiKey": this.apiKey, + }, + }); + if (response.status !== 304) { + console.log(`Failed to close task: ${response.status}`); + } + } +} diff --git a/Logic/State/UserRelatedState.ts b/Logic/State/UserRelatedState.ts index 8fbd9c5df..0fc43ebc8 100644 --- a/Logic/State/UserRelatedState.ts +++ b/Logic/State/UserRelatedState.ts @@ -13,6 +13,7 @@ import ChangeToElementsActor from "../Actors/ChangeToElementsActor"; import PendingChangesUploader from "../Actors/PendingChangesUploader"; import * as translators from "../../assets/translators.json" import {post} from "jquery"; +import Maproulette from "../Maproulette"; /** * The part of the state which keeps track of user-related stuff, e.g. the OSM-connection, @@ -34,6 +35,11 @@ export default class UserRelatedState extends ElementsState { */ public mangroveIdentity: MangroveIdentity; + /** + * Maproulette connection + */ + public maprouletteConnection: Maproulette; + public readonly isTranslator : Store; public readonly installedUserThemes: Store @@ -80,6 +86,8 @@ export default class UserRelatedState extends ElementsState { this.osmConnection.GetLongPreference("identity", "mangrove") ); + this.maprouletteConnection = new Maproulette(); + if (layoutToUse?.hideFromOverview) { this.osmConnection.isLoggedIn.addCallbackAndRunD(loggedIn => { if (loggedIn) { diff --git a/Models/Constants.ts b/Models/Constants.ts index a5b463120..2590a9872 100644 --- a/Models/Constants.ts +++ b/Models/Constants.ts @@ -7,6 +7,15 @@ export default class Constants { public static ImgurApiKey = '7070e7167f0a25a' public static readonly mapillary_client_token_v4 = "MLY|4441509239301885|b40ad2d3ea105435bd40c7e76993ae85" + /** + * API key for Maproulette + * + * Currently there is no user-friendly way to get the user's API key. + * See https://github.com/maproulette/maproulette2/issues/476 for more information. + * Using an empty string however does work for most actions, but will attribute all actions to the Superuser. + */ + public static readonly MaprouletteApiKey = ""; + public static defaultOverpassUrls = [ // The official instance, 10000 queries per day per project allowed "https://overpass-api.de/api/interpreter", diff --git a/UI/Popup/ImportButton.ts b/UI/Popup/ImportButton.ts index e72d60129..cdf299e7d 100644 --- a/UI/Popup/ImportButton.ts +++ b/UI/Popup/ImportButton.ts @@ -550,15 +550,21 @@ export class ImportPointButton extends AbstractImportButton { name: "note_id", doc: "If given, this key will be read. The corresponding note on OSM will be closed, stating 'imported'" }, - {name:"location_picker", + { + name:"location_picker", defaultValue: "photo", - doc: "Chooses the background for the precise location picker, options are 'map', 'photo' or 'osmbasedmap' or 'none' if the precise input picker should be disabled"}], + doc: "Chooses the background for the precise location picker, options are 'map', 'photo' or 'osmbasedmap' or 'none' if the precise input picker should be disabled" + }, + { + name: "maproulette_id", + doc: "If given, the maproulette challenge will be marked as fixed" + }], { showRemovedTags: false} ) } private static createConfirmPanelForPoint( - args: { max_snap_distance: string, snap_onto_layers: string, icon: string, text: string, newTags: UIEventSource, targetLayer: string, note_id: string }, + args: { max_snap_distance: string, snap_onto_layers: string, icon: string, text: string, newTags: UIEventSource, targetLayer: string, note_id: string, maproulette_id: string }, state: FeaturePipelineState, guiState: DefaultGuiState, originalFeatureTags: UIEventSource, @@ -600,6 +606,19 @@ export class ImportPointButton extends AbstractImportButton { originalFeatureTags.data["closed_at"] = new Date().toISOString() originalFeatureTags.ping() } + + let maproulette_id = originalFeatureTags.data[args.maproulette_id]; + console.log("Checking if we need to mark a maproulette task as fixed (" + maproulette_id + ")") + if (maproulette_id !== undefined) { + if (state.featureSwitchIsTesting.data){ + console.log("Not marking maproulette task " + maproulette_id + " as fixed, because we are in testing mode") + } else { + console.log("Marking maproulette task as fixed") + state.maprouletteConnection.closeTask(Number(maproulette_id)); + originalFeatureTags.data["mr_taskStatus"] = "Fixed"; + originalFeatureTags.ping(); + } + } } let preciseInputOption = args["location_picker"] diff --git a/UI/Popup/TagApplyButton.ts b/UI/Popup/TagApplyButton.ts index 0a654895b..7ccca7ca0 100644 --- a/UI/Popup/TagApplyButton.ts +++ b/UI/Popup/TagApplyButton.ts @@ -42,6 +42,13 @@ export default class TagApplyButton implements AutoAction { public static generateTagsToApply(spec: string, tagSource: Store): Store { + // Check whether we need to look up a single value + + if (!spec.includes(";") && !spec.includes("=") && spec.includes("$")){ + // We seem to be dealing with a single value, fetch it + spec = tagSource.data[spec.replace("$","")] + } + const tgsSpec = spec.split(";").map(spec => { const kv = spec.split("=").map(s => s.trim()); if (kv.length != 2) { diff --git a/UI/SpecialVisualizations.ts b/UI/SpecialVisualizations.ts index f2f0f93ef..a2c929107 100644 --- a/UI/SpecialVisualizations.ts +++ b/UI/SpecialVisualizations.ts @@ -1,4 +1,4 @@ -import {Store, UIEventSource} from "../Logic/UIEventSource"; +import {Store, Stores, UIEventSource} from "../Logic/UIEventSource"; import {VariableUiElement} from "./Base/VariableUIElement"; import LiveQueryHandler from "../Logic/Web/LiveQueryHandler"; import {ImageCarousel} from "./Image/ImageCarousel"; @@ -57,8 +57,9 @@ import {SaveButton} from "./Popup/SaveButton"; import {MapillaryLink} from "./BigComponents/MapillaryLink"; import {CheckBox} from "./Input/Checkboxes"; import Slider from "./Input/Slider"; -import {OsmFeature} from "../Models/OsmFeature"; +import List from "./Base/List"; import StatisticsPanel from "./BigComponents/StatisticsPanel"; +import { OsmFeature } from "../Models/OsmFeature"; export interface SpecialVisualization { funcName: string, @@ -1100,6 +1101,39 @@ export default class SpecialVisualizations { }, new NearbyImageVis(), new MapillaryLinkVis(), + { + funcName: "maproulette_task", + args: [], + constr(state, tagSource, argument, guistate) { + let parentId = tagSource.data.mr_challengeId; + let challenge = Stores.FromPromise(Utils.downloadJsonCached(`https://maproulette.org/api/v2/challenge/${parentId}`,24*60*60*1000)); + + let details = new VariableUiElement( challenge.map(challenge => { + let listItems: BaseUIElement[] = []; + let title: BaseUIElement; + + if (challenge?.name) { + title = new Title(challenge.name); + } + + if (challenge?.description) { + listItems.push(new FixedUiElement(challenge.description)); + } + + if (challenge?.instruction) { + listItems.push(new FixedUiElement(challenge.instruction)); + } + + if(listItems.length === 0) { + return undefined; + } else { + return [title, new List(listItems)]; + } + })) + return details; + }, + docs: "Show details of a MapRoulette task" + }, { funcName: "statistics", docs: "Show general statistics about the elements currently in view. Intended to use on the `current_view`-layer", diff --git a/assets/layers/maproulette/license_info.json b/assets/layers/maproulette/license_info.json new file mode 100644 index 000000000..f59ad3208 --- /dev/null +++ b/assets/layers/maproulette/license_info.json @@ -0,0 +1,12 @@ +[ + { + "path": "logomark.svg", + "license": "MIT", + "authors": [ + "MapRoulette" + ], + "sources": [ + "https://github.com/maproulette/docs/blob/master/src/assets/svg/logo.svg" + ] + } +] \ No newline at end of file diff --git a/assets/layers/maproulette/logomark.svg b/assets/layers/maproulette/logomark.svg new file mode 100644 index 000000000..0019edeae --- /dev/null +++ b/assets/layers/maproulette/logomark.svg @@ -0,0 +1,77 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/assets/layers/maproulette/maproulette.json b/assets/layers/maproulette/maproulette.json new file mode 100644 index 000000000..bcac88c47 --- /dev/null +++ b/assets/layers/maproulette/maproulette.json @@ -0,0 +1,225 @@ +{ + "id": "maproulette", + "source": { + "geoJson": "https://maproulette.org/api/v2/tasks/box/{x_min}/{y_min}/{x_max}/{y_max}", + "geoJsonZoomLevel": 16, + "osmTags": "id~*" + }, + "mapRendering": [ + { + "location": [ + "point", + "centroid" + ], + "icon": { + "render": "./assets/layers/maproulette/logomark.svg", + "mappings": [ + { + "if": "status=0", + "then": "pin:#959DFF" + }, + { + "if": "status=1", + "then": "pin:#65D2DA" + }, + { + "if": "status=2", + "then": "pin:#F7BB59" + }, + { + "if": "status=3", + "then": "pin:#F7BB59" + }, + { + "if": "status=4", + "then": "pin:#737373" + }, + { + "if": "status=5", + "then": "pin:#CCB186" + }, + { + "if": "status=6", + "then": "pin:#FF5E63" + }, + { + "if": "status=9", + "then": "pin:#FF349C" + } + ] + }, + "iconSize": "40,40,center" + } + ], + "tagRenderings": [ + { + "id": "status", + "render": "Current status: {status}", + "mappings": [ + { + "if": "status=0", + "then": { + "en": "Task is created" + } + }, + { + "if": "status=1", + "then": { + "en": "Task is fixed" + } + }, + { + "if": "status=2", + "then": { + "en": "Task is a false positive" + } + }, + { + "if": "status=3", + "then": { + "en": "Task is skipped" + } + }, + { + "if": "status=4", + "then": { + "en": "Task is deleted" + } + }, + { + "if": "status=5", + "then": { + "en": "Task is already fixed" + } + }, + { + "if": "status=6", + "then": { + "en": "Task is marked as too hard" + } + }, + { + "if": "status=9", + "then": { + "en": "Task is disabled" + } + } + ] + }, + { + "id": "blurb", + "condition": "blurb~*", + "render": "{blurb}" + } + ], + "description": { + "en": "Layer showing all tasks in MapRoulette" + }, + "minzoom": 15, + "name": { + "en": "MapRoulette Tasks" + }, + "title": { + "render": { + "en": "MapRoulette Item: {parentName}" + } + }, + "titleIcons": [ + { + "id": "maproulette", + "render": "" + } + ], + "filter": [ + { + "id": "status", + "options": [ + { + "question": { + "en": "Show tasks with all statuses" + } + }, + { + "question": { + "en": "Show tasks that are created" + }, + "osmTags": "status=0" + }, + { + "question": { + "en": "Show tasks that are fixed" + }, + "osmTags": "status=1" + }, + { + "question": { + "en": "Show tasks that are false positives" + }, + "osmTags": "status=2" + }, + { + "question": { + "en": "Show tasks that are skipped" + }, + "osmTags": "status=3" + }, + { + "question": { + "en": "Show tasks that are deleted" + }, + "osmTags": "status=4" + }, + { + "question": { + "en": "Show tasks that are already fixed" + }, + "osmTags": "status=5" + }, + { + "question": { + "en": "Show tasks that are marked as too hard" + }, + "osmTags": "status=6" + }, + { + "question": { + "en": "Show tasks that are disabled" + }, + "osmTags": "status=9" + } + ] + }, + { + "id": "parent-name", + "options": [ + { + "osmTags": "parentName~i~.*{search}.*", + "fields": [ + { + "name": "search" + } + ], + "question": { + "en": "Challenge name contains {search}" + } + } + ] + }, + { + "id": "parent-id", + "options": [ + { + "osmTags": "parentId={search}", + "fields": [ + { + "name": "search" + } + ], + "question": { + "en": "Challenge ID matches {search}" + } + } + ] + } + ] +} \ No newline at end of file diff --git a/assets/layers/maproulette_challenge/maproulette_challenge.json b/assets/layers/maproulette_challenge/maproulette_challenge.json new file mode 100644 index 000000000..5e1385e74 --- /dev/null +++ b/assets/layers/maproulette_challenge/maproulette_challenge.json @@ -0,0 +1,194 @@ +{ + "id": "maproulette_challenge", + "name": null, + "description": { + "en": "Layer showing tasks of a MapRoulette challenge" + }, + "source": { + "osmTags": "id~*", + "geoJson": "https://maproulette.org/api/v2/challenge/view/27971", + "isOsmCache": false + }, + "title": { + "render": { + "en": "Item in MapRoulette" + } + }, + "titleIcons": [ + { + "id": "maproulette", + "render": "" + } + ], + "mapRendering": [ + { + "location": [ + "point", + "centroid" + ], + "icon": { + "render": "./assets/layers/maproulette/logomark.svg", + "mappings": [ + { + "if": "mr_taskStatus=Created", + "then": "pin:#959DFF" + }, + { + "if": "mr_taskStatus=Fixed", + "then": "pin:#65D2DA" + }, + { + "if": "mr_taskStatus=False positive", + "then": "pin:#F7BB59" + }, + { + "if": "mr_taskStatus=Skipped", + "then": "pin:#F7BB59" + }, + { + "if": "mr_taskStatus=Deleted", + "then": "pin:#737373" + }, + { + "if": "mr_taskStatus=Already fixed", + "then": "pin:#CCB186" + }, + { + "if": "mr_taskStatus=Too hard", + "then": "pin:#FF5E63" + }, + { + "if": "mr_taskStatus=Disabled", + "then": "pin:#FF349C" + } + ] + }, + "iconSize": "40,40,bottom" + } + ], + "tagRenderings": [ + { + "id": "details", + "render": "{maproulette_task()}" + }, + { + "id": "status", + "render": "Current status: {status}", + "mappings": [ + { + "if": "mr_taskStatus=Created", + "then": { + "en": "Task is created" + } + }, + { + "if": "mr_taskStatus=Fixed", + "then": { + "en": "Task is fixed" + } + }, + { + "if": "mr_taskStatus=False positive", + "then": { + "en": "Task is a false positive" + } + }, + { + "if": "mr_taskStatus=Skipped", + "then": { + "en": "Task is skipped" + } + }, + { + "if": "mr_taskStatus=Deleted", + "then": { + "en": "Task is deleted" + } + }, + { + "if": "mr_taskStatus=Already fixed", + "then": { + "en": "Task is already fixed" + } + }, + { + "if": "mr_taskStatus=Too hard", + "then": { + "en": "Task is marked as too hard" + } + }, + { + "if": "mr_taskStatus=Disabled", + "then": { + "en": "Task is disabled" + } + } + ] + }, + { + "id": "blurb", + "condition": "blurb~*", + "render": "{blurb}" + } + ], + "filter": [ + { + "id": "status", + "options": [ + { + "question": { + "en": "Show tasks with all statuses" + } + }, + { + "question": { + "en": "Show tasks that are created" + }, + "osmTags": "mr_taskStatus=Created" + }, + { + "question": { + "en": "Show tasks that are fixed" + }, + "osmTags": "mr_taskStatus=Fixed" + }, + { + "question": { + "en": "Show tasks that are false positives" + }, + "osmTags": "mr_taskStatus=False positive" + }, + { + "question": { + "en": "Show tasks that are skipped" + }, + "osmTags": "mr_taskStatus=Skipped" + }, + { + "question": { + "en": "Show tasks that are deleted" + }, + "osmTags": "mr_taskStatus=Deleted" + }, + { + "question": { + "en": "Show tasks that are already fixed" + }, + "osmTags": "mr_taskStatus=Already fixed" + }, + { + "question": { + "en": "Show tasks that are marked as too hard" + }, + "osmTags": "mr_taskStatus=Too hard" + }, + { + "question": { + "en": "Show tasks that are disabled" + }, + "osmTags": "mr_taskStatus=Disabled" + } + ] + } + ] +} \ No newline at end of file diff --git a/assets/layers/waste_basket/waste_basket.json b/assets/layers/waste_basket/waste_basket.json index b694e52b9..d7c8f53a9 100644 --- a/assets/layers/waste_basket/waste_basket.json +++ b/assets/layers/waste_basket/waste_basket.json @@ -303,7 +303,7 @@ }, "allowMove": { "enableRelocation": false, - "enableImproveAccuraccy": true + "enableImproveAccuracy": true }, "mapRendering": [ { diff --git a/assets/themes/mapcomplete-changes/mapcomplete-changes.json b/assets/themes/mapcomplete-changes/mapcomplete-changes.json index f745ceede..aa5e2aabd 100644 --- a/assets/themes/mapcomplete-changes/mapcomplete-changes.json +++ b/assets/themes/mapcomplete-changes/mapcomplete-changes.json @@ -246,6 +246,10 @@ "if": "theme=mapcomplete-changes", "then": "./assets/svg/logo.svg" }, + { + "if": "theme=maproulette", + "then": "./assets/layers/maproulette/logomark.svg" + }, { "if": "theme=maps", "then": "./assets/themes/maps/logo.svg" diff --git a/assets/themes/maproulette/maproulette.json b/assets/themes/maproulette/maproulette.json new file mode 100644 index 000000000..9c0ca8f7d --- /dev/null +++ b/assets/themes/maproulette/maproulette.json @@ -0,0 +1,19 @@ +{ + "id": "maproulette", + "title": { + "en": "MapRoulette Tasks" + }, + "description": { + "en": "Theme showing MapRoulette tasks, allowing you to search, filter and fix them." + }, + "version": "1.0.0", + "hideFromOverview": true, + "icon": "./assets/layers/maproulette/logomark.svg", + "maintainer": "", + "startLat": 0, + "startLon": 0, + "startZoom": 4, + "layers": [ + "maproulette" + ] +} \ No newline at end of file diff --git a/assets/themes/onwheels/onwheels.json b/assets/themes/onwheels/onwheels.json index c7fe1fb1e..9991c8ab2 100644 --- a/assets/themes/onwheels/onwheels.json +++ b/assets/themes/onwheels/onwheels.json @@ -20,7 +20,7 @@ "widenFactor": 2, "hideFromOverview": false, "layers": [ - { + { "builtin": "indoors", "override": { "minzoom": 19, @@ -366,6 +366,53 @@ } ] } + }, + { + "builtin": "maproulette_challenge", + "override": { + "source": { + "geoJson": "https://maproulette.org/api/v2/challenge/view/28012" + }, + "calculatedTags": [ + "_closest_osm_hotel=feat.closest('hotel')?.properties?.id", + "_closest_osm_hotel_distance=feat.distanceTo(feat.properties._closest_osm_hotel)", + "_has_closeby_feature=Number(feat.properties._closest_osm_hotel_distance) < 50 ? 'yes' : 'no'" + ], + "+tagRenderings": [ + { + "id": "import-button", + "condition": "_has_closeby_feature=no", + "render": { + "special": { + "type": "import_button", + "targetLayer": "hotel", + "tags": "tags", + "text": { + "en": "Import" + }, + "icon": "./assets/svg/addSmall.svg", + "location_picker": "photo", + "maproulette_id": "mr_taskId" + } + } + }, + { + "id": "tag-apply-button", + "condition": "_has_closeby_feature=yes", + "render": { + "special": { + "type": "tag_apply", + "tags_to_apply": "$tags", + "message": { + "en": "Add all the suggested tags" + }, + "image": "./assets/svg/addSmall.svg", + "id_of_object_to_apply_this_one": "_closest_osm_hotel" + } + } + } + ] + } } ], "overrideAll": { diff --git a/assets/themes/street_lighting/street_lighting_assen.json b/assets/themes/street_lighting/street_lighting_assen.json index 646a82550..ce7e0e485 100644 --- a/assets/themes/street_lighting/street_lighting_assen.json +++ b/assets/themes/street_lighting/street_lighting_assen.json @@ -51,6 +51,22 @@ "tagRenderings": [ "all_tags" ] + }, + { + "builtin": "maproulette_challenge", + "override": { + "calculatedTags": [ + "_closest_osm_street_lamp=feat.closest('street_lamps')?.properties?.id", + "_closest_osm_street_lamp_distance=feat.distanceTo(feat.properties._closest_osm_street_lamp)", + "_has_closeby_feature=Number(feat.properties._closest_osm_street_lamp_distance) < 5 ? 'yes' : 'no'" + ], + "tagRenderings+": [ + { + "id": "import", + "render": "{import_button(street_lamps,tags,Import,./assets/svg/addSmall.svg,,,,photo,mr_taskId)}" + } + ] + } } ], "hideFromOverview": true diff --git a/langs/layers/en.json b/langs/layers/en.json index b4cee7deb..52fe48d14 100644 --- a/langs/layers/en.json +++ b/langs/layers/en.json @@ -4464,6 +4464,159 @@ "render": "Map" } }, + "maproulette": { + "description": "Layer showing all tasks in MapRoulette", + "filter": { + "0": { + "options": { + "0": { + "question": "Show tasks with all statuses" + }, + "1": { + "question": "Show tasks that are created" + }, + "2": { + "question": "Show tasks that are fixed" + }, + "3": { + "question": "Show tasks that are false positives" + }, + "4": { + "question": "Show tasks that are skipped" + }, + "5": { + "question": "Show tasks that are deleted" + }, + "6": { + "question": "Show tasks that are already fixed" + }, + "7": { + "question": "Show tasks that are marked as too hard" + }, + "8": { + "question": "Show tasks that are disabled" + } + } + }, + "1": { + "options": { + "0": { + "question": "Challenge name contains {search}" + } + } + }, + "2": { + "options": { + "0": { + "question": "Challenge ID matches {search}" + } + } + } + }, + "name": "MapRoulette Tasks", + "tagRenderings": { + "status": { + "mappings": { + "0": { + "then": "Task is created" + }, + "1": { + "then": "Task is fixed" + }, + "2": { + "then": "Task is a false positive" + }, + "3": { + "then": "Task is skipped" + }, + "4": { + "then": "Task is deleted" + }, + "5": { + "then": "Task is already fixed" + }, + "6": { + "then": "Task is marked as too hard" + }, + "7": { + "then": "Task is disabled" + } + } + } + }, + "title": { + "render": "MapRoulette Item: {parentName}" + } + }, + "maproulette_challenge": { + "description": "Layer showing tasks of a MapRoulette challenge", + "filter": { + "0": { + "options": { + "0": { + "question": "Show tasks with all statuses" + }, + "1": { + "question": "Show tasks that are created" + }, + "2": { + "question": "Show tasks that are fixed" + }, + "3": { + "question": "Show tasks that are false positives" + }, + "4": { + "question": "Show tasks that are skipped" + }, + "5": { + "question": "Show tasks that are deleted" + }, + "6": { + "question": "Show tasks that are already fixed" + }, + "7": { + "question": "Show tasks that are marked as too hard" + }, + "8": { + "question": "Show tasks that are disabled" + } + } + } + }, + "tagRenderings": { + "status": { + "mappings": { + "0": { + "then": "Task is created" + }, + "1": { + "then": "Task is fixed" + }, + "2": { + "then": "Task is a false positive" + }, + "3": { + "then": "Task is skipped" + }, + "4": { + "then": "Task is deleted" + }, + "5": { + "then": "Task is already fixed" + }, + "6": { + "then": "Task is marked as too hard" + }, + "7": { + "then": "Task is disabled" + } + } + } + }, + "title": { + "render": "Item in MapRoulette" + } + }, "maxspeed": { "description": "Shows the allowed speed for every road", "name": "Maxspeed", diff --git a/langs/themes/en.json b/langs/themes/en.json index 2f0ba3940..54a18b374 100644 --- a/langs/themes/en.json +++ b/langs/themes/en.json @@ -725,6 +725,10 @@ "shortDescription": "Shows changes made by MapComplete", "title": "Changes made with MapComplete" }, + "maproulette": { + "description": "Theme showing MapRoulette tasks, allowing you to search, filter and fix them.", + "title": "MapRoulette Tasks" + }, "maps": { "description": "On this map you can find all maps OpenStreetMap knows - typically a big map on an information board showing the area, city or region, e.g. a tourist map on the back of a billboard, a map of a nature reserve, a map of cycling networks in the region, ...)

If a map is missing, you can easily map this map on OpenStreetMap.", "shortDescription": "This theme shows all (touristic) maps that OpenStreetMap knows of", diff --git a/package-lock.json b/package-lock.json index 3f9406d4c..233d6353c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@turf/length": "^6.5.0", "@turf/turf": "^6.5.0", "@types/chai": "^4.3.0", + "@types/geojson": "^7946.0.10", "@types/jquery": "^3.5.5", "@types/leaflet-markercluster": "^1.0.3", "@types/leaflet-providers": "^1.2.0", @@ -3224,9 +3225,9 @@ "integrity": "sha512-/ceqdqeRraGolFTcfoXNiqjyQhZzbINDngeoAq9GoHa8PPK1yNzTaxWjA6BFWp5Ua9JpXEMSS4s5i9tS0hOJtw==" }, "node_modules/@types/geojson": { - "version": "7946.0.8", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz", - "integrity": "sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA==" + "version": "7946.0.10", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz", + "integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==" }, "node_modules/@types/jquery": { "version": "3.5.5", @@ -7176,6 +7177,11 @@ "rbush": "^3.0.1" } }, + "node_modules/geojson-rbush/node_modules/@types/geojson": { + "version": "7946.0.8", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz", + "integrity": "sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA==" + }, "node_modules/geojson-rbush/node_modules/quickselect": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", @@ -19255,9 +19261,9 @@ "integrity": "sha512-/ceqdqeRraGolFTcfoXNiqjyQhZzbINDngeoAq9GoHa8PPK1yNzTaxWjA6BFWp5Ua9JpXEMSS4s5i9tS0hOJtw==" }, "@types/geojson": { - "version": "7946.0.8", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz", - "integrity": "sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA==" + "version": "7946.0.10", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz", + "integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==" }, "@types/jquery": { "version": "3.5.5", @@ -22428,6 +22434,11 @@ "rbush": "^3.0.1" }, "dependencies": { + "@types/geojson": { + "version": "7946.0.8", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz", + "integrity": "sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA==" + }, "quickselect": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", diff --git a/package.json b/package.json index 3df2d4ed9..6796cb623 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "@turf/length": "^6.5.0", "@turf/turf": "^6.5.0", "@types/chai": "^4.3.0", + "@types/geojson": "^7946.0.10", "@types/jquery": "^3.5.5", "@types/leaflet-markercluster": "^1.0.3", "@types/leaflet-providers": "^1.2.0", diff --git a/scripts/onwheels/constants.ts b/scripts/onwheels/constants.ts new file mode 100644 index 000000000..dbe0cbd36 --- /dev/null +++ b/scripts/onwheels/constants.ts @@ -0,0 +1,102 @@ +/** + * Class containing all constants and tables used in the script + * + * @class Constants + */ +export default class Constants { + /** + * Table used to determine tags for the category + * + * Keys are the original category names, + * values are an object containing the tags + */ + public static categories = { + restaurant: { + amenity: "restaurant", + }, + parking: { + amenity: "parking", + }, + hotel: { + tourism: "hotel", + }, + wc: { + amenity: "toilets", + }, + winkel: { + shop: "yes", + }, + apotheek: { + amenity: "pharmacy", + healthcare: "pharmacy", + }, + ziekenhuis: { + amenity: "hospital", + healthcare: "hospital", + }, + bezienswaardigheid: { + tourism: "attraction", + }, + ontspanning: { + fixme: "Needs proper tags", + }, + cafe: { + amenity: "cafe", + }, + dienst: { + fixme: "Needs proper tags", + }, + bank: { + amenity: "bank", + }, + gas: { + amenity: "fuel", + }, + medical: { + fixme: "Needs proper tags", + }, + obstacle: { + fixme: "Needs proper tags", + }, + }; + + /** + * Table used to rename original Onwheels properties to their corresponding OSM properties + * + * Keys are the original Onwheels properties, values are the corresponding OSM properties + */ + public static names = { + ID: "id", + Naam: "name", + Straat: "addr:street", + Nummer: "addr:housenumber", + Postcode: "addr:postcode", + Plaats: "addr:city", + Website: "website", + Email: "email", + "Aantal aangepaste parkeerplaatsen": "capacity:disabled", + "Aantal treden": "step_count", + "Hellend vlak aanwezig": "ramp", + "Baby verzorging aanwezig": "changing_table", + "Totale hoogte van de treden": "kerb:height", + "Deurbreedte": "door:width", + }; + + /** + * In some cases types might need to be converted as well + * + * Keys are the OSM properties, values are the wanted type + */ + public static types = { + "Hellend vlak aanwezig": "boolean", + "Baby verzorging aanwezig": "boolean", + }; + + /** + * Some tags also need to have units added + */ + public static units = { + "Totale hoogte van de treden": "cm", + "Deurbreedte": "cm", + }; +} diff --git a/scripts/onwheels/convertData.ts b/scripts/onwheels/convertData.ts new file mode 100644 index 000000000..4183d1cd7 --- /dev/null +++ b/scripts/onwheels/convertData.ts @@ -0,0 +1,234 @@ +import { parse } from "csv-parse/sync"; +import { readFileSync, writeFileSync } from "fs"; +import { Feature, FeatureCollection, GeoJsonProperties } from "geojson"; +import Constants from "./constants"; + +/** + * Function to determine the tags for a category + * + * @param category The category of the item + * @returns List of tags for the category + */ +function categoryTags(category: string): GeoJsonProperties { + const tags = { + tags: Object.keys(Constants.categories[category]).map((tag) => { + return `${tag}=${Constants.categories[category][tag]}`; + }), + }; + if (!tags) { + throw `Unknown category: ${category}`; + } + return tags; +} + +/** + * Rename tags to match the OSM standard + * + * @param item The item to convert + * @returns GeoJsonProperties for the item + */ +function renameTags(item): GeoJsonProperties { + const properties: GeoJsonProperties = {}; + properties.tags = []; + // Loop through the original item tags + for (const key in item) { + // Check if we need it and it's not a null value + if (Constants.names[key] && item[key]) { + // Name and id tags need to be in the properties + if (Constants.names[key] == "name" || Constants.names[key] == "id") { + properties[Constants.names[key]] = item[key]; + } + // Other tags need to be in the tags variable + if (Constants.names[key] !== "id") { + // Website needs to have at least any = encoded + if(Constants.names[key] == "website") { + let website = item[key]; + // Encode URL + website = website.replace("=", "%3D"); + item[key] = website; + } + properties.tags.push(Constants.names[key] + "=" + item[key]); + } + } + } + return properties; +} + +/** + * Convert types to match the OSM standard + * + * @param properties The properties to convert + * @returns The converted properties + */ +function convertTypes(properties: GeoJsonProperties): GeoJsonProperties { + // Split the tags into a list + let tags = properties.tags.split(";"); + + for (const tag in tags) { + // Split the tag into key and value + const key = tags[tag].split("=")[0]; + const value = tags[tag].split("=")[1]; + const originalKey = Object.keys(Constants.names).find( + (tag) => Constants.names[tag] === key + ); + + if (Constants.types[originalKey]) { + // We need to convert the value to the correct type + let newValue; + switch (Constants.types[originalKey]) { + case "boolean": + newValue = value === "1" ? "yes" : "no"; + break; + default: + newValue = value; + break; + } + tags[tag] = `${key}=${newValue}`; + } + } + + // Rejoin the tags + properties.tags = tags.join(";"); + + // Return the properties + return properties; +} + +/** + * Function to add units to the properties if necessary + * + * @param properties The properties to add units to + * @returns The properties with units added + */ +function addUnits(properties: GeoJsonProperties): GeoJsonProperties { + // Split the tags into a list + let tags = properties.tags.split(";"); + + for (const tag in tags) { + const key = tags[tag].split("=")[0]; + const value = tags[tag].split("=")[1]; + const originalKey = Object.keys(Constants.names).find( + (tag) => Constants.names[tag] === key + ); + + // Check if the property needs units, and doesn't already have them + if (Constants.units[originalKey] && value.match(/.*([A-z]).*/gi) === null) { + tags[tag] = `${key}=${value} ${Constants.units[originalKey]}`; + } + } + + // Rejoin the tags + properties.tags = tags.join(";"); + + // Return the properties + return properties; +} + +/** + * Function that adds Maproulette instructions and blurb to each item + * + * @param properties The properties to add Maproulette tags to + * @param item The original CSV item + */ +function addMaprouletteTags(properties: GeoJsonProperties, item: any): GeoJsonProperties { + properties[ + "blurb" + ] = `This is feature out of the ${item["Categorie"]} category. + It may match another OSM item, if so, you can add any missing tags to it. + If it doesn't match any other OSM item, you can create a new one. + Here is a list of tags that can be added: + ${properties["tags"].split(";").join("\n")} + You can also easily import this item using MapComplete: https://mapcomplete.osm.be/onwheels.html#${properties["id"]}`; + return properties; +} + +/** + * Main function to convert original CSV into GeoJSON + * + * @param args List of arguments [input.csv] + */ +function main(args: string[]): void { + const csvOptions = { + columns: true, + skip_empty_lines: true, + trim: true, + }; + const file = args[0]; + const output = args[1]; + + // Create an empty list to store the converted features + var items: Feature[] = []; + + // Read CSV file + const csv: Record[] = parse(readFileSync(file), csvOptions); + + // Loop through all the entries + for (var i = 0; i < csv.length; i++) { + const item = csv[i]; + + // Determine coordinates + const lat = Number(item["Latitude"]); + const lon = Number(item["Longitude"]); + + // Check if coordinates are valid + if (isNaN(lat) || isNaN(lon)) { + throw `Not a valid lat or lon for entry ${i}: ${JSON.stringify(item)}`; + } + + // Create a new collection to store the converted properties + var properties: GeoJsonProperties = {}; + + // Add standard tags for category + const category = item["Categorie"]; + const tagsCategory = categoryTags(category); + + // Add the rest of the needed tags + properties = { ...properties, ...renameTags(item) }; + + // Merge them together + properties.tags = [...tagsCategory.tags, ...properties.tags]; + properties.tags = properties.tags.join(";"); + + // Convert types + properties = convertTypes(properties); + + // Add units if necessary + properties = addUnits(properties); + + // Add Maproulette tags + properties = addMaprouletteTags(properties, item); + + // Create the new feature + const feature: Feature = { + type: "Feature", + id: item["ID"], + geometry: { + type: "Point", + coordinates: [lon, lat], + }, + properties, + }; + + // Push it to the list we created earlier + items.push(feature); + } + + // Make a FeatureCollection out of it + const featureCollection: FeatureCollection = { + type: "FeatureCollection", + features: items, + }; + + // Write the data to a file or output to the console + if (output) { + writeFileSync( + `${output}.geojson`, + JSON.stringify(featureCollection, null, 2) + ); + } else { + console.log(JSON.stringify(featureCollection)); + } +} + +// Execute the main function, with the stripped arguments +main(process.argv.slice(2)); diff --git a/test/Models/ThemeConfig/Conversion/PrepareLayer.spec.ts b/test/Models/ThemeConfig/Conversion/PrepareLayer.spec.ts index d395401be..116006a62 100644 --- a/test/Models/ThemeConfig/Conversion/PrepareLayer.spec.ts +++ b/test/Models/ThemeConfig/Conversion/PrepareLayer.spec.ts @@ -123,7 +123,7 @@ describe('RewriteSpecial', function () { const r = new RewriteSpecial().convert(tr, "test").result expect(r).to.deep.eq({ "id": "uk_addresses_import_button", - "render": {'*': "{import_button(address,urpn_count=$urpn_count;ref:GB:uprn=$ref:GB:uprn$,Add this address,./assets/themes/uk_addresses/housenumber_add.svg,,,,none)}"} + "render": {'*': "{import_button(address,urpn_count=$urpn_count;ref:GB:uprn=$ref:GB:uprn$,Add this address,./assets/themes/uk_addresses/housenumber_add.svg,,,,none,)}"} }) }) });