modular meteograms

This commit is contained in:
Vincent van der Wal
2026-07-22 19:28:57 +02:00
parent af9bf42d05
commit bac4759662
10 changed files with 1239 additions and 297 deletions
@@ -1,22 +1,18 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { storedVariablePrefs } from '$lib/stores/settings';
import { type ChartPanel, storedChartLayout } from '$lib/stores/settings';
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { CanvasChart, type ChartSeries } from '$lib/charts';
import { CanvasChart } from '$lib/charts';
import {
type FetchedHourly,
type WeatherUnits,
getPrecipUnit,
getTempUnit,
getWindDirectionLabel,
getWindUnit
} from './types';
import { getWeatherIconName } from '../../utils/weather-codes';
import ChartCustomizer from './ChartCustomizer.svelte';
import { type FetchedHourly, type WeatherUnits } from './types';
import { VARIABLE_BY_KEY, buildPanelDef } from './variables';
interface Props {
data: FetchedHourly;
@@ -30,14 +26,22 @@
const CHART_GROUP = 'week-meteogram';
const SECONDS_PER_DAY = 24 * 3600;
const CHART_HEIGHT = 300;
let chartComponents: CanvasChart[] = $state([]);
let customizerOpen = $state(false);
// Charts persist across data refetches; entries are null while unmounted.
let chartComponents: (CanvasChart | null)[] = $state([]);
let liveCharts = $derived(chartComponents.filter((chart): chart is CanvasChart => chart != null));
// Only render panels that hold at least one known variable.
let panels = $derived(
$storedChartLayout.filter((p) => p.variables.some((k) => VARIABLE_BY_KEY.has(k)))
);
// Timestamps from the service are in milliseconds; CanvasChart uses seconds.
let timestampsSec = $derived(data.timestamps.map((t) => t / 1000));
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
function dayStartSec(day: Date): number | null {
if (!data) return null;
const tz = data.timezone;
@@ -60,8 +64,6 @@
onResetZoom?.();
}
// Range presets: charts show the full week by default, these (or
// Ctrl+scroll) narrow the window.
const rangePresets = [
{ label: 'Today', apply: () => setRangeDays(new Date(), 1) },
{ label: 'Selected day', apply: () => setRangeDays(selectedDay, 1) },
@@ -76,165 +78,46 @@
return start == null ? undefined : { start, end: start + SECONDS_PER_DAY };
});
// ─── Series Building ────────────────────────────────────────────────────────
// ─── Pictograms (weather icons across the top) ──────────────────────────────
let tempUnit = $derived(getTempUnit(units));
let precipUnit = $derived(getPrecipUnit(units));
let windUnit = $derived(getWindUnit(units));
interface ChartDef {
title: string;
unit: string;
unitRight?: string;
yMin?: number;
yMinRight?: number;
yMaxRight?: number;
invertRight?: boolean;
showCredit?: boolean;
series: ChartSeries[];
function isDaytime(tSec: number): boolean {
return data.daylightBands.some((b) => tSec >= b.start && tSec < b.end);
}
let chartDefs = $derived.by((): ChartDef[] => {
if (!data) return [];
const { hourly } = data;
// Variables the user disabled in the sidebar are left out entirely
const on = (key: string): boolean => $storedVariablePrefs.charts?.[key] ?? true;
const defs: ChartDef[] = [];
if (on('temperature') || on('cloud_cover')) {
const series: ChartSeries[] = [];
if (on('cloud_cover')) {
series.push({
name: 'Cloud Cover',
type: 'line',
color: 'rgb(150, 150, 150)',
data: hourly.cloud_cover,
width: 0,
fill: true,
fillOpacity: 0.25,
axis: 'right',
format: (v) => `${v.toFixed(0)}%`
});
}
if (on('temperature')) {
series.push({
name: 'Temperature',
type: 'line',
color: '#ef6c00',
data: hourly.temperature_2m,
width: 3,
fill: true,
fillOpacity: 0.2,
format: (v) => `${v.toFixed(1)} ${tempUnit}`
});
}
defs.push({
title: [on('temperature') && 'Temperature', on('cloud_cover') && 'Cloud Cover']
.filter(Boolean)
.join(' & '),
unit: tempUnit,
// Hidden inverted right axis (0-250) so cloud cover hangs from the top,
// occupying at most the upper 40% of the plot
yMinRight: 0,
yMaxRight: 250,
invertRight: true,
series
});
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)) });
}
if (on('precipitation') || on('precipitation_probability')) {
const series: ChartSeries[] = [];
if (on('precipitation')) {
series.push({
name: 'Precipitation',
type: 'bar',
color: 'rgba(30, 136, 229, 0.8)',
data: hourly.precipitation,
format: (v) => `${v.toFixed(1)} ${precipUnit}`
});
}
if (on('precipitation_probability')) {
series.push({
name: 'Precip. Probability',
type: 'line',
color: '#5c6bc0',
data: hourly.precipitation_probability,
width: 2,
dashed: true,
axis: 'right',
format: (v) => `${v.toFixed(0)}%`
});
}
defs.push({
title: [
on('precipitation') && 'Precipitation',
on('precipitation_probability') && 'Probability'
]
.filter(Boolean)
.join(' & '),
unit: precipUnit,
unitRight: on('precipitation_probability') ? '%' : undefined,
yMin: 0,
yMinRight: 0,
yMaxRight: 100,
series
});
}
if (on('wind') || on('humidity')) {
const series: ChartSeries[] = [];
if (on('wind')) {
series.push({
name: 'Wind Speed',
type: 'line',
color: '#26a69a',
data: hourly.windspeed_10m,
width: 2,
fill: true,
fillOpacity: 0.15,
format: (v, i) => {
const dir = hourly.winddirection_10m[i];
const dirLabel = dir != null && !isNaN(dir) ? ` (${getWindDirectionLabel(dir)})` : '';
return `${v.toFixed(0)} ${windUnit}${dirLabel}`;
}
});
}
if (on('humidity')) {
series.push({
name: 'Humidity',
type: 'line',
color: '#8d6e63',
data: hourly.relative_humidity_2m,
width: 2,
dashed: true,
axis: 'right',
format: (v) => `${v.toFixed(0)}%`
});
}
defs.push({
title: [on('wind') && 'Wind Speed', on('humidity') && 'Humidity']
.filter(Boolean)
.join(' & '),
unit: windUnit,
unitRight: on('humidity') ? '%' : undefined,
yMin: 0,
yMinRight: 0,
yMaxRight: 100,
series
});
}
if (defs.length > 0) defs[defs.length - 1].showCredit = true;
return defs;
return out;
});
// ─── Panel definitions ──────────────────────────────────────────────────────
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 };
})
);
</script>
<section class="mt-8" in:fade={{ duration: 200 }}>
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 class="text-lg font-bold">
Meteogram
Meteograms
<span class="font-semibold text-muted-foreground">
{formatZoned(selectedDay, data.timezone, 'EEEE')}{getRelativeDayLabel(
selectedDay,
@@ -266,42 +149,73 @@
</button>
{/each}
</div>
<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"
onclick={() => (customizerOpen = true)}
>
<svg
class="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" d="M4 6h16M4 12h16M4 18h16M8 4v4m8 2v4M6 16v4" />
</svg>
Customize
</button>
</div>
</div>
{#if chartDefs.length > 0}
<ChartContainer {loading} chartCount={chartDefs.length} chartHeight={300}>
{#each chartDefs as def, i (def.title)}
<CanvasChart
bind:this={chartComponents[i]}
timestamps={timestampsSec}
timezone={data.timezone}
series={def.series}
bands={data.daylightBands}
highlight={selectedDayHighlight}
unit={def.unit}
unitRight={def.unitRight}
yMin={def.yMin}
yMinRight={def.yMinRight}
yMaxRight={def.yMaxRight}
invertRight={def.invertRight}
title={def.title}
showCredit={def.showCredit}
showLegend
height={300}
group={CHART_GROUP}
/>
{#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 — <button
class="cursor-pointer font-semibold text-primary underline-offset-2 hover:underline"
onclick={() => (customizerOpen = true)}>add some variables</button
>.
</div>
{:else}
<div class="flex flex-col gap-6">
{#each renderPanels as panel, i (panel.id)}
<div class="rounded-2xl border border-border/70 bg-card p-3 shadow-sm md:p-4">
<div class="mb-1 flex items-center justify-between px-1">
<h4 class="truncate text-sm font-bold text-muted-foreground">
<span class="hidden md:inline">{panel.title}</span>
<span class="md:hidden">{panel.titleShort}</span>
</h4>
</div>
<ChartContainer {loading} chartCount={1} chartHeight={CHART_HEIGHT} minWidth={520}>
<CanvasChart
bind:this={chartComponents[i]}
timestamps={timestampsSec}
timezone={data.timezone}
series={panel.def.series}
bands={data.daylightBands}
pictograms={panel.def.hasPictograms ? pictograms : []}
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}
</ChartContainer>
</div>
<div class="mt-6 md:mt-10">
<ChartToolbar charts={liveCharts} fileName="week-forecast" />
</div>
{:else}
<div
class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground"
>
All chart variables are hidden — enable some under “Variables”.
</div>
{/if}
</section>
<ChartCustomizer open={customizerOpen} onClose={() => (customizerOpen = false)} />