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
+13 -5
View File
@@ -122,11 +122,19 @@ final class DashboardService
$this->addError(self::ERR_FORECAST);
return ['days' => [], 'stale' => true];
}
$daily = $this->fetch('forecast', fn() => $this->source->forecast($lat, $lon, $tz->getName()), self::ERR_FORECAST);
return [
'days' => $daily['data'] === null ? [] : ForecastMapper::map($daily['data']),
'stale' => $daily['stale'],
];
$result = $this->fetch('forecast', fn() => $this->source->forecast($lat, $lon, $tz->getName()), self::ERR_FORECAST);
if ($result['data'] === null) {
return ['days' => [], 'stale' => $result['stale']];
}
$days = ForecastMapper::map($result['data']['daily'] ?? []);
$hourlyByDate = HourlyForecastMapper::mapByDate($result['data']['hourly'] ?? [], $tz);
foreach ($days as &$day) {
$day['hourly'] = $hourlyByDate[$day['date']] ?? [];
}
unset($day);
return ['days' => $days, 'stale' => $result['stale']];
}
/**
+37 -8
View File
@@ -69,17 +69,46 @@ final class FixtureSource implements WeatherSource
public function forecast(float $lat, float $lon, string $timezone): array
{
$tmin = [11.0, 10.1, 11.3, 12.0, 9.4, 8.1];
$tmax = [17.9, 18.3, 20.1, 17.4, 15.2, 16.0];
$precipDay = [0.0, 0.4, 0.0, 8.2, 3.1, 0.2];
$dates = [];
for ($i = 0; $i < 6; $i++) {
$dates[] = $this->now->modify("+$i days")->format('Y-m-d');
$hourlyTime = [];
$hourlyTemp = [];
$hourlyPrecip = [];
$hourlyWind = [];
$hourlyDir = [];
for ($d = 0; $d < 6; $d++) {
$day = $this->now->modify("+$d days");
$dates[] = $day->format('Y-m-d');
for ($h = 0; $h < 24; $h++) {
$hourlyTime[] = $day->setTime($h, 0)->format('Y-m-d\TH:i');
$t = $tmin[$d] + ($tmax[$d] - $tmin[$d]) * (0.5 + 0.5 * sin((($h - 9) / 24) * 2 * M_PI));
$hourlyTemp[] = round($t, 1);
$p = ($precipDay[$d] > 0 && $h >= 14 && $h <= 16) ? round($precipDay[$d] / 3, 1) : 0.0;
$hourlyPrecip[] = $p;
$hourlyWind[] = round(3 + 6 * (0.5 + 0.5 * sin((($h + $d * 3) / 24) * 2 * M_PI)), 1);
$hourlyDir[] = ($d * 40 + $h * 7) % 360;
}
}
return [
'time' => $dates,
'weather_code' => [3, 2, 0, 61, 80, 95],
'temperature_2m_max' => [17.9, 18.3, 20.1, 17.4, 15.2, 16.0],
'temperature_2m_min' => [11.0, 10.1, 11.3, 12.0, 9.4, 8.1],
'precipitation_sum' => [0.0, 0.4, 0.0, 8.2, 3.1, 0.2],
'precipitation_probability_max' => [10, 20, 5, 80, 60, 15],
'daily' => [
'time' => $dates,
'weather_code' => [3, 2, 0, 61, 80, 95],
'temperature_2m_max' => $tmax,
'temperature_2m_min' => $tmin,
'precipitation_sum' => $precipDay,
'precipitation_probability_max' => [10, 20, 5, 80, 60, 15],
],
'hourly' => [
'time' => $hourlyTime,
'temperature_2m' => $hourlyTemp,
'precipitation' => $hourlyPrecip,
'wind_speed_10m' => $hourlyWind,
'wind_direction_10m' => $hourlyDir,
],
];
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Weerscherm;
/**
* Zet het "hourly"-blok van Open-Meteo om naar uurpunten per kalenderdatum, om het andere uur.
*
* Groepeert eerst chronologisch per datum in de gegeven tijdzone, en neemt daarna om het andere
* punt (index 0, 2, 4, …) binnen die dag. Dat is robuust voor de zomertijddag: een dag met 23 of 25
* uren levert vanzelf iets minder of meer dan 12 punten op, zonder speciale wissel-logica.
*/
final class HourlyForecastMapper
{
private const STEP = 2;
/**
* @param array<string, mixed> $hourly
* @return array<string, list<array{at: string, temp: ?float, precip_mm: ?float, wind_kmh: ?float, wind_dir: ?int}>>
* sleutel is de kalenderdatum (Y-m-d) in $tz
*/
public static function mapByDate(array $hourly, \DateTimeZone $tz): array
{
$times = is_array($hourly['time'] ?? null) ? $hourly['time'] : [];
$perDate = [];
foreach ($times as $i => $iso) {
if (!is_string($iso)) {
continue;
}
$at = self::parse($iso, $tz);
if ($at === null) {
continue;
}
$perDate[$at->format('Y-m-d')][] = [
'at' => $at->format(\DATE_ATOM),
'temp' => self::floatOrNull($hourly['temperature_2m'][$i] ?? null),
'precip_mm' => self::floatOrNull($hourly['precipitation'][$i] ?? null),
'wind_kmh' => self::floatOrNull($hourly['wind_speed_10m'][$i] ?? null),
'wind_dir' => self::intOrNull($hourly['wind_direction_10m'][$i] ?? null),
];
}
$out = [];
foreach ($perDate as $date => $points) {
$sampled = [];
foreach (array_values($points) as $index => $point) {
if ($index % self::STEP === 0) {
$sampled[] = $point;
}
}
$out[$date] = $sampled;
}
return $out;
}
/** Open-Meteo levert lokale tijd zonder offset, bv. "2026-09-14T00:00". */
private static function parse(string $iso, \DateTimeZone $tz): ?\DateTimeImmutable
{
$dt = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i', $iso, $tz);
return $dt === false ? null : $dt;
}
private static function floatOrNull(mixed $v): ?float
{
return is_int($v) || is_float($v) ? (float) $v : null;
}
private static function intOrNull(mixed $v): ?int
{
return is_int($v) || is_float($v) ? (int) round($v) : null;
}
}
+1 -1
View File
@@ -41,6 +41,6 @@ final class LiveSource implements WeatherSource
public function forecast(float $lat, float $lon, string $timezone): array
{
return $this->openMeteo->dailyForecast($lat, $lon, $timezone);
return $this->openMeteo->forecast($lat, $lon, $timezone);
}
}
+7 -3
View File
@@ -13,13 +13,14 @@ final class OpenMeteoClient
{
}
/** @return array<string, mixed> het "daily"-blok */
public function dailyForecast(float $lat, float $lon, string $timezone): array
/** @return array{daily: array<string, mixed>, hourly: array<string, mixed>} */
public function forecast(float $lat, float $lon, string $timezone): array
{
$res = $this->http->get(self::URL, [
'latitude' => $lat,
'longitude' => $lon,
'daily' => 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max',
'hourly' => 'temperature_2m,precipitation,wind_speed_10m,wind_direction_10m',
'timezone' => $timezone,
'forecast_days' => 6,
]);
@@ -30,6 +31,9 @@ final class OpenMeteoClient
if (!is_array($json['daily'] ?? null)) {
throw new UpstreamException('Open-Meteo: geen daily-blok in antwoord');
}
return $json['daily'];
return [
'daily' => $json['daily'],
'hourly' => is_array($json['hourly'] ?? null) ? $json['hourly'] : [],
];
}
}
+1 -1
View File
@@ -16,6 +16,6 @@ interface WeatherSource
/** @return array<string|int, mixed> de "body" van Netatmo getmeasure (timestamp => [hPa]) voor het basisstation */
public function pressureHistory(string $deviceId, int $begin, int $end): array;
/** @return array<string, mixed> het "daily"-blok van Open-Meteo */
/** @return array{daily: array<string, mixed>, hourly: array<string, mixed>} de "daily"- en "hourly"-blokken van Open-Meteo */
public function forecast(float $lat, float $lon, string $timezone): array;
}