feat: uuroverzicht per dag (temperatuur, regen, wind)

Tik op een dag in de verwachtingsstrook voor een uuroverzicht van
temperatuur, regen en wind (richting + snelheid), om de 2 uur, 12
punten per dag. Sluit na 20 seconden vanzelf of bij een tik op de
overlay zelf.

Backend: Open-Meteo levert hourly-data in dezelfde call als daily
(geen extra verzoek, dezelfde 60min-cache). Nieuwe HourlyForecastMapper
groepeert per kalenderdatum en bemonstert om het andere uur, robuust
voor de zomertijddag (23/25 uur). OpenMeteoClient::dailyForecast()
hernoemd naar forecast(), geeft nu {daily, hourly} terug. Elke dag in
de forecast-array krijgt een hourly-lijst.

Frontend: temperatuurlijn hergebruikt sparklinePath (nu ook als
sparklinePoints voor exacte uitlijning met de 12-koloms grid), regen
als kleine staafjes, windpijl (nieuw icoon) wijst waarheen de wind
waait. 20s-balk via CSS transition met reflow-restart. Servicewerker
naar v5.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 10:18:11 +02:00
co-authored by Claude Sonnet 5
parent 90e979f562
commit 8e90eb1129
20 changed files with 1420 additions and 62 deletions
+61 -2
View File
@@ -2,8 +2,9 @@ import {
f1, fInt, fmm, markerPos, barHeights, wmoIcon, trendLabel,
formatDate, formatTime, dayAbbr, monthName, isOlderThan, msUntil,
co2Level, fPct, fHpa, seriesRange, sparklinePath,
windArrowDeg, fKmh, formatHour, formatFullDate,
} from './format.js';
import { icon } from './icons.js';
import { icon, windArrow } from './icons.js';
const CANVAS_W = 1133;
const CANVAS_H = 744;
@@ -13,6 +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 HOURLY_WIDTH = 1045; // = stage-breedte 1133 min 2x 44px padding, zelfde marge als de rest van het scherm
const $ = (id) => document.getElementById(id);
const stage = $('stage');
@@ -104,7 +107,7 @@ function renderPressure(p) {
}
function renderForecast(days) {
$('forecast').innerHTML = days.map((d) => `<div class="a-day">
$('forecast').innerHTML = days.map((d) => `<div class="a-day" data-date="${d.date}">
<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>
@@ -112,6 +115,56 @@ function renderForecast(days) {
</div>`).join('');
}
/* ---------- uuroverzicht per dag ---------- */
let hourlyOverlayTimer = null;
function renderHourlyCharts(hourly) {
const temps = hourly.map((h) => h.temp);
$('ho-temp-path').setAttribute('d', sparklinePath(temps, HOURLY_WIDTH, 64, HOURLY_WIDTH / 24));
$('ho-temp-nums').innerHTML = hourly.map((h) => `<span class="num">${fInt(h.temp)}°</span>`).join('');
const heights = barHeights(hourly.map((h) => h.precip_mm), 40, 2);
$('ho-rain').innerHTML = hourly.map((h, i) => `<div class="ho-rbar">
<i style="height:${heights[i].toFixed(1)}px"></i><span class="num">${fmm(h.precip_mm)}</span>
</div>`).join('');
$('ho-wind').innerHTML = hourly.map((h) => {
const deg = windArrowDeg(h.wind_dir);
const arrow = deg === null
? '<span class="ho-warrow off">–</span>'
: `<span class="ho-warrow" style="transform:rotate(${deg}deg)">${windArrow()}</span>`;
return `<div class="ho-wcell">${arrow}<span class="num">${fKmh(h.wind_kmh)}</span></div>`;
}).join('');
$('ho-hours').innerHTML = hourly.map((h) => `<span class="num">${formatHour(h.at, state.tz)}</span>`).join('');
}
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 || []);
const fill = $('ho-bar-fill');
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.transform = 'scaleX(0)';
$('hour-overlay').hidden = false;
hourlyOverlayTimer = setTimeout(closeHourOverlay, HOURLY_OVERLAY_MS);
}
function closeHourOverlay() {
clearTimeout(hourlyOverlayTimer);
hourlyOverlayTimer = null;
$('hour-overlay').hidden = true;
}
function renderBanner() {
const banner = $('banner');
const errors = state.data ? state.data.errors : [];
@@ -259,6 +312,12 @@ function checkForUpdate() {
/* ---------- start ---------- */
$('forecast').addEventListener('click', (e) => {
const day = e.target.closest('.a-day');
if (day) openHourOverlay(day.dataset.date);
});
$('hour-overlay').addEventListener('click', closeHourOverlay);
window.addEventListener('resize', fit);
window.addEventListener('orientationchange', () => setTimeout(fit, 50));
document.addEventListener('visibilitychange', () => {