Velopark: add first sync tool
This commit is contained in:
parent
300df3fb41
commit
250eede658
8 changed files with 500 additions and 4 deletions
|
@ -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?",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
175
src/Logic/Web/VeloparkLoader.ts
Normal file
175
src/Logic/Web/VeloparkLoader.ts
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
}
|
65
src/UI/Comparison/ComparisonAction.svelte
Normal file
65
src/UI/Comparison/ComparisonAction.svelte
Normal 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>
|
120
src/UI/Comparison/ComparisonTable.svelte
Normal file
120
src/UI/Comparison/ComparisonTable.svelte
Normal 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}
|
||||
|
61
src/UI/Comparison/ComparisonTool.svelte
Normal file
61
src/UI/Comparison/ComparisonTool.svelte
Normal 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}
|
|
@ -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,
|
||||
|
|
|
@ -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))
|
||||
|
|
Loading…
Reference in a new issue