From c65bc1d95711a4ff5ed54ec987b09cda9ad84e88 Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Fri, 11 Sep 2026 14:43:37 +0200 Subject: [PATCH] feat: frontend design A with scaling, formatting, refresh and error states Co-Authored-By: Claude Fable 5.1 --- public/app.js | 238 ++++++++++++++++++++++++++++++++++++++++++++++ public/index.html | 63 ++++++++++++ public/styles.css | 158 ++++++++++++++++++++++++++++++ 3 files changed, 459 insertions(+) create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/styles.css diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..a151310 --- /dev/null +++ b/public/app.js @@ -0,0 +1,238 @@ +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(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..1f2c674 --- /dev/null +++ b/public/index.html @@ -0,0 +1,63 @@ + + + + + +Weerscherm + + + + + + + + + + +
+
+
+   + + + meting – + –:– + +
+ +
+

Buiten

+

–

+
+
+
+ –laagst om – + –hoogst om – +
+
+
+ +
+ +
+
+

Regen

+
+
+

– mm

+

vandaag

+

––

+
+
+
+
+
+
+ + +
+
+
Draai de iPad een kwartslag
+ + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..d2745f7 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,158 @@ +/* Weerscherm, ontwerp A. Vast canvas van 1133 × 744 dat via JS geschaald en gecentreerd wordt. */ + +:root { + --bg: #E8EDF1; + --ink: #1B2A35; + --muted: #5F707C; + --line: #C6D0D8; + --warm: #B8761C; + --cold: #3A75A3; + --rain: #2E6A99; + --sun: #D39A2C; + --iconfill: #E8EDF1; + --w: 1133px; + --h: 744px; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { + height: 100%; + overflow: hidden; + background: var(--bg); + color: var(--ink); + font-family: -apple-system, "SF Pro Display", system-ui, "Segoe UI", Roboto, sans-serif; + font-variant-numeric: tabular-nums; + -webkit-text-size-adjust: none; + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; +} + +[hidden] { display: none !important; } + +#viewport { + position: fixed; + top: env(safe-area-inset-top, 0px); + right: env(safe-area-inset-right, 0px); + bottom: env(safe-area-inset-bottom, 0px); + left: env(safe-area-inset-left, 0px); + overflow: hidden; +} + +#stage { + position: absolute; + top: 0; + left: 0; + width: var(--w); + height: var(--h); + transform-origin: top left; + display: grid; + padding: 30px 44px; + grid-template-columns: 1fr 330px; + grid-template-rows: 40px 1fr 232px; + column-gap: 56px; + background: var(--bg); +} + +.num { font-variant-numeric: tabular-nums; } +.cold { color: var(--cold); } +.warm { color: var(--warm); } +.muted { color: var(--muted); } + +.ic { fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; width: 100%; height: 100%; display: block; } +.i-sun { stroke: var(--sun); } +.i-drop { stroke: var(--rain); } +.i-cloud { fill: var(--iconfill); } + +/* Kopregel */ +.a-top { grid-column: 1 / -1; display: flex; justify-content: space-between; align-items: baseline; } +.a-date { font-size: 22px; font-weight: 500; } +.a-right { display: flex; gap: 18px; align-items: baseline; } +.a-status { font-size: 15px; color: var(--warm); } +.a-upd { font-size: 15px; color: var(--muted); } +.a-upd.stale { color: var(--warm); } +.a-clock { font-size: 26px; font-weight: 300; } + +/* Buiten */ +.a-hero { align-self: center; } +.a-label { font-size: 22px; color: var(--muted); font-weight: 500; display: flex; gap: 14px; align-items: baseline; } +.a-trend { font-size: 17px; font-weight: 400; } +.a-temp { font-size: 200px; font-weight: 200; letter-spacing: -.045em; line-height: .95; margin: 4px 0 24px -8px; } +.a-range { width: 560px; } +.a-track { position: relative; height: 12px; border-radius: 6px; background: linear-gradient(90deg, var(--cold), var(--warm)); } +.a-mark { position: absolute; top: 50%; width: 28px; height: 28px; border-radius: 50%; background: var(--bg); border: 5px solid var(--ink); transform: translate(-50%, -50%); transition: left .6s ease; } +.a-ends { display: flex; justify-content: space-between; margin-top: 14px; font-size: 17px; color: var(--muted); } +.a-ends b { font-size: 24px; font-weight: 600; margin-right: 6px; } + +/* Binnen */ +.a-inside { align-self: center; } +.a-room { padding: 12px 0; border-top: 1px solid var(--line); } +.a-room:first-child { border-top: 0; } +.a-rname { font-size: 19px; color: var(--muted); } +.a-rtemp { font-size: 50px; font-weight: 300; line-height: 1.05; } +.a-room.off .a-rtemp { color: var(--muted); } +.a-rmm { font-size: 17px; display: flex; gap: 18px; } + +/* Onderste strook */ +.a-bottom { grid-column: 1 / -1; border-top: 1px solid var(--line); padding-top: 22px; display: grid; grid-template-columns: 390px 1fr; column-gap: 36px; } +.a-sub { font-size: 19px; color: var(--muted); font-weight: 500; margin-bottom: 10px; } +.a-rainrow { display: flex; justify-content: space-between; align-items: flex-end; } +.a-rbig { font-size: 22px; color: var(--rain); } +.a-rbig span { font-size: 60px; font-weight: 300; line-height: 1; } +.a-rsm { font-size: 17px; color: var(--muted); } +.a-month { font-size: 17px; margin-top: 18px; color: var(--muted); white-space: nowrap; } +.a-month b { color: var(--ink); font-weight: 600; font-size: 20px; margin-left: 4px; } +.a-bars { display: flex; gap: 6px; align-items: flex-end; } +.a-bar { display: flex; flex-direction: column; align-items: center; gap: 5px; font-size: 14px; color: var(--muted); min-width: 38px; } +.a-bar i { display: block; width: 24px; border-radius: 4px 4px 0 0; background: var(--rain); opacity: .4; transition: height .6s ease; } +.a-bar.now i { opacity: 1; } +.a-bar.now span { color: var(--ink); font-weight: 600; } + +/* Verwachting */ +.a-fc { display: grid; grid-template-columns: repeat(5, 1fr); text-align: center; } +.a-day { display: flex; flex-direction: column; align-items: center; gap: 6px; border-left: 1px solid var(--line); } +.a-day:first-child { border-left: 0; } +.a-dname { font-size: 19px; font-weight: 600; } +.a-icon { width: 58px; height: 58px; } +.a-hl { font-size: 28px; font-weight: 500; } +.a-hl span { font-size: 22px; color: var(--muted); font-weight: 400; margin-left: 6px; } +.a-fr { font-size: 16px; color: var(--rain); } +.a-fr span { color: var(--muted); margin-left: 6px; } + +/* Meldingen */ +.banner { + position: absolute; + left: 44px; + right: 44px; + top: 86px; + height: 396px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + padding: 40px 60px; + font-size: 30px; + line-height: 1.4; + color: var(--ink); + background: var(--bg); + border: 1px solid var(--line); + border-radius: 18px; + z-index: 2; +} +.banner small { display: block; margin-top: 16px; font-size: 20px; color: var(--muted); } + +#rotate { + display: none; + position: fixed; + top: 0; right: 0; bottom: 0; left: 0; + align-items: center; + justify-content: center; + font-size: 30px; + color: var(--muted); + background: var(--bg); + z-index: 10; +} +@media (orientation: portrait) { + #rotate { display: flex; } +}