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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user