feat: format.js pure helpers with node tests, weather icons
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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());
|
||||
}
|
||||
Reference in New Issue
Block a user