Themes(postboxes): add 'points_in_time' input element, add 'collection_times' to posbox theme, fix #757

This commit is contained in:
Pieter Vander Vennet 2025-07-28 01:00:07 +02:00
parent 4c0a42d5b6
commit 602c51bcb9
15 changed files with 425 additions and 68 deletions

View file

@ -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": "<h3>Collection times</h3>"
},
"special": {
"key": "collection_times",
"type": "points_in_time"
}
},
"freeform": {
"key": "collection_times",
"type": "points_in_time"
}
},
{
"builtin": "operator",
@ -193,4 +207,4 @@
"pakjes"
]
}
}
}

View file

@ -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": {

View file

@ -9157,6 +9157,12 @@
}
},
"tagRenderings": {
"collection_times": {
"question": "When is the mail collected?",
"render": {
"before": "<h3>Collection times</h3>"
}
},
"operator": {
"override": {
"question": "Who operates this postbox?",

View file

@ -142,6 +142,12 @@ export class MiscTagRenderingChecks extends DesugaringStep<TagRenderingConfigJso
) {
continue
}
if (
json.freeform["type"] === "points_in_time" &&
txt.indexOf("{points_in_time(") >= 0
) {
continue
}
const keyFirstArg = ["canonical", "fediverse_link", "translated"]
if (
keyFirstArg.some(

View file

@ -0,0 +1,53 @@
<script lang="ts">
/*
* Visualises collection times for a single collection range
*/
import { OH } from "../OpeningHours/OpeningHours"
import type { OpeningRange } from "../OpeningHours/OpeningHours"
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
export let range: OpeningRange[][]
const wt = Translations.t.general.weekdays
const weekdays: Translation[] = [wt.sunday, wt.monday, wt.tuesday, wt.wednesday, wt.thursday, wt.friday, wt.saturday]
let allTheSame = OH.weekdaysIdentical(range, 0, range.length - 1)
let today = new Date().getDay()
let dayZero = -1
for (let i = 0; i < range.length; i++) {
const moments = range[i]
if (moments.length === 0) {
continue
}
const moment = moments[0]
const day = moment.startDate.getDay()
dayZero = day - i
}
function isToday(i:number ){
i = (i+dayZero) % 7
return i === today
}
</script>
{#if allTheSame && range[0]?.length > 0}
<div class="flex justify-between">
<slot />
{#each range[0] as moment (moment)}
<div class="ml-8">{moment.startDate.toLocaleTimeString()}</div>
{/each}
</div>
{:else if dayZero >= 0 } <!-- /*If dayZero == -1, then we got no valid values at all*/ -->
{#each range as moments, i (moments)}
<div class="flex gap-x-4 justify-between w-full px-2" class:interactive={isToday(i)} class:text-bold={isToday(i)} >
<Tr t={weekdays[(i + dayZero) % 7]} />
{#if range[i].length > 0}
{#each moments as moment (moment)}
<div class="ml-8">{moment.startDate.toLocaleTimeString()}</div>
{/each}
{:else}
<Tr cls="italic subtle" t={Translations.t.general.points_in_time.closed}/>
{/if}
</div>
{/each}
{/if}

View file

@ -0,0 +1,55 @@
<script lang="ts">
import CollectionTimeRange from "./CollectionTimeRange.svelte"
import opening_hours from "opening_hours"
import { OH } from "../OpeningHours/OpeningHours"
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
export let times: opening_hours
let monday = OH.getMondayBefore(new Date())
let sunday = new Date()
sunday.setTime(monday.getTime() + 7 * 24 * 60 * 60 * 1000)
let ranges = OH.getRanges(times, monday, sunday)
let weekdays = ranges.slice(0, 5)
let weekend = ranges.slice(5, 7)
let everyDaySame = OH.weekdaysIdentical(ranges, 0, ranges.length - 1)
let weekdaysAndWeekendsSame = OH.weekdaysIdentical(weekdays, 0, 4) && OH.weekdaysIdentical(weekend, 0, 1)
const t = Translations.t.general.points_in_time
</script>
<div class="m-4 border">
{#if everyDaySame || !weekdaysAndWeekendsSame}
<CollectionTimeRange range={ranges}>
<Tr t={t.daily} />
</CollectionTimeRange>
{:else if times.isWeekStable()}
<div class="flex flex-col w-fit">
<CollectionTimeRange range={weekdays}>
<Tr t={t.weekdays} />
</CollectionTimeRange>
<CollectionTimeRange range={weekend}>
<Tr t={t.weekends} />
</CollectionTimeRange>
</div>
{:else}
{#each ranges as range (range)}
{#if range.length > 0}
<div class="flex justify-between">
{range[0].startDate.toLocaleDateString()}
<div class="flex gap-x-4">
{#each range as moment}
<div>
{moment.startDate.toLocaleTimeString()}
</div>
{/each}
</div>
</div>
{/if}
{/each}
{/if}
</div>

View file

@ -0,0 +1,48 @@
<script lang="ts">/**
* Multiple 'SingleCollectionTime'-rules togehter
*/
import { Stores, UIEventSource } from "../../../../Logic/UIEventSource"
import SingleCollectionTime from "./SingleCollectionTime.svelte"
import { Utils } from "../../../../Utils"
import { TrashIcon } from "@rgossiaux/svelte-heroicons/solid"
export let value: UIEventSource<string>
let initialRules: string[] = Utils.NoEmpty(value.data?.split(";")?.map(v => v.trim()))
let singleRules: UIEventSource<UIEventSource<string>[]> = new UIEventSource(
initialRules?.map(v => new UIEventSource(v)) ?? []
)
if(singleRules.data.length === 0){
singleRules.data.push(new UIEventSource(undefined))
}
singleRules.bindD(stores => Stores.concat(stores)).addCallbackAndRunD(subrules => {
console.log("Setting subrules", subrules)
value.set(Utils.NoEmpty(subrules).join("; "))
})
function rm(rule: UIEventSource){
const index = singleRules.data.indexOf(rule)
singleRules.data.splice(index, 1)
singleRules.ping()
}
</script>
<div class="interactive">
{#each $singleRules as rule}
<SingleCollectionTime value={rule}>
<svelte:fragment slot="right">
{#if $singleRules.length > 1}
<button on:click={() => { rm(rule) }} class="as-link">
<TrashIcon class="w-6 h-6" />
</button>
{/if}
</svelte:fragment>
</SingleCollectionTime>
{/each}
<button on:click={() => {singleRules.data.push(new UIEventSource(undefined)); singleRules.ping()}}>Add schedule
</button>
</div>

View file

@ -0,0 +1,122 @@
<script lang="ts">
import PlusCircle from "@rgossiaux/svelte-heroicons/solid/PlusCircle"
import TimeInput from "../TimeInput.svelte"
import { TrashIcon } from "@rgossiaux/svelte-heroicons/solid"
import Checkbox from "../../../Base/Checkbox.svelte"
import Tr from "../../../Base/Tr.svelte"
import { Stores, UIEventSource } from "../../../../Logic/UIEventSource"
import Translations from "../../../i18n/Translations"
import { OH } from "../../../OpeningHours/OpeningHours"
import { Utils } from "../../../../Utils"
export let value: UIEventSource<string>
const wt = Translations.t.general.weekdays.abbreviations
/*
Single rule for collection times, e.g. "Mo-Fr 10:00, 17:00"
*/
let weekdays: Translation[] = [wt.monday, wt.tuesday, wt.wednesday, wt.thursday, wt.friday, wt.saturday, wt.sunday, Translations.t.general.opening_hours.ph]
let initialTimes= Utils.NoEmpty(value.data?.split(" ")?.[1]?.split(",")?.map(s => s.trim())??[])
let values = new UIEventSource(initialTimes.map(t => new UIEventSource(t)))
if(values.data.length === 0){
values.data.push(new UIEventSource(""))
}
let daysOfTheWeek = [...OH.days, "PH"]
let selectedDays = daysOfTheWeek.map(() => new UIEventSource(false))
let initialDays = Utils.NoEmpty(value.data?.split(" ")?.[0]?.split(",") ?? [])
for (const initialDay of initialDays) {
if (initialDay.indexOf("-") > 0) {
let [start, end] = initialDay.split("-")
let startindex = daysOfTheWeek.indexOf(start)
let stopindex = daysOfTheWeek.indexOf(end)
for (let i = startindex; i <= stopindex; i++) {
selectedDays[i]?.set(true)
}
} else {
let index = daysOfTheWeek.indexOf(initialDay)
if (index >= 0) {
selectedDays[index]?.set(true)
}
}
}
let selectedDaysBound = Stores.concat(selectedDays)
.mapD(days => Utils.NoNull(days.map((selected, i) => selected ? daysOfTheWeek[i] : undefined)))
let valuesConcat: Store<string[]> = values.bindD(values => Stores.concat(values))
.mapD(values => Utils.NoEmpty(values))
valuesConcat.mapD(times => {
console.log(times)
times = Utils.NoNull(times)
if (!times || times?.length === 0) {
return undefined
}
times?.sort(/*concatted, new array*/)
return (Utils.NoEmpty(selectedDaysBound.data).join(",") + " " + times).trim()
}, [selectedDaysBound]).addCallbackAndRunD(v => value.set(v))
function selectWeekdays() {
for (let i = 0; i < 5; i++) {
selectedDays[i].set(true)
}
for (let i = 5; i < selectedDays.length; i++) {
selectedDays[i].set(false)
}
}
function clearDays() {
for (let i = 0; i < selectedDays.length; i++) {
selectedDays[i].set(false)
}
}
</script>
<div class="rounded-xl my-2 p-2 low-interaction flex w-full justify-between flex-wrap">
<div class="flex flex-col">
<div class="flex flex-wrap">
{#each $values as value, i}
<div class="flex mx-4 gap-x-1 items-center">
<TimeInput {value} />
{#if $values.length > 1}
<button class="as-link">
<TrashIcon class="w-6 h-6" />
</button>
{/if}
</div>
{/each}
<button on:click={() => {values.data.push(new UIEventSource(undefined)); values.ping()}}>
<PlusCircle class="w-6 h-6" />
Add time
</button>
</div>
<div class="flex w-fit flex-wrap">
{#each daysOfTheWeek as day, i}
<div class="w-fit">
<Checkbox selected={selectedDays[i]}>
<Tr t={weekdays[i]} />
</Checkbox>
</div>
{/each}
</div>
</div>
<div class="flex flex-wrap justify-between w-full">
<div class="flex flex-wrap gap-x-4">
<button class="as-link text-sm" on:click={() => selectWeekdays()}>Select weekdays</button>
<button class="as-link text-sm" on:click={() => clearDays()}>Clear days</button>
</div>
<slot name="right" />
</div>
</div>

View file

@ -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<string | object>
@ -42,6 +43,8 @@
<DateInput {value} />
{:else if type === "time"}
<TimeInput {value} />
{:else if type === "points_in_time"}
<CollectionTimes {value} />
{:else if type === "color"}
<ColorInput {value} />
{:else if type === "image"}

View file

@ -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
*/

View file

@ -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()

View file

@ -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")
}
}

View file

@ -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<string, string | number> & { _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,
},
},
<any>{ tag_key: "opening_hours" }
<optional_conf> {
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 = <any> [
* [
* {
@ -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++) {

View file

@ -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"

View file

@ -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<Record<string, string>>,
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<Record<string, string>>,
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<Record<string, string>>,
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<Record<string, string>>
tagSource: UIEventSource<Record<string, string>>,
): BaseUIElement {
const translation = tagSource.map((tags) => {
const layer = state.theme.getMatchingLayer(tags)
@ -251,7 +248,7 @@ class PresetTypeSelect extends SpecialVisualizationSvelte {
tags: UIEventSource<Record<string, string>>,
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<Record<string, string>>,
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<Record<string, string>>, 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(<any>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(),