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/14-day/[location]/+page.svelte
T

279 lines
8.4 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { storedEnsembleModel, storedLocation } from '$lib/stores/settings';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import { CHART_COLORS, CanvasChart, type ChartSeries, isColumnUnit } from '$lib/charts';
import {
type DaylightBand,
type EnsembleForecastResult,
fetchEnsembleForecast
} from '$lib/services/weather';
import { defaultParameters, ensembleModelGroups } from '../../options';
import ModelSelector from '../../week/[location]/ModelSelector.svelte';
import type { PageData } from './$types';
const CHART_GROUP = '14-day-ensemble';
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
let showLegend = $state(true);
// ─── 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'],
models: ['ncep_gefs_seamless']
});
// ─── Cached API Response ────────────────────────────────────────────────────
interface FetchedData {
ensembleResult: EnsembleForecastResult;
timestamps: number[];
timezone: string;
daylightBands: DaylightBand[];
}
let fetchedData: FetchedData | null = $state(null);
// ─── Lifecycle ──────────────────────────────────────────────────────────────
onMount(() => {
// preselect the persisted ensemble model (client-only, keeps SSR stable)
params.models = [get(storedEnsembleModel)];
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;
fetchEnsembleForecast({
latitude: loc.latitude!,
longitude: loc.longitude!,
hourlyVariables: hourlyVars,
models: modelList,
forecast_days: 14,
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: EnsembleForecastResult) => {
if (version !== requestVersion) return;
fetchedData = {
ensembleResult: result,
timestamps: result.timestamps,
timezone: result.timezone,
daylightBands: result.daylightBands
};
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[];
}
const BAND_COLOR = 'rgba(115, 192, 222, 0.9)';
let chartDefs = $derived.by((): ChartDef[] => {
if (!fetchedData) return [];
const { ensembleResult } = fetchedData;
const variables = params.hourly || [];
const defs: ChartDef[] = [];
for (let vi = 0; vi < variables.length; vi++) {
const variable = variables[vi];
const varData = ensembleResult.variables[variable];
if (!varData) continue;
const unit = varData.unit;
const isColumn = isColumnUnit(unit);
const memberCount = varData.members.length;
// Min/max spread band + mean, instead of every individual member
const series: ChartSeries[] = [
{
name: 'Max',
type: 'line',
color: BAND_COLOR,
data: varData.max,
width: 1,
fill: true,
fillOpacity: 0.25,
bandTo: varData.min,
format: (v) => `${v.toFixed(1)} ${unit}`
},
{
name: 'Min',
type: 'line',
color: BAND_COLOR,
data: varData.min,
width: 1,
format: (v) => `${v.toFixed(1)} ${unit}`
},
{
name: 'Mean',
type: isColumn ? 'bar' : 'line',
color: CHART_COLORS.average,
data: varData.average,
width: 3,
dashed: !isColumn,
format: (v) => `${v.toFixed(1)} ${unit}`
}
];
const isFirst = vi === 0;
const isLast = vi === variables.length - 1;
defs.push({
title: isFirst ? 'Ensemble Spread' : undefined,
subtitle: isFirst
? `${variable} min/mean/max across ${memberCount} members (${params.models?.[0] ?? ''})`
: undefined,
unit,
showCredit: isLast,
series
});
}
return defs;
});
</script>
<!-- ─── Page hero: location + ensemble model selection ─────────────────────── -->
<div class="mb-5 flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
<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">
{#if location.admin1}{location.admin1},
{/if}{location.country ?? ''}
<span class="mx-1 opacity-50">·</span>
14-day ensemble forecast
</p>
</div>
</div>
<ModelSelector
selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'}
groups={ensembleModelGroups}
label="Ensemble model"
onModelChange={(model) => {
params.models = [model];
storedEnsembleModel.set(model);
}}
/>
</div>
<!-- ─── 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}
<ChartContainer {loading} chartCount={params.hourly?.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>
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
<div class="mt-6 md:mt-10">
<ChartToolbar charts={liveCharts} fileName="14-day-forecast">
{#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>