diff --git a/assets/layers/bike_parking/bike_parking.json b/assets/layers/bike_parking/bike_parking.json index 77b74f3b7..62e4dc5d0 100644 --- a/assets/layers/bike_parking/bike_parking.json +++ b/assets/layers/bike_parking/bike_parking.json @@ -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": "{operator:phone}", + "mappings": [ + { + "if": "phone~*", + "hideInAnswer": true, + "then": { + "*": "{phone}" + }, + "icon": "./assets/layers/questions/phone.svg" + }, + { + "if": "contact:phone~*", + "hideInAnswer": true, + "then": { + "*": "{contact:phone}" + }, + "icon": "./assets/layers/questions/phone.svg" + } + ] + }, { "question": { "en": "Does this bicycle parking have spots for cargo bikes?", diff --git a/assets/themes/velopark/velopark.json b/assets/themes/velopark/velopark.json index a275f503c..32ddf3d9b 100644 --- a/assets/themes/velopark/velopark.json +++ b/assets/themes/velopark/velopark.json @@ -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" + } + } } ] } diff --git a/src/Logic/Web/VeloparkLoader.ts b/src/Logic/Web/VeloparkLoader.ts new file mode 100644 index 000000000..91ea8899b --- /dev/null +++ b/src/Logic/Web/VeloparkLoader.ts @@ -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 { + + 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 + } + ] + } + ] + +} diff --git a/src/UI/Comparison/ComparisonAction.svelte b/src/UI/Comparison/ComparisonAction.svelte new file mode 100644 index 000000000..b16f216d4 --- /dev/null +++ b/src/UI/Comparison/ComparisonAction.svelte @@ -0,0 +1,65 @@ + + + + {key} + + + {#if externalProperties[key].startsWith("http")} + + {externalProperties[key]} + + {:else} + {externalProperties[key]} + {/if} + + + {#if currentStep === "init"} + + {:else if currentStep === "applying"} + + {:else if currentStep === "done"} +
Done
+ {:else } +
Error
+ {/if} + + diff --git a/src/UI/Comparison/ComparisonTable.svelte b/src/UI/Comparison/ComparisonTable.svelte new file mode 100644 index 000000000..f6a8758f6 --- /dev/null +++ b/src/UI/Comparison/ComparisonTable.svelte @@ -0,0 +1,120 @@ + + +{#if different.length > 0} +

Conflicting items

+ {JSON.stringify(different)} +{/if} + +{#if missing.length > 0} +

Missing items

+ + {#if currentStep === "init"} + + + + + + + {#each missing as key} + + {/each} + +
KeyExternal
+ + {:else if currentStep === "applying_all"} + Applying all missing values + {:else if currentStep === "all_applied"} +
+ All values are applied +
+ {/if} +{/if} + + +{#if unknownImages.length === 0 && missing.length === 0 && different.length === 0} +
+ + All data from Velopark is also included into OpenStreetMap +
+{/if} + +{#if unknownImages.length > 0} +

Missing pictures

+ {#each unknownImages as image} + + {/each} + + +{/if} + diff --git a/src/UI/Comparison/ComparisonTool.svelte b/src/UI/Comparison/ComparisonTool.svelte new file mode 100644 index 000000000..92652aec8 --- /dev/null +++ b/src/UI/Comparison/ComparisonTool.svelte @@ -0,0 +1,61 @@ + + + +{#if error !== undefined} +
+ Something went wrong: {error} +
+{:else if data === undefined} + + Loading {$tags[url]} + +{:else} + +{/if} diff --git a/src/UI/Image/LinkableImage.svelte b/src/UI/Image/LinkableImage.svelte index 572cc6776..8675cc8a4 100644 --- a/src/UI/Image/LinkableImage.svelte +++ b/src/UI/Image/LinkableImage.svelte @@ -15,8 +15,6 @@ import SpecialTranslation from "../Popup/TagRendering/SpecialTranslation.svelte" export let tags: UIEventSource - 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, diff --git a/src/UI/SpecialVisualizations.ts b/src/UI/SpecialVisualizations.ts index 6bf55e99f..207362620 100644 --- a/src/UI/SpecialVisualizations.ts +++ b/src/UI/SpecialVisualizations.ts @@ -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>, 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))