more fluent texts

This commit is contained in:
Vincent van der Wal
2026-08-01 15:27:26 +02:00
parent 774afa6c65
commit 0d5dbbc61f
18 changed files with 694 additions and 201 deletions
+67 -7
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import { get } from 'svelte/store';
import { fade, fly } from 'svelte/transition';
import { afterNavigate, onNavigate } from '$app/navigation';
import { page } from '$app/stores';
import { storedTheme } from '$lib/stores/settings';
@@ -13,6 +15,8 @@
import { routePath } from '$lib/i18n';
import { pageContentReady } from '$lib/stores/page-transition.svelte';
import './layout.css';
let { children } = $props();
@@ -26,21 +30,74 @@
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const apply = () => {
const root = document.documentElement;
const dark = theme === 'dark' || (theme === 'system' && mq.matches);
const paint = () => root.classList.toggle('dark', dark);
// The very first application is just painting the stored theme - only
// an actual switch afterwards is worth cross-fading.
if (themeSettled) {
root.classList.add('theme-transition');
clearTimeout(themeTimer);
themeTimer = window.setTimeout(() => root.classList.remove('theme-transition'), 400);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!themeSettled || reduced) {
themeSettled = true;
paint();
return;
}
themeSettled = true;
root.classList.toggle('dark', theme === 'dark' || (theme === 'system' && mq.matches));
if (document.startViewTransition) {
// one cross-fade of the whole document; component transitions untouched
document.startViewTransition(paint);
return;
}
// no view transitions: fall back to fading the colours for one window
root.classList.add('theme-transition');
clearTimeout(themeTimer);
themeTimer = window.setTimeout(() => root.classList.remove('theme-transition'), 400);
paint();
};
apply();
mq.addEventListener('change', apply);
return () => mq.removeEventListener('change', apply);
});
// ── Page cross-fade ───────────────────────────────────────────────────────
// The weather pages fetch their forecast after the route swap, so tying the
// fade to navigation alone would cross-fade one skeleton into another and
// then cut hard to the real content. Instead the outgoing page fades out on
// navigation and the incoming one fades in once it reports that its data has
// landed - with a ceiling so a slow or silent page is never left hidden.
const FADE_OUT_MS = 170;
const REVEAL_CEILING_MS = 2500;
let contentVisible = $state(true);
let revealTimer = 0;
const reducedMotion = () =>
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
onNavigate(() => {
if (reducedMotion()) return;
clearTimeout(revealTimer);
contentVisible = false;
return new Promise((resolve) => setTimeout(resolve, FADE_OUT_MS));
});
afterNavigate(() => {
if (reducedMotion()) {
contentVisible = true;
return;
}
const startedAt = Date.now();
const reveal = () => {
if (get(pageContentReady) || Date.now() - startedAt > REVEAL_CEILING_MS) {
contentVisible = true;
return;
}
revealTimer = window.setTimeout(reveal, 60);
};
reveal();
});
// the maps page embeds a full-bleed map: no padding, no scrolling
let fullBleed = $derived(routePath($page.url.pathname).startsWith('/weather/maps'));
@@ -110,7 +167,10 @@
{:else}
<!-- cap the content width on very large screens; the footer below
gives the page its ending, so only modest bottom room is needed -->
<div class="mx-auto w-full max-w-[1536px] flex-1 pb-24">
<div
class="page-fade mx-auto w-full max-w-[1536px] flex-1 pb-24"
class:page-fade-hidden={!contentVisible}
>
{@render children()}
</div>
<!-- full-bleed footer inside the scroll area (its own inner max-w),
+73 -7
View File
@@ -133,10 +133,73 @@
@apply bg-background text-foreground;
}
/* Added around a theme change only (see routes/+layout.svelte), so the whole
page cross-fades between light and dark instead of snapping. It is off the
rest of the time: a permanent global transition would smear every hover
and every chart repaint. */
/* Page cross-fade: driven from routes/+layout.svelte, revealed when the new
page reports its data has arrived. */
.page-fade {
transition: opacity 320ms ease;
}
.page-fade-hidden {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.page-fade {
transition: none;
}
.page-fade-hidden {
opacity: 1;
}
}
/* ── Day switching ────────────────────────────────────────────────────────
Only the three regions whose content depends on the selected day take part
in the cross-fade. Everything else keeps its pixels: the `day-switch`
class cancels the root animation, so the strip, header and page chrome do
not so much as flicker while the table, summary and charts swap over. */
.day-region-table {
view-transition-name: day-table;
}
.day-region-summary {
view-transition-name: day-summary;
}
.day-region-charts {
view-transition-name: day-charts;
}
::view-transition-old(day-table),
::view-transition-new(day-table),
::view-transition-old(day-summary),
::view-transition-new(day-summary),
::view-transition-old(day-charts),
::view-transition-new(day-charts) {
animation-duration: 420ms;
animation-timing-function: ease;
}
:root.day-switch::view-transition-old(root),
:root.day-switch::view-transition-new(root) {
animation: none;
}
@media (prefers-reduced-motion: reduce) {
.day-region-table,
.day-region-summary,
.day-region-charts {
view-transition-name: none;
}
}
/* Theme changes cross-fade the whole document in one pass (see
routes/+layout.svelte). Doing it as a view transition instead of a blanket
`* { transition }` matters: that blanket rule also stretched every
component's own hover and focus transitions to 400ms for the duration of
the switch, which read as lag on interactive controls. */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 400ms;
animation-timing-function: ease;
}
/* Fallback for browsers without view transitions: fade the colours only. */
:root.theme-transition,
:root.theme-transition *,
:root.theme-transition *::before,
@@ -146,16 +209,19 @@
border-color 400ms ease,
color 400ms ease,
fill 400ms ease,
stroke 400ms ease,
box-shadow 400ms ease !important;
stroke 400ms ease;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
:root.theme-transition,
:root.theme-transition *,
:root.theme-transition *::before,
:root.theme-transition *::after {
transition: none !important;
transition: none;
}
}
}
@@ -2,8 +2,14 @@
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { page } from '$app/stores';
import { storedEnsembleModel, storedLocation, storedUnits } from '$lib/stores/settings';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { syncSearchParams } from '$lib/utils/url-state';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
@@ -38,6 +44,9 @@
let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
reportPageReady(() => fetchedData != null);
useHeroActions(heroActions);
// the URL is the source of truth: location comes from the load function,
@@ -76,11 +85,19 @@
// ─── Lifecycle ──────────────────────────────────────────────────────────────
onMount(() => {
// preselect the persisted ensemble model (client-only, keeps SSR stable)
params.models = [get(storedEnsembleModel)];
// a shared link carries its model; otherwise fall back to the stored choice
const fromUrl = get(page).url.searchParams.get('model');
params.models = [fromUrl || get(storedEnsembleModel)];
mounted = true;
});
// keep the plotted ensemble in the URL
$effect(() => {
const model = params.models?.[0];
if (!mounted || !model) return;
syncSearchParams($page.url, { model });
});
// components persist across refetches; entries are null while unmounted
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
@@ -3,9 +3,14 @@
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import { page } from '$app/stores';
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { formatZoned } from '$lib/utils/date';
import { readList, syncSearchParams } from '$lib/utils/url-state';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Checkbox } from '$lib/components/ui/checkbox';
@@ -53,6 +58,9 @@
let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
reportPageReady(() => fetchedData != null);
useHeroActions(heroActions);
// the URL is the source of truth: location comes from the load function,
@@ -94,14 +102,34 @@
// ─── 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];
// A link carries the exact comparison it was shared with; without one, the
// model chosen elsewhere on the site joins the default line-up.
const url = get(page).url;
const urlModels = readList(url, 'models');
const urlVars = readList(url, 'vars');
if (urlModels) params.models = urlModels;
else {
const selectedModel = get(storedModel);
if (selectedModel !== 'best_match' && !params.models.includes(selectedModel)) {
params.models = [selectedModel, ...params.models];
}
}
if (urlVars) params.hourly = urlVars;
mounted = true;
});
// Mirror the comparison back into the URL so it can be shared or reloaded.
$effect(() => {
const models = params.models;
const vars = params.hourly;
if (!mounted) return;
syncSearchParams($page.url, {
models: models?.length ? models.join(',') : null,
vars: vars?.length ? vars.join(',') : null
});
});
// components persist across refetches; entries are null while unmounted
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
@@ -10,6 +10,8 @@
storedVariablePrefs
} from '$lib/stores/settings';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { ChartContainer } from '$lib/components/charts';
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
@@ -33,6 +35,9 @@
let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
reportPageReady(() => result != null);
let location = $derived(data.location);
$effect(() => {
storedLocation.set(data.location);
@@ -4,6 +4,8 @@
import { storedLocation, storedUnits } from '$lib/stores/settings';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
@@ -29,6 +31,9 @@
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,
@@ -4,6 +4,8 @@
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import { page } from '$app/stores';
import {
storedChartLayout,
storedLocation,
@@ -12,9 +14,12 @@
storedVariablePrefs
} from '$lib/stores/settings';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { formatZoned } from '$lib/utils/date';
import { daySwap } from '$lib/utils/day-swap';
import { daySwap, runDayTransition } from '$lib/utils/day-swap';
import { buildLocationRoute } from '$lib/utils/location';
import { syncSearchParams } from '$lib/utils/url-state';
import { ChartContainer } from '$lib/components/charts';
@@ -41,6 +46,9 @@
let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
reportPageReady(() => fetchedDaily != null && fetchedHourly != null);
useHeroActions(heroActions);
let params = $state({
@@ -140,15 +148,41 @@
// Charts intentionally keep their current range: they show the full week
// unless the user narrows it via the range presets or Ctrl+scroll.
const switchDay = (date: Date) => {
selectedDay.setTime(date.getTime());
runDayTransition(() => selectedDay.setTime(date.getTime()));
};
onMount(() => {
// preselect the persisted model (client-only so prerendered HTML stays stable)
params.models = [get(storedModel)];
// the URL wins over the persisted choice, so a shared link opens on the
// same model the sender was looking at
const fromUrl = get(page).url.searchParams.get('model');
params.models = [fromUrl || get(storedModel)];
mounted = true;
});
// Apply ?day= once the forecast is in: the parameter is a plain calendar date,
// which only means something against the location's own timezone.
let appliedDayParam = false;
$effect(() => {
const fd = fetchedDaily;
if (appliedDayParam || !fd) return;
appliedDayParam = true;
const wanted = get(page).url.searchParams.get('day');
if (!wanted) return;
const match = fd.dailyDates.find((d) => formatZoned(d, fd.timezone, 'yyyy-MM-dd') === wanted);
if (match) selectedDay.setTime(match.getTime());
});
// Mirror the open day and the plotted model back into the URL.
$effect(() => {
const dayKey = selectedDayKey;
const model = params.models?.[0];
if (!mounted || !dayKey) return;
syncSearchParams($page.url, {
day: dayKey,
model: model && model !== 'best_match' ? model : null
});
});
$effect(() => {
const loc = location;
const modelList = params.models;
@@ -328,7 +362,7 @@
/>
{#if fetchedHourly && fetchedDaily}
<div use:daySwap={selectedDayKey}>
<div class="day-region-table" use:daySwap={selectedDayKey}>
<HourlyTable
data={fetchedHourly}
daily={fetchedDaily}
@@ -371,7 +405,7 @@
{/if}
{#if fetchedHourly && fetchedDaily}
<div use:daySwap={selectedDayKey}>
<div class="day-region-summary" use:daySwap={selectedDayKey}>
<DaySummary data={fetchedHourly} daily={fetchedDaily} {selectedDay} units={params} />
</div>
{:else}
@@ -383,7 +417,7 @@
{/if}
{#if fetchedHourly}
<div use:daySwap={selectedDayKey}>
<div class="day-region-charts" use:daySwap={selectedDayKey}>
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
</div>
{:else}
@@ -136,6 +136,24 @@ function capitalise(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/**
* Stable pseudo-random pick. The same day always reads the same way (so the
* text doesn't churn on every re-render) while different days get different
* phrasings - that variety is what stops the summary sounding like a template.
*/
function seedFrom(key: string): number {
let h = 2166136261;
for (let i = 0; i < key.length; i++) {
h ^= key.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return Math.abs(h);
}
function pick<T>(variants: T[], seed: number, salt: number): T {
return variants[(seed + salt) % variants.length];
}
/**
* Builds the summary as a list of sentences (the caller renders them as one
* paragraph). Returns an empty list when the day has no usable data.
@@ -158,7 +176,33 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
const temp = (v: number) => `${v.toFixed(0)}${tempUnit}`;
const speed = (v: number) => `${v.toFixed(0)} ${windUnit}`;
const sentences: string[] = [];
// one seed per day, so the wording is stable for a given day but varies
// from one day (and one place) to the next
const seed = seedFrom(formatZoned(day, timezone, 'yyyy-MM-dd') + timezone);
const SKY_ALL = [m.sky_all_1, m.sky_all_2, m.sky_all_3];
const SKY_TWO = [m.sky_two_1, m.sky_two_2, m.sky_two_3];
const SKY_THREE = [m.sky_three_1, m.sky_three_2, m.sky_three_3];
const TEMP = [m.temp_1, m.temp_2, m.temp_3];
const TEMP_FEELS = [m.temp_feels_1, m.temp_feels_2, m.temp_feels_3];
const PRECIP_WINDOW = [m.precip_window_1, m.precip_window_2, m.precip_window_3];
const PRECIP_SPREAD = [m.precip_spread_1, m.precip_spread_2, m.precip_spread_3];
const PRECIP_CHANCE = [m.precip_chance_1, m.precip_chance_2, m.precip_chance_3];
const PRECIP_DRY = [m.precip_dry_1, m.precip_dry_2, m.precip_dry_3];
const WIND_DIR = [m.wind_dir_1, m.wind_dir_2, m.wind_dir_3];
const WIND_DIR_GUSTS = [m.wind_dir_gusts_1, m.wind_dir_gusts_2, m.wind_dir_gusts_3];
const WIND = [m.wind_1, m.wind_2, m.wind_3];
const WIND_GUSTS = [m.wind_gusts_1, m.wind_gusts_2, m.wind_gusts_3];
const CALM = [m.calm_1, m.calm_2, m.calm_3];
const UV = [m.uv_1, m.uv_2, m.uv_3];
// each fact becomes its own sentence; the order of the middle three varies
let sky: string | null = null;
let temperature: string | null = null;
// always set by the branch below, so no initial value to overwrite
let precipitation: string;
let wind: string | null = null;
let ultraviolet: string | null = null;
// ─── How the sky behaves through the day ────────────────────────────────────
const segments: { period: Period; category: Category }[] = [];
@@ -182,33 +226,37 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
const phrase = (r: { category: Category }) => CATEGORY_MESSAGE[r.category]();
if (runs.length === 1) {
sentences.push(capitalise(m.forecast_sky_all_day({ condition: phrase(runs[0]) })));
sky = capitalise(pick(SKY_ALL, seed, 0)({ condition: phrase(runs[0]) }));
} else if (runs.length === 2) {
sentences.push(
capitalise(
m.forecast_sky_two({
c1: phrase(runs[0]),
p1: runs[0].period.message(),
c2: phrase(runs[1]),
p2: runs[1].period.message()
})
)
sky = capitalise(
pick(
SKY_TWO,
seed,
1
)({
c1: phrase(runs[0]),
p1: runs[0].period.message(),
c2: phrase(runs[1]),
p2: runs[1].period.message()
})
);
} else {
// Four clauses is a mouthful: keep the opening, the first change and
// where the day ends up.
const kept = [runs[0], runs[1], runs[runs.length - 1]];
sentences.push(
capitalise(
m.forecast_sky_three({
c1: phrase(kept[0]),
p1: kept[0].period.message(),
c2: phrase(kept[1]),
p2: kept[1].period.message(),
c3: phrase(kept[2]),
p3: kept[2].period.message()
})
)
sky = capitalise(
pick(
SKY_THREE,
seed,
2
)({
c1: phrase(kept[0]),
p1: kept[0].period.message(),
c2: phrase(kept[1]),
p2: kept[1].period.message(),
c3: phrase(kept[2]),
p3: kept[2].period.message()
})
);
}
}
@@ -220,11 +268,10 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
const low = Math.min(...temps);
const feels = idx.map((i) => hourly.apparent_temperature?.[i]).filter(finite);
const feelsHigh = feels.length > 0 ? Math.max(...feels) : null;
sentences.push(
temperature =
feelsHigh != null && Math.abs(feelsHigh - high) >= 3
? m.forecast_temp_feels({ high: temp(high), low: temp(low), feels: temp(feelsHigh) })
: m.forecast_temp({ high: temp(high), low: temp(low) })
);
? pick(TEMP_FEELS, seed, 3)({ high: temp(high), low: temp(low), feels: temp(feelsHigh) })
: pick(TEMP, seed, 4)({ high: temp(high), low: temp(low) });
}
// ─── Precipitation ──────────────────────────────────────────────────────────
@@ -252,15 +299,14 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
}
}
const amount = `${total.toFixed(total < 10 ? 1 : 0)} ${precipUnit}`;
sentences.push(
precipitation =
bestPeriod && bestAmount / total >= 0.5
? m.forecast_precip_window({ amount, when: bestPeriod.message() })
: m.forecast_precip_spread({ amount })
);
? pick(PRECIP_WINDOW, seed, 5)({ amount, when: bestPeriod.message() })
: pick(PRECIP_SPREAD, seed, 6)({ amount });
} else if (peakProb >= 30) {
sentences.push(m.forecast_precip_chance({ percent: Math.round(peakProb) }));
precipitation = pick(PRECIP_CHANCE, seed, 7)({ percent: Math.round(peakProb) });
} else {
sentences.push(m.forecast_precip_dry());
precipitation = pick(PRECIP_DRY, seed, 8)();
}
// ─── Wind ───────────────────────────────────────────────────────────────────
@@ -275,30 +321,44 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
const calm = maxWind < (windUnit === 'm/s' ? 1.5 : windUnit === 'kn' ? 3 : 5);
if (calm && !gusty) {
sentences.push(m.forecast_calm());
wind = pick(CALM, seed, 9)();
} else if (finite(dir)) {
const direction = getWindDirectionLabel(dir);
sentences.push(
gusty
? m.forecast_wind_dir_gusts({ direction, speed: speed(maxWind), gust: speed(maxGust) })
: m.forecast_wind_dir({ direction, speed: speed(maxWind) })
);
wind = gusty
? pick(
WIND_DIR_GUSTS,
seed,
10
)({
direction,
speed: speed(maxWind),
gust: speed(maxGust)
})
: pick(WIND_DIR, seed, 11)({ direction, speed: speed(maxWind) });
} else {
sentences.push(
gusty
? m.forecast_wind_gusts({ speed: speed(maxWind), gust: speed(maxGust) })
: m.forecast_wind({ speed: speed(maxWind) })
);
wind = gusty
? pick(WIND_GUSTS, seed, 12)({ speed: speed(maxWind), gust: speed(maxGust) })
: pick(WIND, seed, 13)({ speed: speed(maxWind) });
}
}
// ─── UV ─────────────────────────────────────────────────────────────────────
const uv = dayIndex >= 0 ? at(daily.uv_index_max, dayIndex) : undefined;
if (finite(uv) && uv >= 6) {
sentences.push(m.forecast_uv({ value: uv.toFixed(0), label: uvLabel(uv).toLowerCase() }));
ultraviolet = pick(UV, seed, 14)({ value: uv.toFixed(0), label: uvLabel(uv).toLowerCase() });
}
return sentences;
// Position varies too: the sky always opens and any UV warning always closes,
// but which of temperature, rain and wind comes next rotates per day.
const ORDERS = [
[temperature, precipitation, wind],
[precipitation, temperature, wind],
[temperature, wind, precipitation],
[wind, temperature, precipitation]
];
const middle = pick(ORDERS, seed, 15);
return [sky, ...middle, ultraviolet].filter((s): s is string => s != null && s.length > 0);
}
/** WHO exposure category for a UV index value. */