/** * Pixel ↔ time conversions for the timetable canvas. * * The grid is anchored on a CarbonImmutable `gridStart` (typically * the sub-event's start_at, possibly extended to the previous hour * boundary). All other coordinates are derived from minutes-since- * `gridStart` × `pxPerMin`. * * Pure functions — no DOM access. Input is ISO-8601 strings or epoch ms; * the grid origin is captured once per render in the page entry. */ export interface TimeGridConfig { /** Minute 0 of the canvas, as ISO-8601 (the sub-event start anchor). */ gridStartIso: string /** Total minutes spanned by the canvas (e.g. 13h × 60 = 780). */ totalMinutes: number /** Horizontal pixels per minute (drives stretch/zoom). */ pxPerMin: number } export function isoToMinutes(iso: string, gridStartIso: string): number { const start = Date.parse(gridStartIso) const at = Date.parse(iso) return Math.round((at - start) / 60_000) } export function minutesToIso(minutes: number, gridStartIso: string): string { const start = Date.parse(gridStartIso) return new Date(start + minutes * 60_000).toISOString() } export function minutesToPx(minutes: number, pxPerMin: number): number { return minutes * pxPerMin } export function pxToMinutes(px: number, pxPerMin: number): number { if (pxPerMin <= 0) return 0 return px / pxPerMin } /** * The wall-clock label for a tick at `minute`. RFC v0.2 §D21 defaults * to a 24h HH:MM format in nl-NL locale; days-spanning canvases * preserve the wrap (00:00 + minutes since previous midnight). */ export function formatTickLabel(minute: number, gridStartIso: string): string { const iso = minutesToIso(minute, gridStartIso) const date = new Date(iso) return date.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', hour12: false }) } /** * Generate evenly-spaced tick positions in [0, totalMinutes] at `intervalMin`. */ export function generateTicks(totalMinutes: number, intervalMin: number): number[] { const ticks: number[] = [] for (let m = 0; m <= totalMinutes; m += intervalMin) ticks.push(m) return ticks }