Er is een vierde binnenmodule bijgekomen. De kaart verscheen al vanzelf (StationMapper leest alle NAModule4-modules uit), maar de kolom was op drie kaarten ontworpen: vier kaarten van 120px duwden de rij op 481px en daarmee de onderste strook 39px voorbij de onderrand van het canvas. De kolom heeft nu een vaste hoogte van 412px met overflow verborgen, en fitRooms() verkleint de inhoud met een schaal zodra die niet past. Bij drie modules verandert er niets (schaal 1), bij vier wordt het 0,86 en met een vastgezette module 0,78. Zo blijft het scherm kloppen, ongeacht hoeveel modules erbij komen. De dev-fixture heeft er een vierde module bij, zodat dit lokaal te zien is zonder Netatmo-koppeling. Servicewerker naar v12. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
468 lines
16 KiB
JavaScript
468 lines
16 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, 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 `<span style="${style}">${formatTime(t.at, state.tz)}</span>`;
|
||
})
|
||
.concat(['<span style="right:0">nu</span>'])
|
||
.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 = `<span class="pill ${co2.level} num" title="${co2.label}">${co2.level === 'unknown' ? '–' : r.co2 + ' ppm'}</span>`;
|
||
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 ? '' : `<svg class="a-rspark" viewBox="0 0 200 40" preserveAspectRatio="none" aria-hidden="true">
|
||
<path class="a-spark-line" d="${sparklinePath(points.map((p) => p.temp), 200, 40, 3)}"/>
|
||
</svg>`;
|
||
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>
|
||
<div class="a-rrow"><p class="a-rtemp num">${off ? '–' : f1(r.temp) + '°'}</p>${spark}</div>
|
||
<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('');
|
||
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) => `<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-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 `<span style="${style}">${formatTime(t.at, state.tz)}</span>`;
|
||
})
|
||
.concat(last ? ['<span style="right:0">nu</span>'] : [])
|
||
.join('');
|
||
}
|
||
|
||
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);
|
||
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 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();
|