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:
+61
-2
@@ -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', () => {
|
||||
|
||||
+47
-10
@@ -148,23 +148,60 @@ export function seriesRange(values) {
|
||||
return { min: Math.min(...nums), max: Math.max(...nums) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Coördinaten van een reeks binnen een box van w×h met `pad` px marge, geschaald op min..max.
|
||||
* Een vlakke reeks komt op het midden. `y` is null voor een onbekende waarde; `x` blijft altijd gezet
|
||||
* zodat labels/staafjes/pijlen op dezelfde plek als de lijn kunnen worden gepositioneerd.
|
||||
*/
|
||||
export function sparklinePoints(values, w, h, pad = 3) {
|
||||
const range = seriesRange(values);
|
||||
const innerW = w - 2 * pad;
|
||||
const innerH = h - 2 * pad;
|
||||
const step = values.length > 1 ? innerW / (values.length - 1) : 0;
|
||||
const span = range ? range.max - range.min : 0;
|
||||
return values.map((v, i) => {
|
||||
const x = pad + i * step;
|
||||
if (!isNum(v) || !range) return { x, y: null };
|
||||
const y = span === 0 ? h / 2 : pad + innerH - ((v - range.min) / span) * innerH;
|
||||
return { x, y };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* SVG-pad voor een sparkline: punten gelijkmatig over de breedte, waarden geschaald op min..max
|
||||
* met `pad` px marge. Een vlakke reeks wordt een lijn door het midden. Minder dan 2 punten -> "".
|
||||
*/
|
||||
export function sparklinePath(values, w, h, pad = 3) {
|
||||
const range = seriesRange(values);
|
||||
if (!range || values.length < 2) return '';
|
||||
const span = range.max - range.min;
|
||||
const innerW = w - 2 * pad;
|
||||
const innerH = h - 2 * pad;
|
||||
const step = innerW / (values.length - 1);
|
||||
if (values.length < 2) return '';
|
||||
const parts = [];
|
||||
values.forEach((v, i) => {
|
||||
if (!isNum(v)) return;
|
||||
const x = pad + i * step;
|
||||
const y = span === 0 ? h / 2 : pad + innerH - ((v - range.min) / span) * innerH;
|
||||
sparklinePoints(values, w, h, pad).forEach(({ x, y }) => {
|
||||
if (y === null) return;
|
||||
parts.push(`${parts.length === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`);
|
||||
});
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/** Windrichting (meteorologisch: vanwaar de wind komt) -> rotatie zodat de pijl waarheen wijst. */
|
||||
export function windArrowDeg(dir) {
|
||||
return isNum(dir) ? (dir + 180) % 360 : null;
|
||||
}
|
||||
|
||||
/** 8.4 -> "8 km/u"; null -> "–" */
|
||||
export function fKmh(v) {
|
||||
return isNum(v) ? `${Math.round(v)} km/u` : DASH;
|
||||
}
|
||||
|
||||
/** ISO-tijdstip -> "14" (tweecijferig uur) in de tijdzone; null -> "–" */
|
||||
export function formatHour(iso, tz) {
|
||||
const d = iso instanceof Date ? iso : new Date(iso);
|
||||
if (!iso || Number.isNaN(d.getTime())) return DASH;
|
||||
const parts = new Intl.DateTimeFormat('en-GB', { hour: '2-digit', hourCycle: 'h23', timeZone: tz }).formatToParts(d);
|
||||
return parts.find((p) => p.type === 'hour').value;
|
||||
}
|
||||
|
||||
/** "2026-09-14" -> "Maandag 14 september" in de tijdzone (net als formatDate, maar vanuit een datumstring). */
|
||||
export function formatFullDate(dateStr, tz) {
|
||||
const d = new Date(dateStr + 'T12:00:00Z');
|
||||
if (Number.isNaN(d.getTime())) return DASH;
|
||||
return formatDate(d, tz);
|
||||
}
|
||||
|
||||
@@ -30,3 +30,8 @@ export function icon(name) {
|
||||
const inner = ICONS[name] || ICONS.cloud;
|
||||
return `<svg class="ic" viewBox="0 0 48 48" aria-hidden="true">${inner}</svg>`;
|
||||
}
|
||||
|
||||
/** Compacte windpijl, wijst standaard naar boven. Draai 'm met een inline transform naar de juiste hoek. */
|
||||
export function windArrow() {
|
||||
return '<svg class="wind-ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 19V5M6.5 10.5 12 5l5.5 5.5"/></svg>';
|
||||
}
|
||||
|
||||
@@ -62,6 +62,23 @@
|
||||
</section>
|
||||
|
||||
<div class="banner" id="banner">Gegevens laden…</div>
|
||||
|
||||
<div class="hour-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>
|
||||
<span class="ho-hint">tik om te sluiten</span>
|
||||
</header>
|
||||
<div class="ho-temp">
|
||||
<svg class="ho-temp-svg" viewBox="0 0 1045 64" preserveAspectRatio="none" aria-hidden="true">
|
||||
<path class="ho-temp-line" id="ho-temp-path" d=""/>
|
||||
</svg>
|
||||
<div class="ho-grid" id="ho-temp-nums"></div>
|
||||
</div>
|
||||
<div class="ho-grid ho-rain" id="ho-rain"></div>
|
||||
<div class="ho-grid ho-wind" id="ho-wind"></div>
|
||||
<div class="ho-grid ho-hours" id="ho-hours"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<div id="rotate">Draai de iPad een kwartslag</div>
|
||||
|
||||
+45
-1
@@ -62,7 +62,7 @@ html, body {
|
||||
.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; }
|
||||
.ic, .wind-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); }
|
||||
@@ -162,6 +162,50 @@ 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 {
|
||||
position: absolute;
|
||||
left: 44px;
|
||||
right: 44px;
|
||||
top: 40px;
|
||||
bottom: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
padding: 26px 0 22px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 12px 32px rgba(27, 42, 53, .12);
|
||||
z-index: 5;
|
||||
}
|
||||
.hour-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; }
|
||||
|
||||
.ho-head { margin: 0 30px; display: flex; justify-content: space-between; align-items: baseline; }
|
||||
.ho-date { font-size: 22px; font-weight: 500; }
|
||||
.ho-hint { font-size: 16px; color: var(--muted); }
|
||||
|
||||
.ho-grid { display: grid; grid-template-columns: repeat(12, 1fr); text-align: center; }
|
||||
|
||||
.ho-temp { display: flex; flex-direction: column; gap: 6px; }
|
||||
#ho-temp-nums .num { font-size: 22px; font-weight: 500; }
|
||||
.ho-temp-svg { display: block; width: 100%; height: 64px; }
|
||||
.ho-temp-line { fill: none; stroke: var(--ink); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
|
||||
|
||||
.ho-rain .ho-rbar { display: flex; flex-direction: column; align-items: center; justify-content: flex-end; gap: 6px; height: 66px; }
|
||||
.ho-rbar i { display: block; width: 14px; border-radius: 3px 3px 0 0; background: var(--rain); opacity: .55; }
|
||||
.ho-rbar .num { font-size: 14px; color: var(--muted); }
|
||||
|
||||
.ho-wind .ho-wcell { display: flex; flex-direction: column; align-items: center; gap: 6px; }
|
||||
.ho-warrow { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; color: var(--muted); }
|
||||
.ho-warrow.off { font-size: 18px; }
|
||||
.ho-wcell .num { font-size: 15px; color: var(--muted); }
|
||||
|
||||
.ho-hours span { font-size: 16px; color: var(--muted); }
|
||||
|
||||
#rotate {
|
||||
display: none;
|
||||
position: fixed;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
/* Service worker: app-shell cache-first, API network-first met timeout en fallback op de laatste response. */
|
||||
|
||||
const VERSION = 'weerscherm-v4';
|
||||
const VERSION = 'weerscherm-v5';
|
||||
const SHELL = [
|
||||
'./',
|
||||
'./index.html',
|
||||
|
||||
Reference in New Issue
Block a user