feat: frontend design A with scaling, formatting, refresh and error states
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+238
@@ -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 `<div class="a-room${off ? ' off' : ''}">
|
||||
<p class="a-rname">${esc(r.name)}</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></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 renderForecast(days) {
|
||||
$('forecast').innerHTML = days.map((d) => `<div class="a-day">
|
||||
<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('');
|
||||
}
|
||||
|
||||
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);
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 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();
|
||||
@@ -0,0 +1,63 @@
|
||||
<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
|
||||
<title>Weerscherm</title>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="Weer">
|
||||
<meta name="theme-color" content="#E8EDF1">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<link rel="apple-touch-icon" href="icons/icon-180.png">
|
||||
<link rel="icon" href="icons/icon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="viewport">
|
||||
<main id="stage" class="da" aria-live="polite">
|
||||
<header class="a-top">
|
||||
<span class="a-date" id="date"> </span>
|
||||
<span class="a-right">
|
||||
<span class="a-status" id="status" hidden></span>
|
||||
<span class="a-upd" id="upd">meting –</span>
|
||||
<span class="a-clock num" id="clock">–:–</span>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<section class="a-hero">
|
||||
<p class="a-label">Buiten <span class="a-trend" id="trend"></span></p>
|
||||
<p class="a-temp num" id="temp">–</p>
|
||||
<div class="a-range">
|
||||
<div class="a-track"><span class="a-mark" id="mark" style="left:50%"></span></div>
|
||||
<div class="a-ends num">
|
||||
<span><b class="cold" id="min">–</b><span id="min-at">laagst om –</span></span>
|
||||
<span><b class="warm" id="max">–</b><span id="max-at">hoogst om –</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="a-inside" id="rooms"></section>
|
||||
|
||||
<section class="a-bottom">
|
||||
<div>
|
||||
<p class="a-sub">Regen</p>
|
||||
<div class="a-rainrow">
|
||||
<div>
|
||||
<p class="a-rbig"><span class="num" id="rain-today">–</span> mm</p>
|
||||
<p class="a-rsm">vandaag</p>
|
||||
<p class="a-month"><span id="month-name">–</span><b class="num" id="month-total">–</b></p>
|
||||
</div>
|
||||
<div class="a-bars" id="bars"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="a-fc" id="forecast"></div>
|
||||
</section>
|
||||
|
||||
<div class="banner" id="banner">Gegevens laden…</div>
|
||||
</main>
|
||||
</div>
|
||||
<div id="rotate">Draai de iPad een kwartslag</div>
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user