historical weather

This commit is contained in:
Vincent van der Wal
2026-07-25 15:24:03 +02:00
parent f8d7c7699f
commit 61d84e4450
15 changed files with 1653 additions and 25 deletions
@@ -0,0 +1,23 @@
<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { storedLocation } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location';
// at build time this page knows nothing about the visitor, so the redirect
// target (the persisted location) is resolved in the browser instead of
// being baked to the default city during prerender
onMount(() => {
goto(
resolve('/weather/historical/[location]', {
location: buildLocationRoute(get(storedLocation))
}),
{ replaceState: true }
);
});
</script>
@@ -0,0 +1,276 @@
<script lang="ts">
import { onMount } from 'svelte';
import { SvelteDate } from 'svelte/reactivity';
import { fade } from 'svelte/transition';
import {
storedChartLayout,
storedLocation,
storedUnits,
storedVariablePrefs
} from '$lib/stores/settings';
import { ChartContainer } from '$lib/components/charts';
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
import { isPremium } from '$lib/paywall/premium';
import {
type ClimateNormals,
type HistoricalForecastResult,
fetchClimateNormals,
fetchHistoricalWeather
} from '$lib/services/weather';
import { defaultParameters } from '../../options';
import HourlyTable from '../../week/[location]/HourlyTable.svelte';
import { neededHourlyApiVars } from '../../week/[location]/variables';
import DateRangeControls from './DateRangeControls.svelte';
import HistoricalDaily from './HistoricalDaily.svelte';
import HistoricalMeteograms from './HistoricalMeteograms.svelte';
import type { FetchedDaily, FetchedHourly } from '../../week/[location]/types';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let location = $derived(data.location);
$effect(() => {
storedLocation.set(data.location);
});
let params = $state({ ...defaultParameters });
$effect(() => {
params.temperature_unit = $storedUnits.temperature_unit;
params.wind_speed_unit = $storedUnits.wind_speed_unit;
params.precipitation_unit = $storedUnits.precipitation_unit;
});
// Request only what the table rows + meteogram layout actually show.
let hourlyVars = $derived(
neededHourlyApiVars(
$storedVariablePrefs.table,
$storedChartLayout.flatMap((p) => p.variables)
)
);
// ─── Date range ───────────────────────────────────────────────────────────
const iso = (d: Date): string => d.toISOString().slice(0, 10);
const addDays = (d: Date, n: number): Date => {
const c = new Date(d);
c.setUTCDate(c.getUTCDate() + n);
return c;
};
const MIN_DATE = '1940-01-01'; // ERA5 archive start
// The reanalysis archive lags real time by a few days.
let maxDate = $state(iso(addDays(new Date(), -5)));
let startDate = $state(iso(addDays(new Date(), -34)));
let endDate = $state(iso(addDays(new Date(), -5)));
onMount(() => {
const today = new Date();
maxDate = iso(addDays(today, -5));
endDate = maxDate;
startDate = iso(addDays(today, -34));
mounted = true;
});
function onRangeChange(s: string, e: string) {
startDate = s;
endDate = e;
}
// ─── Fetch state ────────────────────────────────────────────────────────────
let mounted = $state(false);
let loading = $state(true);
let loadError = $state<string | null>(null);
let requestVersion = 0;
let result = $state<HistoricalForecastResult | null>(null);
let normals = $state<ClimateNormals | null>(null);
const selectedDay = new SvelteDate();
// Historical data: refetch on location / range / units / requested-vars change.
$effect(() => {
const loc = location;
const s = startDate;
const e = endDate;
const vars = hourlyVars;
if (!mounted || !$isPremium || !loc || !s || !e) return;
const version = ++requestVersion;
loading = true;
loadError = null;
fetchHistoricalWeather({
latitude: loc.latitude!,
longitude: loc.longitude!,
start_date: s,
end_date: e,
hourlyVariables: vars,
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
timezone: loc.timezone
})
.then((r) => {
if (version !== requestVersion) return;
result = r;
// default the hourly drill-down to the last day in range
if (r.dailyDates.length > 0) {
selectedDay.setTime(r.dailyDates[r.dailyDates.length - 1].getTime());
}
loading = false;
})
.catch((err: unknown) => {
if (version !== requestVersion) return;
loadError = err instanceof Error ? err.message : String(err);
loading = false;
});
});
// Climate normals: independent of the range, so fetch once per location/units.
let normalsKey = $derived(
`${location?.latitude},${location?.longitude},${params.temperature_unit},${params.precipitation_unit}`
);
let normalsVersion = 0;
$effect(() => {
const key = normalsKey;
const loc = location;
if (!mounted || !$isPremium || !loc) return;
const version = ++normalsVersion;
normals = null;
fetchClimateNormals({
latitude: loc.latitude!,
longitude: loc.longitude!,
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
precipitation_unit: params.precipitation_unit as 'mm' | 'inch'
})
.then((n) => {
if (version === normalsVersion) normals = n;
})
.catch(() => {
// normals are a nice-to-have; a failure just hides the comparison
if (version === normalsVersion) normals = null;
});
// re-read key so the effect tracks it
void key;
});
// ─── Adapters so the reused week components accept historical data ──────────
let fetchedHourly = $derived<FetchedHourly | null>(
result
? {
hourly: result.hourly,
utc_offset_seconds: result.utcOffsetSeconds,
timezone: result.timezone,
timestamps: result.hourlyTimestamps,
hourlyDates: result.hourlyDates,
daylightBands: result.daylightBands
}
: null
);
let fetchedDaily = $derived<FetchedDaily | null>(
result
? {
daily: {
weather_code: result.daily.weather_code,
temperature_2m_max: result.daily.temperature_2m_max,
temperature_2m_min: result.daily.temperature_2m_min,
sunrise: result.daily.sunrise,
sunset: result.daily.sunset,
sunshine_duration: result.daily.sunshine_duration,
precipitation_sum: result.daily.precipitation_sum,
windspeed_10m_max: result.daily.windspeed_10m_max,
windgusts_10m_max: result.daily.windgusts_10m_max,
winddirection_10m_dominant: result.daily.winddirection_10m_dominant
},
timezone: result.timezone,
dailyDates: result.dailyDates
}
: null
);
function switchDay(date: Date) {
selectedDay.setTime(date.getTime());
}
</script>
<svelte:head>
<title>Drizz.li | Historical weather</title>
<meta name="description" content="Past weather and climate-normal comparisons for any location" />
</svelte:head>
<!-- Page hero -->
<div class="relative mb-3 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
<div class="flex min-w-0 items-center gap-3">
<img
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
alt={location.country ?? ''}
/>
<div class="min-w-0">
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
{location.name}
</h1>
<p class="truncate text-sm text-muted-foreground">
<span class="lg:hidden"
>{#if location.admin1}{location.admin1},
{/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span
>Historical weather
</p>
</div>
</div>
</div>
<PaywallGate feature="Historical weather">
<DateRangeControls
start={startDate}
end={endDate}
minDate={MIN_DATE}
{maxDate}
onChange={onRangeChange}
/>
{#if loadError}
<div
class="mt-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
>
Failed to load historical data: {loadError}
</div>
{/if}
<div class="mt-4">
{#if result && fetchedHourly && fetchedDaily}
<HistoricalDaily
daily={result.daily}
dailyDates={result.dailyDates}
timezone={result.timezone}
units={params}
{normals}
{selectedDay}
onSelectDay={switchDay}
/>
<div class="mt-6">
<HourlyTable
data={fetchedHourly}
daily={fetchedDaily}
{selectedDay}
units={params}
locationName={location.name ?? ''}
/>
</div>
<HistoricalMeteograms data={fetchedHourly} units={params} {loading} {selectedDay} />
{:else}
<div transition:fade={{ duration: 200 }} class="grid gap-3">
<div class="h-28 animate-pulse rounded-2xl border border-border/70 bg-card"></div>
<ChartContainer loading chartCount={3} chartHeight={300} bleed={false} />
</div>
{/if}
</div>
</PaywallGate>
@@ -0,0 +1,13 @@
import { resolveLocationFromRoute } from '$lib/utils/location';
import type { PageLoad } from './$types';
export const load: PageLoad = async (event) => {
const location = await resolveLocationFromRoute({
urlLocation: event.params.location,
routePrefix: '/weather/historical/',
event
});
return { location };
};
@@ -0,0 +1,108 @@
<script lang="ts">
interface Props {
start: string;
end: string;
/** Latest selectable date (archive lags real time by a few days). */
maxDate: string;
minDate: string;
onChange: (start: string, end: string) => void;
}
let { start, end, maxDate, minDate, onChange }: Props = $props();
// ─── Presets ────────────────────────────────────────────────────────────────
const iso = (d: Date): string => d.toISOString().slice(0, 10);
const addDays = (d: Date, n: number): Date => {
const c = new Date(d);
c.setUTCDate(c.getUTCDate() + n);
return c;
};
function applyLastDays(days: number) {
const endD = new Date(`${maxDate}T00:00:00Z`);
const startD = addDays(endD, -(days - 1));
onChange(iso(startD), iso(endD));
}
function applyThisMonthLastYear() {
const end = new Date(`${maxDate}T00:00:00Z`);
const y = end.getUTCFullYear() - 1;
const m = end.getUTCMonth();
const first = new Date(Date.UTC(y, m, 1));
const last = new Date(Date.UTC(y, m + 1, 0));
onChange(iso(first), iso(last));
}
const presets = [
{ label: '7 days', apply: () => applyLastDays(7) },
{ label: '30 days', apply: () => applyLastDays(30) },
{ label: '90 days', apply: () => applyLastDays(90) },
{ label: 'Month, last year', apply: applyThisMonthLastYear }
];
// ─── Manual inputs ────────────────────────────────────────────────────────────
// Editable mirrors of the props, re-seeded whenever a preset changes the range.
let localStart = $state('');
let localEnd = $state('');
$effect(() => {
localStart = start;
localEnd = end;
});
// Keep start <= end and inside the allowed window before emitting.
function commit() {
let s = localStart;
let e = localEnd;
if (s > e) [s, e] = [e, s];
if (s < minDate) s = minDate;
if (e > maxDate) e = maxDate;
onChange(s, e);
}
</script>
<div
class="flex flex-col gap-3 rounded-xl border border-border/70 bg-card p-3 shadow-sm sm:flex-row sm:items-end sm:justify-between"
>
<div class="flex flex-wrap items-end gap-3">
<div class="grid gap-1">
<label for="hist-start" class="text-xs font-semibold text-muted-foreground">From</label>
<input
id="hist-start"
type="date"
bind:value={localStart}
min={minDate}
max={maxDate}
onchange={commit}
class="h-9 rounded-lg border border-border bg-background px-2.5 text-sm"
/>
</div>
<div class="grid gap-1">
<label for="hist-end" class="text-xs font-semibold text-muted-foreground">To</label>
<input
id="hist-end"
type="date"
bind:value={localEnd}
min={minDate}
max={maxDate}
onchange={commit}
class="h-9 rounded-lg border border-border bg-background px-2.5 text-sm"
/>
</div>
</div>
<div
class="inline-flex flex-wrap items-center gap-0.5 rounded-lg bg-muted p-0.5 text-xs font-semibold"
role="group"
aria-label="Quick ranges"
>
{#each presets as preset (preset.label)}
<button
type="button"
class="cursor-pointer rounded-md px-2.5 py-1.5 whitespace-nowrap text-muted-foreground transition-colors hover:bg-background hover:text-foreground hover:shadow-sm"
onclick={preset.apply}
>
{preset.label}
</button>
{/each}
</div>
</div>
@@ -0,0 +1,260 @@
<script lang="ts">
import { formatZoned, isSameDayInZone } from '$lib/utils/date';
import {
type ClimateNormals,
type HistoricalDailyData,
monthDayToOrdinal
} from '$lib/services/weather';
import { getColor, getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes';
import { type WeatherUnits, getPrecipUnit, getTempUnit } from '../../week/[location]/types';
interface Props {
daily: HistoricalDailyData;
dailyDates: Date[];
timezone: string;
units: WeatherUnits;
normals: ClimateNormals | null;
selectedDay: Date;
onSelectDay: (date: Date) => void;
}
let { daily, dailyDates, timezone, units, normals, selectedDay, onSelectDay }: Props = $props();
const tempUnit = $derived(getTempUnit(units));
const precipUnit = $derived(getPrecipUnit(units));
// Day-of-year ordinal for each day, so we can look up its climate normal.
let ordinals = $derived(
dailyDates.map((d) =>
monthDayToOrdinal(
Number(formatZoned(d, timezone, 'M')),
Number(formatZoned(d, timezone, 'd'))
)
)
);
const finite = (v: number | undefined): v is number => v != null && Number.isFinite(v);
const mean = (xs: number[]): number =>
xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : NaN;
// ─── Range scaling for the min/max bars ─────────────────────────────────────
const BAR_H = 78;
let scale = $derived.by(() => {
const lo = Math.min(...daily.temperature_2m_min.filter(finite));
const hi = Math.max(...daily.temperature_2m_max.filter(finite));
const span = hi - lo || 1;
return { lo, hi, span };
});
const yOf = (v: number): number => (1 - (v - scale.lo) / scale.span) * BAR_H;
// ─── Summary statistics ─────────────────────────────────────────────────────
let stats = $derived.by(() => {
const means = daily.temperature_2m_mean.filter(finite);
const avg = mean(means);
// warmest / coldest day
let warm = { t: -Infinity, i: -1 };
let cold = { t: Infinity, i: -1 };
for (let i = 0; i < dailyDates.length; i++) {
const mx = daily.temperature_2m_max[i];
const mn = daily.temperature_2m_min[i];
if (finite(mx) && mx > warm.t) warm = { t: mx, i };
if (finite(mn) && mn < cold.t) cold = { t: mn, i };
}
const totalPrecip = daily.precipitation_sum.filter(finite).reduce((a, b) => a + b, 0);
const wetDays = daily.precipitation_sum.filter((p) => finite(p) && p >= 1).length;
// climate comparison (only when normals are available)
let tempAnomaly: number | null = null;
let normalPrecip: number | null = null;
if (normals) {
const normMeans: number[] = [];
let np = 0;
let npCount = 0;
for (let i = 0; i < ordinals.length; i++) {
const nm = normals.tmean[ordinals[i]];
if (finite(nm)) normMeans.push(nm);
const npv = normals.precip[ordinals[i]];
if (finite(npv)) {
np += npv;
npCount++;
}
}
if (normMeans.length && finite(avg)) tempAnomaly = avg - mean(normMeans);
if (npCount) normalPrecip = np;
}
return { avg, warm, cold, totalPrecip, wetDays, tempAnomaly, normalPrecip };
});
const fmtTemp = (v: number): string => (finite(v) ? `${v.toFixed(1)}°` : '');
const fmtSigned = (v: number): string => `${v >= 0 ? '+' : ''}${v.toFixed(1)}°`;
const fmtPrecip = (v: number): string => `${v.toFixed(v < 10 ? 1 : 0)} ${precipUnit}`;
function anomalyColor(delta: number): string {
const a = Math.min(0.9, 0.25 + Math.abs(delta) / 12);
return delta >= 0 ? `rgba(220, 70, 60, ${a})` : `rgba(50, 110, 210, ${a})`;
}
</script>
<!-- ─── KPI tiles ────────────────────────────────────────────────────────────── -->
<div class="grid grid-cols-2 gap-2.5 lg:grid-cols-4">
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
<p class="text-xs font-medium text-muted-foreground">Average temperature</p>
<p class="mt-1 text-2xl font-bold tabular-nums">
{fmtTemp(stats.avg)}<span class="text-base font-semibold text-muted-foreground"
>{tempUnit.replace('°', '')}</span
>
</p>
{#if stats.tempAnomaly != null}
<p
class="mt-0.5 text-xs font-semibold"
class:text-red-600={stats.tempAnomaly >= 0}
class:text-blue-600={stats.tempAnomaly < 0}
class:dark:text-red-400={stats.tempAnomaly >= 0}
class:dark:text-blue-400={stats.tempAnomaly < 0}
>
{fmtSigned(stats.tempAnomaly)} vs normal
</p>
{:else}
<p class="mt-0.5 text-xs text-muted-foreground">19912020 normal loading…</p>
{/if}
</div>
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
<p class="text-xs font-medium text-muted-foreground">Total precipitation</p>
<p class="mt-1 text-2xl font-bold tabular-nums">{fmtPrecip(stats.totalPrecip)}</p>
{#if stats.normalPrecip != null}
<p class="mt-0.5 text-xs font-semibold text-muted-foreground">
normal {fmtPrecip(stats.normalPrecip)} · {stats.wetDays} wet {stats.wetDays === 1
? 'day'
: 'days'}
</p>
{:else}
<p class="mt-0.5 text-xs text-muted-foreground">{stats.wetDays} wet days</p>
{/if}
</div>
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
<p class="text-xs font-medium text-muted-foreground">Warmest day</p>
<p class="mt-1 text-2xl font-bold tabular-nums">{fmtTemp(stats.warm.t)}</p>
{#if stats.warm.i >= 0}
<p class="mt-0.5 text-xs font-semibold text-muted-foreground">
{formatZoned(dailyDates[stats.warm.i], timezone, 'EEE d LLL')}
</p>
{/if}
</div>
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
<p class="text-xs font-medium text-muted-foreground">Coldest day</p>
<p class="mt-1 text-2xl font-bold tabular-nums">{fmtTemp(stats.cold.t)}</p>
{#if stats.cold.i >= 0}
<p class="mt-0.5 text-xs font-semibold text-muted-foreground">
{formatZoned(dailyDates[stats.cold.i], timezone, 'EEE d LLL')}
</p>
{/if}
</div>
</div>
<!-- ─── Daily strip: min/max range bars + anomaly + precip ─────────────────────── -->
<section
class="mt-4 -mx-3 overflow-hidden border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border"
>
<div class="flex items-center justify-between border-b border-border/70 bg-muted/40 px-4 py-2.5">
<h3 class="text-base font-bold">
Daily <span class="font-semibold text-muted-foreground"> select a day for hourly detail</span
>
</h3>
{#if normals}
<span class="hidden text-xs text-muted-foreground sm:inline">
normal band = 19912020 mean
</span>
{/if}
</div>
<div class="overflow-x-auto">
<div class="flex min-w-min">
{#each dailyDates as date, i (date.getTime())}
{@const selected = isSameDayInZone(date, selectedDay, timezone)}
{@const tMax = daily.temperature_2m_max[i]}
{@const tMin = daily.temperature_2m_min[i]}
{@const tMean = daily.temperature_2m_mean[i]}
{@const norm = normals ? normals.tmean[ordinals[i]] : NaN}
{@const anomaly = finite(tMean) && finite(norm) ? tMean - norm : null}
{@const precip = daily.precipitation_sum[i]}
<button
type="button"
class="flex w-[52px] shrink-0 cursor-pointer flex-col items-center gap-1 border-r border-border/40 px-1 py-2 text-center transition-colors hover:bg-muted/60 {selected
? 'bg-primary/10'
: ''}"
onclick={() => onSelectDay(date)}
aria-pressed={selected}
>
<span class="text-[11px] font-semibold {selected ? 'text-primary' : 'text-foreground'}">
{formatZoned(date, timezone, 'EEE')}
</span>
<span class="text-[10px] text-muted-foreground"
>{formatZoned(date, timezone, 'd MMM')}</span
>
<svg class="my-0.5" width="20" height="20" aria-hidden="true">
<use
xlink:href="/images/weather-icons/{getWeatherIconName(
daily.weather_code[i],
true
)}.svg#Layer_1"
></use>
</svg>
<!-- min/max range bar -->
<div class="relative w-3.5" style="height:{BAR_H}px">
{#if finite(norm)}
<!-- normal marker -->
<div
class="absolute -left-0.5 -right-0.5 border-t border-dashed border-muted-foreground/50"
style="top:{yOf(norm)}px"
></div>
{/if}
{#if finite(tMax) && finite(tMin)}
<div
class="absolute left-0 w-full rounded-full"
style="top:{yOf(tMax)}px;height:{Math.max(
3,
yOf(tMin) - yOf(tMax)
)}px;background:{getColor(tMean ?? (tMax + tMin) / 2, units.temperature_unit)}"
></div>
{/if}
</div>
<span
class="text-[11px] font-bold tabular-nums"
style="color:{getTempStyle(tMax, units.temperature_unit).bg}">{fmtTemp(tMax)}</span
>
<span class="text-[10px] font-medium text-muted-foreground tabular-nums"
>{fmtTemp(tMin)}</span
>
{#if anomaly != null}
<span
class="mt-0.5 inline-block h-1.5 w-6 rounded-full"
style="background:{anomalyColor(anomaly)}"
title="{fmtSigned(anomaly)} vs normal"
></span>
{/if}
{#if finite(precip) && precip >= 0.1}
<span
class="mt-0.5 text-[10px] font-semibold text-sky-600 dark:text-sky-400 tabular-nums"
>
{precip.toFixed(precip < 10 ? 1 : 0)}
</span>
{/if}
</button>
{/each}
</div>
</div>
</section>
@@ -0,0 +1,209 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { type ChartPanel, storedChartLayout } from '$lib/stores/settings';
import { formatZoned } from '$lib/utils/date';
import { ChartContainer, downloadChartsPng } from '$lib/components/charts';
import { CanvasChart, groupRange } from '$lib/charts';
import { getWeatherIconName } from '../../utils/weather-codes';
import { type FetchedHourly, type WeatherUnits } from '../../week/[location]/types';
import { VARIABLE_BY_KEY, buildPanelDef } from '../../week/[location]/variables';
interface Props {
data: FetchedHourly;
units: WeatherUnits;
loading: boolean;
selectedDay: Date;
}
let { data, units, loading, selectedDay }: Props = $props();
const CHART_GROUP = 'historical-meteogram';
const SECONDS_PER_DAY = 24 * 3600;
const CHART_HEIGHT = 300;
let downloadingPng = $state(false);
let chartComponents: (CanvasChart | null)[] = $state([]);
let liveCharts = $derived(chartComponents.filter((c): c is CanvasChart => c != null));
// Same customizable layout as the 7-day meteograms, so the two pages match.
let panels = $derived(
$storedChartLayout.filter((p) => p.variables.some((k) => VARIABLE_BY_KEY.has(k)))
);
let timestampsSec = $derived(data.timestamps.map((t) => t / 1000));
function dayStartSec(day: Date): number | null {
const targetDayStr = formatZoned(day, data.timezone, 'yyyy-MM-dd');
const idx = data.hourlyDates.findIndex(
(d) => formatZoned(d, data.timezone, 'yyyy-MM-dd') === targetDayStr
);
return idx === -1 ? null : data.timestamps[idx] / 1000;
}
// Soft band marking the day currently open in the hourly table below.
let selectedDayHighlight = $derived.by(() => {
const start = dayStartSec(selectedDay);
return start == null ? undefined : { start, end: start + SECONDS_PER_DAY };
});
function isDaytime(tSec: number): boolean {
return data.daylightBands.some((b) => tSec >= b.start && tSec < b.end);
}
let pictograms = $derived.by((): { t: number; icon: string }[] => {
const codes = data.hourly.weather_code ?? [];
const out: { t: number; icon: string }[] = [];
for (let i = 0; i < timestampsSec.length; i++) {
const code = codes[i];
if (code == null || !isFinite(code)) continue;
const t = timestampsSec[i];
out.push({ t, icon: getWeatherIconName(code, isDaytime(t)) });
}
return out;
});
let windArrowMarks = $derived.by((): { t: number; deg: number }[] => {
const dirs = data.hourly.winddirection_10m ?? [];
const out: { t: number; deg: number }[] = [];
for (let i = 0; i < timestampsSec.length; i++) {
const d = dirs[i];
if (d == null || !isFinite(d)) continue;
out.push({ t: timestampsSec[i], deg: d });
}
return out;
});
let zoomActive = $derived(groupRange(CHART_GROUP) != null);
function resetZoom(): void {
liveCharts[0]?.resetRange();
}
interface RenderPanel extends ChartPanel {
def: ReturnType<typeof buildPanelDef>;
title: string;
titleShort: string;
}
let renderPanels = $derived.by((): RenderPanel[] =>
panels.map((p) => {
const def = buildPanelDef(p.variables, data.hourly, units);
const title = def.series.map((s) => s.name).join(' · ');
const titleShort = def.series.map((s) => s.shortName ?? s.name).join(' · ');
return { ...p, def, title, titleShort };
})
);
let anyRightAxis = $derived(renderPanels.some((p) => p.def.unitRight != null));
let maxTopRows = $derived(
Math.max(
0,
...renderPanels.map((p) => (p.def.hasPictograms ? 1 : 0) + (p.def.hasWindArrows ? 1 : 0))
)
);
async function downloadPng(): Promise<void> {
if (liveCharts.length === 0 || downloadingPng) return;
downloadingPng = true;
try {
const items = renderPanels.map((p, i) => ({ chart: chartComponents[i], title: p.title }));
await downloadChartsPng(items, 'historical-weather');
} finally {
setTimeout(() => (downloadingPng = false), 500);
}
}
</script>
<section class="mt-8" transition:fade={{ duration: 200 }}>
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 class="text-lg font-bold">
Meteograms <span class="font-semibold text-muted-foreground"> full range</span>
</h3>
<div class="flex flex-wrap items-center gap-3">
<span class="hidden text-xs text-muted-foreground md:inline">
drag or
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]"
>Ctrl</kbd
>
+ scroll to zoom
</span>
{#if zoomActive}
<button
type="button"
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-primary/50 bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary transition-colors hover:bg-primary/15"
onclick={resetZoom}
>
Reset zoom
</button>
{/if}
<button
type="button"
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
disabled={liveCharts.length === 0 || downloadingPng}
onclick={downloadPng}
title="Download meteogram as PNG image"
>
{#if downloadingPng}Rendering…{:else}PNG{/if}
</button>
</div>
</div>
{#if renderPanels.length === 0}
<div
class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground"
>
No meteograms configured. Add variables from the 7-day forecast page.
</div>
{:else}
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
{#each renderPanels as panel, i (panel.id)}
<div
class="px-0 pt-0.5 pb-0 lg:px-4 lg:pt-2 lg:pb-1 {i > 0
? 'border-t border-border/50'
: 'lg:pt-4'} {i === renderPanels.length - 1 ? 'pb-1 lg:pb-4' : ''}"
>
<div class="mb-0 flex items-center justify-between px-3 lg:mb-0.5 lg:px-0">
<h4 class="truncate text-xs font-bold tracking-wide text-muted-foreground uppercase">
<span class="hidden lg:inline">{panel.title}</span>
<span class="lg:hidden">{panel.titleShort}</span>
</h4>
</div>
<ChartContainer
{loading}
chartCount={1}
chartHeight={CHART_HEIGHT}
minWidth={520}
bleed={false}
>
<CanvasChart
bind:this={chartComponents[i]}
timestamps={timestampsSec}
timezone={data.timezone}
series={panel.def.series}
bands={data.daylightBands}
pictograms={panel.def.hasPictograms ? pictograms : []}
windArrows={panel.def.hasWindArrows ? windArrowMarks : []}
reserveRightAxis={anyRightAxis}
reserveTopRows={maxTopRows}
highlight={selectedDayHighlight}
unit={panel.def.unit}
unitRight={panel.def.unitRight}
yMin={panel.def.yMin}
zeroBaseLeft={panel.def.zeroBaseLeft}
yMinRight={panel.def.yMinRight}
yMaxRight={panel.def.yMaxRight}
showCredit={i === renderPanels.length - 1}
showLegend
height={CHART_HEIGHT}
group={CHART_GROUP}
/>
</ChartContainer>
</div>
{/each}
</div>
{/if}
</section>