This commit is contained in:
Vincent van der Wal
2026-08-01 17:58:03 +02:00
parent b2c8108c8c
commit 01cc6225aa
430 changed files with 3150 additions and 441 deletions
@@ -1,14 +1,16 @@
<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, storedUnits } from '$lib/stores/settings';
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 {
@@ -19,7 +21,8 @@
} from '$lib/services/weather';
import { useHeroActions } from '../../hero.svelte';
import { defaultParameters } from '../../options';
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';
@@ -79,7 +82,9 @@
let result = $state<SeasonalForecastResult | null>(null);
let normals = $state<ClimateNormals | null>(null);
let seasonalModel = $state('best_match');
onMount(() => {
seasonalModel = get(storedSeasonalModel);
mounted = true;
});
@@ -89,6 +94,7 @@
const loc = location;
const tempUnit = params.temperature_unit;
const precipUnit = params.precipitation_unit;
const model = seasonalModel;
if (!mounted || !$isSupporter || !loc) return;
const version = ++requestVersion;
@@ -99,6 +105,7 @@
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',
@@ -173,7 +180,7 @@
</script>
<svelte:head>
<title>Drizz.li | Seasonal forecast</title>
<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"
@@ -182,32 +189,43 @@
<!-- the range buttons ride in the layout's location row (see weather/+layout) -->
{#snippet heroActions()}
{#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="Outlook range"
>
{#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}
<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
@@ -226,17 +244,16 @@
<circle cx="12" cy="12" r="9" />
</svg>
<p class="text-muted-foreground">
A seasonal forecast shows how a whole month is likely to
<strong class="font-semibold text-foreground">depart from its climate normal</strong> - not the
weather on any given day. Read the monthly trend and the ensemble agreement, not the daily
wiggles.
{m.seasonal_explainer_before()}
<strong class="font-semibold text-foreground">{m.seasonal_explainer_strong()}</strong>
{m.seasonal_explainer_after()}
{#if lastDayLabel}
This outlook runs to <strong class="font-semibold text-foreground">{lastDayLabel}</strong>.
{m.seasonal_runs_to({ date: lastDayLabel })}
{/if}
</p>
</div>
<PaywallGate feature="The seasonal outlook">
<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"
@@ -265,7 +282,7 @@
<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"
>Show legend</Label
>{m.legend_show()}</Label
>
</div>
{/snippet}