Themes(benches): add script that reads the openbenches-dataset from their git repo, add (experimental) maproulette import layer to benches

This commit is contained in:
Pieter Vander Vennet 2025-10-03 04:21:28 +02:00
parent 872a97d128
commit 3ebef308d6
10 changed files with 354 additions and 31 deletions

View file

@ -21,12 +21,12 @@ Hint: MapRoulette has a button 'rebuild task', where you can first 'remove all i
**Most of the heavy lifting is done in [layer `maproulette-challenge`](./Docs/Layers/maproulette_challenge.md). Extend this layer with your needs.** **Most of the heavy lifting is done in [layer `maproulette-challenge`](./Docs/Layers/maproulette_challenge.md). Extend this layer with your needs.**
The API is shortly discussed here for future reference only. The API is shortly discussed here for future reference only.
There is an API-endpoint at `https://maproulette.org/api/v2/tasks/box/{x_min}/{y_min}/{x_max}/{y_max}` which can be used Hint: use [the maproulette theme in debug mode](https://mapcomplete.org/maproulette?debug=true) to inspect all properties.
to query _all_ tasks in a bbox and returns this as geojson. Hint:
use [the maproulette theme in debug mode](https://mapcomplete.org/maproulette?debug=true) to inspect all properties.
To view the overview a single challenge, visit `https://maproulette.org/browse/challenges/<challenge-id>` with your To view the overview a single challenge, visit `https://maproulette.org/browse/challenges/<challenge-id>` with your
browser. browser.
To get the challenge-id, visit the overview of a challenge in your browser. The URL will end with `/project/<project-id>/challenge/<challenge-id>?`. You can ignore the project-id and use the `challenge-id`. Challenge ids are unique and straight from the database, even over users and projects.
The API endpoint for a single challenge is `https://maproulette.org/api/v2/challenge/view/<challenge-id>` which returns a The API endpoint for a single challenge is `https://maproulette.org/api/v2/challenge/view/<challenge-id>` which returns a
geojson. This endpoint supports a bbox to only return a part of the challenge: `https://maproulette.org/api/v2/challenge/view/<challenge-id>?bbox=west,south,east,north` geojson. This endpoint supports a bbox to only return a part of the challenge: `https://maproulette.org/api/v2/challenge/view/<challenge-id>?bbox=west,south,east,north`

View file

@ -236,7 +236,8 @@
"nl": "Toon taken die zijn gecreëerd", "nl": "Toon taken die zijn gecreëerd",
"pl": "Pokaż zadania, które zostały stworzone" "pl": "Pokaż zadania, które zostały stworzone"
}, },
"osmTags": "mr_taskStatus=Created" "osmTags": "mr_taskStatus=Created",
"default": true
}, },
{ {
"question": { "question": {

View file

@ -81,7 +81,84 @@
"layers": [ "layers": [
"picnic_table", "picnic_table",
"bench", "bench",
"bench_at_pt" "bench_at_pt",
{
"builtin": "maproulette_challenge",
"override": {
"name=": {
"en": "Data from OpenBenches.org"
},
"minzoom": 10,
"source": {
"#": "Alternatively, if combined with `geojsonZoomLevel`, use geoJson with {y_min}, {y_max}, {x_min} and {x_max}. Only replace <id> in the next string: `https://maproulette.org/api/v2/challenge/view/<id>?bbox={x_min},{y_max},{x_max},{y_min}`",
"geoJsonZoomLevel": 12,
"idKey": "",
"geoJson": "https://maproulette.org/api/v2/challenge/view/53298?bbox={x_min},{y_max},{x_max},{y_min}"
},
"calculatedTags": [
"_id:=''+get(feat)('tags')['openbenches:id']",
"image:=get(feat)('tags')['image']",
"_osm_poi_with_this_ref=closestn(feat)('bench',25, undefined, 50).filter(f => f.feat.properties['openbenches:id'] === feat.properties['_id']).map(f => f.feat.properties.id).join(';')",
"_nearby_osm_poi=closestn(feat)('bench', 5, undefined, 15)",
"_nearby_osm_poi:count=get(feat)('_nearby_osm_poi')?.length",
"_nearby_osm_poi:props=get(feat)('_nearby_osm_poi')?.map(f => ({_distance: Math.round(f.distance), _mr_id: feat.properties.id, ...f.feat.properties}))"
],
"=tagRenderings": [
{
"id": "images",
"render": "<img src='{image}'/>"
},
{
"id": "closeness-indicator",
"condition": "_has_closeby_feature=yes",
"render": {
"en": "OpenStreetMap knows about <a href='#{_closest_osm_poi}'>a bench which is {_closest_osm_poi_distance} meter away.</a> "
}
},
{
"id": "list_nearby_pois",
"condition": {
"and": [
"mr_taskStatus=Created",
"_nearby_osm_poi:count>0"
]
},
"render": {
"before": {
"en": "Choose below which bench you want to link.",
"nl": "Kies hieronder welke bank je wilt linken."
},
"special": {
"classes": "p-2 m-1 my-4 border-2 border-dashed border-black",
"key": "_nearby_osm_poi:props",
"tagrendering": "<b><a href='#{id}'>{id}</a></b> ({_distance}m, {openbenches:id}) {minimap(17,id;_original:id)} {tag_apply($_original:tags,Link this object.,link,id,_original:id)}",
"type": "multi"
}
}
},
{
"id": "import_point",
"condition": "mr_taskStatus=Created",
"render": {
"special": {
"type": "import_button",
"targetLayer": "bench",
"tags": "$tags",
"text": {
"en": "Create a bench in OSM with the properties of openBenches.org",
"nl": "Maak een bank aan in OSM met de attributen van openBenches.org"
},
"to_point": "yes",
"maproulette_id": "mr_taskId"
}
}
},
"maproulette.controls",
"all_tags"
]
}
}
], ],
"widenFactor": 1.5 "widenFactor": 1.5
} }

View file

@ -0,0 +1,230 @@
import Script from "../Script"
import { promises as fs, writeFileSync } from "fs"
import { Feature, Point } from "geojson"
import { join } from "path"
import sqlite3, { Database } from "sqlite3"
import { open } from "sqlite"
import ScriptUtils from "../ScriptUtils"
import { Lists } from "../../src/Utils/Lists"
/**
* Note:
* npm i sqlite sqlite3
* I didn't want this into the deps
* "sqlite": "^5.1.1",
* "sqlite3": "^5.1.7",
*/
interface Bench {
benchID: number,
latitude: number,
longitude: number,
address: string,
inscription: string,
description: string,
present: 0 | 1,
published: 0 | 1,
added: string,
userID: number
}
interface User {
name: string,
providerID: string,
provider: string,
userID: number
}
interface Tag {
tagID: number,
tagText: string
}
function mediaUrl(sha: string | { "sha1": string }): string {
if (sha["sha1"]) {
sha = sha["sha1"]
}
return `https://openbenches.org/image/${sha}.jpg`
}
class Openbenches extends Script {
private db: Database
constructor() {
super("Creates the OpenBenches dataset to upload to maproulette")
}
async buildDatabase(sqlDir: string, dbFile: string) {
const db = await open({
filename: dbFile,
driver: sqlite3.Database,
})
const files = await fs.readdir(sqlDir)
const sqlFiles = files.filter(f => f.endsWith(".sql"))
const skip = ["database.sql"]
const order = ["tags", "users", "tag_map", "media_types", "benches", "media"]
for (let file of order) {
console.log("Exec file", file)
file = "openbenc_benches_table_" + file + ".sql"
let content = await fs.readFile(join(sqlDir, file), "utf-8")
content = content.replaceAll("ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", "")
.replaceAll("\\'", "''")
await db.exec(content)
}
await db.close()
console.log("DB has been seeded")
}
all<T>(query): Promise<T[]> {
return new Promise<T[]>((resolve, reject) => {
this.db.all(query, (err, rows) => {
if (err) {
reject(err)
} else {
resolve(<any>rows)
}
})
})
}
async loadDb(dbFile: string): Promise<Database> {
const db = await open({
filename: dbFile,
driver: sqlite3.Database,
})
console.log(db)
console.dir(db)
return <any>db.db
}
async createBenchInfo(benchWithUser: Bench & User, tags: string[]): Promise<Feature<Point>> {
const id = benchWithUser.benchID
const media = await this.all<{
sha1: string,
media_type: string
}>("SELECT * FROM media WHERE media.benchID = " + id)
const mediaBench = media.filter(m => m.media_type === "bench")
const mediaInscr = media.filter(m => m.media_type === "inscription")
const mediaView = media.filter(m => m.media_type === "view")
const properties = {
// added: benchWithUser.added,
"openbenches:id": id,
inscription: benchWithUser.inscription.replaceAll("\\r\\n", "\n"),
amenity: "bench",
// lastModifiedBy: benchWithUser.name,
}
let mediaMerged = Lists.dedup(mediaBench.concat(mediaInscr).map(m => mediaUrl(m)))
for (let i = 0; i < mediaMerged.length; i++) {
const m = mediaMerged[i]
if (i === 0) {
properties["image"] = m
} else {
properties["image:" + (i - 1)] = m
}
}
for (let i = 0; i < mediaView.length; i++) {
const m = mediaView[i]
if (i === 0) {
properties["image:view"] = mediaUrl(m)
} else {
properties["image:view:" + (i - 1)] = mediaUrl(m)
}
}
const tagsToProperties = {
"wooden": "material=wood",
"metal": "material=metal",
"indoors": "indoor=yes",
"stone": "material=stone",
"poem": "artwork=poem",
"statue": "artwork=statue",
"composite": "material=plastic",
/*"cat":"subject=cat",
"dog":"subject=dog" Not always a pet, sometimes also a 'dogwalker', someone mentioning their cat, ... */
// EMOJI: very broad category, basically that a little image is part of the 'inscription'. Should be handled by adding the emoji directly
// Twinned: basically, two people are remembered, often a couple -> inscription and/or subject handles this
// Picture: plaque has a little picture -> subset of plaque
// Famous: someone "famous" is remembered, although I don't know half of 'm. Too subjective for OSM
// FUnny: talk about subjective...
}
for (const tag of (tags ?? [])) {
const match = tagsToProperties[tag]
if (!match) {
continue
}
const [k, v] = match.split("=")
properties[k] = v
tags.splice(tags.indexOf(tag), 1)
}
return {
type: "Feature",
properties: { tags: JSON.stringify(properties) },
geometry: {
type: "Point",
coordinates: [benchWithUser.longitude, benchWithUser.latitude],
},
}
}
async main(args: string[]): Promise<void> {
const dbFile = "openbenches.sqlite"
let createTest = false
// rmSync(dbFile)
// await this.buildDatabase("/home/pietervdvn/git/openbenches.org/database", dbFile)
this.db = await this.loadDb(dbFile)
const tags = new Map<number, string>()
const tagRows = await this.all<Tag>("SELECT * FROM tags")
for (const tag of tagRows) {
tags.set(tag.tagID, tag.tagText)
}
const tagsOnBenches = new Map<number, string[]>()
const tagOnBench = await this.all<{ benchID: number, tagID: number }>("SELECT * from tag_map")
for (const tg of tagOnBench) {
const bench = tg.benchID
if (!tagsOnBenches.has(bench)) {
tagsOnBenches.set(bench, [])
}
tagsOnBenches.get(bench).push(tags.get(tg.tagID))
}
const r = await this.all<Bench & User>("SELECT * FROM benches INNER JOIN users ON benches.userID = users.userID")
const features: Feature<Point>[] = []
for (let i = 0; i < r.length; i++) {
const benchWithUser = r[i]
if (benchWithUser.present === 0 || benchWithUser.published === 0) {
continue
}
const tags = tagsOnBenches.get(benchWithUser.benchID)
if (i % 100 === 0) {
ScriptUtils.erasableLog(`Processing bench ${i}/${r.length} (${Math.round(100 * i / r.length)}%) `)
}
features.push(await this.createBenchInfo(benchWithUser, tags))
if (createTest && features.length > 1000) {
break
}
}
writeFileSync(`openbenches_export${createTest ? "_test" : ""}.geojson`, JSON.stringify({
type: "FeatureCollection", features,
}, null, " "), "utf-8")
//console.log(r)
}
}
new Openbenches().run()

View file

@ -471,7 +471,7 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> {
private static escapeStr(v: string, context: ConversionContext): string { private static escapeStr(v: string, context: ConversionContext): string {
if (typeof v !== "string") { if (typeof v !== "string") {
context.err("Detected a non-string value where one expected a string: " + v) context.err("Detected a non-string value where one expected a string (while rewriting a special): " + JSON.stringify(v))
return RewriteSpecial.escapeStr("" + v, context) return RewriteSpecial.escapeStr("" + v, context)
} }
return v return v

View file

@ -72,7 +72,7 @@ export class PrevalidateLayer extends DesugaringStep<LayerConfigJson> {
.enters("source", "osmTags") .enters("source", "osmTags")
.err( .err(
"The tags that will be used to load data from OpenStreetMap are all negative - this means that they all match something that _doesn't_ have a certain tag. For example, `key=` means anything without `key`. Did you perhaps mean to use `key~*`, meaning anything _with_ this key set? The tags are:\n\t" + "The tags that will be used to load data from OpenStreetMap are all negative - this means that they all match something that _doesn't_ have a certain tag. For example, `key=` means anything without `key`. Did you perhaps mean to use `key~*`, meaning anything _with_ this key set? The tags are:\n\t" +
osmTags.asHumanString(false, false, {}) osmTags.asHumanString(false)
) )
} }
} }
@ -156,6 +156,9 @@ export class PrevalidateLayer extends DesugaringStep<LayerConfigJson> {
context.err("Layer " + json.id + " does not have an explicit 'allowMove'") context.err("Layer " + json.id + " does not have an explicit 'allowMove'")
} }
} }
if(json.source["geojsonZoomLevel"]){
context.enter("source").err("Use geoJsonZoomLevel (with capital J) instead of geojsonZoomLevel")
}
if (context.hasErrors()) { if (context.hasErrors()) {
return undefined return undefined
@ -408,7 +411,7 @@ export class PrevalidateLayer extends DesugaringStep<LayerConfigJson> {
"This preset does not match the required tags of this layer. This implies that a newly added point will not show up.\n A newly created point will have properties: " + "This preset does not match the required tags of this layer. This implies that a newly added point will not show up.\n A newly created point will have properties: " +
tags.asHumanString(false, false, {}) + tags.asHumanString(false, false, {}) +
"\n The required tags are: " + "\n The required tags are: " +
baseTags.asHumanString(false, false, {}) baseTags.asHumanString(false)
) )
} }
} }

View file

@ -17,15 +17,11 @@
export let idkeys: string[] export let idkeys: string[]
export let feature: Feature export let feature: Feature
export let clss: string = "h-40 rounded" export let clss: string = "h-40 rounded"
const keys = idkeys let featuresToShow: Store<Feature<Geometry, OsmTags>[]> = state.indexedFeatures.featuresById.mapD(
let featuresToShow: Store<Feature<Geometry, OsmTags>[]> = state.indexedFeatures.featuresById.map( (featuresById: Map<string, Feature>) => {
(featuresById) => {
if (featuresById === undefined) {
return []
}
const properties = tags.data const properties = tags.data
const features: Feature<Geometry, OsmTags>[] = [] const features: Feature<Geometry, OsmTags>[] = []
for (const key of keys) { for (const key of idkeys) {
const value = properties[key] const value = properties[key]
if (value === undefined || value === null) { if (value === undefined || value === null) {
continue continue
@ -59,7 +55,7 @@
let mla = new MapLibreAdaptor(mlmap, { let mla = new MapLibreAdaptor(mlmap, {
rasterLayer: state.mapProperties.rasterLayer, rasterLayer: state.mapProperties.rasterLayer,
zoom: new UIEventSource<number>(17), zoom: new UIEventSource<number>(17),
maxzoom: new UIEventSource<number>(17), maxzoom: new UIEventSource<number>(22),
rotation: state.mapProperties.rotation.followingClone(), rotation: state.mapProperties.rotation.followingClone(),
allowRotating: state.mapProperties.allowRotating.followingClone(), allowRotating: state.mapProperties.allowRotating.followingClone(),
}) })

View file

@ -22,10 +22,12 @@
export let onApply: () => Promise<void> export let onApply: () => Promise<void>
export let state: SpecialVisualizationState export let state: SpecialVisualizationState
const t = Translations.t.general.apply_button const t = Translations.t.general.apply_button
export let maprouletteIdKey: string
// THis button might be shown on MapRoulette-items, which might already have been applied // THis button might be shown on MapRoulette-items, which might already have been applied
// This will default to 'false' for non-maproulette challenges // This will default to 'false' for non-maproulette challenges
let isMaprouletteAndApplied = let isMaprouletteAndApplied = tags?.data?.["mr_taskStatus"] !== undefined && tags?.data?.["mr_taskStatus"] !== "Created"
tags?.data?.["mr_taskStatus"] !== undefined && tags?.data?.["mr_taskStatus"] !== "Created"
let currentState: UIEventSource<"init" | "applying" | "applied"> = new UIEventSource( let currentState: UIEventSource<"init" | "applying" | "applied"> = new UIEventSource(
isMaprouletteAndApplied ? "applied" : "init" isMaprouletteAndApplied ? "applied" : "init"
@ -33,15 +35,17 @@
async function apply() { async function apply() {
currentState.set("applying") currentState.set("applying")
await onApply() window.requestIdleCallback(async () => {
currentState.set("applied") await onApply()
currentState.set("applied")
}, {timeout: 2000})
} }
</script> </script>
<LoginToggle {state} ignoreLoading> <LoginToggle {state} ignoreLoading>
{#if $currentState === "init"} {#if $currentState === "init"}
<button on:click={() => apply()}> <button on:click={() => apply()}>
<Icon icon={image} /> <Icon icon={image} clss="w-8" />
<div class="flex flex-col"> <div class="flex flex-col">
<div>{msg}</div> <div>{msg}</div>
{#if targetIdKey} {#if targetIdKey}
@ -55,6 +59,11 @@
{:else} {:else}
<TagHint tags={$tagsToApply} /> <TagHint tags={$tagsToApply} />
{/if} {/if}
{#if maprouletteIdKey}
<div class="subtle">
Closes maproulette {$tags[maprouletteIdKey]}
</div>
{/if}
</div> </div>
</button> </button>
{:else if $currentState === "applying"} {:else if $currentState === "applying"}

View file

@ -26,7 +26,7 @@ export default class TagApplyViz extends SpecialVisualization implements AutoAct
public readonly args = [ public readonly args = [
{ {
name: "tags_to_apply", name: "tags_to_apply",
doc: "A specification of the tags to apply. This is either hardcoded in the layer or the `$name` of a property containing the tags to apply. If redirected and the value of the linked property starts with `{`, the other property will be interpreted as a json object", doc: "A specification of the tags to apply. This is either hardcoded in the layer or the `$name` of a property containing the tags to apply. If redirected and the value of the linked property starts with `{`, this value will be interpreted as a json object. All the keys and values will be applied from this object",
}, },
{ {
name: "message", name: "message",
@ -68,7 +68,7 @@ export default class TagApplyViz extends SpecialVisualization implements AutoAct
const properties = JSON.parse(spec) const properties = JSON.parse(spec)
tgsSpec = [] tgsSpec = []
for (const key of Object.keys(properties)) { for (const key of Object.keys(properties)) {
tgsSpec.push([key, properties[key]]) tgsSpec.push([key, ""+ properties[key]])
} }
} else { } else {
tgsSpec = TagApplyViz.parseTagSpec(spec) tgsSpec = TagApplyViz.parseTagSpec(spec)
@ -182,10 +182,11 @@ export default class TagApplyViz extends SpecialVisualization implements AutoAct
const tagsToApply: Store<Tag[]> = TagApplyViz.generateTagsToApply(args[0], tags) const tagsToApply: Store<Tag[]> = TagApplyViz.generateTagsToApply(args[0], tags)
const msg = args[1] const msg = args[1]
let image = args[2]?.trim() let image = args[2]?.trim()
const targetIdKey = args[3]
const maprouletteId = args[4]
if (image === "" || image === "undefined") { if (image === "" || image === "undefined") {
image = undefined image = undefined
} }
const targetIdKey = args[3]
const onApply = async () => { const onApply = async () => {
await this.applyActionOn(feature, state, tags, args) await this.applyActionOn(feature, state, tags, args)
@ -199,6 +200,7 @@ export default class TagApplyViz extends SpecialVisualization implements AutoAct
image, image,
targetIdKey, targetIdKey,
onApply, onApply,
maprouletteIdKey: maprouletteId
}) })
} }
} }

View file

@ -96,7 +96,7 @@ class Multi extends SpecialVisualization {
funcName = "multi" funcName = "multi"
group = "tagrendering_manipulation" group = "tagrendering_manipulation"
docs = docs =
"Given an embedded tagRendering (read only) and a key, will read the keyname as a JSON-list. Every element of this list will be considered as tags and rendered with the tagRendering" "Given an embedded tagRendering (read only) and a key, will read `properties[key]` as a JSON-list. Every element of this list will be considered as the main 'tags' and rendered with the tagRendering. The original tags are available as _original:<key>"
example = example =
"```json\n" + "```json\n" +
JSON.stringify( JSON.stringify(
@ -139,13 +139,13 @@ class Multi extends SpecialVisualization {
const translation = new Translation({ "*": tr }) const translation = new Translation({ "*": tr })
return new VariableUiElement( return new VariableUiElement(
tags.map((tags) => { tags.map((tags) => {
let properties: object[] let propertiess: object[]
if (typeof tags[key] === "string") { if (typeof tags[key] === "string") {
properties = JSON.parse(tags[key]) propertiess = JSON.parse(tags[key])
} else { } else {
properties = <object[]>(<unknown>tags[key]) propertiess = <object[]>(<unknown>tags[key])
} }
if (!properties) { if (!propertiess) {
console.debug( console.debug(
"Could not create a special visualization for multi(", "Could not create a special visualization for multi(",
args.join(", ") + ")", args.join(", ") + ")",
@ -155,10 +155,15 @@ class Multi extends SpecialVisualization {
return undefined return undefined
} }
const elements = [] const elements = []
for (const property of properties) {
for (const properties of propertiess) {
const propertiesWithOriginal = {...properties}
for (const k in tags) {
propertiesWithOriginal["_original:"+k]=tags[k]
}
const subsTr = new SvelteUIElement(SpecialTranslation, { const subsTr = new SvelteUIElement(SpecialTranslation, {
t: translation, t: translation,
tags: new ImmutableStore(property), tags: new ImmutableStore(propertiesWithOriginal),
state, state,
feature, feature,
layer, layer,