125 lines
4.1 KiB
JavaScript
125 lines
4.1 KiB
JavaScript
/**
|
||
* 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());
|
||
}
|