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/seasonal/[location]/+page.svelte
T
2026-08-01 17:58:03 +02:00

302 lines
9.8 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { storedLocation, storedSeasonalModel, storedUnits } 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 * as m from '$lib/paraglide/messages';
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
import { isSupporter } from '$lib/paywall/supporter';
import {
type ClimateNormals,
type SeasonalForecastResult,
fetchClimateNormals,
fetchSeasonalForecast
} from '$lib/services/weather';
import { useHeroActions } from '../../hero.svelte';
import { defaultParameters, seasonalModelGroups } from '../../options';
import ModelSelector from '../../week/[location]/ModelSelector.svelte';
import { getPrecipUnit } from '../../week/[location]/types';
import SeasonalCharts from './SeasonalCharts.svelte';
import SeasonalMonths from './SeasonalMonths.svelte';
import { buildMonthOutlooks, sliceSeasonal } from './outlook';
import type { CanvasChart } from '$lib/charts';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
reportPageReady(() => result != null);
useHeroActions(heroActions);
// 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 });
$effect(() => {
params.temperature_unit = $storedUnits.temperature_unit;
params.wind_speed_unit = $storedUnits.wind_speed_unit;
params.precipitation_unit = $storedUnits.precipitation_unit;
});
// Only the variables the outlook actually renders: every extra one costs a
// full member set (50+ series) over half a year of days.
const SEASONAL_VARS = [
'temperature_2m_max',
'temperature_2m_min',
'temperature_2m_mean',
'precipitation_sum'
];
// ─── Display state (no refetch) ─────────────────────────────────────────────
const RANGES = [
{ label: '3 months', days: 92 },
{ label: '6 months', days: 183 },
{ label: 'Full range', days: Infinity }
];
let rangeIndex = $state(1);
let showLegend = $state(true);
let chartComponents: CanvasChart[] = $state([]);
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
// ─── Fetch state ────────────────────────────────────────────────────────────
let mounted = $state(false);
let loading = $state(true);
let loadError = $state<string | null>(null);
let requestVersion = 0;
let result = $state<SeasonalForecastResult | null>(null);
let normals = $state<ClimateNormals | null>(null);
let seasonalModel = $state('best_match');
onMount(() => {
seasonalModel = get(storedSeasonalModel);
mounted = true;
});
// The full horizon is fetched once per location/units; the range buttons only
// reslice it.
$effect(() => {
const loc = location;
const tempUnit = params.temperature_unit;
const precipUnit = params.precipitation_unit;
const model = seasonalModel;
if (!mounted || !$isSupporter || !loc) return;
const version = ++requestVersion;
loading = true;
loadError = null;
fetchSeasonalForecast({
latitude: loc.latitude!,
longitude: loc.longitude!,
dailyVariables: SEASONAL_VARS,
model,
temperature_unit: tempUnit as 'celsius' | 'fahrenheit',
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
precipitation_unit: precipUnit as 'mm' | 'inch',
timezone: loc.timezone
})
.then((r) => {
if (version !== requestVersion) return;
result = r;
loading = false;
})
.catch((err: unknown) => {
if (version !== requestVersion) return;
loadError = err instanceof Error ? err.message : String(err);
loading = false;
});
});
// Normals are the whole point of a seasonal outlook (everything is shown as a
// departure from them), but a failure only drops the comparison.
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 || !$isSupporter || !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(() => {
if (version === normalsVersion) normals = null;
});
void key;
});
// ─── Derived view ───────────────────────────────────────────────────────────
let visible = $derived(result ? sliceSeasonal(result, RANGES[rangeIndex].days) : null);
// 1 mm and 0.04 in are the same "measurable rain" threshold in either unit.
let wetDayThreshold = $derived(getPrecipUnit(params) === 'in' ? 0.04 : 1);
let months = $derived(visible ? buildMonthOutlooks(visible, normals, { wetDayThreshold }) : []);
let horizonDays = $derived(result?.timestamps.length ?? 0);
let lastDay = $derived(
visible && visible.dailyDates.length > 0
? visible.dailyDates[visible.dailyDates.length - 1]
: null
);
// dailyDates are local wall time held as UTC instants (see the service), so
// the label has to be formatted in UTC to read back the local date.
let lastDayLabel = $derived(
lastDay
? lastDay.toLocaleDateString(undefined, {
month: 'long',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC'
})
: ''
);
</script>
<svelte:head>
<title>Drizz.li | {m.page_seasonal_subtitle()}</title>
<meta
name="description"
content="Multi-month seasonal outlook: monthly temperature and precipitation trends against the 1991-2020 climate normal"
/>
</svelte:head>
<!-- the range buttons ride in the layout's location row (see weather/+layout) -->
{#snippet heroActions()}
<div class="flex w-full flex-wrap items-center gap-3 sm:w-auto">
{#if result}
<!-- range buttons reslice the already-fetched horizon (no refetch) -->
<div
class="flex w-full gap-1 rounded-lg border border-border bg-card p-1 sm:w-auto"
role="group"
aria-label={m.seasonal_range_aria()}
>
{#each RANGES as range, i (range.label)}
{@const disabled = range.days !== Infinity && range.days > horizonDays}
<button
type="button"
class="flex-1 cursor-pointer rounded-md px-3 py-1.5 text-xs font-semibold whitespace-nowrap transition-colors sm:flex-none {rangeIndex ===
i
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'} {disabled
? 'cursor-not-allowed opacity-40'
: ''}"
aria-pressed={rangeIndex === i}
{disabled}
onclick={() => (rangeIndex = i)}
>
{range.label}
</button>
{/each}
</div>
{/if}
<ModelSelector
selectedModel={seasonalModel}
groups={seasonalModelGroups}
label={m.model_seasonal()}
onModelChange={(model) => {
seasonalModel = model;
storedSeasonalModel.set(model);
}}
/>
</div>
{/snippet}
<!-- What a seasonal forecast is (and is not): without this the daily-looking
charts invite over-reading. -->
<div
class="mb-4 flex items-start gap-2.5 rounded-md border border-border bg-muted/40 px-3.5 py-2.5 text-sm"
>
<svg
class="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 16v-4m0-4h.01" />
<circle cx="12" cy="12" r="9" />
</svg>
<p class="text-muted-foreground">
{m.seasonal_explainer_before()}
<strong class="font-semibold text-foreground">{m.seasonal_explainer_strong()}</strong>
{m.seasonal_explainer_after()}
{#if lastDayLabel}
{m.seasonal_runs_to({ date: lastDayLabel })}
{/if}
</p>
</div>
<PaywallGate feature={m.page_seasonal_subtitle()}>
{#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 the seasonal outlook: {loadError}
</div>
{/if}
{#if visible && months.length > 0}
<SeasonalMonths {months} units={params} {normals} />
<div class="mt-6">
<SeasonalCharts
result={visible}
{normals}
units={params}
{loading}
{showLegend}
bind:charts={chartComponents}
/>
</div>
<div class="mt-6 md:mt-10">
<ChartToolbar charts={liveCharts} fileName="seasonal-outlook">
{#snippet controls()}
<div class="flex items-center gap-2">
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
<Label for="show_legend" class="cursor-pointer text-base leading-none"
>{m.legend_show()}</Label
>
</div>
{/snippet}
</ChartToolbar>
</div>
{:else if !loadError}
<div transition:fade={{ duration: 200 }} class="grid gap-3">
<div class="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
{#each [0, 1, 2, 3, 4, 5] as i (i)}
<div class="h-44 animate-pulse rounded-xl border border-border/70 bg-card"></div>
{/each}
</div>
<ChartContainer loading chartCount={2} chartHeight={300} bleed={false} />
</div>
{/if}
</PaywallGate>