From 602c51bcb94c7facc14e8b5b3990e5c49d71fd4a Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Mon, 28 Jul 2025 01:00:07 +0200 Subject: [PATCH] Themes(postboxes): add 'points_in_time' input element, add 'collection_times' to posbox theme, fix #757 --- assets/layers/postboxes/postboxes.json | 22 +++- langs/en.json | 7 + langs/layers/en.json | 6 + .../Conversion/MiscTagRenderingChecks.ts | 6 + .../CollectionTimeRange.svelte | 53 ++++++++ src/UI/CollectionTimes/CollectionTimes.svelte | 55 ++++++++ .../CollectionTimes/CollectionTimes.svelte | 48 +++++++ .../SingleCollectionTime.svelte | 122 ++++++++++++++++++ src/UI/InputElement/InputHelper.svelte | 3 + src/UI/InputElement/InputHelpers.ts | 4 +- src/UI/InputElement/Validators.ts | 3 + .../Validators/CollectionTimesValidator.ts | 7 + src/UI/OpeningHours/OpeningHours.ts | 100 +++++++------- .../Visualisation/OpeningHours.svelte | 2 +- src/UI/Popup/DataVisualisations.ts | 55 +++++--- 15 files changed, 425 insertions(+), 68 deletions(-) create mode 100644 src/UI/CollectionTimes/CollectionTimeRange.svelte create mode 100644 src/UI/CollectionTimes/CollectionTimes.svelte create mode 100644 src/UI/InputElement/Helpers/CollectionTimes/CollectionTimes.svelte create mode 100644 src/UI/InputElement/Helpers/CollectionTimes/SingleCollectionTime.svelte create mode 100644 src/UI/InputElement/Validators/CollectionTimesValidator.ts diff --git a/assets/layers/postboxes/postboxes.json b/assets/layers/postboxes/postboxes.json index 6ba6bcb22..8975a2672 100644 --- a/assets/layers/postboxes/postboxes.json +++ b/assets/layers/postboxes/postboxes.json @@ -139,10 +139,24 @@ } ], "tagRenderings": [ - "images", { - "id": "minimap", - "render": "{minimap(18): height: 5rem; overflow: hidden; border-radius:3rem; }" + "id": "collection_times", + "question": { + "en": "When is the mail collected?" + }, + "render": { + "before": { + "en": "

Collection times

" + }, + "special": { + "key": "collection_times", + "type": "points_in_time" + } + }, + "freeform": { + "key": "collection_times", + "type": "points_in_time" + } }, { "builtin": "operator", @@ -193,4 +207,4 @@ "pakjes" ] } -} \ No newline at end of file +} diff --git a/langs/en.json b/langs/en.json index a00ce8cfe..c5b3db254 100644 --- a/langs/en.json +++ b/langs/en.json @@ -353,6 +353,7 @@ "open_during_ph": "During a public holiday, it is", "open_until": "Closes at {date}", "opensAt": "from", + "ph": "Public holidays", "ph_closed": "closed", "ph_not_known": " ", "ph_open": "open", @@ -373,6 +374,12 @@ "versionInfo": "v{version} - generated on {date}" }, "pickLanguage": "Select language", + "points_in_time": { + "closed": "Closed", + "daily": "Every day", + "weekdays": "On weekdays", + "weekends": "On weekends" + }, "poweredByMapComplete": "Powered by MapComplete - crowdsourced, thematic maps with OpenStreetMap", "poweredByOsm": "Powered by OpenStreetMap", "questionBox": { diff --git a/langs/layers/en.json b/langs/layers/en.json index 369848bd8..d5c81adcf 100644 --- a/langs/layers/en.json +++ b/langs/layers/en.json @@ -9157,6 +9157,12 @@ } }, "tagRenderings": { + "collection_times": { + "question": "When is the mail collected?", + "render": { + "before": "

Collection times

" + } + }, "operator": { "override": { "question": "Who operates this postbox?", diff --git a/src/Models/ThemeConfig/Conversion/MiscTagRenderingChecks.ts b/src/Models/ThemeConfig/Conversion/MiscTagRenderingChecks.ts index 5fb5ffc97..32f79c986 100644 --- a/src/Models/ThemeConfig/Conversion/MiscTagRenderingChecks.ts +++ b/src/Models/ThemeConfig/Conversion/MiscTagRenderingChecks.ts @@ -142,6 +142,12 @@ export class MiscTagRenderingChecks extends DesugaringStep= 0 + ) { + continue + } const keyFirstArg = ["canonical", "fediverse_link", "translated"] if ( keyFirstArg.some( diff --git a/src/UI/CollectionTimes/CollectionTimeRange.svelte b/src/UI/CollectionTimes/CollectionTimeRange.svelte new file mode 100644 index 000000000..7be4b3d64 --- /dev/null +++ b/src/UI/CollectionTimes/CollectionTimeRange.svelte @@ -0,0 +1,53 @@ + + +{#if allTheSame && range[0]?.length > 0} +
+ + {#each range[0] as moment (moment)} +
{moment.startDate.toLocaleTimeString()}
+ {/each} +
+{:else if dayZero >= 0 } + {#each range as moments, i (moments)} +
+ + {#if range[i].length > 0} + {#each moments as moment (moment)} +
{moment.startDate.toLocaleTimeString()}
+ {/each} + {:else} + + {/if} +
+ {/each} +{/if} diff --git a/src/UI/CollectionTimes/CollectionTimes.svelte b/src/UI/CollectionTimes/CollectionTimes.svelte new file mode 100644 index 000000000..3bdd96afe --- /dev/null +++ b/src/UI/CollectionTimes/CollectionTimes.svelte @@ -0,0 +1,55 @@ + + + +
+ + {#if everyDaySame || !weekdaysAndWeekendsSame} + + + + {:else if times.isWeekStable()} +
+ + + + + + + + +
+ {:else} + {#each ranges as range (range)} + {#if range.length > 0} +
+ {range[0].startDate.toLocaleDateString()} +
+ {#each range as moment} +
+ {moment.startDate.toLocaleTimeString()} +
+ {/each} +
+
+ {/if} + {/each} + {/if} +
diff --git a/src/UI/InputElement/Helpers/CollectionTimes/CollectionTimes.svelte b/src/UI/InputElement/Helpers/CollectionTimes/CollectionTimes.svelte new file mode 100644 index 000000000..850c3589d --- /dev/null +++ b/src/UI/InputElement/Helpers/CollectionTimes/CollectionTimes.svelte @@ -0,0 +1,48 @@ + + +
+ + {#each $singleRules as rule} + + + {#if $singleRules.length > 1} + + + {/if} + + + {/each} + +
diff --git a/src/UI/InputElement/Helpers/CollectionTimes/SingleCollectionTime.svelte b/src/UI/InputElement/Helpers/CollectionTimes/SingleCollectionTime.svelte new file mode 100644 index 000000000..f0328841f --- /dev/null +++ b/src/UI/InputElement/Helpers/CollectionTimes/SingleCollectionTime.svelte @@ -0,0 +1,122 @@ + + +
+ +
+ +
+ {#each $values as value, i} +
+ + {#if $values.length > 1} + + {/if} +
+ {/each} + +
+
+ {#each daysOfTheWeek as day, i} +
+ + + +
+ {/each} +
+ +
+
+ +
+ + + +
+ +
+
diff --git a/src/UI/InputElement/InputHelper.svelte b/src/UI/InputElement/InputHelper.svelte index c0dddc7da..200e2afc2 100644 --- a/src/UI/InputElement/InputHelper.svelte +++ b/src/UI/InputElement/InputHelper.svelte @@ -21,6 +21,7 @@ import WikidataInputHelper from "./Helpers/WikidataInputHelper.svelte" import DistanceInput from "./Helpers/DistanceInput.svelte" import TimeInput from "./Helpers/TimeInput.svelte" + import CollectionTimes from "./Helpers/CollectionTimes/CollectionTimes.svelte" export let type: ValidatorType export let value: UIEventSource @@ -42,6 +43,8 @@ {:else if type === "time"} +{:else if type === "points_in_time"} + {:else if type === "color"} {:else if type === "image"} diff --git a/src/UI/InputElement/InputHelpers.ts b/src/UI/InputElement/InputHelpers.ts index a7d25514b..bae5aaec9 100644 --- a/src/UI/InputElement/InputHelpers.ts +++ b/src/UI/InputElement/InputHelpers.ts @@ -3,6 +3,7 @@ import { UIEventSource } from "../../Logic/UIEventSource" import { MapProperties } from "../../Models/MapProperties" import { Feature } from "geojson" import { GeoOperations } from "../../Logic/GeoOperations" +import { ValidatorType } from "./Validators" export interface InputHelperProperties { /** @@ -25,11 +26,12 @@ export interface InputHelperProperties { } export default class InputHelpers { - public static hideInputField: string[] = ["translation", "simple_tag", "tag","time"] + public static hideInputField: ValidatorType[] = ["translation", "simple_tag", "tag","time"] /** * Constructs a mapProperties-object for the given properties. * Assumes that the first helper-args contains the desired zoom-level + * Used for the 'direction' input helper * @param properties * @private */ diff --git a/src/UI/InputElement/Validators.ts b/src/UI/InputElement/Validators.ts index ce0d5945b..6c830c213 100644 --- a/src/UI/InputElement/Validators.ts +++ b/src/UI/InputElement/Validators.ts @@ -29,6 +29,7 @@ import NameSuggestionIndexValidator from "./Validators/NameSuggestionIndexValida import CurrencyValidator from "./Validators/CurrencyValidator" import RegexValidator from "./Validators/RegexValidator" import { TimeValidator } from "./Validators/TimeValidator" +import CollectionTimesValidator from "./Validators/CollectionTimesValidator" export type ValidatorType = (typeof Validators.availableTypes)[number] @@ -54,6 +55,7 @@ export default class Validators { "pfloat", "phone", "pnat", + "points_in_time", "regex", "simple_tag", "slope", @@ -97,6 +99,7 @@ export default class Validators { new NameSuggestionIndexValidator(), new CurrencyValidator(), new RegexValidator(), + new CollectionTimesValidator() ] private static _byType = Validators._byTypeConstructor() diff --git a/src/UI/InputElement/Validators/CollectionTimesValidator.ts b/src/UI/InputElement/Validators/CollectionTimesValidator.ts new file mode 100644 index 000000000..5847657c8 --- /dev/null +++ b/src/UI/InputElement/Validators/CollectionTimesValidator.ts @@ -0,0 +1,7 @@ +import StringValidator from "./StringValidator" + +export default class CollectionTimesValidator extends StringValidator{ + constructor() { + super("points_in_time", "'Points in time' are points according to a fixed schedule, e.g. 'every monday at 10:00'. They are typically used for postbox collection times or times of mass at a place of worship") + } +} diff --git a/src/UI/OpeningHours/OpeningHours.ts b/src/UI/OpeningHours/OpeningHours.ts index c4facd9ab..ab4b53d62 100644 --- a/src/UI/OpeningHours/OpeningHours.ts +++ b/src/UI/OpeningHours/OpeningHours.ts @@ -1,5 +1,5 @@ import { Utils } from "../../Utils" -import opening_hours from "opening_hours" +import opening_hours, { mode, optional_conf } from "opening_hours" import { Store } from "../../Logic/UIEventSource" import { Translation, TypedTranslation } from "../i18n/Translation" import Translations from "../i18n/Translations" @@ -25,7 +25,7 @@ export interface OpeningRange { * Various utilities manipulating opening hours */ export class OH { - private static readonly days = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] + public static readonly days = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] private static readonly daysIndexed = { mo: 0, tu: 1, @@ -570,7 +570,8 @@ changes // => [[36000,61200], ["10:00", "17:00"]] public static createOhObject( tags: Record & { _lat: number; _lon: number; _country?: string }, textToParse: string, - country: string + country: string, + mode?: mode, ) { return new opening_hours( textToParse, @@ -582,11 +583,61 @@ changes // => [[36000,61200], ["10:00", "17:00"]] state: undefined, }, }, - { tag_key: "opening_hours" } + { + tag_key: "opening_hours", + mode, + }, + ) } + /** + * Constructs the opening-ranges for either this week, or for next week if there are no more openings this week. + * Note: 'today' is mostly used for testing + * + * const oh = new opening_hours("mar 15 - oct 15") + * const ranges = OH.createRangesForApplicableWeek(oh, new Date(2025,4,20,10,0,0)) + * ranges // => {ranges: [[],[],[],[],[],[],[]], startingMonday: new Date(2025,4,18,24,0,0)} + */ + public static createRangesForApplicableWeek( + oh: opening_hours, + today?: Date, + ): { + ranges: OpeningRange[][] + startingMonday: Date + } { + today ??= new Date() + today.setHours(0, 0, 0, 0) + const lastMonday = OH.getMondayBefore(today) + const nextSunday = new Date(lastMonday) + nextSunday.setDate(nextSunday.getDate() + 7) + + if (!oh.getState() && !oh.getUnknown()) { + // POI is currently closed + const nextChange: Date = oh.getNextChange() + if ( + // Shop isn't gonna open anymore in this timerange + nextSunday < nextChange && + // And we are already in the weekend to show next week + (today.getDay() == 0 || today.getDay() == 6) + ) { + // We move the range to next week! + lastMonday.setDate(lastMonday.getDate() + 7) + nextSunday.setDate(nextSunday.getDate() + 7) + } + } + + /* We calculate the ranges when it is opened! */ + return { startingMonday: lastMonday, ranges: OH.getRanges(oh, lastMonday, nextSunday) } + } + /** + * + * Checks that the days are identical + * + * @param startday start index, inclusive + * @param endday end index, INCLUSIVE (!) + * * let ranges = [ * [ * { @@ -666,47 +717,6 @@ changes // => [[36000,61200], ["10:00", "17:00"]] * OH.weekdaysIdentical(ranges, 4, 5) // => false * */ - - /** - * Constructs the opening-ranges for either this week, or for next week if there are no more openings this week. - * Note: 'today' is mostly used for testing - * - * const oh = new opening_hours("mar 15 - oct 15") - * const ranges = OH.createRangesForApplicableWeek(oh, new Date(2025,4,20,10,0,0)) - * ranges // => {ranges: [[],[],[],[],[],[],[]], startingMonday: new Date(2025,4,18,24,0,0)} - */ - public static createRangesForApplicableWeek( - oh: opening_hours, - today?: Date - ): { - ranges: OpeningRange[][] - startingMonday: Date - } { - today ??= new Date() - today.setHours(0, 0, 0, 0) - const lastMonday = OH.getMondayBefore(today) - const nextSunday = new Date(lastMonday) - nextSunday.setDate(nextSunday.getDate() + 7) - - if (!oh.getState() && !oh.getUnknown()) { - // POI is currently closed - const nextChange: Date = oh.getNextChange() - if ( - // Shop isn't gonna open anymore in this timerange - nextSunday < nextChange && - // And we are already in the weekend to show next week - (today.getDay() == 0 || today.getDay() == 6) - ) { - // We move the range to next week! - lastMonday.setDate(lastMonday.getDate() + 7) - nextSunday.setDate(nextSunday.getDate() + 7) - } - } - - /* We calculate the ranges when it is opened! */ - return { startingMonday: lastMonday, ranges: OH.getRanges(oh, lastMonday, nextSunday) } - } - public static weekdaysIdentical(openingRanges: OpeningRange[][], startday = 0, endday = 4) { const monday = openingRanges[startday] for (let i = startday + 1; i <= endday; i++) { diff --git a/src/UI/OpeningHours/Visualisation/OpeningHours.svelte b/src/UI/OpeningHours/Visualisation/OpeningHours.svelte index 25e1eff5d..bdf41324f 100644 --- a/src/UI/OpeningHours/Visualisation/OpeningHours.svelte +++ b/src/UI/OpeningHours/Visualisation/OpeningHours.svelte @@ -4,7 +4,7 @@ */ import type { OpeningRange } from "../OpeningHours" - import { OH, ToTextualDescription } from "../OpeningHours" + import { ToTextualDescription } from "../OpeningHours" import opening_hours from "opening_hours" import { ariaLabel } from "../../../Utils/ariaLabel" import RegularOpeningHoursTable from "./RegularOpeningHoursTable.svelte" diff --git a/src/UI/Popup/DataVisualisations.ts b/src/UI/Popup/DataVisualisations.ts index fc9f6bc7d..a643209bf 100644 --- a/src/UI/Popup/DataVisualisations.ts +++ b/src/UI/Popup/DataVisualisations.ts @@ -1,8 +1,4 @@ -import { - SpecialVisualization, - SpecialVisualizationState, - SpecialVisualizationSvelte, -} from "../SpecialVisualization" +import { SpecialVisualization, SpecialVisualizationState, SpecialVisualizationSvelte } from "../SpecialVisualization" import { HistogramViz } from "./HistogramViz" import { Store, UIEventSource } from "../../Logic/UIEventSource" import { Feature } from "geojson" @@ -28,6 +24,7 @@ import TagRenderingEditable from "./TagRendering/TagRenderingEditable.svelte" import AllTagsPanel from "./AllTagsPanel/AllTagsPanel.svelte" import { FixedUiElement } from "../Base/FixedUiElement" import { TagUtils } from "../../Logic/Tags/TagUtils" +import CollectionTimes from "../CollectionTimes/CollectionTimes.svelte" class DirectionIndicatorVis extends SpecialVisualization { funcName = "direction_indicator" @@ -41,7 +38,7 @@ class DirectionIndicatorVis extends SpecialVisualization { state: SpecialVisualizationState, tagSource: UIEventSource>, argument: string[], - feature: Feature + feature: Feature, ): BaseUIElement { return new SvelteUIElement(DirectionIndicator, { state, feature }) } @@ -69,7 +66,7 @@ class DirectionAbsolute extends SpecialVisualization { constr( state: SpecialVisualizationState, tagSource: UIEventSource>, - args: string[] + args: string[], ): BaseUIElement { const key = args[0] === "" ? "_direction:centerpoint" : args[0] const offset = args[1] === "" ? 0 : Number(args[1]) @@ -82,11 +79,11 @@ class DirectionAbsolute extends SpecialVisualization { }) .mapD((value) => { const dir = GeoOperations.bearingToHuman( - GeoOperations.parseBearing(value) + offset + GeoOperations.parseBearing(value) + offset, ) console.log("Human dir", dir) return Translations.t.general.visualFeedback.directionsAbsolute[dir] - }) + }), ) } } @@ -156,7 +153,7 @@ class OpeningHoursState extends SpecialVisualizationSvelte { constr( state: SpecialVisualizationState, tags: UIEventSource>, - args: string[] + args: string[], ): SvelteUIElement { const keyToUse = args[0] const prefix = args[1] @@ -198,7 +195,7 @@ class Canonical extends SpecialVisualization { return undefined } const allUnits: Unit[] = [].concat( - ...(state?.theme?.layers?.map((lyr) => lyr.units) ?? []) + ...(state?.theme?.layers?.map((lyr) => lyr.units) ?? []), ) const unit = allUnits.filter((unit) => unit.isApplicableToKey(key))[0] if (unit === undefined) { @@ -206,7 +203,7 @@ class Canonical extends SpecialVisualization { } const getCountry = () => tagSource.data._country return unit.asHumanLongValue(value, getCountry) - }) + }), ) } } @@ -231,7 +228,7 @@ class PresetDescription extends SpecialVisualization { constr( state: SpecialVisualizationState, - tagSource: UIEventSource> + tagSource: UIEventSource>, ): BaseUIElement { const translation = tagSource.map((tags) => { const layer = state.theme.getMatchingLayer(tags) @@ -251,7 +248,7 @@ class PresetTypeSelect extends SpecialVisualizationSvelte { tags: UIEventSource>, argument: string[], selectedElement: Feature, - layer: LayerConfig + layer: LayerConfig, ): SvelteUIElement { const t = Translations.t.preset_type if (layer._basedOn !== layer.id) { @@ -312,7 +309,7 @@ class TagsVis extends SpecialVisualization { constr( state: SpecialVisualizationState, tagSource: UIEventSource>, - argument: string[] + argument: string[], ): BaseUIElement { const key = argument[0] ?? "value" return new VariableUiElement( @@ -329,14 +326,37 @@ class TagsVis extends SpecialVisualization { return parsed.asHumanString(true, false, {}) } catch (e) { return new FixedUiElement( - "Could not parse this tag: " + JSON.stringify(value) + " due to " + e + "Could not parse this tag: " + JSON.stringify(value) + " due to " + e, ).SetClass("alert") } - }) + }), ) } } +class PointsInTimeVis extends SpecialVisualization { + docs = "Creates a visualisation for 'points in time', e.g. collection times of a postbox" + group = "data" + funcName = "points_in_time" + args = [ + { + name: "key", + required: true, + doc: "The key out of which the points_in_time will be parsed", + }, + ] + + constr(state: SpecialVisualizationState, tagSource: UIEventSource>, args: string[], feature: Feature, layer: LayerConfig): BaseUIElement { + const key = args[0] + const points_in_time = tagSource.map(tags => tags[key]) + const times = points_in_time.map(times => + OH.createOhObject(tagSource.data, times, tagSource.data["_country"], 1), [tagSource]) + return new VariableUiElement(times.map(times => + new SvelteUIElement(CollectionTimes, { times }), + )) + } +} + export class DataVisualisations { public static initList(): SpecialVisualization[] { return [ @@ -346,6 +366,7 @@ export class DataVisualisations { new DirectionIndicatorVis(), new OpeningHoursTableVis(), new OpeningHoursState(), + new PointsInTimeVis(), new Canonical(), new LanguageElement(), new PresetDescription(),