import { f1, fInt, fmm, markerPos, barHeights, wmoIcon, trendLabel, formatDate, formatTime, dayAbbr, monthName, isOlderThan, msUntil, } from './format.js'; import { icon } 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 $ = (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 }; /* ---------- 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('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 html = rooms.map((r) => { const off = !r.reachable || r.temp === null; return `

${esc(r.name)}

${off ? '–' : f1(r.temp) + '°'}

↓ ${off ? '–' : f1(r.min) + '°'}↑ ${off ? '–' : f1(r.max) + '°'}

`; }); $('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) => `
${f1(b.mm)}${b.label}
`).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(''); } 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); renderRooms(d.indoor); renderRain(d.rain, now); 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. } } /* ---------- start ---------- */ window.addEventListener('resize', fit); window.addEventListener('orientationchange', () => setTimeout(fit, 50)); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { fit(); refresh(); requestWakeLock(); } }); fit(); render(); refresh(); setInterval(refresh, REFRESH_MS); scheduleClock(); scheduleMidnight(); scheduleReload(); requestWakeLock();