more fluent texts
This commit is contained in:
@@ -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
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user