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
+21 -11
View File
@@ -3,7 +3,12 @@
import { SvelteDate } from 'svelte/reactivity';
import { get } from 'svelte/store';
import { storedLocation, storedModel, storedVariablePrefs } from '$lib/stores/settings';
import {
storedChartLayout,
storedLocation,
storedModel,
storedVariablePrefs
} from '$lib/stores/settings';
import { ChartContainer } from '$lib/components/charts';
@@ -15,6 +20,7 @@
import MeteogramCharts from './MeteogramCharts.svelte';
import ModelSelector from './ModelSelector.svelte';
import VariableSidebar from './VariableSidebar.svelte';
import { neededHourlyApiVars } from './variables';
import type { PageData } from './$types';
import type { FetchedDaily, FetchedHourly } from './types';
@@ -28,16 +34,18 @@
let variableSidebarOpen = $state(false);
// Number of meteogram chart panels currently enabled: used to reserve the
// exact chart area height before data arrives (no layout shift)
let enabledChartCount = $derived.by(() => {
const on = (key: string) => $storedVariablePrefs.charts?.[key] ?? true;
return (
(on('temperature') || on('cloud_cover') ? 1 : 0) +
(on('precipitation') || on('precipitation_probability') ? 1 : 0) +
(on('wind') || on('humidity') ? 1 : 0)
);
});
// Number of meteogram panels: reserves the chart area height before data
// arrives (no layout shift)
let enabledChartCount = $derived($storedChartLayout.filter((p) => p.variables.length > 0).length);
// Request only the hourly variables the table rows and meteograms actually
// show, so unused variables are never fetched.
let hourlyVars = $derived(
neededHourlyApiVars(
$storedVariablePrefs.table,
$storedChartLayout.flatMap((p) => p.variables)
)
);
// the URL is the source of truth: location comes from the load function,
// which is also correct on hydrated prerendered pages. The persisted store
@@ -72,6 +80,7 @@
$effect(() => {
const loc = location;
const modelList = params.models;
const requestVars = hourlyVars;
if (!mounted || !loc || !modelList?.length) return;
@@ -84,6 +93,7 @@
latitude: loc.latitude!,
longitude: loc.longitude!,
model: modelList[0],
hourlyVariables: requestVars,
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',
@@ -0,0 +1,315 @@
<script lang="ts">
import { fade, fly } from 'svelte/transition';
import { type ChartPanel, defaultChartLayout, storedChartLayout } from '$lib/stores/settings';
import { CHART_VARIABLES, VARIABLE_BY_KEY } from './variables';
interface Props {
open: boolean;
onClose: () => void;
}
let { open, onClose }: Props = $props();
// Variables not placed in any panel form the "available" pool.
let usedKeys = $derived(new Set($storedChartLayout.flatMap((p) => p.variables)));
let availableVars = $derived(CHART_VARIABLES.filter((v) => !usedKeys.has(v.key)));
// ─── Drag state ─────────────────────────────────────────────────────────────
let dragKey = $state<string | null>(null);
let dragLabel = $state('');
let dragColor = $state('');
let dragPos = $state({ x: 0, y: 0 });
let dropZone = $state<string | null>(null); // panel id or 'pool'
let dropIndex = $state(0);
let pointerStart: { x: number; y: number } | null = null;
let started = $state(false);
function beginDrag(e: PointerEvent, key: string): void {
const def = VARIABLE_BY_KEY.get(key);
if (!def) return;
pointerStart = { x: e.clientX, y: e.clientY };
started = false;
dragKey = key;
dragLabel = def.label;
dragColor = def.color;
dragPos = { x: e.clientX, y: e.clientY };
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function onDragMove(e: PointerEvent): void {
if (dragKey === null || !pointerStart) return;
if (!started) {
const dx = Math.abs(e.clientX - pointerStart.x);
const dy = Math.abs(e.clientY - pointerStart.y);
if (dx < 5 && dy < 5) return;
started = true;
}
dragPos = { x: e.clientX, y: e.clientY };
const under = document.elementFromPoint(e.clientX, e.clientY);
const zoneEl = under?.closest('[data-zone]') as HTMLElement | null;
if (!zoneEl) {
dropZone = null;
return;
}
dropZone = zoneEl.dataset.zone ?? null;
// Insert before the chip whose centre is to the right of the pointer.
const chips = [...zoneEl.querySelectorAll('[data-chip]')] as HTMLElement[];
let idx = chips.length;
for (let i = 0; i < chips.length; i++) {
const r = chips[i].getBoundingClientRect();
if (e.clientY < r.top || (e.clientY <= r.bottom && e.clientX < r.left + r.width / 2)) {
idx = i;
break;
}
}
dropIndex = idx;
}
function endDrag(e: PointerEvent): void {
if (dragKey === null) return;
const key = dragKey;
const zone = started ? dropZone : null;
(e.currentTarget as HTMLElement)?.releasePointerCapture?.(e.pointerId);
dragKey = null;
pointerStart = null;
if (zone) moveVar(key, zone, dropIndex);
dropZone = null;
}
function moveVar(key: string, toZone: string, toIndex: number): void {
const layout: ChartPanel[] = $storedChartLayout.map((p) => ({
id: p.id,
variables: p.variables.filter((k) => k !== key)
}));
if (toZone !== 'pool') {
const panel = layout.find((p) => p.id === toZone);
if (panel) panel.variables.splice(Math.min(toIndex, panel.variables.length), 0, key);
}
storedChartLayout.set(layout);
}
function removeVar(key: string): void {
moveVar(key, 'pool', 0);
}
function addPanel(): void {
const maxN = $storedChartLayout.reduce((m, p) => {
const n = parseInt(p.id.replace(/\D/g, ''), 10);
return Number.isFinite(n) ? Math.max(m, n) : m;
}, 0);
storedChartLayout.set([...$storedChartLayout, { id: `panel-${maxN + 1}`, variables: [] }]);
}
function deletePanel(id: string): void {
storedChartLayout.set($storedChartLayout.filter((p) => p.id !== id));
}
function resetLayout(): void {
storedChartLayout.set(structuredClone(defaultChartLayout));
}
</script>
<svelte:window
onkeydown={(e) => {
if (e.key === 'Escape' && open && dragKey === null) onClose();
}}
/>
{#if open}
<div class="fixed inset-0 z-50 flex items-stretch justify-center md:items-center md:p-6">
<div
class="absolute inset-0 bg-black/40"
transition:fade={{ duration: 150 }}
onclick={() => dragKey === null && onClose()}
onkeydown={onClose}
role="presentation"
></div>
<div
class="relative flex w-full max-w-3xl flex-col overflow-hidden bg-card shadow-2xl md:rounded-2xl md:border md:border-border"
transition:fly={{ y: 20, duration: 200 }}
>
<div class="flex items-center justify-between border-b border-border px-5 py-4">
<div>
<h2 class="text-base font-bold">Customize meteograms</h2>
<p class="text-xs text-muted-foreground">
Drag variables between charts to build your own layout.
</p>
</div>
<button
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onclick={onClose}
aria-label="Close"
>
<svg
class="h-4.5 w-4.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<div class="flex-1 space-y-4 overflow-y-auto px-5 py-4">
{#each $storedChartLayout as panel, i (panel.id)}
<div
data-zone={panel.id}
class="rounded-xl border-2 border-dashed p-3 transition-colors {dropZone === panel.id
? 'border-primary bg-primary/5'
: 'border-border'}"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-[11px] font-bold tracking-wider text-primary uppercase"
>Chart {i + 1}</span
>
<button
class="cursor-pointer rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-destructive"
onclick={() => deletePanel(panel.id)}
aria-label="Delete chart {i + 1}"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" d="M6 7h12M9 7V5h6v2m-1 0v12H10V7M4 7h16" />
</svg>
</button>
</div>
<div class="flex min-h-9 flex-wrap gap-2">
{#each panel.variables as key (key)}
{@const def = VARIABLE_BY_KEY.get(key)}
{#if def}
<div
data-chip
role="button"
tabindex="0"
aria-label="Drag {def.label}"
class="flex cursor-grab touch-none items-center gap-1.5 rounded-lg border border-border bg-background py-1.5 pr-1 pl-2.5 text-sm shadow-sm select-none active:cursor-grabbing {dragKey ===
key
? 'opacity-30'
: ''}"
onpointerdown={(e) => beginDrag(e, key)}
onpointermove={onDragMove}
onpointerup={endDrag}
onpointercancel={endDrag}
>
<span
class="h-2.5 w-2.5 shrink-0 rounded-full"
style:background-color={def.color}
></span>
<span class="font-medium">{def.label}</span>
<button
class="ml-0.5 flex h-5 w-5 cursor-pointer items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
onpointerdown={(e) => e.stopPropagation()}
onclick={() => removeVar(key)}
aria-label="Remove {def.label}"
>
<svg
class="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2.5"
>
<path stroke-linecap="round" d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
{/if}
{/each}
{#if panel.variables.length === 0}
<span class="self-center text-xs text-muted-foreground italic"
>Drop variables here</span
>
{/if}
</div>
</div>
{/each}
<button
class="w-full cursor-pointer rounded-xl border-2 border-dashed border-border py-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
onclick={addPanel}
>
+ Add chart
</button>
<div>
<h3 class="mb-2 text-[11px] font-bold tracking-wider text-muted-foreground uppercase">
Available variables
</h3>
<div
data-zone="pool"
class="flex min-h-12 flex-wrap gap-2 rounded-xl border-2 border-dashed p-3 transition-colors {dropZone ===
'pool'
? 'border-primary bg-primary/5'
: 'border-border'}"
>
{#each availableVars as def (def.key)}
<div
data-chip
role="button"
tabindex="0"
aria-label="Drag {def.label}"
class="flex cursor-grab touch-none items-center gap-1.5 rounded-lg border border-border bg-background px-2.5 py-1.5 text-sm text-muted-foreground shadow-sm select-none active:cursor-grabbing {dragKey ===
def.key
? 'opacity-30'
: ''}"
onpointerdown={(e) => beginDrag(e, def.key)}
onpointermove={onDragMove}
onpointerup={endDrag}
onpointercancel={endDrag}
>
<span class="h-2.5 w-2.5 shrink-0 rounded-full" style:background-color={def.color}
></span>
<span class="font-medium">{def.label}</span>
</div>
{/each}
{#if availableVars.length === 0}
<span class="self-center text-xs text-muted-foreground italic"
>All variables are in use</span
>
{/if}
</div>
</div>
</div>
<div class="flex items-center justify-between border-t border-border px-5 py-3">
<button
class="cursor-pointer text-xs font-medium text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline"
onclick={resetLayout}
>
Reset to defaults
</button>
<button
class="cursor-pointer rounded-lg bg-primary px-4 py-1.5 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90"
onclick={onClose}
>
Done
</button>
</div>
</div>
</div>
<!-- Floating drag preview -->
{#if dragKey !== null && started}
<div
class="pointer-events-none fixed z-60 flex items-center gap-1.5 rounded-lg border border-primary bg-card px-2.5 py-1.5 text-sm font-medium shadow-xl"
style:left="{dragPos.x + 8}px"
style:top="{dragPos.y + 8}px"
>
<span class="h-2.5 w-2.5 shrink-0 rounded-full" style:background-color={dragColor}></span>
{dragLabel}
</div>
{/if}
{/if}
@@ -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)} />
@@ -23,15 +23,6 @@
{ key: 'precipitation', label: 'Precipitation' }
];
const chartVariables = [
{ key: 'temperature', label: 'Temperature' },
{ key: 'cloud_cover', label: 'Cloud cover' },
{ key: 'precipitation', label: 'Precipitation' },
{ key: 'precipitation_probability', label: 'Precipitation probability' },
{ key: 'wind', label: 'Wind speed' },
{ key: 'humidity', label: 'Humidity' }
];
function toggle(section: 'table' | 'charts', key: string) {
storedVariablePrefs.update((prefs) => {
const current = { ...defaultVariablePrefs[section], ...prefs[section] };
@@ -107,26 +98,10 @@
</div>
</section>
<section>
<h3 class="mb-2 text-[11px] font-bold tracking-wider text-primary uppercase">
Meteogram charts
</h3>
<div class="flex flex-col gap-1">
{#each chartVariables as variable (variable.key)}
<div class="flex items-center gap-2.5 rounded-md px-1 py-1 hover:bg-muted/60">
<Checkbox
id="chart_var_{variable.key}"
class="cursor-pointer"
checked={$storedVariablePrefs.charts?.[variable.key] ?? true}
onCheckedChange={() => toggle('charts', variable.key)}
/>
<Label class="flex-1 cursor-pointer text-sm" for="chart_var_{variable.key}">
{variable.label}
</Label>
</div>
{/each}
</div>
</section>
<p class="text-xs text-muted-foreground">
Meteogram variables are configured with the <span class="font-semibold">Customize</span>
button above the charts.
</p>
</div>
<div class="border-t border-border px-5 py-3">
@@ -0,0 +1,446 @@
/**
* Registry of every variable that can be plotted on the customizable
* meteograms. Each entry carries the metadata needed to build a chart series
* (data field, render style, colour, unit family) so the panels can be
* assembled dynamically from a user-defined layout.
*/
import { getColor } from '../../utils/colors';
import {
type WeatherUnits,
getPrecipUnit,
getTempUnit,
getWindDirectionLabel,
getWindUnit
} from './types';
import type { ChartSeries } from '$lib/charts';
import type { WeekHourlyData } from '$lib/services/weather';
/** Families of variables that share a y-axis and unit. */
export type UnitKind =
'temp' | 'precip' | 'snow' | 'wind' | 'percent' | 'pressure' | 'uv' | 'distance' | 'energy';
export interface ChartVariableDef {
/** Stable id used in the persisted layout */
key: string;
label: string;
/** Short label for the tooltip / legend */
short: string;
/** Data array on the hourly response */
field: keyof WeekHourlyData;
/** Open-Meteo API variable name (defaults to `field` when identical) */
api?: string;
type: 'line' | 'bar';
kind: UnitKind;
color: string;
dashed?: boolean;
fill?: boolean;
fillOpacity?: number;
width?: number;
/** Stroke the line coloured by the temperature scale */
colorScale?: boolean;
/** Draw a contrasting halo under the line */
outline?: boolean;
/** Annotate local minima / maxima with their value */
extrema?: boolean;
/** Draw weather-code pictograms across the top of the chart */
pictograms?: boolean;
/** Render as a puffy cloud band hanging from the top (0-100 → 0-40px) */
cloudBand?: boolean;
/** Transform raw values before plotting (e.g. m → km) */
transform?: (v: number) => number;
/** Preset range to use when this variable lands on a shared right axis */
rightPreset?: { min: number; max: number; invert?: boolean };
}
export const CHART_VARIABLES: ChartVariableDef[] = [
{
key: 'temperature',
label: 'Temperature',
short: 'Temp',
field: 'temperature_2m',
type: 'line',
kind: 'temp',
color: '#ef6c00',
width: 4,
fill: true,
fillOpacity: 0.12,
colorScale: true,
outline: true,
extrema: true,
pictograms: true
},
{
key: 'apparent_temperature',
label: 'Apparent Temp',
short: 'Feels',
field: 'apparent_temperature',
type: 'line',
kind: 'temp',
color: '#c2410c',
width: 2,
dashed: true
},
{
key: 'dew_point',
label: 'Dew Point',
short: 'Dew',
field: 'dew_point_2m',
type: 'line',
kind: 'temp',
color: '#0e7490',
width: 2
},
{
key: 'cloud_cover',
label: 'Cloud Cover',
short: 'Cloud',
field: 'cloud_cover',
type: 'line',
kind: 'percent',
color: 'rgb(150, 155, 165)',
cloudBand: true
},
{
key: 'cloud_cover_low',
label: 'Cloud Cover Low',
short: 'Low',
field: 'cloud_cover_low',
type: 'line',
kind: 'percent',
color: '#94a3b8',
width: 2
},
{
key: 'cloud_cover_mid',
label: 'Cloud Cover Mid',
short: 'Mid',
field: 'cloud_cover_mid',
type: 'line',
kind: 'percent',
color: '#64748b',
width: 2
},
{
key: 'cloud_cover_high',
label: 'Cloud Cover High',
short: 'High',
field: 'cloud_cover_high',
type: 'line',
kind: 'percent',
color: '#cbd5e1',
width: 2
},
{
key: 'precipitation',
label: 'Precipitation',
short: 'Precip',
field: 'precipitation',
type: 'bar',
kind: 'precip',
color: 'rgba(30, 136, 229, 0.8)'
},
{
key: 'precipitation_probability',
label: 'Precip. Probability',
short: 'PoP',
field: 'precipitation_probability',
type: 'line',
kind: 'percent',
color: '#5c6bc0',
width: 2,
dashed: true
},
{
key: 'rain',
label: 'Rain',
short: 'Rain',
field: 'rain',
type: 'bar',
kind: 'precip',
color: 'rgba(37, 99, 235, 0.75)'
},
{
key: 'showers',
label: 'Showers',
short: 'Shwr',
field: 'showers',
type: 'bar',
kind: 'precip',
color: 'rgba(6, 182, 212, 0.75)'
},
{
key: 'snowfall',
label: 'Snowfall',
short: 'Snow',
field: 'snowfall',
type: 'bar',
kind: 'snow',
color: 'rgba(147, 197, 253, 0.9)'
},
{
key: 'wind',
label: 'Wind Speed',
short: 'Wind',
field: 'windspeed_10m',
api: 'wind_speed_10m',
type: 'line',
kind: 'wind',
color: '#26a69a',
width: 2,
fill: true,
fillOpacity: 0.15
},
{
key: 'wind_gusts',
label: 'Wind Gusts',
short: 'Gusts',
field: 'wind_gusts_10m',
type: 'line',
kind: 'wind',
color: '#0d9488',
width: 2,
dashed: true
},
{
key: 'humidity',
label: 'Humidity',
short: 'RH',
field: 'relative_humidity_2m',
type: 'line',
kind: 'percent',
color: '#8d6e63',
width: 2,
dashed: true
},
{
key: 'pressure_msl',
label: 'Pressure (MSL)',
short: 'MSLP',
field: 'pressure_msl',
type: 'line',
kind: 'pressure',
color: '#7c3aed',
width: 2
},
{
key: 'surface_pressure',
label: 'Surface Pressure',
short: 'Psfc',
field: 'surface_pressure',
type: 'line',
kind: 'pressure',
color: '#a855f7',
width: 2,
dashed: true
},
{
key: 'uv_index',
label: 'UV Index',
short: 'UV',
field: 'uv_index',
type: 'line',
kind: 'uv',
color: '#eab308',
width: 2,
fill: true,
fillOpacity: 0.15
},
{
key: 'visibility',
label: 'Visibility',
short: 'Vis',
field: 'visibility',
type: 'line',
kind: 'distance',
color: '#0891b2',
width: 2,
transform: (v) => v / 1000
},
{
key: 'cape',
label: 'CAPE',
short: 'CAPE',
field: 'cape',
type: 'line',
kind: 'energy',
color: '#dc2626',
width: 2,
fill: true,
fillOpacity: 0.12
}
];
export const VARIABLE_BY_KEY: Map<string, ChartVariableDef> = new Map(
CHART_VARIABLES.map((v) => [v.key, v])
);
/** Open-Meteo API variable name for a registry entry. */
export function apiNameOf(def: ChartVariableDef): string {
return def.api ?? def.field;
}
/**
* The set of API hourly variables needed to render the current table rows and
* chart layout, so the fetch requests only what is actually shown.
*/
export function neededHourlyApiVars(
tablePrefs: Record<string, boolean> | undefined,
layoutKeys: string[]
): string[] {
const on = (key: string): boolean => tablePrefs?.[key] ?? true;
const s = new Set<string>();
// Hourly table rows
if (on('icons')) s.add('weather_code');
if (on('temperature')) s.add('temperature_2m');
if (on('feels')) s.add('apparent_temperature');
if (on('wind')) {
s.add('wind_speed_10m');
s.add('wind_direction_10m');
}
if (on('humidity')) s.add('relative_humidity_2m');
if (on('clouds')) s.add('cloud_cover');
if (on('precipitation')) {
s.add('precipitation');
s.add('precipitation_probability');
}
// Meteogram variables
for (const key of layoutKeys) {
const def = VARIABLE_BY_KEY.get(key);
if (!def) continue;
s.add(apiNameOf(def));
if (def.pictograms) s.add('weather_code');
if (def.key === 'wind') s.add('wind_direction_10m');
}
return [...s];
}
/** Unit label for a variable family, honouring the user's unit settings. */
export function unitForKind(kind: UnitKind, units: WeatherUnits): string {
switch (kind) {
case 'temp':
return getTempUnit(units);
case 'precip':
return getPrecipUnit(units);
case 'snow':
return 'cm';
case 'wind':
return getWindUnit(units);
case 'percent':
return '%';
case 'pressure':
return 'hPa';
case 'uv':
return '';
case 'distance':
return 'km';
case 'energy':
return 'J/kg';
}
}
/** Families whose axis should always start at zero. */
export function isZeroBased(kind: UnitKind): boolean {
return kind !== 'temp' && kind !== 'pressure';
}
/** Sensible decimal places for tooltip / label formatting. */
export function decimalsForKind(kind: UnitKind): number {
switch (kind) {
case 'precip':
case 'snow':
case 'uv':
case 'distance':
return 1;
default:
return 0;
}
}
export interface PanelDef {
series: ChartSeries[];
unit: string;
unitRight?: string;
yMin?: number;
yMinRight?: number;
yMaxRight?: number;
/** Whether the left axis should include zero (false for pressure) */
zeroBaseLeft: boolean;
hasPictograms: boolean;
}
/**
* Builds a chart definition for one panel: turns its ordered variable keys into
* series and works out the left / right axis units. The first variable's family
* owns the left axis; the first differing family gets the right axis.
*/
export function buildPanelDef(
variableKeys: string[],
hourly: WeekHourlyData,
units: WeatherUnits
): PanelDef {
const defs = variableKeys
.map((k) => VARIABLE_BY_KEY.get(k))
.filter((d): d is ChartVariableDef => d != null);
// Cloud-band variables float above the plot and don't claim an axis.
const axisDefs = defs.filter((d) => !d.cloudBand);
const kinds: UnitKind[] = [];
for (const d of axisDefs) if (!kinds.includes(d.kind)) kinds.push(d.kind);
const leftKind = kinds[0];
const rightKind = kinds.find((k) => k !== leftKind);
const series: ChartSeries[] = defs.map((d) => {
const raw = (hourly[d.field] as number[]) ?? [];
const data: (number | null)[] = d.transform
? raw.map((v) => (v == null || !isFinite(v) ? null : d.transform!(v)))
: raw;
const kindUnit = unitForKind(d.kind, units);
const dec = decimalsForKind(d.kind);
const axis: 'left' | 'right' = d.cloudBand || d.kind === leftKind ? 'left' : 'right';
return {
name: d.label,
shortName: d.short,
type: d.type,
color: d.color,
data,
width: d.width,
fill: d.fill,
fillOpacity: d.fillOpacity,
dashed: d.dashed,
axis,
cloudBand: d.cloudBand,
segmentColor: d.colorScale ? (v: number) => getColor(v, units.temperature_unit) : undefined,
outline: d.outline,
labelExtrema: d.extrema,
labelFormat: d.extrema
? (v: number) => (d.kind === 'temp' ? `${v.toFixed(0)}°` : `${v.toFixed(dec)}${kindUnit}`)
: undefined,
format:
d.key === 'wind'
? (v: number, i: number) => {
const dir = hourly.winddirection_10m?.[i];
const dl = dir != null && !isNaN(dir) ? ` (${getWindDirectionLabel(dir)})` : '';
return `${v.toFixed(dec)} ${kindUnit}${dl}`;
}
: (v: number) => `${v.toFixed(dec)}${kindUnit ? ' ' + kindUnit : ''}`
} satisfies ChartSeries;
});
const rightZero = rightKind ? isZeroBased(rightKind) : false;
return {
series,
unit: leftKind ? unitForKind(leftKind, units) : '',
unitRight: rightKind ? unitForKind(rightKind, units) : undefined,
yMin: leftKind && isZeroBased(leftKind) ? 0 : undefined,
// pressure sits far from zero, so its axis is derived from the data
zeroBaseLeft: leftKind !== 'pressure',
yMinRight: rightKind && rightZero ? 0 : undefined,
yMaxRight: rightKind === 'percent' ? 100 : undefined,
hasPictograms: defs.some((d) => d.pictograms)
};
}