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