/** * Pure formatteer- en rekenfuncties voor het weerscherm. Geen DOM, geen fetch: los testbaar met node --test. * Notatie nl-NL met decimale komma; tijden in de tijdzone van het station. */ const DASH = '–'; const WMO_ICONS = [ [[0], 'sun'], [[1, 2], 'partly'], [[3], 'cloud'], [[45, 48], 'fog'], [[51, 52, 53, 54, 55, 56, 57], 'drizzle'], [[61, 62, 63, 64, 65, 66, 67], 'rain'], [[80, 81, 82], 'showers'], [[71, 72, 73, 74, 75, 76, 77, 85, 86], 'snow'], [[95, 96, 97, 98, 99], 'thunder'], ]; const TRENDS = { up: '↗ stijgend', down: '↘ dalend', stable: '→ stabiel' }; function isNum(v) { return typeof v === 'number' && Number.isFinite(v); } function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); } /** 16.4 -> "16,4"; null -> "–" */ export function f1(v) { return isNum(v) ? v.toFixed(1).replace('.', ',') : DASH; } /** 18.3 -> "18"; null -> "–" */ export function fInt(v) { return isNum(v) ? String(Math.round(v)) : DASH; } /** 0 -> "0 mm"; 0.4 -> "0,4 mm"; null -> "–" */ export function fmm(v) { if (!isNum(v)) return DASH; return v === 0 ? '0 mm' : f1(v) + ' mm'; } /** Positie van de marker op de min/max-balk, 0–100. Bij max == min of onbekend: 50. */ export function markerPos(temp, min, max) { if (!isNum(temp) || !isNum(min) || !isNum(max) || max === min) return 50; const pos = ((temp - min) / (max - min)) * 100; return Math.min(100, Math.max(0, pos)); } /** Hoogte per staaf in px: mm / max(waarden) * maxPx, minimaal minPx. Alles 0 of null -> allemaal minPx. */ export function barHeights(values, maxPx = 70, minPx = 2) { const mm = values.map((v) => (isNum(v) ? v : 0)); const top = Math.max(...mm, 0); return mm.map((v) => (top > 0 ? Math.max(minPx, (v / top) * maxPx) : minPx)); } /** WMO-weercode -> icoonnaam. Onbekend -> "cloud". */ export function wmoIcon(code) { for (const [codes, name] of WMO_ICONS) { if (codes.includes(code)) return name; } return 'cloud'; } /** "up" -> "↗ stijgend" enz.; onbekend -> "" */ export function trendLabel(trend) { return TRENDS[trend] || ''; } /** "Vrijdag 11 september" */ export function formatDate(date, tz) { const s = new Intl.DateTimeFormat('nl-NL', { weekday: 'long', day: 'numeric', month: 'long', timeZone: tz }).format(date); return cap(s); } /** ISO-tijdstip -> "06:10" in de tijdzone; null -> "–" */ export function formatTime(iso, tz) { const d = iso instanceof Date ? iso : new Date(iso); if (!iso || Number.isNaN(d.getTime())) return DASH; return new Intl.DateTimeFormat('nl-NL', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone: tz }).format(d); } /** "2026-09-12" -> "Za" */ export function dayAbbr(dateStr, tz) { const d = new Date(dateStr + 'T12:00:00Z'); if (Number.isNaN(d.getTime())) return DASH; const s = new Intl.DateTimeFormat('nl-NL', { weekday: 'short', timeZone: 'UTC' }).format(d); return cap(s.replace('.', '')).slice(0, 2); } /** "September" */ export function monthName(date, tz) { return cap(new Intl.DateTimeFormat('nl-NL', { month: 'long', timeZone: tz }).format(date)); } /** Is het ISO-tijdstip ouder dan `minutes` minuten ten opzichte van `now`? Onbekend telt als verouderd. */ export function isOlderThan(iso, now, minutes) { if (!iso) return true; const t = new Date(iso).getTime(); if (Number.isNaN(t)) return true; return now.getTime() - t > minutes * 60000; } /** Lokale tijd (uur, minuut, seconde) van `now` in de tijdzone. */ function localParts(now, tz) { const parts = new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', timeZone: tz, }).formatToParts(now); const get = (type) => Number(parts.find((p) => p.type === type).value); return { h: get('hour'), m: get('minute'), s: get('second') }; } /** Milliseconden tot de volgende keer dat het `hour`:`minute` is in de tijdzone (minimaal 1 s). */ export function msUntil(now, tz, hour, minute = 0) { const { h, m, s } = localParts(now, tz); const target = hour * 3600 + minute * 60; const current = h * 3600 + m * 60 + s; let diff = target - current; if (diff <= 0) diff += 86400; return Math.max(1000, diff * 1000 - now.getMilliseconds()); }