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)}

`; }); $('rooms').innerHTML = html.join(''); fitRooms(); } /** * Houdt de modulekolom binnen zijn vaste hoogte. Bij drie modules past alles op ware grootte; komt er een * module bij (of groeit er een doordat je hem vastzet), dan wordt het geheel net genoeg verkleind. Zonder * dit duwt een vierde kaart de onderste strook van het scherm af. */ function fitRooms() { const el = $('rooms'); el.style.transform = 'none'; const scale = scaleToFit(el.scrollHeight, INSIDE_H); el.style.transform = scale < 1 ? `scale(${scale.toFixed(4)})` : ''; } 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) => `
${f1(b.mm)}${b.label}
`).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-max', range ? String(Math.round(range.max)) : '–'); setText('press-min', range ? String(Math.round(range.min)) : '–'); $('press-path').setAttribute('d', sparklinePath(values, 560, 56)); // Hele uren op hun echte tijdspositie, plus "nu" op de rechterrand. const first = p.history[0]; const last = p.history[p.history.length - 1]; const ticks = first && last ? hourTicks(first.at, last.at, state.tz) : []; $('press-hours').innerHTML = ticks .map((t) => { // Een markering vlak op de linkerrand niet centreren, anders steekt het label buiten de grafiek. const style = t.percent < 3 ? 'left:0' : `left:${t.percent.toFixed(1)}%;transform:translateX(-50%)`; return `${formatTime(t.at, state.tz)}`; }) .concat(last ? ['nu'] : []) .join(''); } function renderForecast(days) { $('forecast').innerHTML = days.map((d) => `

${dayAbbr(d.date, state.tz)}

${icon(wmoIcon(d.weather_code))}

${fInt(d.temp_max)}°${fInt(d.temp_min)}°

${fmm(d.precip_mm)}${d.precip_prob === null ? '–' : d.precip_prob + '%'}

`).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) => `${fInt(h.temp)}°`).join(''); const heights = barHeights(hourly.map((h) => h.precip_mm), 40, 2); $('ho-rain').innerHTML = hourly.map((h, i) => `
${fmm(h.precip_mm)}
`).join(''); $('ho-wind').innerHTML = hourly.map((h) => { const deg = windArrowDeg(h.wind_dir); const arrow = deg === null ? '–' : `${windArrow()}`; return `
${arrow}${fKmh(h.wind_kmh)}
`; }).join(''); $('ho-hours').innerHTML = hourly.map((h) => `${formatHour(h.at, state.tz)}`).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) => `

${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 verlopenVoer bin/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();