move settings to one icon mobile

This commit is contained in:
Vincent van der Wal
2026-08-01 12:39:19 +02:00
parent 8ad02ea3f9
commit 5cc9a5428c
23 changed files with 1511 additions and 189 deletions
@@ -13,7 +13,7 @@
import { ChartContainer } from '$lib/components/charts';
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
import { isPremium } from '$lib/paywall/premium';
import { isSupporter } from '$lib/paywall/supporter';
import {
type ClimateNormals,
type HistoricalForecastResult,
@@ -97,7 +97,7 @@
const s = startDate;
const e = endDate;
const vars = hourlyVars;
if (!mounted || !$isPremium || !loc || !s || !e) return;
if (!mounted || !$isSupporter || !loc || !s || !e) return;
const version = ++requestVersion;
loading = true;
@@ -138,7 +138,7 @@
$effect(() => {
const key = normalsKey;
const loc = location;
if (!mounted || !$isPremium || !loc) return;
if (!mounted || !$isSupporter || !loc) return;
const version = ++normalsVersion;
normals = null;
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { storedLocation } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location';
// at build time this page knows nothing about the visitor, so the redirect
// target (the persisted location) is resolved in the browser instead of
// being baked to the default city during prerender
onMount(() => {
goto(
resolve('/weather/seasonal/[location]', {
location: buildLocationRoute(get(storedLocation))
}),
{ replaceState: true }
);
});
</script>
@@ -0,0 +1,296 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { storedLocation, 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 PaywallGate from '$lib/paywall/PaywallGate.svelte';
import { isSupporter } from '$lib/paywall/supporter';
import {
type ClimateNormals,
type SeasonalForecastResult,
fetchClimateNormals,
fetchSeasonalForecast
} from '$lib/services/weather';
import { defaultParameters } from '../../options';
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 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);
onMount(() => {
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;
if (!mounted || !$isSupporter || !loc) return;
const version = ++requestVersion;
loading = true;
loadError = null;
fetchSeasonalForecast({
latitude: loc.latitude!,
longitude: loc.longitude!,
dailyVariables: SEASONAL_VARS,
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 | Seasonal forecast</title>
<meta
name="description"
content="Multi-month seasonal outlook: monthly temperature and precipitation trends against the 1991-2020 climate normal"
/>
</svelte:head>
<!-- Page hero -->
<div class="relative mb-3 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
<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">
<span class="lg:hidden"
>{#if location.admin1}{location.admin1},
{/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span
>Seasonal outlook
</p>
</div>
</div>
{#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>
<!-- 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">
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.
{#if lastDayLabel}
This outlook runs to <strong class="font-semibold text-foreground">{lastDayLabel}</strong>.
{/if}
</p>
</div>
<PaywallGate feature="The seasonal outlook">
{#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"
>Show legend</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>
@@ -0,0 +1,13 @@
import { resolveLocationFromRoute } from '$lib/utils/location';
import type { PageLoad } from './$types';
export const load: PageLoad = async (event) => {
const location = await resolveLocationFromRoute({
urlLocation: event.params.location,
routePrefix: '/weather/seasonal/',
event
});
return { location };
};
@@ -0,0 +1,211 @@
<script lang="ts">
import { ChartContainer } from '$lib/components/charts';
import { CHART_COLORS, CanvasChart, type ChartSeries } from '$lib/charts';
import {
type ClimateNormals,
type SeasonalForecastResult,
monthDayToOrdinal
} from '$lib/services/weather';
import { type WeatherUnits, getPrecipUnit, getTempUnit } from '../../week/[location]/types';
import { rollingMean, rollingSum } from './outlook';
interface Props {
result: SeasonalForecastResult;
normals: ClimateNormals | null;
units: WeatherUnits;
loading?: boolean;
showLegend?: boolean;
/** Smoothing window in days, shared by both charts. */
smoothing?: number;
charts?: CanvasChart[];
}
let {
result,
normals,
units,
loading = false,
showLegend = true,
smoothing = 7,
charts = $bindable([])
}: Props = $props();
const CHART_GROUP = 'seasonal-outlook';
const SPREAD_COLOR = 'rgba(115, 192, 222, 0.75)';
const BAND_COLOR = 'rgba(56, 132, 189, 0.9)';
const NORMAL_COLOR = '#9ca3af';
const tempUnit = $derived(getTempUnit(units));
const precipUnit = $derived(getPrecipUnit(units));
// CanvasChart works in epoch seconds. The axis is fed local wall time (and
// labelled in UTC) because the seasonal series carries a single fixed offset:
// resolving it against the IANA zone would slide every date past a DST change.
let timestamps = $derived(result.dailyDates.map((d) => d.getTime() / 1000));
// Climate normal for each forecast day, so it can be drawn as a reference
// line on the same axis as the ensemble.
let ordinals = $derived(
result.dateKeys.map((key) =>
monthDayToOrdinal(Number(key.slice(5, 7)), Number(key.slice(8, 10)))
)
);
let normalTemp = $derived(normals ? ordinals.map((o) => normals.tmean[o]) : null);
let normalPrecip = $derived(normals ? ordinals.map((o) => normals.precip[o]) : null);
const fmt =
(unit: string, digits = 1) =>
(v: number) =>
`${v.toFixed(digits)} ${unit}`;
let tempSeries = $derived.by((): ChartSeries[] => {
const v = result.variables['temperature_2m_mean'];
if (!v) return [];
const smooth = (xs: number[]) => rollingMean(xs, smoothing);
const series: ChartSeries[] = [
{
name: 'Full spread',
type: 'line',
color: SPREAD_COLOR,
data: smooth(v.max),
width: 0,
fill: true,
fillOpacity: 0.16,
bandTo: smooth(v.min),
shortName: 'spread',
format: fmt(tempUnit)
},
{
name: 'Likely range (25-75%)',
type: 'line',
color: BAND_COLOR,
data: smooth(v.p75),
width: 0,
fill: true,
fillOpacity: 0.3,
bandTo: smooth(v.p25),
shortName: 'likely',
format: fmt(tempUnit)
},
{
name: 'Ensemble mean',
type: 'line',
color: CHART_COLORS.average,
data: smooth(v.mean),
width: 3,
outline: true,
format: fmt(tempUnit)
}
];
if (normalTemp) {
series.push({
name: 'Climate normal',
type: 'line',
color: NORMAL_COLOR,
data: smooth(normalTemp),
width: 2,
dashed: true,
shortName: 'normal',
format: fmt(tempUnit)
});
}
return series;
});
let precipSeries = $derived.by((): ChartSeries[] => {
const v = result.variables['precipitation_sum'];
if (!v) return [];
const total = (xs: number[]) => rollingSum(xs, smoothing);
const series: ChartSeries[] = [
{
name: 'Likely range (25-75%)',
type: 'line',
color: BAND_COLOR,
data: total(v.p75),
width: 0,
fill: true,
fillOpacity: 0.28,
bandTo: total(v.p25),
shortName: 'likely',
format: fmt(precipUnit)
},
{
name: 'Ensemble mean',
type: 'line',
color: CHART_COLORS.average,
data: total(v.mean),
width: 3,
outline: true,
format: fmt(precipUnit)
}
];
if (normalPrecip) {
series.push({
name: 'Climate normal',
type: 'line',
color: NORMAL_COLOR,
data: total(normalPrecip),
width: 2,
dashed: true,
shortName: 'normal',
format: fmt(precipUnit)
});
}
return series;
});
let chartDefs = $derived(
[
{
title: 'Temperature outlook',
subtitle: `${smoothing}-day smoothed daily mean · ${result.memberCount} ensemble members`,
unit: tempUnit,
series: tempSeries,
zeroBaseLeft: false,
showCredit: false
},
{
title: 'Precipitation outlook',
subtitle: `${smoothing}-day rolling total (${precipUnit})`,
unit: precipUnit,
series: precipSeries,
zeroBaseLeft: true,
showCredit: true
}
].filter((def) => def.series.length > 0)
);
</script>
<!-- full-bleed graphs until lg / contained card on lg+ (matches the 14-day page) -->
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
{#each chartDefs as def, i (def.title)}
<div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}">
<div class="mb-1 px-3 lg:px-0">
<h4 class="text-sm font-bold tracking-tight">{def.title}</h4>
<p class="text-xs text-muted-foreground">{def.subtitle}</p>
</div>
<ChartContainer {loading} chartCount={1} chartHeight={300} minWidth={520} bleed={false}>
<CanvasChart
bind:this={charts[i]}
{timestamps}
timezone="UTC"
series={def.series}
unit={def.unit}
zeroBaseLeft={def.zeroBaseLeft}
showCredit={def.showCredit}
{showLegend}
height={300}
group={CHART_GROUP}
/>
</ChartContainer>
</div>
{/each}
</div>
@@ -0,0 +1,150 @@
<script lang="ts">
import { getColor } from '../../utils/colors';
import { type WeatherUnits, getPrecipUnit, getTempUnit } from '../../week/[location]/types';
import type { ClimateNormals } from '$lib/services/weather';
import type { MonthOutlook } from './outlook';
interface Props {
months: MonthOutlook[];
units: WeatherUnits;
normals: ClimateNormals | null;
}
let { months, units, normals }: Props = $props();
const tempUnit = $derived(getTempUnit(units));
const precipUnit = $derived(getPrecipUnit(units));
const finite = (v: number | null | undefined): v is number => v != null && Number.isFinite(v);
const fmtTemp = (v: number): string => (finite(v) ? `${v.toFixed(1)}°` : '');
const fmtSigned = (v: number): string => `${v >= 0 ? '+' : ''}${v.toFixed(1)}°`;
const fmtPrecip = (v: number): string => `${v.toFixed(v < 10 ? 1 : 0)}`;
// Members agreeing on the sign of the anomaly is the honest confidence signal
// for a seasonal outlook: a big anomaly that only half the members share means
// nothing. Below this the card says so instead of implying a trend.
const AGREEMENT_FLOOR = 0.6;
function anomalyLabel(delta: number): string {
const a = Math.abs(delta);
if (a < 0.3) return 'near normal';
const word = a < 1 ? 'slightly' : a < 2.5 ? '' : 'well';
return `${word} ${delta > 0 ? 'above' : 'below'} normal`.replace(/\s+/g, ' ').trim();
}
</script>
<div class="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
{#each months as month (month.key)}
{@const agree = Math.max(month.warmerShare, 1 - month.warmerShare)}
{@const confident = agree >= AGREEMENT_FLOOR}
<article class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
<div class="flex items-baseline justify-between gap-2">
<h3 class="text-base font-bold tracking-tight">{month.label}</h3>
<span class="text-xs text-muted-foreground">
{month.days}
{month.days === 1 ? 'day' : 'days'}{month.partial ? ' (partial)' : ''}
</span>
</div>
<!-- headline: mean temperature and its departure from the 1991-2020 normal -->
<div class="mt-2.5 flex items-end gap-3">
<div>
<p class="text-xs font-medium text-muted-foreground">Mean temperature</p>
<p class="text-2xl leading-tight font-bold tabular-nums">
{fmtTemp(month.tMean)}<span class="text-base font-semibold text-muted-foreground"
>{tempUnit.replace('°', '')}</span
>
</p>
</div>
<div class="ml-auto text-right">
{#if finite(month.anomaly)}
<p
class="text-lg leading-tight font-bold tabular-nums"
class:text-red-600={month.anomaly >= 0}
class:text-blue-600={month.anomaly < 0}
class:dark:text-red-400={month.anomaly >= 0}
class:dark:text-blue-400={month.anomaly < 0}
>
{fmtSigned(month.anomaly)}
</p>
<p class="text-[11px] text-muted-foreground">{anomalyLabel(month.anomaly)}</p>
{:else if normals}
<p class="text-[11px] text-muted-foreground">no normal for this month</p>
{:else}
<p class="text-[11px] text-muted-foreground">normal loading…</p>
{/if}
</div>
</div>
<!-- day / night means, coloured on the same temperature scale as the strip -->
<div class="mt-2.5 flex items-center gap-2 text-xs">
<span class="inline-flex items-center gap-1.5">
<span
class="inline-block h-2.5 w-2.5 rounded-full"
style="background:{getColor(month.tMax, units.temperature_unit)}"
></span>
<span class="font-semibold tabular-nums">{fmtTemp(month.tMax)}</span>
<span class="text-muted-foreground">day</span>
</span>
<span class="inline-flex items-center gap-1.5">
<span
class="inline-block h-2.5 w-2.5 rounded-full"
style="background:{getColor(month.tMin, units.temperature_unit)}"
></span>
<span class="font-semibold tabular-nums">{fmtTemp(month.tMin)}</span>
<span class="text-muted-foreground">night</span>
</span>
</div>
<!-- precipitation against its own normal, as a share bar -->
<div class="mt-3 border-t border-border/60 pt-2.5">
<div class="flex items-baseline justify-between gap-2 text-xs">
<span class="font-medium text-muted-foreground">Precipitation</span>
<span class="font-semibold tabular-nums">
{fmtPrecip(month.precip)}
{precipUnit}
{#if finite(month.precipNormal)}
<span class="font-medium text-muted-foreground">
/ normal {fmtPrecip(month.precipNormal)}
</span>
{/if}
</span>
</div>
{#if finite(month.precipShare)}
{@const pct = Math.min(200, month.precipShare * 100)}
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full"
style="width:{pct / 2}%;background:{month.precipShare >= 1
? 'var(--color-sky-500, #0ea5e9)'
: 'var(--color-amber-500, #f59e0b)'}"
></div>
</div>
<p class="mt-1 text-[11px] text-muted-foreground">
{Math.round(month.precipShare * 100)}% of normal · {month.wetDays} wet days
</p>
{/if}
</div>
<!-- ensemble agreement: the actual confidence in the anomaly above -->
<div class="mt-2.5 flex items-center gap-2">
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full"
class:bg-primary={confident}
class:bg-muted-foreground={!confident}
style="width:{Math.round(agree * 100)}%"
></div>
</div>
<span class="text-[11px] whitespace-nowrap text-muted-foreground">
{#if confident}
{Math.round(agree * 100)}% of members {month.warmerShare >= 0.5 ? 'warmer' : 'colder'}
{:else}
members split - low confidence
{/if}
</span>
</div>
</article>
{/each}
</div>
@@ -0,0 +1,199 @@
/**
* Turns the raw seasonal ensemble into the per-calendar-month outlook the page
* shows. Seasonal models carry no day-to-day skill, so everything here is an
* aggregate: monthly means, the departure from the climate normal, and how much
* of the ensemble actually agrees on that departure.
*/
import {
type ClimateNormals,
type SeasonalForecastResult,
monthDayToOrdinal
} from '$lib/services/weather';
export interface MonthOutlook {
/** `YYYY-MM` in the location's local calendar. */
key: string;
/** Month name and year, e.g. "August 2026". */
label: string;
/** Forecast days covered (a leading/trailing month is usually incomplete). */
days: number;
partial: boolean;
tMean: number;
tMax: number;
tMin: number;
/** Mean temperature minus the climate normal, or null without normals. */
anomaly: number | null;
precip: number;
precipNormal: number | null;
/** Forecast precipitation as a share of normal (1 = exactly normal). */
precipShare: number | null;
wetDays: number;
/** Share of ensemble members whose monthly mean is above the normal. */
warmerShare: number;
}
const finite = (v: number | null | undefined): v is number => v != null && Number.isFinite(v);
// The month key already carries the local calendar month, so the label is
// formatted in UTC - anything zone-aware would just reintroduce the shift.
const MONTH_LABEL = new Intl.DateTimeFormat(undefined, {
month: 'long',
year: 'numeric',
timeZone: 'UTC'
});
const mean = (xs: number[]): number =>
xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : NaN;
/** Days in a calendar month; `month` is 1-based. */
function daysInMonth(year: number, month: number): number {
return new Date(Date.UTC(year, month, 0)).getUTCDate();
}
interface BuildOptions {
/** Daily total counted as a wet day, in the active precipitation unit. */
wetDayThreshold: number;
}
export function buildMonthOutlooks(
result: SeasonalForecastResult,
normals: ClimateNormals | null,
{ wetDayThreshold }: BuildOptions
): MonthOutlook[] {
const tMeanVar = result.variables['temperature_2m_mean'];
const tMaxVar = result.variables['temperature_2m_max'];
const tMinVar = result.variables['temperature_2m_min'];
const precipVar = result.variables['precipitation_sum'];
if (!tMeanVar) return [];
const { dateKeys } = result;
// Day indices grouped by calendar month, plus the normals lookup per day.
// Both read the API's own local dates: deriving them from the instants would
// double-count the day a DST change falls on.
const groups = new Map<string, number[]>();
const ordinals = dateKeys.map((key) =>
monthDayToOrdinal(Number(key.slice(5, 7)), Number(key.slice(8, 10)))
);
for (let i = 0; i < dateKeys.length; i++) {
const key = dateKeys[i].slice(0, 7);
const bucket = groups.get(key);
if (bucket) bucket.push(i);
else groups.set(key, [i]);
}
const months: MonthOutlook[] = [];
for (const [key, indices] of groups) {
const pick = (values: number[] | undefined): number =>
values ? mean(indices.map((i) => values[i]).filter(finite)) : NaN;
const [year, month] = key.split('-').map(Number);
// Normals are per day-of-year, so the comparison uses exactly the days the
// forecast covers - a half month is never compared against a full one.
let normalMean = NaN;
let normalPrecip = NaN;
if (normals) {
normalMean = mean(indices.map((i) => normals.tmean[ordinals[i]]).filter(finite));
const np = indices.map((i) => normals.precip[ordinals[i]]).filter(finite);
if (np.length) normalPrecip = np.reduce((a, b) => a + b, 0);
}
const tMean = pick(tMeanVar.mean);
// Per-member monthly means decide the agreement share: a member counts as
// warmer only if it beats the same normal the anomaly is measured against.
let warmer = 0;
let counted = 0;
if (finite(normalMean)) {
for (const member of tMeanVar.members) {
const memberMean = mean(indices.map((i) => member[i]).filter(finite));
if (!finite(memberMean)) continue;
counted++;
if (memberMean > normalMean) warmer++;
}
}
const precipDays = precipVar ? indices.map((i) => precipVar.mean[i]).filter(finite) : [];
const precip = precipDays.reduce((a, b) => a + b, 0);
months.push({
key,
label: MONTH_LABEL.format(Date.UTC(year, month - 1, 1)),
days: indices.length,
partial: indices.length < daysInMonth(year, month),
tMean,
tMax: pick(tMaxVar?.mean),
tMin: pick(tMinVar?.mean),
anomaly: finite(normalMean) && finite(tMean) ? tMean - normalMean : null,
precip,
precipNormal: finite(normalPrecip) ? normalPrecip : null,
precipShare: finite(normalPrecip) && normalPrecip > 0 ? precip / normalPrecip : null,
wetDays: precipDays.filter((p) => p >= wetDayThreshold).length,
warmerShare: counted > 0 ? warmer / counted : 0.5
});
}
return months;
}
/**
* Narrows an outlook to its first `days` days. The fetch always asks for the
* model's full horizon, so the range buttons only reslice what is already in
* memory instead of issuing another (large) request.
*/
export function sliceSeasonal(
result: SeasonalForecastResult,
days: number
): SeasonalForecastResult {
if (days >= result.timestamps.length) return result;
const variables: SeasonalForecastResult['variables'] = {};
for (const [name, data] of Object.entries(result.variables)) {
variables[name] = {
members: data.members.map((m) => m.slice(0, days)),
mean: data.mean.slice(0, days),
min: data.min.slice(0, days),
max: data.max.slice(0, days),
p25: data.p25.slice(0, days),
p75: data.p75.slice(0, days),
unit: data.unit
};
}
return {
...result,
variables,
timestamps: result.timestamps.slice(0, days),
dailyDates: result.dailyDates.slice(0, days),
dateKeys: result.dateKeys.slice(0, days)
};
}
/**
* Centered rolling mean. Seasonal ensembles are far too noisy to read day by
* day; smoothing shows the trend the model actually claims to resolve. Windows
* shrink at the edges instead of dropping data.
*/
export function rollingMean(values: number[], window: number): number[] {
const half = Math.floor(window / 2);
return values.map((_, i) => {
let sum = 0;
let count = 0;
for (let k = i - half; k <= i + half; k++) {
const v = values[k];
if (finite(v)) {
sum += v;
count++;
}
}
return count > 0 ? sum / count : NaN;
});
}
/** Rolling total over the same window (used for precipitation). */
export function rollingSum(values: number[], window: number): number[] {
return rollingMean(values, window).map((v) => (finite(v) ? v * window : NaN));
}
@@ -12,6 +12,8 @@
storedVariablePrefs
} from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location';
import { ChartContainer } from '$lib/components/charts';
import {
@@ -76,6 +78,10 @@
storedLocation.set(data.location);
});
// Lets the strip's side buttons hand over to the archive / seasonal outlook
// for the same place once their range is exhausted.
let locationRoute = $derived(buildLocationRoute(location));
let mounted = $state(false);
let loading = $state(true);
let loadError = $state<FriendlyWeatherError | null>(null);
@@ -318,6 +324,7 @@
onExtend={() => (forecastDays = 15)}
canExtendPast={pastDays < 3}
onExtendPast={() => (pastDays = 3)}
{locationRoute}
/>
{/if}
@@ -1,6 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
import { getTempStyle } from '../../utils/colors';
@@ -23,6 +25,11 @@
onExtend?: () => void;
canExtendPast?: boolean;
onExtendPast?: () => void;
/**
* Location segment for the hand-off links that replace the side buttons
* once their range is exhausted. Omit to leave the slots empty.
*/
locationRoute?: string;
}
let {
@@ -33,7 +40,8 @@
canExtend = false,
onExtend,
canExtendPast = false,
onExtendPast
onExtendPast,
locationRoute
}: Props = $props();
// ─── Scroll-driven collapse (full cards → compact strip) ────────────────────
@@ -82,10 +90,16 @@
let scrolledForRef: FetchedDaily | null = null;
$effect(() => {
const d = daily;
if (!d || !canExtendPast || !stripScrollEl || !daysWrapEl || scrolledForRef === d) return;
if (!d || !stripScrollEl || !daysWrapEl || scrolledForRef === d) return;
scrolledForRef = d;
const scroll = stripScrollEl;
const wrap = daysWrapEl;
if (!canExtendPast) {
// the past button has been replaced by the history link, which is a
// destination rather than a control: it stays in view instead of parked
scroll.scrollLeft = 0;
return;
}
// the delta form is self-correcting (a no-op once right), so apply after
// layout and once more after late-loading CSS/fonts settle — otherwise a
// sliver of the past button can stay visible on first paint
@@ -110,7 +124,7 @@
<div bind:this={sentinelEl} class="sentinel" aria-hidden="true"></div>
<div
class="daystrip sticky -top-3 z-30 -mx-3 lg:-top-6 lg:-mx-8"
class="daystrip sticky -top-3 z-30 -mx-3 lg:-top-6 lg:-mx-8 mt-1"
class:js-snap={needsSnapFallback}
class:compact
class:stuck
@@ -122,7 +136,7 @@
match the 15-days button on the other end -->
<button
type="button"
class="strip-side flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
class="strip-side strip-park flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
onclick={onExtendPast}
aria-label="Load the past 3 days"
title="Load the past 3 days"
@@ -141,6 +155,31 @@
</span>
<span class="text-[9px] leading-tight font-semibold">past</span>
</button>
{:else if locationRoute}
<!-- the past days are already in the strip: the only way further back
is the archive, so the button hands over to it -->
<a
href={resolve('/weather/historical/[location]', { location: locationRoute })}
class="strip-side flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
aria-label="Open the historical weather archive"
title="Historical weather: any past date back to 1940"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 2" />
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.5 9a9 9 0 1 0 2.2-3.6L3 8m0-4.5V8h4.5"
/>
</svg>
<span class="text-[9px] leading-tight font-semibold">history</span>
</a>
{/if}
<div class="strip-days flex" bind:this={daysWrapEl}>
@@ -322,6 +361,27 @@
</span>
<span class="text-[9px] leading-tight font-semibold">days</span>
</button>
{:else if locationRoute}
<!-- the strip is at the model's limit: anything further out is a
seasonal outlook, so the button hands over to it -->
<a
href={resolve('/weather/seasonal/[location]', { location: locationRoute })}
class="strip-side flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
aria-label="Open the seasonal outlook"
title="Seasonal outlook: monthly trends for the months ahead"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M3 17l6-6 4 4 7-7" />
<path stroke-linecap="round" stroke-linejoin="round" d="M16 8h5v5" />
</svg>
<span class="text-[9px] leading-tight font-semibold">seasonal</span>
</a>
{/if}
</div>
{/if}
@@ -514,7 +574,7 @@
(Browsers too old to register --strip-p simply switch instantly.) */
.daystrip.js-snap {
transition:
--strip-p 0.28s ease,
--strip-p 0.32s ease,
--stuck 0.15s ease;
}
.daystrip.js-snap.compact {
@@ -527,7 +587,7 @@
/* larger cards need a touch longer to feel smooth */
.daystrip.js-snap {
transition:
--strip-p 0.55s ease,
--strip-p 0.42s ease,
--stuck 0.15s ease;
}
}
@@ -549,13 +609,14 @@
}
/* Scroll containers clip at the PADDING edge, so a parked past button would
always leak a sliver across the row's left padding. Extending the days
group's left edge (only while the past button exists) moves max-scroll so
the button parks fully beyond the clip edge. */
.strip-side + .strip-days {
group's left edge (only while the parked past button exists) moves
max-scroll so the button parks fully beyond the clip edge. The history
link that replaces it is never parked, so it keeps the normal gap. */
.strip-park + .strip-days {
padding-left: calc(12px - var(--gap-min));
}
@media (min-width: 1024px) {
.strip-side + .strip-days {
.strip-park + .strip-days {
padding-left: calc(32px - var(--gap-min));
}
}