import { f1, fInt, fmm, markerPos, barHeights, wmoIcon, trendLabel, formatDate, formatTime, dayAbbr, monthName, isOlderThan, msUntil, co2Level, fPct, fHpa, seriesRange, sparklinePath, windArrowDeg, fKmh, formatHour, formatFullDate, pinRoom, hourTicks, scaleToFit, } 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 INSIDE_H = 412; // hoogte van de middelste rij: 744 - 2x30 padding - 40 kopregel - 232 onderstrook 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)}`); } /** Verloop van vandaag naast het grote getal. Met minder dan twee punten valt er niets te tekenen. */ function renderTodayChart(history) { const block = $('today-chart'); const points = history?.points || []; if (points.length < 2) { block.hidden = true; return; } block.hidden = false; $('today-path').setAttribute('d', sparklinePath(points.map((p) => p.temp), 200, 96, 4)); const ticks = hourTicks(points[0].at, points[points.length - 1].at, state.tz); $('today-hours').innerHTML = ticks .map((t) => { const style = t.percent < 6 ? 'left:0' : `left:${t.percent.toFixed(1)}%;transform:translateX(-50%)`; return `${formatTime(t.at, state.tz)}`; }) .concat(['nu']) .join(''); } 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 = `${co2.level === 'unknown' ? '–' : r.co2 + ' ppm'}`; const pinned = r.id === state.pinnedRoomId; const points = r.history || []; // Zonder twee punten valt er niets te tekenen; dan blijft de plek naast het getal gewoon leeg. const spark = points.length < 2 ? '' : ``; return `
${esc(r.name)}${pill}
${off ? '–' : f1(r.temp) + '°'}
${spark}↓ ${off ? '–' : f1(r.min) + '°'}↑ ${off ? '–' : f1(r.max) + '°'}RV ${off ? '–' : fPct(r.humidity)}
${dayAbbr(d.date, state.tz)}
${fInt(d.temp_max)}°${fInt(d.temp_min)}°
${fmm(d.precip_mm)}${d.precip_prob === null ? '–' : d.precip_prob + '%'}
${dayAbbr(p.date, state.tz)}${fmm(p.mm)}
`) .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 verlopenVoerbin/netatmo-init.php opnieuw uit met een nieuwe refresh token.';
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);
renderTodayChart(d.outdoor_history);
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 serverEr wordt elke 5 minuten opnieuw geprobeerd.';
}
} 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();