Het regenblok linksonder opent een overlay met de geanimeerde regenradar van Buienradar (550x512, tijd in het beeld), naast de regencijfers die we al hebben: vandaag, de 3 vorige dagen en het maandtotaal. Sluit na 20 seconden of bij een tik, net als het uuroverzicht. Het beeld loopt via onze eigen /api/radar.php in plaats van een <img> rechtstreeks naar Buienradar: de spec wil alles lokaal geserveerd, de iPad praat zo nooit met een derde partij, we cachen 4 minuten (de radar ververst elke 5) en de servicewerker kan het laatste beeld offline bewaren. Bij een storing komt het laatst opgehaalde beeld terug; pas als er niets is, een nette melding in plaats van een leeg vlak. Bronvermelding staat in de overlay, zoals Buienradar vraagt. Verder: de overlay-machinerie (20s-timer, balk, sluiten) staat nu een keer in plaats van per overlay, Http kan optioneel redirects volgen (de beeld-API leidt door naar een CDN), en alle /api/-paden zijn in de servicewerker network-first met een cachesleutel zonder querystring. Servicewerker naar v7. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
412 lines
14 KiB
JavaScript
412 lines
14 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, pinRoom,
|
||
} 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 OVERLAY_MS = 20000; // hoe lang een overlay blijft staan zonder tik
|
||
const RADAR_BUCKET_MS = 240000; // hergebruikvenster voor het radarbeeld, gelijk aan de servercache
|
||
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
|
||
pinnedRoomId: null, // id van de module die vastgezet is bovenaan; blijft staan tot opnieuw getikt of een volledige herlaad
|
||
};
|
||
|
||
/* ---------- 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 ordered = pinRoom(rooms, state.pinnedRoomId);
|
||
const html = ordered.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>`;
|
||
const pinned = r.id === state.pinnedRoomId;
|
||
return `<div class="a-room${off ? ' off' : ''}${pinned ? ' pinned' : ''}" data-id="${esc(r.id)}">
|
||
<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 overlayTimer = null;
|
||
let openOverlayId = 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;
|
||
|
||
setText('ho-date', formatFullDate(day.date, state.tz));
|
||
renderHourlyCharts(day.hourly || []);
|
||
showOverlay('hour-overlay', 'ho-bar-fill');
|
||
}
|
||
|
||
/* ---------- regenradar ---------- */
|
||
|
||
function renderRainDetails(rain) {
|
||
setText('ro-today', f1(rain.today));
|
||
$('ro-days').innerHTML = rain.previous_days
|
||
.map((p) => `<p><span>${dayAbbr(p.date, state.tz)}</span><b class="num">${fmm(p.mm)}</b></p>`)
|
||
.join('');
|
||
setText('ro-month', `${monthName(new Date(), state.tz)} ${f1(rain.month_total)} mm`);
|
||
}
|
||
|
||
/**
|
||
* Zet het radarbeeld klaar. De bucket in de URL verandert elke RADAR_BUCKET_MS, zodat de browser
|
||
* binnen dat venster het al geladen beeld hergebruikt (tik = meteen zichtbaar) en daarna vanzelf
|
||
* een verser beeld ophaalt. Er wordt niets op de achtergrond geladen zolang niemand kijkt.
|
||
*/
|
||
function loadRadarImage() {
|
||
const img = $('ro-img');
|
||
const status = $('ro-status');
|
||
const src = `api/radar.php?t=${Math.floor(Date.now() / RADAR_BUCKET_MS)}`;
|
||
if (img.getAttribute('src') === src && img.complete && img.naturalWidth > 0) {
|
||
return;
|
||
}
|
||
|
||
status.textContent = 'Radar laden…';
|
||
status.hidden = false;
|
||
img.hidden = true;
|
||
img.onload = () => {
|
||
status.hidden = true;
|
||
img.hidden = false;
|
||
};
|
||
img.onerror = () => {
|
||
status.textContent = 'Radar niet beschikbaar';
|
||
status.hidden = false;
|
||
img.hidden = true;
|
||
};
|
||
img.setAttribute('src', src);
|
||
}
|
||
|
||
function openRadarOverlay() {
|
||
if (!state.data) return;
|
||
renderRainDetails(state.data.rain);
|
||
loadRadarImage();
|
||
showOverlay('radar-overlay', 'ro-bar-fill');
|
||
}
|
||
|
||
/* ---------- gedeelde overlay-machinerie ---------- */
|
||
|
||
/** Toont een overlay, start de 20s-balk opnieuw en zet de sluittimer. */
|
||
function showOverlay(id, barFillId) {
|
||
closeOverlay();
|
||
|
||
const fill = $(barFillId);
|
||
fill.style.transition = 'none';
|
||
fill.style.transform = 'scaleX(1)';
|
||
fill.getBoundingClientRect(); // forceer reflow zodat de volgende transitie opnieuw start
|
||
fill.style.transition = `transform ${OVERLAY_MS}ms linear`;
|
||
fill.style.transform = 'scaleX(0)';
|
||
|
||
$(id).hidden = false;
|
||
openOverlayId = id;
|
||
overlayTimer = setTimeout(closeOverlay, OVERLAY_MS);
|
||
}
|
||
|
||
function closeOverlay() {
|
||
clearTimeout(overlayTimer);
|
||
overlayTimer = null;
|
||
if (openOverlayId !== null) {
|
||
$(openOverlayId).hidden = true;
|
||
openOverlayId = null;
|
||
}
|
||
}
|
||
|
||
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', closeOverlay);
|
||
$('radar-overlay').addEventListener('click', closeOverlay);
|
||
$('rain-block').addEventListener('click', openRadarOverlay);
|
||
|
||
$('rooms').addEventListener('click', (e) => {
|
||
const room = e.target.closest('.a-room');
|
||
if (!room || !state.data) return;
|
||
state.pinnedRoomId = state.pinnedRoomId === room.dataset.id ? null : room.dataset.id;
|
||
renderRooms(state.data.indoor);
|
||
});
|
||
|
||
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();
|