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
@@ -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. */