Tik op een dag in de verwachtingsstrook voor een uuroverzicht van
temperatuur, regen en wind (richting + snelheid), om de 2 uur, 12
punten per dag. Sluit na 20 seconden vanzelf of bij een tik op de
overlay zelf.
Backend: Open-Meteo levert hourly-data in dezelfde call als daily
(geen extra verzoek, dezelfde 60min-cache). Nieuwe HourlyForecastMapper
groepeert per kalenderdatum en bemonstert om het andere uur, robuust
voor de zomertijddag (23/25 uur). OpenMeteoClient::dailyForecast()
hernoemd naar forecast(), geeft nu {daily, hourly} terug. Elke dag in
de forecast-array krijgt een hourly-lijst.
Frontend: temperatuurlijn hergebruikt sparklinePath (nu ook als
sparklinePoints voor exacte uitlijning met de 12-koloms grid), regen
als kleine staafjes, windpijl (nieuw icoon) wijst waarheen de wind
waait. 20s-balk via CSS transition met reflow-restart. Servicewerker
naar v5.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
342 lines
11 KiB
JavaScript
342 lines
11 KiB
JavaScript
import {
|
||
f1, fInt, fmm, markerPos, barHeights, wmoIcon, trendLabel,
|
||
formatDate, formatTime, dayAbbr, monthName, isOlderThan, msUntil,
|
||
co2Level, fPct, fHpa, seriesRange, sparklinePath,
|
||
windArrowDeg, fKmh, formatHour, formatFullDate,
|
||
} from './format.js';
|
||
import { icon, windArrow } from './icons.js';
|
||
|
||
const CANVAS_W = 1133;
|
||
const CANVAS_H = 744;
|
||
const API_URL = 'api/data.php';
|
||
const REFRESH_MS = 5 * 60 * 1000;
|
||
const FETCH_TIMEOUT_MS = 10000;
|
||
const STALE_MINUTES = 30;
|
||
const RELOAD_HOUR = 4;
|
||
const FALLBACK_TZ = 'Europe/Berlin';
|
||
const HOURLY_OVERLAY_MS = 20000;
|
||
const HOURLY_WIDTH = 1045; // = stage-breedte 1133 min 2x 44px padding, zelfde marge als de rest van het scherm
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
const stage = $('stage');
|
||
const viewport = $('viewport');
|
||
|
||
const state = {
|
||
data: null, // laatste payload, ook als die uit de SW-cache komt
|
||
tz: FALLBACK_TZ,
|
||
lastOk: null, // Date van de laatste geslaagde live-fetch
|
||
offlineSince: null // Date vanaf wanneer er geen verbinding is
|
||
};
|
||
|
||
/* ---------- schaal ---------- */
|
||
|
||
function fit() {
|
||
const w = viewport.clientWidth;
|
||
const h = viewport.clientHeight;
|
||
const s = Math.min(w / CANVAS_W, h / CANVAS_H);
|
||
const x = Math.round((w - CANVAS_W * s) / 2);
|
||
const y = Math.round((h - CANVAS_H * s) / 2);
|
||
stage.style.transform = `translate(${x}px, ${y}px) scale(${s})`;
|
||
}
|
||
|
||
/* ---------- render ---------- */
|
||
|
||
function setText(id, text) {
|
||
$(id).textContent = text;
|
||
}
|
||
|
||
function renderHeader(now) {
|
||
setText('date', formatDate(now, state.tz));
|
||
setText('clock', formatTime(now, state.tz));
|
||
}
|
||
|
||
function renderMeasurement(now) {
|
||
const d = state.data;
|
||
const upd = $('upd');
|
||
if (!d) return;
|
||
const stale = d.station.stale || isOlderThan(d.station.measured_at, now, STALE_MINUTES);
|
||
upd.textContent = `meting ${formatTime(d.station.measured_at, state.tz)}${stale ? ' · verouderd' : ''}`;
|
||
upd.classList.toggle('stale', stale);
|
||
}
|
||
|
||
function renderOutdoor(o) {
|
||
setText('trend', trendLabel(o.trend));
|
||
setText('out-hum', o.humidity === null || o.humidity === undefined ? '' : `RV ${fPct(o.humidity)}`);
|
||
setText('temp', `${f1(o.temp)}°`);
|
||
$('mark').style.left = `${markerPos(o.temp, o.min, o.max).toFixed(1)}%`;
|
||
setText('min', `${f1(o.min)}°`);
|
||
setText('min-at', `laagst om ${formatTime(o.min_at, state.tz)}`);
|
||
setText('max', `${f1(o.max)}°`);
|
||
setText('max-at', `hoogst om ${formatTime(o.max_at, state.tz)}`);
|
||
}
|
||
|
||
function renderRooms(rooms) {
|
||
const html = rooms.map((r) => {
|
||
const off = !r.reachable || r.temp === null;
|
||
const co2 = co2Level(off ? null : r.co2);
|
||
const pill = `<span class="pill ${co2.level} num" title="${co2.label}">${co2.level === 'unknown' ? '–' : r.co2 + ' ppm'}</span>`;
|
||
return `<div class="a-room${off ? ' off' : ''}">
|
||
<p class="a-rname"><span>${esc(r.name)}</span>${pill}</p>
|
||
<p class="a-rtemp num">${off ? '–' : f1(r.temp) + '°'}</p>
|
||
<p class="a-rmm num"><span class="cold">↓ ${off ? '–' : f1(r.min) + '°'}</span><span class="warm">↑ ${off ? '–' : f1(r.max) + '°'}</span><span class="rv">RV ${off ? '–' : fPct(r.humidity)}</span></p>
|
||
</div>`;
|
||
});
|
||
$('rooms').innerHTML = html.join('');
|
||
}
|
||
|
||
function renderRain(rain, now) {
|
||
setText('rain-today', f1(rain.today));
|
||
setText('month-name', monthName(now, state.tz));
|
||
setText('month-total', `${f1(rain.month_total)} mm`);
|
||
|
||
const bars = rain.previous_days.map((p) => ({ label: dayAbbr(p.date, state.tz), mm: p.mm }));
|
||
bars.push({ label: 'Vandaag', mm: rain.today, now: true });
|
||
const heights = barHeights(bars.map((b) => b.mm));
|
||
$('bars').innerHTML = bars.map((b, i) => `<div class="a-bar${b.now ? ' now' : ''}">
|
||
<span class="num">${f1(b.mm)}</span><i style="height:${heights[i].toFixed(1)}px"></i><span>${b.label}</span>
|
||
</div>`).join('');
|
||
}
|
||
|
||
function renderPressure(p) {
|
||
setText('press', fHpa(p.current));
|
||
setText('press-trend', trendLabel(p.trend));
|
||
const values = p.history.map((h) => h.hpa);
|
||
const range = seriesRange(values);
|
||
setText('press-range', range ? `${Math.round(range.min)} – ${Math.round(range.max)} hPa in 24 u` : '');
|
||
$('press-path').setAttribute('d', sparklinePath(values, 560, 56));
|
||
}
|
||
|
||
function renderForecast(days) {
|
||
$('forecast').innerHTML = days.map((d) => `<div class="a-day" data-date="${d.date}">
|
||
<p class="a-dname">${dayAbbr(d.date, state.tz)}</p>
|
||
<div class="a-icon">${icon(wmoIcon(d.weather_code))}</div>
|
||
<p class="a-hl num">${fInt(d.temp_max)}°<span>${fInt(d.temp_min)}°</span></p>
|
||
<p class="a-fr num">${fmm(d.precip_mm)}<span>${d.precip_prob === null ? '–' : d.precip_prob + '%'}</span></p>
|
||
</div>`).join('');
|
||
}
|
||
|
||
/* ---------- uuroverzicht per dag ---------- */
|
||
|
||
let hourlyOverlayTimer = null;
|
||
|
||
function renderHourlyCharts(hourly) {
|
||
const temps = hourly.map((h) => h.temp);
|
||
$('ho-temp-path').setAttribute('d', sparklinePath(temps, HOURLY_WIDTH, 64, HOURLY_WIDTH / 24));
|
||
$('ho-temp-nums').innerHTML = hourly.map((h) => `<span class="num">${fInt(h.temp)}°</span>`).join('');
|
||
|
||
const heights = barHeights(hourly.map((h) => h.precip_mm), 40, 2);
|
||
$('ho-rain').innerHTML = hourly.map((h, i) => `<div class="ho-rbar">
|
||
<i style="height:${heights[i].toFixed(1)}px"></i><span class="num">${fmm(h.precip_mm)}</span>
|
||
</div>`).join('');
|
||
|
||
$('ho-wind').innerHTML = hourly.map((h) => {
|
||
const deg = windArrowDeg(h.wind_dir);
|
||
const arrow = deg === null
|
||
? '<span class="ho-warrow off">–</span>'
|
||
: `<span class="ho-warrow" style="transform:rotate(${deg}deg)">${windArrow()}</span>`;
|
||
return `<div class="ho-wcell">${arrow}<span class="num">${fKmh(h.wind_kmh)}</span></div>`;
|
||
}).join('');
|
||
|
||
$('ho-hours').innerHTML = hourly.map((h) => `<span class="num">${formatHour(h.at, state.tz)}</span>`).join('');
|
||
}
|
||
|
||
function openHourOverlay(date) {
|
||
const day = (state.data?.forecast || []).find((d) => d.date === date);
|
||
if (!day) return;
|
||
|
||
clearTimeout(hourlyOverlayTimer);
|
||
setText('ho-date', formatFullDate(day.date, state.tz));
|
||
renderHourlyCharts(day.hourly || []);
|
||
|
||
const fill = $('ho-bar-fill');
|
||
fill.style.transition = 'none';
|
||
fill.style.transform = 'scaleX(1)';
|
||
fill.getBoundingClientRect(); // forceer reflow zodat de volgende transitie opnieuw start
|
||
fill.style.transition = `transform ${HOURLY_OVERLAY_MS}ms linear`;
|
||
fill.style.transform = 'scaleX(0)';
|
||
|
||
$('hour-overlay').hidden = false;
|
||
hourlyOverlayTimer = setTimeout(closeHourOverlay, HOURLY_OVERLAY_MS);
|
||
}
|
||
|
||
function closeHourOverlay() {
|
||
clearTimeout(hourlyOverlayTimer);
|
||
hourlyOverlayTimer = null;
|
||
$('hour-overlay').hidden = true;
|
||
}
|
||
|
||
function renderBanner() {
|
||
const banner = $('banner');
|
||
const errors = state.data ? state.data.errors : [];
|
||
if (errors.includes('netatmo_auth')) {
|
||
banner.innerHTML = 'Netatmo-koppeling verlopen<small>Voer <code>bin/netatmo-init.php</code> opnieuw uit met een nieuwe refresh token.</small>';
|
||
banner.hidden = false;
|
||
} else if (!state.data) {
|
||
banner.hidden = false;
|
||
} else {
|
||
banner.hidden = true;
|
||
}
|
||
}
|
||
|
||
function renderStatus() {
|
||
const el = $('status');
|
||
if (state.offlineSince) {
|
||
el.textContent = `geen verbinding sinds ${formatTime(state.offlineSince, state.tz)}`;
|
||
el.hidden = false;
|
||
} else {
|
||
el.hidden = true;
|
||
}
|
||
}
|
||
|
||
function render() {
|
||
const now = new Date();
|
||
const d = state.data;
|
||
renderHeader(now);
|
||
if (d) {
|
||
renderMeasurement(now);
|
||
renderOutdoor(d.outdoor);
|
||
renderRooms(d.indoor);
|
||
renderRain(d.rain, now);
|
||
renderPressure(d.pressure || { current: null, trend: null, history: [] });
|
||
renderForecast(d.forecast);
|
||
}
|
||
renderBanner();
|
||
renderStatus();
|
||
}
|
||
|
||
function esc(s) {
|
||
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||
}
|
||
|
||
/* ---------- data ---------- */
|
||
|
||
async function fetchData() {
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||
try {
|
||
const res = await fetch(API_URL, { cache: 'no-store', signal: controller.signal });
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const json = await res.json();
|
||
if (!json || !json.station) throw new Error('onvolledig antwoord');
|
||
return { json, fromCache: res.headers.get('X-From-Cache') === '1' };
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
let refreshing = false;
|
||
|
||
async function refresh() {
|
||
if (refreshing) return;
|
||
refreshing = true;
|
||
try {
|
||
const { json, fromCache } = await fetchData();
|
||
state.data = json;
|
||
state.tz = json.timezone || FALLBACK_TZ;
|
||
if (fromCache) {
|
||
state.offlineSince = state.offlineSince || state.lastOk || new Date();
|
||
} else {
|
||
state.lastOk = new Date();
|
||
state.offlineSince = null;
|
||
}
|
||
} catch (err) {
|
||
console.warn('weerscherm: ophalen mislukt', err);
|
||
if (!state.offlineSince) state.offlineSince = state.lastOk || new Date();
|
||
if (!state.data) {
|
||
$('banner').innerHTML = 'Geen verbinding met de server<small>Er wordt elke 5 minuten opnieuw geprobeerd.</small>';
|
||
}
|
||
} finally {
|
||
refreshing = false;
|
||
render();
|
||
}
|
||
}
|
||
|
||
/* ---------- timers ---------- */
|
||
|
||
function scheduleClock() {
|
||
const now = new Date();
|
||
const wait = 60000 - (now.getSeconds() * 1000 + now.getMilliseconds());
|
||
setTimeout(() => {
|
||
renderHeader(new Date());
|
||
renderMeasurement(new Date());
|
||
scheduleClock();
|
||
}, wait);
|
||
}
|
||
|
||
function scheduleMidnight() {
|
||
setTimeout(() => {
|
||
refresh();
|
||
scheduleMidnight();
|
||
}, msUntil(new Date(), state.tz, 0));
|
||
}
|
||
|
||
function scheduleReload() {
|
||
setTimeout(() => location.reload(), msUntil(new Date(), state.tz, RELOAD_HOUR));
|
||
}
|
||
|
||
async function requestWakeLock() {
|
||
if (!('wakeLock' in navigator)) return;
|
||
try {
|
||
await navigator.wakeLock.request('screen');
|
||
} catch (err) {
|
||
// Niet beschikbaar of geweigerd; Automatisch vergrendelen op "Nooit" is de primaire oplossing.
|
||
}
|
||
}
|
||
|
||
/* ---------- service worker ---------- */
|
||
|
||
let swRegistration = null;
|
||
|
||
function registerServiceWorker() {
|
||
if (!('serviceWorker' in navigator)) return;
|
||
navigator.serviceWorker.register('./sw.js')
|
||
.then((reg) => { swRegistration = reg; })
|
||
.catch((err) => console.warn('weerscherm: service worker', err));
|
||
let reloading = false;
|
||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||
if (reloading) return;
|
||
reloading = true;
|
||
location.reload();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Vraagt de browser expliciet een nieuwe sw.js te controleren. Op een kiosk-scherm dat nooit navigeert
|
||
* (geen tabwissel, geen echte reload) gebeurt dat anders alleen impliciet en onvoorspelbaar laat — soms
|
||
* pas bij de volgende dagelijkse 04:00-reload. Met skipWaiting + clients.claim in sw.js activeert een
|
||
* gevonden update zich meteen, en de controllerchange-listener hierboven herlaadt de pagina dan vanzelf.
|
||
*/
|
||
function checkForUpdate() {
|
||
swRegistration?.update().catch(() => {});
|
||
}
|
||
|
||
/* ---------- start ---------- */
|
||
|
||
$('forecast').addEventListener('click', (e) => {
|
||
const day = e.target.closest('.a-day');
|
||
if (day) openHourOverlay(day.dataset.date);
|
||
});
|
||
$('hour-overlay').addEventListener('click', closeHourOverlay);
|
||
|
||
window.addEventListener('resize', fit);
|
||
window.addEventListener('orientationchange', () => setTimeout(fit, 50));
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.visibilityState === 'visible') {
|
||
fit();
|
||
refresh();
|
||
requestWakeLock();
|
||
checkForUpdate();
|
||
}
|
||
});
|
||
|
||
fit();
|
||
render();
|
||
refresh();
|
||
setInterval(refresh, REFRESH_MS);
|
||
setInterval(checkForUpdate, REFRESH_MS);
|
||
scheduleClock();
|
||
scheduleMidnight();
|
||
scheduleReload();
|
||
requestWakeLock();
|
||
registerServiceWorker();
|