Refactoring: port opening hours visualisation to svelte

This commit is contained in:
Pieter Vander Vennet 2025-06-03 02:12:51 +02:00
parent 3b2c2462c5
commit cc96df94e9
12 changed files with 290 additions and 285 deletions

View file

@ -1,22 +0,0 @@
import Combine from "./Combine"
import Translations from "../i18n/Translations"
import BaseUIElement from "../BaseUIElement"
import SvelteUIElement from "./SvelteUIElement"
import { default as LoadingSvg } from "../../assets/svg/Loading.svelte"
/**
* @deprecated
*/
export default class Loading extends Combine {
constructor(msg?: BaseUIElement | string) {
const t = Translations.W(msg) ?? Translations.t.general.loading
t.SetClass("pl-2")
super([
new SvelteUIElement(LoadingSvg)
.SetClass("animate-spin self-center")
.SetStyle("width: 1.5rem; height: 1.5rem; min-width: 1.5rem;"),
t,
])
this.SetClass("flex p-1")
}
}

View file

@ -2,8 +2,8 @@
import { createEventDispatcher } from "svelte"
import { twJoin } from "tailwind-merge"
import { Translation } from "../i18n/Translation"
import { ariaLabel, ariaLabelStore } from "../../Utils/ariaLabel"
import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource"
import { ariaLabelStore } from "../../Utils/ariaLabel"
import { ImmutableStore, Store } from "../../Logic/UIEventSource"
/**
* A round button with an icon and possible a small text, which hovers above the map

View file

@ -5,7 +5,6 @@
import { Translation } from "../i18n/Translation"
import WeblateLink from "./WeblateLink.svelte"
import { Store } from "../../Logic/UIEventSource"
import FromHtml from "./FromHtml.svelte"
import { Utils } from "../../Utils"
export let t: Translation

View file

@ -1,8 +1,9 @@
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { Store } from "../../Logic/UIEventSource"
import BaseUIElement from "../BaseUIElement"
import { VariableUiElement } from "../Base/VariableUIElement"
/**
* @deprecated
* The 'Toggle' is a UIElement showing either one of two elements, depending on the state.
* It can be used to implement e.g. checkboxes or collapsible elements
*/

View file

@ -967,6 +967,52 @@ changes // => [[36000,61200], ["10:00", "17:00"]]
}
return oh
}
/**
*
* @param changeHours number of seconds 'till the start of the day, assuming sorted
* @param changeHourText
* @param maxDiff minimum required seconds between two items to be in the same group
*
* OH.partitionOHForDistance([0, 15, 3615], ["start", "15s", "1h15s"]) // => [{changeHours: [0, 3615], changeTexts: ["start", "1h15s"]}, {changeHours: [15], changeTexts: ["15 seconds"]}}]
*
*/
public static partitionOHForDistance(changeHours: number[], changeHourText: string[], maxDiff = 3600): {
changeHours: number[],
changeTexts: string[]
}[] {
const partitionedHours: { changeHours: number[], changeTexts: string[] }[] = [
{ changeHours: [changeHours[0]], changeTexts: [changeHourText[0]] }
]
for (let i = 1 /*skip the first one, inited ^*/; i < changeHours.length; i++) {
const moment = changeHours[i]
const text = changeHourText[i]
let depth = 0
while (depth < partitionedHours.length) {
const candidate = partitionedHours[depth]
const lastMoment = candidate.changeHours.at(-1)
const diff = moment - lastMoment
if (diff >= maxDiff) {
candidate.changeHours.push(moment)
candidate.changeTexts.push(text)
break
}
depth++
}
if (depth == partitionedHours.length) {
// No candidate found - make a new list
partitionedHours.push({
changeTexts: [text],
changeHours: [moment]
})
}
}
return partitionedHours
}
}
export class ToTextualDescription {

View file

@ -1,255 +0,0 @@
import { UIEventSource } from "../../Logic/UIEventSource"
import Combine from "../Base/Combine"
import { FixedUiElement } from "../Base/FixedUiElement"
import { OH, OpeningRange, ToTextualDescription } from "./OpeningHours"
import Translations from "../i18n/Translations"
import BaseUIElement from "../BaseUIElement"
import Toggle from "../Input/Toggle"
import { VariableUiElement } from "../Base/VariableUIElement"
import Table from "../Base/Table"
import { Translation } from "../i18n/Translation"
import Loading from "../Base/Loading"
import opening_hours from "opening_hours"
import Locale from "../i18n/Locale"
import SpecialCase from "./Visualisation/SpecialCase.svelte"
import SvelteUIElement from "../Base/SvelteUIElement"
import OpeningHoursRangeElement from "./Visualisation/OpeningHoursRangeElement.svelte"
export default class OpeningHoursVisualization extends Toggle {
private static readonly weekdays: Translation[] = [
Translations.t.general.weekdays.abbreviations.monday,
Translations.t.general.weekdays.abbreviations.tuesday,
Translations.t.general.weekdays.abbreviations.wednesday,
Translations.t.general.weekdays.abbreviations.thursday,
Translations.t.general.weekdays.abbreviations.friday,
Translations.t.general.weekdays.abbreviations.saturday,
Translations.t.general.weekdays.abbreviations.sunday,
]
constructor(
tags: UIEventSource<Record<string, string>>,
key: string,
prefix = "",
postfix = ""
) {
const openingHoursStore = OH.CreateOhObjectStore(tags, key, prefix, postfix)
const ohTable = new VariableUiElement(
openingHoursStore.map((opening_hours_obj) => {
if (opening_hours_obj === undefined) {
return new FixedUiElement("No opening hours defined with key " + key).SetClass(
"alert"
)
}
if (opening_hours_obj === "error") {
return Translations.t.general.opening_hours.error_loading
}
const applicableWeek = OH.createRangesForApplicableWeek(opening_hours_obj)
const textual = ToTextualDescription.createTextualDescriptionFor(
opening_hours_obj,
applicableWeek.ranges
)
const vis = OpeningHoursVisualization.CreateFullVisualisation(
opening_hours_obj,
applicableWeek.ranges,
applicableWeek.startingMonday
)
Locale.language.mapD((lng) => {
console.debug("Setting OH description to", lng, textual)
vis.ConstructElement().ariaLabel = textual?.textFor(lng)
})
return vis
})
)
super(
ohTable,
new Loading(Translations.t.general.opening_hours.loadingCountry),
tags.map((tgs) => tgs._country !== undefined)
)
this.SetClass("no-weblate")
}
private static CreateFullVisualisation(
oh: opening_hours,
ranges: OpeningRange[][],
lastMonday: Date
): BaseUIElement {
// First, a small sanity check. The business might be permanently closed, 24/7 opened or be another special case
if (ranges.some((range) => range.length > 0)) {
// The normal case: we have items for the coming days
return OpeningHoursVisualization.ConstructVizTable(oh, ranges, lastMonday)
}
// The special case that range is completely empty
return new SvelteUIElement(SpecialCase, { oh })
}
private static ConstructVizTable(
oh: any,
ranges: {
isOpen: boolean
isSpecial: boolean
comment: string
startDate: Date
endDate: Date
}[][],
rangeStart: Date
): BaseUIElement {
const isWeekstable: boolean = oh.isWeekStable()
const [changeHours, changeHourText] = OH.allChangeMoments(ranges)
const today = new Date()
today.setHours(0, 0, 0, 0)
const todayIndex = Math.ceil(
(today.getTime() - rangeStart.getTime()) / (1000 * 60 * 60 * 24)
)
// By default, we always show the range between 8 - 19h, in order to give a stable impression
// Ofc, a bigger range is used if needed
const earliestOpen = Math.min(8 * 60 * 60, ...changeHours)
let latestclose = Math.max(...changeHours)
// We always make sure there is 30m of leeway in order to give enough room for the closing entry
latestclose = Math.max(19 * 60 * 60, latestclose + 30 * 60)
const availableArea = latestclose - earliestOpen
/*
* The OH-visualisation is a table, consisting of 8 rows and 2 columns:
* The first row is a header row (which is NOT passed as header but just as a normal row!) containing empty for the first column and one object giving all the end times
* The other rows are one for each weekday: the first element showing 'mo', 'tu', ..., the second element containing the bars.
* Note that the bars are actually an embedded <div> spanning the full width, containing multiple sub-elements
* */
const [header, headerHeight] = OpeningHoursVisualization.ConstructHeaderElement(
availableArea,
changeHours,
changeHourText,
earliestOpen
)
const weekdays = []
const weekdayStyles = []
for (let i = 0; i < 7; i++) {
const day = OpeningHoursVisualization.weekdays[i].Clone()
day.SetClass("w-full h-full flex")
const rangesForDay = ranges[i].map((range) =>
new SvelteUIElement(OpeningHoursRangeElement, {
availableArea,
earliestOpen,
latestclose,
range,
isWeekstable
})
)
const allRanges = new Combine([
...OpeningHoursVisualization.CreateLinesAtChangeHours(
changeHours,
availableArea,
earliestOpen
),
...rangesForDay,
]).SetClass("w-full block")
let extraStyle = ""
if (todayIndex == i) {
extraStyle = "background-color: var(--subtle-detail-color);"
allRanges.SetClass("ohviz-today")
} else if (i >= 5) {
extraStyle = "background-color: rgba(230, 231, 235, 1);"
}
weekdays.push([day, allRanges])
weekdayStyles.push([
"padding-left: 0.5em;" + extraStyle,
`position: relative;` + extraStyle,
])
}
return new Table(undefined, [["&nbsp", header], ...weekdays], {
contentStyle: [
["width: 5%", `position: relative; height: ${headerHeight}`],
...weekdayStyles,
],
})
.SetClass("w-full")
.SetStyle(
"border-collapse: collapse; word-break; word-break: normal; word-wrap: normal"
)
}
private static CreateLinesAtChangeHours(
changeHours: number[],
availableArea: number,
earliestOpen: number
): BaseUIElement[] {
const allLines: BaseUIElement[] = []
for (const changeMoment of changeHours) {
const offset = (100 * (changeMoment - earliestOpen)) / availableArea
if (offset < 0 || offset > 100) {
continue
}
const el = new FixedUiElement("").SetStyle(`left:${offset}%;`).SetClass("ohviz-line")
allLines.push(el)
}
return allLines
}
/**
* The OH-Visualization header element, a single bar with hours
* @param availableArea
* @param changeHours
* @param changeHourText
* @param earliestOpen
* @constructor
* @private
*/
private static ConstructHeaderElement(
availableArea: number,
changeHours: number[],
changeHourText: string[],
earliestOpen: number
): [BaseUIElement, string] {
const header: BaseUIElement[] = []
header.push(
...OpeningHoursVisualization.CreateLinesAtChangeHours(
changeHours,
availableArea,
earliestOpen
)
)
let showHigher = false
let showHigherUsed = false
for (let i = 0; i < changeHours.length; i++) {
const changeMoment = changeHours[i]
const offset = (100 * (changeMoment - earliestOpen)) / availableArea
if (offset < 0 || offset > 100) {
continue
}
if (i > 0 && (changeMoment - changeHours[i - 1]) / (60 * 60) < 2) {
// Quite close to the previous value
// We alternate the heights
showHigherUsed = true
showHigher = !showHigher
} else {
showHigher = false
}
const el = new Combine([
new FixedUiElement(changeHourText[i])
.SetClass(
"relative bg-white pl-1 pr-1 h-3 font-sm rounded-xl border-2 border-black border-opacity-50"
)
.SetStyle("left: -50%; word-break:initial"),
])
.SetStyle(`left:${offset}%;margin-top: ${showHigher ? "1.4rem;" : "0.1rem"}`)
.SetClass("block absolute top-0 m-0 h-full box-border ohviz-time-indication")
header.push(el)
}
const headerElem = new Combine(header)
.SetClass(`w-full absolute block ${showHigherUsed ? "h-16" : "h-8"}`)
.SetStyle("margin-top: -1rem")
const headerHeight = showHigherUsed ? "4rem" : "2rem"
return [headerElem, headerHeight]
}
}

View file

@ -0,0 +1,34 @@
<script lang="ts">
/**
* Full opening hours visualisations table, dispatches to special cases
*/
import { OH, ToTextualDescription } from "../OpeningHours"
import opening_hours from "opening_hours"
import { ariaLabel } from "../../../Utils/ariaLabel"
import RegularOpeningHoursTable from "./RegularOpeningHoursTable.svelte"
import SpecialCase from "./SpecialCase.svelte"
import { Translation } from "../../i18n/Translation"
import type { OpeningRange } from "../OpeningHours"
export let opening_hours_obj: opening_hours
let applicableWeek = OH.createRangesForApplicableWeek(opening_hours_obj)
let oh = opening_hours_obj
let textual: Translation = ToTextualDescription.createTextualDescriptionFor(oh, applicableWeek.ranges)
let applicableWeekRanges: { ranges: OpeningRange[][]; startingMonday: Date } = OH.createRangesForApplicableWeek(oh)
let ranges = applicableWeekRanges.ranges
let lastMonday = applicableWeekRanges.startingMonday
</script>
<div use:ariaLabel={textual} class="no-weblate">
<!-- First, a small sanity check. The business might be permanently closed, 24/7 opened or be another special case -->
{#if ranges.some((range) => range.length > 0)}
<!-- The normal case: we have items for the coming days -->
<RegularOpeningHoursTable {ranges} rangeStart={lastMonday} oh={opening_hours_obj} />
{:else}
<!-- The special case that range is completely empty -->
<SpecialCase oh={opening_hours_obj} />
{/if}
</div>

View file

@ -0,0 +1,38 @@
<script lang="ts">
import BaseUIElement from "../../BaseUIElement"
import Combine from "../../Base/Combine"
import { FixedUiElement } from "../../Base/FixedUiElement"
/**
* The element showing an "hour" in a bubble, above or below the opening hours table
* Dumbly shows one row of what is given.
*
* Does not include lines
*/
export let availableArea: number
export let changeHours: number[]
export let changeHourText: string[]
export let earliestOpen: number
export let todayChangeMoments: Set<number>
function calcOffset(changeMoment: number) {
return (100 * (changeMoment - earliestOpen)) / availableArea
}
</script>
<div class="w-full absolute block h-8" style="margin-top: -1rem">
{#each changeHours as changeMoment, i}
{#if calcOffset(changeMoment) >= 0 && calcOffset(changeMoment) <= 100}
<div style={`left:${calcOffset(changeMoment)}%; margin-top: 0.1rem`}
class="block absolute top-0 m-0 h-full box-border ohviz-time-indication">
<div
style="left: -50%; word-break: initial;"
class:border-opacity-50={!todayChangeMoments?.has(changeMoment)}
class="relative h-fit bg-white pl-1 pr-1 h-3 font-sm rounded-xl border-2 border-black">
{changeHourText[i]}
</div>
</div>
{/if}
{/each}
</div>

View file

@ -19,7 +19,7 @@
startOfDay.setHours(0, 0, 0, 0)
let startpoint = (range.startDate.getTime() - startOfDay.getTime()) / 1000 - earliestOpen
// prettier-ignore
let width = (100 * (range.endDate.getTime() - range.startDate.getTime()) / 1000) / (latestclose - earliestOpen)
let width = (100 * (range.endDate.getTime() - range.startDate.getTime()) / 1000) / availableArea
let startPercentage = (100 * startpoint) / availableArea
</script>

View file

@ -0,0 +1,28 @@
<script lang="ts">/**
* Wrapper around 'OpeningHours' so that the latter can deal with the opening_hours object directly
*/
import { Store, UIEventSource } from "../../../Logic/UIEventSource"
import type opening_hours from "opening_hours"
import Translations from "../../i18n/Translations"
import Loading from "../../Base/Loading.svelte"
import Tr from "../../Base/Tr.svelte"
import OpeningHours from "./OpeningHours.svelte"
export let tags: UIEventSource<Record<string, string>>
export let opening_hours_obj: Store<opening_hours | "error">
export let key: string
</script>
{#if $tags._country === undefined}
<Loading>
<Tr t={Translations.t.general.opening_hours.loadingCountry} />
</Loading>
{:else if $opening_hours_obj === undefined}
<div class="alert">No opening hours defined with key {key}</div>
{:else if $opening_hours_obj === "error"}
<Tr cls="alert" t={Translations.t.general.opening_hours.error_loading} />
{:else}
<OpeningHours opening_hours_obj={$opening_hours_obj} />
{/if}

View file

@ -0,0 +1,129 @@
<script lang="ts">/**
* The main visualisation which shows ranges, one or more top/bottom headers, ...
* Does not handle the special cases
*/
import opening_hours from "opening_hours"
import OpeningHoursHeader from "./OpeningHoursHeader.svelte"
import { default as Transl } from "../../Base/Tr.svelte" /* The IDE confuses <tr> (table row) and <Tr> (translation) as they are normally case insensitive -> import under a different name */
import OpeningHoursRangeElement from "./OpeningHoursRangeElement.svelte"
import { Translation } from "../../i18n/Translation"
import Translations from "../../i18n/Translations"
import { OH } from "../OpeningHours"
import { Utils } from "../../../Utils"
export let oh: opening_hours
export let ranges: {
isOpen: boolean
isSpecial: boolean
comment: string
startDate: Date
endDate: Date
}[][] // Per weekday
export let rangeStart: Date
let isWeekstable: boolean = oh.isWeekStable()
let today = new Date()
today.setHours(0, 0, 0, 0)
let todayIndex = Math.ceil((today.getTime() - rangeStart.getTime()) / (1000 * 60 * 60 * 24))
let weekdayRanges = ranges.map(ranges => ranges.filter(r => r.startDate.getDay() != 0 && r.startDate.getDay() != 6))
let weekendRanges = ranges.map(ranges => ranges.filter(r => r.startDate.getDay() == 0 || r.startDate.getDay() == 6))
let todayRanges = ranges.map(((r, i) => r.filter(() => i === todayIndex)))
const [changeHours, changeHourText] = OH.allChangeMoments(weekdayRanges)
const [changeHoursWeekend, changeHourTextWeekend] = OH.allChangeMoments(weekendRanges)
const weekdayHeaders: {
changeHours: number[];
changeTexts: string[]
}[] = OH.partitionOHForDistance(changeHours, changeHourText)
const weekendDayHeaders: {
changeHours: number[];
changeTexts: string[]
}[] = OH.partitionOHForDistance(changeHoursWeekend, changeHourTextWeekend)
let allChangeMoments: number[] = Utils.DedupT([...changeHours, ...changeHoursWeekend])
let todayChangeMoments: Set<number> = new Set(OH.allChangeMoments(todayRanges)[0])
// By default, we always show the range between 8 - 19h, in order to give a stable impression
// Ofc, a bigger range is used if needed
let earliestOpen = Math.min(8 * 60 * 60, ...changeHours)
// We always make sure there is 30m of leeway in order to give enough room for the closing entry
let latestclose = Math.max(19 * 60 * 60, Math.max(...changeHours) + 30 * 60)
let availableArea = latestclose - earliestOpen
function calcLineOffset(moment: number) {
return 100 * (moment - earliestOpen) / availableArea
}
let weekdays: Translation[] = [
Translations.t.general.weekdays.abbreviations.monday,
Translations.t.general.weekdays.abbreviations.tuesday,
Translations.t.general.weekdays.abbreviations.wednesday,
Translations.t.general.weekdays.abbreviations.thursday,
Translations.t.general.weekdays.abbreviations.friday,
Translations.t.general.weekdays.abbreviations.saturday,
Translations.t.general.weekdays.abbreviations.sunday
]
</script>
<div class="w-full h-fit relative">
{#each allChangeMoments as moment}
<div class="w-full absolute h-full">
<div class="w-full h-full flex">
<div style="height: 5rem; width: 5%; min-width: 2.75rem" />
<div class="grow">
<div class="border-x h-full"
style={`width: calc( ${calcLineOffset(moment)}% ); border-color: ${todayChangeMoments.has(moment) ? "#000" : "#bbb"}`} />
</div>
</div>
</div>
{/each}
<table class="w-full" style="border-collapse: collapse; word-break: normal; word-wrap: normal">
{#each weekdayHeaders as weekdayHeader}
<tr>
<td style="width: 5%; min-width: 2.75rem;"></td>
<td class="relative h-8">
<OpeningHoursHeader {earliestOpen} {availableArea} changeHours={weekdayHeader.changeHours}
{todayChangeMoments}
changeHourText={weekdayHeader.changeTexts} />
</td>
</tr>
{/each}
{#each weekdays as weekday, i}
<tr class:interactive={i >= 5}>
<td style="width: 5%">
<Transl t={weekday} />
</td>
<td class="relative p-0 m-0" class:ohviz-today={i===todayIndex}>
<div class="w-full" style="margin-left: -0px">
{#each ranges[i] as range}
<OpeningHoursRangeElement
{availableArea}
{earliestOpen}
{latestclose}
{range}
{isWeekstable}
/>
{/each}
</div>
</td>
</tr>
{/each}
{#each weekendDayHeaders as weekdayHeader}
<tr>
<td style="width: 5%"></td>
<td class="relative h-8">
<OpeningHoursHeader {earliestOpen} {availableArea} changeHours={weekdayHeader.changeHours}
{todayChangeMoments}
changeHourText={weekdayHeader.changeTexts} />
</td>
</tr>
{/each}
</table>
</div>

View file

@ -5,12 +5,11 @@ import { RenderingSpecification, SpecialVisualization, SpecialVisualizationState
import { HistogramViz } from "./Popup/HistogramViz"
import { UploadToOsmViz } from "./Popup/UploadToOsmViz"
import { MultiApplyViz } from "./Popup/MultiApplyViz"
import { UIEventSource } from "../Logic/UIEventSource"
import { Store, UIEventSource } from "../Logic/UIEventSource"
import AllTagsPanel from "./Popup/AllTagsPanel/AllTagsPanel.svelte"
import { VariableUiElement } from "./Base/VariableUIElement"
import { Translation } from "./i18n/Translation"
import Translations from "./i18n/Translations"
import OpeningHoursVisualization from "./OpeningHours/OpeningHoursVisualization"
import AutoApplyButtonVis from "./Popup/AutoApplyButtonVis"
import { LanguageElement } from "./Popup/LanguageElement/LanguageElement"
import SvelteUIElement from "./Base/SvelteUIElement"
@ -43,6 +42,9 @@ import {
} from "./SpecialVisualisations/WebAndCommunicationSpecialVisualisations"
import ClearGPSHistory from "./BigComponents/ClearGPSHistory.svelte"
import AllFeaturesStatistics from "./Statistics/AllFeaturesStatistics.svelte"
import OpeningHoursWithError from "./OpeningHours/Visualisation/OpeningHoursWithError.svelte"
import { OH } from "./OpeningHours/OpeningHours"
import opening_hours from "opening_hours"
export default class SpecialVisualizations {
public static specialVisualizations: SpecialVisualization[] = SpecialVisualizations.initList()
@ -276,7 +278,12 @@ export default class SpecialVisualizations {
"A normal opening hours table can be invoked with `{opening_hours_table()}`. A table for e.g. conditional access with opening hours can be `{opening_hours_table(access:conditional, no @ &LPARENS, &RPARENS)}`",
constr: (state, tagSource: UIEventSource<any>, args) => {
const [key, prefix, postfix] = args
return new OpeningHoursVisualization(tagSource, key, prefix, postfix)
const openingHoursStore: Store<opening_hours | "error" | undefined> = OH.CreateOhObjectStore(tagSource, key, prefix, postfix)
return new SvelteUIElement(OpeningHoursWithError, {
tags: tagSource,
key,
opening_hours_obj: openingHoursStore
})
},
},
{