This repository has been archived on 2026-08-10. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
drizzli/src/routes/weather/compare/[location]/+page.svelte
T
2026-07-19 23:07:47 +02:00

380 lines
12 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import { storedLocation, storedModel } from '$lib/stores/settings';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import {
CHART_COLORS,
CanvasChart,
type ChartSeries,
SERIES_COLORS,
calculateAverage,
findUnit,
isColumnUnit
} from '$lib/charts';
import {
type DaylightBand,
type ModelCompareResult,
fetchModelComparison
} from '$lib/services/weather';
import { hourly, modelGroups } from '../../options';
import { defaultParameters } from '../../options';
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
import type { PageData } from './$types';
const models = modelGroups.map((group) => group.models);
const CHART_GROUP = 'model-compare';
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
let showLegend = $state(false);
// ─── Data Fetch State ───────────────────────────────────────────────────────
let chartComponents: CanvasChart[] = $state([]);
let mounted = $state(false);
let loading = $state(true);
let loadError = $state<string | null>(null);
let requestVersion = 0;
let { data }: { data: PageData } = $props();
// the URL is the source of truth: location comes from the load function,
// which is also correct on hydrated prerendered pages. The persisted store
// only mirrors it so the header and bare /weather/* redirects follow along.
let location = $derived(data.location);
$effect(() => {
storedLocation.set(data.location);
});
let params = $state({
...defaultParameters,
hourly: ['temperature_2m', 'rain', 'relative_humidity_2m', 'wind_speed_10m'],
models: ['ecmwf_ifs', 'meteofrance_seamless', 'ukmo_seamless', 'icon_seamless', 'gfs_seamless']
});
// ─── Cached API Response ────────────────────────────────────────────────────
interface FetchedData {
hourly: Record<string, unknown>;
hourly_units: Record<string, string>;
timezone: string;
daylightBands: DaylightBand[];
timestamps: number[];
sunrise: number[];
sunset: number[];
}
let fetchedData: FetchedData | null = $state(null);
// ─── Lifecycle ──────────────────────────────────────────────────────────────
onMount(() => {
// the model chosen elsewhere on the site is always part of the comparison
const selectedModel = get(storedModel);
if (selectedModel !== 'best_match' && !params.models.includes(selectedModel)) {
params.models = [selectedModel, ...params.models];
}
mounted = true;
});
// components persist across refetches; entries are null while unmounted
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
// ─── Data Fetching (only when params.hourly or params.models change) ───────
$effect(() => {
const hourlyVars = params.hourly;
const modelList = params.models;
if (!mounted || !hourlyVars?.length || !modelList?.length) return;
const loc = location;
// versioned so a slow stale response can never overwrite a newer one
const version = ++requestVersion;
loading = true;
loadError = null;
fetchModelComparison({
latitude: loc.latitude!,
longitude: loc.longitude!,
hourlyVariables: [...new Set([...hourlyVars, 'weather_code'])],
models: modelList,
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((result: ModelCompareResult) => {
if (version !== requestVersion) return;
fetchedData = {
hourly: result.hourlyFlat,
hourly_units: result.hourlyUnitsFlat,
timezone: result.timezone,
daylightBands: result.daylightBands,
timestamps: result.timestamps,
sunrise: result.sunrise,
sunset: result.sunset
};
loading = false;
})
.catch((err: unknown) => {
if (version !== requestVersion) return;
loadError = err instanceof Error ? err.message : String(err);
loading = false;
});
});
// ─── Chart Building (runs when fetchedData or the variable list changes) ────
// Timestamps from the service are in milliseconds; CanvasChart uses seconds.
let timestampsSec = $derived.by(() =>
fetchedData ? fetchedData.timestamps.map((t) => t / 1000) : []
);
interface ChartDef {
title?: string;
subtitle?: string;
unit: string;
showCredit: boolean;
series: ChartSeries[];
}
let chartDefs = $derived.by((): ChartDef[] => {
if (!fetchedData) return [];
const { hourly: hourlyData, hourly_units, timestamps } = fetchedData;
const chartVariables = params.hourly?.filter((v) => v !== 'weather_code') || [];
const variableCount = chartVariables.length;
const timeLength = timestamps.length;
const defs: ChartDef[] = [];
for (let vi = 0; vi < variableCount; vi++) {
const variable = chartVariables[vi];
const unit = findUnit(hourly_units, hourlyData, variable);
const isColumn = isColumnUnit(unit);
const series: ChartSeries[] = [];
let modelIndex = 0;
for (const [model, values] of Object.entries(hourlyData)) {
if (model === 'time') continue;
if (!model.startsWith(variable)) continue;
series.push({
name: model,
type: isColumn ? 'bar' : 'line',
color: SERIES_COLORS[modelIndex % SERIES_COLORS.length],
data: values as (number | null)[],
width: 2
});
modelIndex++;
}
const { average } = calculateAverage(hourlyData, variable, timeLength);
series.push({
name: variable + '_average',
type: isColumn ? 'bar' : 'line',
color: CHART_COLORS.average,
data: average,
width: 4,
dashed: !isColumn
});
const isFirst = vi === 0;
const isLast = vi === variableCount - 1;
defs.push({
title: isFirst ? 'Model Compare' : undefined,
subtitle: isFirst
? `Compare ${chartVariables.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
: undefined,
unit,
showCredit: isLast,
series
});
}
return defs;
});
</script>
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
{#if loadError}
<div
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
>
Failed to load weather data: {loadError}
</div>
{/if}
<!-- chart count derives from the selected variables (not the fetched data),
so the reserved height is right even before the response arrives -->
<ChartContainer
{loading}
chartCount={params.hourly?.filter((v) => v !== 'weather_code').length || 1}
chartHeight={300}
>
{#if fetchedData}
{#each chartDefs as def, i (i)}
<CanvasChart
bind:this={chartComponents[i]}
timestamps={timestampsSec}
timezone={fetchedData.timezone}
series={def.series}
bands={fetchedData.daylightBands}
unit={def.unit}
title={def.title}
subtitle={def.subtitle}
showCredit={def.showCredit}
{showLegend}
height={300}
group={CHART_GROUP}
/>
{/each}
{/if}
</ChartContainer>
{#if fetchedData && !loading}
<ModelPictogramTimeline
timestamps={fetchedData.timestamps}
hourlyFlat={fetchedData.hourly as Record<string, number[]>}
models={params.models || []}
sunrise={fetchedData.sunrise}
sunset={fetchedData.sunset}
timezone={fetchedData.timezone}
/>
{/if}
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
<div class="mt-6 md:mt-10">
<ChartToolbar charts={liveCharts} fileName="model-comparison">
{#snippet controls()}
<div class="flex gap-2">
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
<Label for="show_legend" class="mb-0.5 cursor-pointer text-lg">Show legend</Label>
</div>
{/snippet}
</ChartToolbar>
</div>
<!-- ─── Models Selection ───────────────────────────────────────────────────── -->
<div class="mt-4 md:mt-8">
<div class="flex">
<a href="#models"><h2 id="models" class="text-2xl md:text-3xl">Models</h2></a>
{#if params.models && params.models.length > 0}
<div transition:fade={{ duration: 200 }} class="relative mt-1.25">
<div
class="absolute -top-1 ml-2 rounded-full border-2 border-foreground/25 bg-secondary px-3 py-1 text-sm no-underline"
>
{params.models?.length || 0}&nbsp;/&nbsp;{models.flat().length}
</div>
</div>
{/if}
</div>
<div class="mt-2 grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{#each models as group, i (i)}
<div class="mb-3">
{#each group as item (item.value)}
{@const { value, label } = item as { value: string; label: string }}
<div class="group flex items-center" title={label}>
<Checkbox
id="{value}_model"
class="border-border-dark cursor-pointer bg-muted/50 duration-100 group-hover:border-[currentColor]"
{value}
checked={params.models?.includes(value)}
aria-labelledby="{value}_label"
onCheckedChange={() => {
if (params.models?.includes(value)) {
params.models = params.models.filter((item) => {
return item !== value;
});
} else if (params.models) {
params.models = [...params.models, value];
}
}}
/>
<Label
id="{value}_model_label"
for="{value}_model"
class="cursor-pointer truncate py-[0.1rem] pl-[0.42rem]">{label}</Label
>
</div>
{/each}
</div>
{/each}
</div>
<!-- ─── Hourly Variables Selection ─────────────────────────────────────── -->
<div class="mt-6 md:mt-12">
<div class="flex">
<a href="#hourly_weather_variables"
><h2 id="hourly_weather_variables" class="text-2xl md:text-3xl">
Hourly Weather Variables
</h2></a
>
{#if params.hourly && params.hourly.length > 0}
<div transition:fade={{ duration: 200 }} class="relative mt-1.25">
<div
class="absolute -top-1 ml-2 rounded-full border-2 border-foreground/25 bg-secondary px-3 py-1 text-sm no-underline"
>
{params.hourly?.length || 0}&nbsp;/&nbsp;{hourly.flat().length}
</div>
</div>
{/if}
</div>
<div
class="mt-2 grid grid-flow-row gap-x-2 gap-y-2 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4"
>
{#each hourly as group, i (i)}
<div>
{#each group as { value, label } (value)}
<div class="group flex items-center" title={label}>
<Checkbox
id="{value}_hourly"
class="border-border-dark cursor-pointer bg-muted/50 duration-100 group-hover:border-[currentColor]"
{value}
checked={params.hourly?.includes(value)}
aria-labelledby="{value}_label"
onCheckedChange={() => {
if (params.hourly?.includes(value)) {
params.hourly = params.hourly.filter((item) => {
return item !== value;
});
} else if (params.hourly) {
params.hourly = [...params.hourly, value];
}
}}
/>
<Label
id="{value}_label"
for="{value}_hourly"
class="cursor-pointer truncate py-[0.1rem] pl-[0.42rem]">{label}</Label
>
</div>
{/each}
</div>
{/each}
</div>
</div>
</div>