feat: tik op het regenblok voor de Buienradar-animatie

Het regenblok linksonder opent een overlay met de geanimeerde regenradar
van Buienradar (550x512, tijd in het beeld), naast de regencijfers die we
al hebben: vandaag, de 3 vorige dagen en het maandtotaal. Sluit na 20
seconden of bij een tik, net als het uuroverzicht.

Het beeld loopt via onze eigen /api/radar.php in plaats van een <img>
rechtstreeks naar Buienradar: de spec wil alles lokaal geserveerd, de
iPad praat zo nooit met een derde partij, we cachen 4 minuten (de radar
ververst elke 5) en de servicewerker kan het laatste beeld offline
bewaren. Bij een storing komt het laatst opgehaalde beeld terug; pas als
er niets is, een nette melding in plaats van een leeg vlak.
Bronvermelding staat in de overlay, zoals Buienradar vraagt.

Verder: de overlay-machinerie (20s-timer, balk, sluiten) staat nu een
keer in plaats van per overlay, Http kan optioneel redirects volgen (de
beeld-API leidt door naar een CDN), en alle /api/-paden zijn in de
servicewerker network-first met een cachesleutel zonder querystring.
Servicewerker naar v7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 11:13:28 +02:00
co-authored by Claude Opus 5
parent 2200a849f9
commit c542601d4e
15 changed files with 515 additions and 32 deletions
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use Weerscherm\BuienradarClient;
use Weerscherm\Config;
use Weerscherm\Http;
use Weerscherm\RadarService;
$root = dirname(__DIR__, 2);
require $root . '/src/autoload.php';
header('X-Robots-Tag: noindex');
$configFile = $root . '/config.php';
if (!is_file($configFile)) {
http_response_code(500);
header('Content-Type: text/plain; charset=utf-8');
echo "config.php ontbreekt\n";
exit;
}
try {
$config = Config::load($configFile);
$ttl = $config->get('cache_ttl')['radar'] ?? 240;
$service = new RadarService(
// Buienradar leidt door naar een CDN, dus deze client volgt redirects.
new BuienradarClient(new Http(followRedirects: true)),
rtrim((string) $config->get('storage_path'), '/') . '/cache/radar.gif',
(int) $ttl,
);
$radar = $service->image();
header('Content-Type: image/gif');
header('Content-Length: ' . strlen($radar['bytes']));
// De browser mag het beeld hergebruiken tot de radar zelf ververst is.
header('Cache-Control: public, max-age=' . max(30, (int) $ttl - $radar['age']));
header('X-Radar-Stale: ' . ($radar['stale'] ? '1' : '0'));
echo $radar['bytes'];
} catch (Throwable $e) {
error_log('weerscherm: radar: ' . $e->getMessage());
http_response_code(503);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-store');
echo "Radarbeeld niet beschikbaar\n";
}
+72 -12
View File
@@ -14,7 +14,8 @@ const FETCH_TIMEOUT_MS = 10000;
const STALE_MINUTES = 30;
const RELOAD_HOUR = 4;
const FALLBACK_TZ = 'Europe/Berlin';
const HOURLY_OVERLAY_MS = 20000;
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 $ = (id) => document.getElementById(id);
@@ -120,7 +121,8 @@ function renderForecast(days) {
/* ---------- uuroverzicht per dag ---------- */
let hourlyOverlayTimer = null;
let overlayTimer = null;
let openOverlayId = null;
function renderHourlyCharts(hourly) {
const temps = hourly.map((h) => h.temp);
@@ -147,25 +149,81 @@ 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 || []);
showOverlay('hour-overlay', 'ho-bar-fill');
}
const fill = $('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 ${HOURLY_OVERLAY_MS}ms linear`;
fill.style.transition = `transform ${OVERLAY_MS}ms linear`;
fill.style.transform = 'scaleX(0)';
$('hour-overlay').hidden = false;
hourlyOverlayTimer = setTimeout(closeHourOverlay, HOURLY_OVERLAY_MS);
$(id).hidden = false;
openOverlayId = id;
overlayTimer = setTimeout(closeOverlay, OVERLAY_MS);
}
function closeHourOverlay() {
clearTimeout(hourlyOverlayTimer);
hourlyOverlayTimer = null;
$('hour-overlay').hidden = true;
function closeOverlay() {
clearTimeout(overlayTimer);
overlayTimer = null;
if (openOverlayId !== null) {
$(openOverlayId).hidden = true;
openOverlayId = null;
}
}
function renderBanner() {
@@ -319,7 +377,9 @@ $('forecast').addEventListener('click', (e) => {
const day = e.target.closest('.a-day');
if (day) openHourOverlay(day.dataset.date);
});
$('hour-overlay').addEventListener('click', closeHourOverlay);
$('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');
+23 -2
View File
@@ -47,7 +47,7 @@
<section class="a-inside" id="rooms"></section>
<section class="a-bottom">
<div>
<div id="rain-block">
<p class="a-sub">Regen</p>
<div class="a-rainrow">
<div>
@@ -63,7 +63,7 @@
<div class="banner" id="banner">Gegevens laden…</div>
<div class="hour-overlay" id="hour-overlay" hidden>
<div class="overlay" id="hour-overlay" hidden>
<div class="ho-bar"><i id="ho-bar-fill"></i></div>
<header class="ho-head">
<span class="ho-date" id="ho-date"></span>
@@ -79,6 +79,27 @@
<div class="ho-grid ho-wind" id="ho-wind"></div>
<div class="ho-grid ho-hours" id="ho-hours"></div>
</div>
<div class="overlay" id="radar-overlay" hidden>
<div class="ho-bar"><i id="ro-bar-fill"></i></div>
<header class="ho-head">
<span class="ho-date">Regen</span>
<span class="ho-hint">tik om te sluiten</span>
</header>
<div class="ro-body">
<div class="ro-map">
<p class="ro-status" id="ro-status">Radar laden…</p>
<img class="ro-img" id="ro-img" alt="Regenradar" hidden>
</div>
<div class="ro-side">
<p class="ro-big"><span class="num" id="ro-today">–</span> mm</p>
<p class="ro-sub">vandaag</p>
<div class="ro-days" id="ro-days"></div>
<p class="ro-month num" id="ro-month">–</p>
<p class="ro-source">bron: Buienradar.nl</p>
</div>
</div>
</div>
</main>
</div>
<div id="rotate">Draai de iPad een kwartslag</div>
+19 -3
View File
@@ -163,8 +163,9 @@ html, body {
}
.banner small { display: block; margin-top: 16px; font-size: 20px; color: var(--muted); }
/* Uuroverzicht per dag: tik op een dagkolom om te openen, tik erop of wacht 20s om te sluiten. */
.hour-overlay {
/* Overlays (uuroverzicht, regenradar): tik om te openen, tik erop of wacht 20s om te sluiten.
De .ho-*-chrome (balk, kop) wordt door beide overlays gedeeld. */
.overlay {
position: absolute;
left: 44px;
right: 44px;
@@ -180,7 +181,7 @@ html, body {
box-shadow: 0 12px 32px rgba(27, 42, 53, .12);
z-index: 5;
}
.hour-overlay[hidden] { display: none; }
.overlay[hidden] { display: none; }
.ho-bar { margin: 0 30px; height: 4px; border-radius: 2px; background: var(--line); overflow: hidden; }
.ho-bar i { display: block; width: 100%; height: 100%; background: var(--rain); transform-origin: left; }
@@ -207,6 +208,21 @@ html, body {
.ho-hours span { font-size: 16px; color: var(--muted); }
/* Regenradar */
.ro-body { display: flex; gap: 56px; margin: 0 30px; min-height: 0; }
.ro-map { display: flex; align-items: center; justify-content: center; width: 560px; flex: none; }
.ro-img { height: 512px; width: auto; display: block; border-radius: 10px; }
.ro-status { font-size: 22px; color: var(--muted); }
.ro-side { display: flex; flex-direction: column; justify-content: center; }
.ro-big { font-size: 26px; color: var(--rain); }
.ro-big span { font-size: 76px; font-weight: 300; line-height: 1; }
.ro-sub { font-size: 19px; color: var(--muted); margin-bottom: 26px; }
.ro-days p { display: flex; justify-content: space-between; width: 260px; font-size: 21px; color: var(--muted); padding: 7px 0; border-top: 1px solid var(--line); }
.ro-days b { color: var(--ink); font-weight: 600; }
.ro-month { width: 260px; font-size: 19px; color: var(--muted); margin-top: 18px; }
.ro-source { font-size: 15px; color: var(--muted); margin-top: auto; padding-top: 22px; }
#rotate {
display: none;
position: fixed;
+17 -9
View File
@@ -1,6 +1,6 @@
/* Service worker: app-shell cache-first, API network-first met timeout en fallback op de laatste response. */
const VERSION = 'weerscherm-v6';
const VERSION = 'weerscherm-v7';
const SHELL = [
'./',
'./index.html',
@@ -14,7 +14,8 @@ const SHELL = [
'./icons/icon-192.png',
'./icons/icon-512.png',
];
const API_PATH = '/api/data.php';
const API_PREFIX = '/api/';
const DATA_PATH = '/api/data.php';
const API_TIMEOUT_MS = 8000;
self.addEventListener('install', (event) => {
@@ -37,7 +38,7 @@ self.addEventListener('fetch', (event) => {
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
if (url.pathname.endsWith(API_PATH)) {
if (url.pathname.includes(API_PREFIX)) {
event.respondWith(networkFirst(req));
} else {
event.respondWith(cacheFirst(req));
@@ -46,25 +47,32 @@ self.addEventListener('fetch', (event) => {
async function networkFirst(req) {
const cache = await caches.open(VERSION);
const url = new URL(req.url);
// Sleutel zonder querystring: het radarbeeld krijgt elke paar minuten een andere ?t=, en zonder
// normaliseren zou de cache vollopen met varianten en zou offline nooit een treffer geven.
const key = url.origin + url.pathname;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
try {
const res = await fetch(req, { signal: controller.signal, cache: 'no-store' });
if (res.ok) {
cache.put(req, res.clone());
cache.put(key, res.clone());
}
return res;
} catch (err) {
const cached = await cache.match(req);
const cached = await cache.match(key);
if (cached) {
const headers = new Headers(cached.headers);
headers.set('X-From-Cache', '1');
return new Response(await cached.arrayBuffer(), { status: 200, statusText: 'OK', headers });
}
return new Response(JSON.stringify({ errors: ['offline'] }), {
status: 503,
headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' },
});
if (url.pathname.endsWith(DATA_PATH)) {
return new Response(JSON.stringify({ errors: ['offline'] }), {
status: 503,
headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' },
});
}
return new Response('offline', { status: 503, headers: { 'Cache-Control': 'no-store' } });
} finally {
clearTimeout(timer);
}