171 lines
5.8 KiB
JavaScript
171 lines
5.8 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());
|
||
}
|
||
|
||
/** 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(' ');
|
||
}
|