Files
weerscherm/public/app.js
T
theoandClaude Sonnet 5 350dafe591 feat: tik op een module om die bovenaan te zetten met een groter temperatuurgetal
Tik op een van de drie binnenmodules zet die bovenaan de lijst (de
overige twee behouden hun onderlinge volgorde) en vergroot alleen het
temperatuurgetal van 50 naar 92px. Nogmaals tikken op de bovenste
(vastgezette) module zet 'm weer los. Puur client-side, geen nieuwe
API-velden: state.pinnedRoomId + de pure functie pinRoom() (met tests)
in format.js. De keuze overleeft de 5-minuten-verversing, reset bij een
volledige herlaad. Servicewerker naar v6.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 10:27:09 +02:00

352 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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
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 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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);
$('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();