/** * 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()); } /** CO₂-niveau volgens de Netatmo-drempels: tot 1000 goed, tot 1500 matig, daarboven slecht. */ export function co2Level(ppm) { if (!isNum(ppm)) return { level: 'unknown', label: DASH }; if (ppm <= 1000) return { level: 'good', label: 'goed' }; if (ppm <= 1500) return { level: 'medium', label: 'matig' }; return { level: 'bad', label: 'slecht' }; } /** 48 -> "48%"; null -> "–" */ export function fPct(v) { return isNum(v) ? `${Math.round(v)}%` : DASH; } /** 1014.2 -> "1014 hPa"; null -> "–" */ export function fHpa(v) { return isNum(v) ? `${Math.round(v)} hPa` : DASH; } /** Laagste en hoogste waarde van een reeks (null-waarden genegeerd), of null zonder geldige waarden. */ export function seriesRange(values) { const nums = values.filter(isNum); if (nums.length === 0) return null; return { min: Math.min(...nums), max: Math.max(...nums) }; } /** * Coördinaten van een reeks binnen een box van w×h met `pad` px marge, geschaald op min..max. * Een vlakke reeks komt op het midden. `y` is null voor een onbekende waarde; `x` blijft altijd gezet * zodat labels/staafjes/pijlen op dezelfde plek als de lijn kunnen worden gepositioneerd. */ export function sparklinePoints(values, w, h, pad = 3) { const range = seriesRange(values); const innerW = w - 2 * pad; const innerH = h - 2 * pad; const step = values.length > 1 ? innerW / (values.length - 1) : 0; const span = range ? range.max - range.min : 0; return values.map((v, i) => { const x = pad + i * step; if (!isNum(v) || !range) return { x, y: null }; const y = span === 0 ? h / 2 : pad + innerH - ((v - range.min) / span) * innerH; return { x, y }; }); } /** * SVG-pad voor een sparkline: punten gelijkmatig over de breedte, waarden geschaald op min..max * met `pad` px marge. Een vlakke reeks wordt een lijn door het midden. Minder dan 2 punten -> "". */ export function sparklinePath(values, w, h, pad = 3) { if (values.length < 2) return ''; const parts = []; sparklinePoints(values, w, h, pad).forEach(({ x, y }) => { if (y === null) return; parts.push(`${parts.length === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`); }); return parts.join(' '); } /** Windrichting (meteorologisch: vanwaar de wind komt) -> rotatie zodat de pijl waarheen wijst. */ export function windArrowDeg(dir) { return isNum(dir) ? (dir + 180) % 360 : null; } /** 8.4 -> "8 km/u"; null -> "–" */ export function fKmh(v) { return isNum(v) ? `${Math.round(v)} km/u` : DASH; } /** ISO-tijdstip -> "14" (tweecijferig uur) in de tijdzone; null -> "–" */ export function formatHour(iso, tz) { const d = iso instanceof Date ? iso : new Date(iso); if (!iso || Number.isNaN(d.getTime())) return DASH; const parts = new Intl.DateTimeFormat('en-GB', { hour: '2-digit', hourCycle: 'h23', timeZone: tz }).formatToParts(d); return parts.find((p) => p.type === 'hour').value; } /** "2026-09-14" -> "Maandag 14 september" in de tijdzone (net als formatDate, maar vanuit een datumstring). */ export function formatFullDate(dateStr, tz) { const d = new Date(dateStr + 'T12:00:00Z'); if (Number.isNaN(d.getTime())) return DASH; return formatDate(d, tz); } /** * Herschikt kamers zodat de kamer met `pinnedId` vooraan staat; de rest behoudt de onderlinge volgorde. * Zonder pinnedId, of als die niet voorkomt of al vooraan staat, blijft de oorspronkelijke array (referentie) terug. */ export function pinRoom(rooms, pinnedId) { if (!pinnedId) return rooms; const index = rooms.findIndex((r) => r.id === pinnedId); if (index <= 0) return rooms; const copy = rooms.slice(); const [pinned] = copy.splice(index, 1); copy.unshift(pinned); return copy; } /** * Markeringen op hele uren binnen een tijdvenster, uitgelijnd op de klok: elke `stepHours` uur * gerekend vanaf middernacht in de gegeven tijdzone, dus 00:00, 06:00, 12:00, 18:00 bij een stap van 6. * Per markering het tijdstip en de positie in procenten van het venster. * * Er wordt uur voor uur door het venster gelopen en telkens de lokale klok geraadpleegd, zodat de * zomertijdwissel vanzelf goed gaat: een dag van 23 of 25 uur levert gewoon de juiste hele uren op. * Markeringen binnen `minGapPercent` van het einde vallen weg; daar staat het label "nu". */ export function hourTicks(startIso, endIso, tz, stepHours = 6, minGapPercent = 9) { const t0 = new Date(startIso).getTime(); const t1 = new Date(endIso).getTime(); if (Number.isNaN(t0) || Number.isNaN(t1) || t1 <= t0) return []; const hourMs = 3600000; const span = t1 - t0; const ticks = []; for (let t = Math.ceil(t0 / hourMs) * hourMs; t <= t1; t += hourMs) { const at = new Date(t).toISOString(); if (Number(formatHour(at, tz)) % stepHours !== 0) continue; const percent = ((t - t0) / span) * 100; if (percent > 100 - minGapPercent) break; ticks.push({ at, percent }); } return ticks; } /** Schaal om `natural` px binnen `available` px te passen; nooit vergroten, nooit negatief. */ export function scaleToFit(natural, available) { if (!(natural > 0) || !(available > 0)) return 1; return Math.min(1, available / natural); }