Velopark: add first sync tool

This commit is contained in:
Pieter Vander Vennet 2024-01-13 05:24:56 +01:00
parent 300df3fb41
commit 250eede658
8 changed files with 500 additions and 4 deletions

View file

@ -668,6 +668,45 @@
]
}
},
{
"id": "operator_phone",
"question": {
"en": "What is the phone number of the operator of this bicycle parking?",
"nl": "Wat is het telefoonnummer van de operator van deze fietsenstalling?"
},
"questionHint": {
"en": "One might be able to call this number in case of problems, e.g. to remove unmaintained bicycles",
"nl": "Men kan dit nummer bellen om bv. fietswrakken of defecten te melden"
},
"icon": "./assets/layers/questions/phone.svg",
"freeform": {
"key": "operator:phone",
"type": "phone",
"addExtraTags": [
"phone=",
"contact:phone="
]
},
"render": "<a href='tel:{operator:phone}'>{operator:phone}</a>",
"mappings": [
{
"if": "phone~*",
"hideInAnswer": true,
"then": {
"*": "<a href='tel:{phone}'>{phone}</a>"
},
"icon": "./assets/layers/questions/phone.svg"
},
{
"if": "contact:phone~*",
"hideInAnswer": true,
"then": {
"*": "<a href='tel:{contact:phone}'>{contact:phone}</a>"
},
"icon": "./assets/layers/questions/phone.svg"
}
]
},
{
"question": {
"en": "Does this bicycle parking have spots for cargo bikes?",

View file

@ -63,7 +63,12 @@
"minzoom": 8
}
},
"bike_parking",
{
"builtin": ["bike_parking"],
"override": {
"minzoom": 14
}
},
{
"builtin": ["toilet","bike_repair_station","bicycle_rental"],
"override": {
@ -96,6 +101,17 @@
"text": "{ref:velopark}"
}
}
},
{
"id": "comparison_tool",
"condition": "ref:velopark~https://data.velopark.be/data/.*" ,
"render": {
"special": {
"type": "compare_data",
"url": "ref:velopark",
"postprocessing": "velopark"
}
}
}
]
}

View file

@ -0,0 +1,175 @@
import { Feature, Point } from "geojson"
import { OH } from "../../UI/OpeningHours/OpeningHours"
import EmailValidator from "../../UI/InputElement/Validators/EmailValidator"
import PhoneValidator from "../../UI/InputElement/Validators/PhoneValidator"
import { CountryCoder } from "latlon2country"
import Constants from "../../Models/Constants"
import { Utils } from "../../Utils"
/**
* Commissioned code, to be kept until 2030
*
* Reads a velopark-json, converts it to a geojson
*/
export default class VeloparkLoader {
private static readonly emailReformatting = new EmailValidator()
private static readonly phoneValidator = new PhoneValidator()
private static readonly coder = new CountryCoder(
Constants.countryCoderEndpoint,
Utils.downloadJson
)
public static convert(veloparkData: VeloparkData): Feature<Point> {
const properties: {
"operator:email"?: string,
"operator:phone"?: string,
fee?: string,
opening_hours?: string
access?: string
maxstay?: string
operator?: string
} = {}
properties.operator = veloparkData.operatedBy?.companyName
if (veloparkData.contactPoint?.email) {
properties["operator:email"] = VeloparkLoader.emailReformatting.reformat(veloparkData.contactPoint?.email)
}
if (veloparkData.contactPoint?.telephone) {
properties["operator:phone"] = VeloparkLoader.phoneValidator.reformat(veloparkData.contactPoint?.telephone, () => "be")
}
veloparkData.photos.forEach((p, i) => {
if (i === 0) {
properties["image"] = p.image
} else {
properties["image:" + i] = p.image
}
})
let coordinates: [number, number] = undefined
for (const g of veloparkData["@graph"]) {
coordinates = [g.geo[0].longitude, g.geo[0].latitude]
if (g.maximumParkingDuration?.endsWith("D") && g.maximumParkingDuration?.startsWith("P")) {
const duration = g.maximumParkingDuration.substring(1, g.maximumParkingDuration.length - 1)
properties.maxstay = duration + " days"
}
properties.access = g.publicAccess ? "yes" : "no"
const prefix = "http://schema.org/"
const oh = OH.simplify(g.openingHoursSpecification.map(spec => {
const dayOfWeek = spec.dayOfWeek.substring(prefix.length, prefix.length + 2).toLowerCase()
const startHour = spec.opens
const endHour = spec.closes === "23:59" ? "24:00" : spec.closes
const merged = OH.MergeTimes(OH.ParseRule(dayOfWeek + " " + startHour + "-" + endHour))
return OH.ToString(merged)
}).join("; "))
properties.opening_hours = oh
if (g.priceSpecification[0]) {
properties.fee = g.priceSpecification[0].freeOfCharge ? "no" : "yes"
}
}
return { type: "Feature", properties, geometry: { type: "Point", coordinates } }
}
}
interface VeloparkData {
"@context": any,
"@id": string // "https://data.velopark.be/data/NMBS_541",
"@type": "BicycleParkingStation",
"dateModified": string,
"identifier": number,
"name": [
{
"@value": string,
"@language": "nl"
}
],
"ownedBy": {
"@id": string,
"@type": "BusinessEntity",
"companyName": string
},
"operatedBy": {
"@type": "BusinessEntity",
"companyName": string
},
"address": any,
"hasMap": any,
"contactPoint": {
"@type": "ContactPoint",
"email": string,
"telephone": string
},
"photos": {
"@type": "Photograph",
"image": string
}[],
"interactionService": {
"@type": "WebSite",
"url": string
},
/**
* Contains various extra pieces of data, e.g. services or opening hours
*/
"@graph": [
{
"@type": "https://data.velopark.be/openvelopark/terms#PublicBicycleParking",
"openingHoursSpecification": {
"@type": "OpeningHoursSpecification",
/**
* Ends with 'Monday', 'Tuesday', ...
*/
"dayOfWeek": "http://schema.org/Monday"
| "http://schema.org/Tuesday"
| "http://schema.org/Wednesday"
| "http://schema.org/Thursday"
| "http://schema.org/Friday"
| "http://schema.org/Saturday"
| "http://schema.org/Sunday",
/**
* opens: 00:00 and closes 23:59 for the entire day
*/
"opens": string,
"closes": string
}[],
/**
* P30D = 30 days
*/
"maximumParkingDuration": "P30D",
"publicAccess": true,
"totalCapacity": 110,
"allows": [
{
"@type": "AllowedBicycle",
/* TODO is cargo bikes etc also available?*/
"bicycleType": "https://data.velopark.be/openvelopark/terms#RegularBicycle",
"bicyclesAmount": number
}
],
"geo": [
{
"@type": "GeoCoordinates",
"latitude": number,
"longitude": number
}
],
"priceSpecification": [
{
"@type": "PriceSpecification",
"freeOfCharge": boolean
}
]
}
]
}

View file

@ -0,0 +1,65 @@
<script lang="ts">
import { UIEventSource } from "../../Logic/UIEventSource"
import type { OsmTags } from "../../Models/OsmFeature"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import type { Feature } from "geojson"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction"
import { Tag } from "../../Logic/Tags/Tag"
import Loading from "../Base/Loading.svelte"
export let key: string
export let externalProperties: Record<string, string>
export let tags: UIEventSource<OsmTags>
export let state: SpecialVisualizationState
export let feature: Feature
export let layer: LayerConfig
let currentStep: "init" | "applying" | "done" = "init"
/**
* Copy the given key into OSM
* @param key
*/
async function apply(key: string) {
currentStep = "applying"
const change = new ChangeTagAction(
tags.data.id,
new Tag(key, externalProperties[key]),
tags.data,
{
theme: state.layout.id,
changeType: "import",
})
await state.changes.applyChanges(await change.CreateChangeDescriptions())
currentStep = "done"
}
</script>
<tr>
<td><b>{key}</b></td>
<td>
{#if externalProperties[key].startsWith("http")}
<a href={externalProperties[key]} target="_blank">
{externalProperties[key]}
</a>
{:else}
{externalProperties[key]}
{/if}
</td>
<td>
{#if currentStep === "init"}
<button class="small" on:click={() => apply(key)}>
Apply
</button>
{:else if currentStep === "applying"}
<Loading />
{:else if currentStep === "done"}
<div class="thanks">Done</div>
{:else }
<div class="alert">Error</div>
{/if}
</td>
</tr>

View file

@ -0,0 +1,120 @@
<script lang="ts">
import LinkableImage from "../Image/LinkableImage.svelte"
import { UIEventSource } from "../../Logic/UIEventSource"
import type { OsmTags } from "../../Models/OsmFeature"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import type { P4CPicture } from "../../Logic/Web/NearbyImagesSearch"
import type { Feature } from "geojson"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import ComparisonAction from "./ComparisonAction.svelte"
import Party from "../../assets/svg/Party.svelte"
import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction"
import { Tag } from "../../Logic/Tags/Tag"
import { And } from "../../Logic/Tags/And"
import Loading from "../Base/Loading.svelte"
export let osmProperties: Record<string, string>
export let externalProperties: Record<string, string>
export let tags: UIEventSource<OsmTags>
export let state: SpecialVisualizationState
export let image: P4CPicture
export let feature: Feature
export let layer: LayerConfig
let externalKeys: string[] = (Object.keys(externalProperties))
.sort()
const imageKeyRegex = /image|image:[0-9]+/
console.log("Calculating knwon images")
let knownImages = new Set(Object.keys(osmProperties).filter(k => k.match(imageKeyRegex))
.map(k => osmProperties[k]))
console.log("Known images are:", knownImages)
let unknownImages = externalKeys.filter(k => k.match(imageKeyRegex))
.map(k => externalProperties[k])
.filter(i => !knownImages.has(i))
let propertyKeysExternal = externalKeys.filter(k => k.match(imageKeyRegex) === null)
let missing = propertyKeysExternal.filter(k => osmProperties[k] === undefined)
let same = propertyKeysExternal.filter(key => osmProperties[key] === externalProperties[key])
let different = propertyKeysExternal.filter(key => osmProperties[key] !== undefined && osmProperties[key] !== externalProperties[key])
let currentStep: "init" | "applying_all" | "all_applied" = "init"
async function applyAllMissing() {
currentStep = "applying_all"
const tagsToApply = missing.map(k => new Tag(k, externalProperties[k]))
const change = new ChangeTagAction(
tags.data.id,
new And(tagsToApply),
tags.data,
{
theme: state.layout.id,
changeType: "import",
})
await state.changes.applyChanges(await change.CreateChangeDescriptions())
currentStep = "all_applied"
}
</script>
{#if different.length > 0}
<h3>Conflicting items</h3>
{JSON.stringify(different)}
{/if}
{#if missing.length > 0}
<h3>Missing items</h3>
{#if currentStep === "init"}
<table class="w-full">
<tr>
<th>Key</th>
<th>External</th>
</tr>
{#each missing as key}
<ComparisonAction {key} {state} {tags} {externalProperties} {layer} {feature} />
{/each}
</table>
<button on:click={() => applyAllMissing()}>Apply all missing values</button>
{:else if currentStep === "applying_all"}
<Loading>Applying all missing values</Loading>
{:else if currentStep === "all_applied"}
<div class="thanks">
All values are applied
</div>
{/if}
{/if}
{#if unknownImages.length === 0 && missing.length === 0 && different.length === 0}
<div class="thanks flex items-center gap-x-2 px-2 m-0">
<Party class="w-8 h-8" />
All data from Velopark is also included into OpenStreetMap
</div>
{/if}
{#if unknownImages.length > 0}
<h3>Missing pictures</h3>
{#each unknownImages as image}
<LinkableImage
{tags}
{state}
image={{
pictureUrl: image,
provider: "Velopark",
thumbUrl: image,
details: undefined,
coordinates: undefined,
osmTags : {image}
} }
{feature}
{layer} />
{/each}
{/if}

View file

@ -0,0 +1,61 @@
<script lang="ts">/**
* The comparison tool loads json-data from a speficied URL, eventually post-processes it
* and compares it with the current object
*/
import { onMount } from "svelte"
import { Utils } from "../../Utils"
import VeloparkLoader from "../../Logic/Web/VeloparkLoader"
import Loading from "../Base/Loading.svelte"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import { UIEventSource } from "../../Logic/UIEventSource"
import ComparisonTable from "./ComparisonTable.svelte"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import type { Feature } from "geojson"
export let url: string
export let postprocessVelopark: boolean
export let state: SpecialVisualizationState
export let tags: UIEventSource<Record<string, string>>
export let layer: LayerConfig
export let feature: Feature
let data: any = undefined
let error: any = undefined
onMount(async () => {
const _url = tags.data[url]
if (!_url) {
error = "No URL found in attribute" + url
}
try {
console.log("Attempting to download", _url)
const downloaded = await Utils.downloadJsonAdvanced(_url)
if (downloaded["error"]) {
console.error(downloaded)
error = downloaded["error"]
return
}
if (postprocessVelopark) {
data = VeloparkLoader.convert(downloaded["content"])
return
}
data = downloaded["content"]
} catch (e) {
console.error(e)
error = "" + e
}
})
</script>
{#if error !== undefined}
<div class="alert">
Something went wrong: {error}
</div>
{:else if data === undefined}
<Loading>
Loading {$tags[url]}
</Loading>
{:else}
<ComparisonTable externalProperties={data.properties} osmProperties={$tags} {state} {feature} {layer} {tags}/>
{/if}

View file

@ -15,8 +15,6 @@
import SpecialTranslation from "../Popup/TagRendering/SpecialTranslation.svelte"
export let tags: UIEventSource<OsmTags>
export let lon: number
export let lat: number
export let state: SpecialVisualizationState
export let image: P4CPicture
export let feature: Feature
@ -26,7 +24,6 @@
let isLinked = Object.values(tags.data).some((v) => image.pictureUrl === v)
const t = Translations.t.image.nearby
const c = [lon, lat]
const providedImage: ProvidedImage = {
url: image.thumbUrl ?? image.pictureUrl,
url_hd: image.pictureUrl,

View file

@ -88,6 +88,7 @@ import MaprouletteSetStatus from "./MapRoulette/MaprouletteSetStatus.svelte"
import DirectionIndicator from "./Base/DirectionIndicator.svelte"
import Img from "./Base/Img"
import Qr from "../Utils/Qr"
import ComparisonTool from "./Comparison/ComparisonTool.svelte"
class NearbyImageVis implements SpecialVisualization {
// Class must be in SpecialVisualisations due to weird cyclical import that breaks the tests
@ -1638,6 +1639,28 @@ export default class SpecialVisualizations {
)
},
},
{
funcName: "compare_data",
needsUrls: (args) => args[0],
args:[
{
name: "url",
required: true,
doc: "The attribute containing the url where to fetch more data"
},
{
name: "postprocessing",
required: false,
doc: "Apply some postprocessing. Currently, only 'velopark' is allowed as value"
}
],
docs: "Gives an interactive element which shows a tag comparison between the OSM-object and the upstream object. This allows to copy some or all tags into OSM",
constr(state: SpecialVisualizationState, tagSource: UIEventSource<Record<string, string>>, args: string[], feature: Feature, layer: LayerConfig): BaseUIElement {
const url = args[0]
const postprocessVelopark = args[1] === "velopark"
return new SvelteUIElement(ComparisonTool, {url, postprocessVelopark, state, tags: tagSource, layer, feature})
}
}
]
specialVisualizations.push(new AutoApplyButton(specialVisualizations))