Files
weerscherm/public/format.js
T
theoandClaude Opus 5 5d61b6ec90 feat: tijdlabels op hele uren in plaats van de ruwe meettijden
De labels toonden de werkelijke meettijdstippen van Netatmo (11:41,
17:41, 23:41), wat onrustig leest. Nu markeringen op hele uren die op de
klok zijn uitgelijnd: elke 6 uur vanaf middernacht, dus 00:00, 06:00,
12:00, 18:00, elk op zijn echte tijdspositie in het venster. "nu" blijft
op de rechterrand; een markering die daar te dicht bij komt valt weg.

axisMarks() is vervangen door hourTicks(), die uur voor uur door het
venster loopt en telkens de lokale klok raadpleegt. Daardoor blijven de
labels ook op de zomertijdwissel hele lokale uren, waar optellen in vaste
stappen er een uur naast zou zitten; daar is een test voor. Een label
vlak op de linkerrand wordt niet gecentreerd, zodat het binnen de
grafiek blijft. Servicewerker naar v9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 11:33:14 +02:00

249 lines
8.9 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) };
}
/**
* 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;
}