Files
weerscherm/public/format.js
T

171 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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) };
}
/**
* 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) {
const range = seriesRange(values);
if (!range || values.length < 2) return '';
const span = range.max - range.min;
const innerW = w - 2 * pad;
const innerH = h - 2 * pad;
const step = innerW / (values.length - 1);
const parts = [];
values.forEach((v, i) => {
if (!isNum(v)) return;
const x = pad + i * step;
const y = span === 0 ? h / 2 : pad + innerH - ((v - range.min) / span) * innerH;
parts.push(`${parts.length === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`);
});
return parts.join(' ');
}