i18n
This commit is contained in:
@@ -11,6 +11,8 @@
|
||||
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
|
||||
import { routePath } from '$lib/i18n';
|
||||
|
||||
import './layout.css';
|
||||
|
||||
let { children } = $props();
|
||||
@@ -40,7 +42,7 @@
|
||||
});
|
||||
|
||||
// the maps page embeds a full-bleed map: no padding, no scrolling
|
||||
let fullBleed = $derived($page.url.pathname.startsWith('/weather/maps'));
|
||||
let fullBleed = $derived(routePath($page.url.pathname).startsWith('/weather/maps'));
|
||||
|
||||
let sidebarCollapsed = $state(false);
|
||||
let mobileMenuOpen = $state(false);
|
||||
|
||||
+5
-1
@@ -1,7 +1,11 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
import { localizeHref } from '$lib/paraglide/runtime';
|
||||
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async () => {
|
||||
throw redirect(303, '/weather/week/');
|
||||
// bare `/` lands in the locale the strategy resolved (URL, cookie, then the
|
||||
// browser's own languages), not always English
|
||||
throw redirect(303, localizeHref('/weather/week/'));
|
||||
}) satisfies PageLoad;
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
import { storedLocation } from '$lib/stores/settings';
|
||||
|
||||
import { routePath } from '$lib/i18n';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -29,15 +32,15 @@
|
||||
// load resolves (and any weather page that doesn't carry a location).
|
||||
let location = $derived($page.data.location ?? $storedLocation);
|
||||
|
||||
const SUBTITLES: [string, string][] = [
|
||||
['/weather/week', '7-day forecast'],
|
||||
['/weather/compare', 'Model comparison'],
|
||||
['/weather/14-day', '14-day ensemble forecast'],
|
||||
['/weather/seasonal', 'Seasonal outlook'],
|
||||
['/weather/historical', 'Historical weather']
|
||||
const SUBTITLES: [string, () => string][] = [
|
||||
['/weather/week', m.page_week_subtitle],
|
||||
['/weather/compare', m.page_compare_subtitle],
|
||||
['/weather/14-day', m.page_14day_subtitle],
|
||||
['/weather/seasonal', m.page_seasonal_subtitle],
|
||||
['/weather/historical', m.page_historical_subtitle]
|
||||
];
|
||||
let subtitle = $derived(
|
||||
SUBTITLES.find(([prefix]) => $page.url.pathname.startsWith(prefix))?.[1] ?? null
|
||||
SUBTITLES.find(([prefix]) => routePath($page.url.pathname).startsWith(prefix))?.[1]?.() ?? null
|
||||
);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
import { localizeHref } from '$lib/paraglide/runtime';
|
||||
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async () => {
|
||||
throw redirect(303, '/weather/week/');
|
||||
// keep the visitor in their language when the bare /weather path is hit
|
||||
throw redirect(303, localizeHref('/weather/week/'));
|
||||
}) satisfies PageLoad;
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
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';
|
||||
|
||||
import { href } from '$lib/i18n';
|
||||
|
||||
// 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/14-day/[location]', { location: buildLocationRoute(get(storedLocation)) }),
|
||||
href('/weather/14-day/[location]', { location: buildLocationRoute(get(storedLocation)) }),
|
||||
{
|
||||
replaceState: true
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
|
||||
import { CHART_COLORS, CanvasChart, type ChartSeries, isColumnUnit } from '$lib/charts';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import {
|
||||
type DaylightBand,
|
||||
type EnsembleForecastResult,
|
||||
@@ -281,7 +282,7 @@
|
||||
<ModelSelector
|
||||
selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'}
|
||||
groups={ensembleModelGroups}
|
||||
label="Ensemble model"
|
||||
label={m.model_ensemble()}
|
||||
onModelChange={(model) => {
|
||||
params.models = [model];
|
||||
storedEnsembleModel.set(model);
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
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';
|
||||
|
||||
import { href } from '$lib/i18n';
|
||||
|
||||
// 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/compare/[location]', { location: buildLocationRoute(get(storedLocation)) }),
|
||||
href('/weather/compare/[location]', { location: buildLocationRoute(get(storedLocation)) }),
|
||||
{
|
||||
replaceState: true
|
||||
}
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
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';
|
||||
|
||||
import { href } from '$lib/i18n';
|
||||
|
||||
// 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/historical/[location]', {
|
||||
href('/weather/historical/[location]', {
|
||||
location: buildLocationRoute(get(storedLocation))
|
||||
}),
|
||||
{ replaceState: true }
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
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';
|
||||
|
||||
import { href } from '$lib/i18n';
|
||||
|
||||
// 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]', {
|
||||
href('/weather/seasonal/[location]', {
|
||||
location: buildLocationRoute(get(storedLocation))
|
||||
}),
|
||||
{ replaceState: true }
|
||||
|
||||
@@ -3,21 +3,19 @@
|
||||
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';
|
||||
|
||||
import { href } from '$lib/i18n';
|
||||
|
||||
// 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/week/[location]', { location: buildLocationRoute(get(storedLocation)) }),
|
||||
{
|
||||
replaceState: true
|
||||
}
|
||||
);
|
||||
goto(href('/weather/week/[location]', { location: buildLocationRoute(get(storedLocation)) }), {
|
||||
replaceState: true
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
storedChartRange
|
||||
} from '$lib/stores/settings';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import { CHART_VARIABLES, VARIABLE_BY_KEY } from './variables';
|
||||
|
||||
interface Props {
|
||||
@@ -22,11 +24,11 @@
|
||||
let usedKeys = $derived(new Set($storedChartLayout.flatMap((p) => p.variables)));
|
||||
|
||||
const RANGE_OPTIONS: { value: ChartRangePref; label: string; hint: string }[] = [
|
||||
{ value: 'auto', label: 'Auto', hint: '3 days on phones, everything on wider screens' },
|
||||
{ value: 'today', label: 'Today', hint: 'Open on the current day' },
|
||||
{ value: '3d', label: '3 days', hint: 'Open on the next three days' },
|
||||
{ value: '5d', label: '5 days', hint: 'Open on the next five days' },
|
||||
{ value: 'all', label: 'All', hint: 'Open on the full forecast' }
|
||||
{ value: 'auto', label: m.default_range_auto(), hint: m.default_range_auto_hint() },
|
||||
{ value: 'today', label: m.range_today(), hint: '' },
|
||||
{ value: '3d', label: m.range_3_days(), hint: '' },
|
||||
{ value: '5d', label: m.range_5_days(), hint: '' },
|
||||
{ value: 'all', label: m.range_all(), hint: '' }
|
||||
];
|
||||
let availableVars = $derived(CHART_VARIABLES.filter((v) => !usedKeys.has(v.key)));
|
||||
|
||||
@@ -177,7 +179,7 @@
|
||||
<div class="rounded-xl border border-border p-3">
|
||||
<div class="mb-2 flex items-baseline justify-between gap-2">
|
||||
<span class="text-[11px] font-bold tracking-wider text-primary uppercase">
|
||||
Default time range
|
||||
{m.default_range_title()}
|
||||
</span>
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
{RANGE_OPTIONS.find((o) => o.value === $storedChartRange)?.hint}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
|
||||
|
||||
import { href } from '$lib/i18n';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import { getTempStyle } from '../../utils/colors';
|
||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||
import {
|
||||
@@ -142,8 +143,8 @@
|
||||
type="button"
|
||||
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"
|
||||
aria-label={m.strip_past_aria()}
|
||||
title={m.strip_past_aria()}
|
||||
>
|
||||
<span class="inline-flex items-baseline gap-0.5">
|
||||
<svg
|
||||
@@ -157,16 +158,16 @@
|
||||
</svg>
|
||||
<span class="text-sm leading-none font-extrabold">3</span>
|
||||
</span>
|
||||
<span class="text-[9px] leading-tight font-semibold">past</span>
|
||||
<span class="text-[9px] leading-tight font-semibold">{m.strip_past_label()}</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 })}
|
||||
href={href('/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"
|
||||
aria-label={m.strip_history_aria()}
|
||||
title={m.strip_history_title()}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -182,7 +183,7 @@
|
||||
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>
|
||||
<span class="text-[9px] leading-tight font-semibold">{m.strip_history_label()}</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
@@ -355,8 +356,8 @@
|
||||
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"
|
||||
onclick={onExtend}
|
||||
aria-label="Show the full 15-day forecast"
|
||||
title="Show the full 15-day forecast"
|
||||
aria-label={m.strip_extend_aria()}
|
||||
title={m.strip_extend_aria()}
|
||||
>
|
||||
<span class="inline-flex items-baseline gap-0.5">
|
||||
<span class="text-sm leading-none font-extrabold">15</span>
|
||||
@@ -370,16 +371,16 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="text-[9px] leading-tight font-semibold">days</span>
|
||||
<span class="text-[9px] leading-tight font-semibold">{m.strip_days_label()}</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 })}
|
||||
href={href('/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"
|
||||
aria-label={m.strip_seasonal_aria()}
|
||||
title={m.strip_seasonal_title()}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -391,7 +392,7 @@
|
||||
<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>
|
||||
<span class="text-[9px] leading-tight font-semibold">{m.strip_seasonal_label()}</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import {
|
||||
buildDayNarrative,
|
||||
moonIllumination,
|
||||
@@ -28,7 +30,6 @@
|
||||
daily.dailyDates.findIndex((d) => formatZoned(d, timezone, 'yyyy-MM-dd') === dayKey)
|
||||
);
|
||||
let relLabel = $derived(getRelativeDayLabel(selectedDay, timezone));
|
||||
let isToday = $derived(relLabel === 'Today');
|
||||
|
||||
let sentences = $derived(
|
||||
buildDayNarrative({
|
||||
@@ -38,8 +39,7 @@
|
||||
dailyDates: daily.dailyDates,
|
||||
timezone,
|
||||
day: selectedDay,
|
||||
units,
|
||||
isToday
|
||||
units
|
||||
})
|
||||
);
|
||||
|
||||
@@ -70,8 +70,8 @@
|
||||
let daylight = $derived.by(() => {
|
||||
if (!finite(daylightSeconds)) return null;
|
||||
const h = Math.floor(daylightSeconds / 3600);
|
||||
const m = Math.round((daylightSeconds % 3600) / 60);
|
||||
return `${h} h ${String(m).padStart(2, '0')} m`;
|
||||
const min = Math.round((daylightSeconds % 3600) / 60);
|
||||
return m.daylight_hours({ hours: h, minutes: String(min).padStart(2, '0') });
|
||||
});
|
||||
|
||||
let sunshine = $derived.by(() => {
|
||||
@@ -98,13 +98,13 @@
|
||||
let illumination = $derived(finite(phase) ? Math.round(moonIllumination(phase) * 100) : null);
|
||||
</script>
|
||||
|
||||
<section class="mt-6" aria-label="Written forecast">
|
||||
<section class="mt-6" aria-label={m.summary_heading()}>
|
||||
<div class="overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm">
|
||||
<div class="border-b border-border/70 bg-muted/40 px-4 py-2.5">
|
||||
<h3 class="text-base font-bold">
|
||||
{formatZoned(selectedDay, timezone, 'EEEE')}
|
||||
<span class="font-semibold text-muted-foreground">
|
||||
– in words{relLabel === formatZoned(selectedDay, timezone, 'EEEE')
|
||||
– {m.summary_heading()}{relLabel === formatZoned(selectedDay, timezone, 'EEEE')
|
||||
? ''
|
||||
: ` (${relLabel})`}
|
||||
</span>
|
||||
@@ -118,7 +118,7 @@
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-[15px] leading-relaxed text-muted-foreground">
|
||||
No hourly detail available for this day.
|
||||
{m.summary_no_data()}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
{#if sunrise}
|
||||
<div>
|
||||
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
Sunrise
|
||||
{m.label_sunrise()}
|
||||
</dt>
|
||||
<dd class="font-semibold tabular-nums">{sunrise}</dd>
|
||||
</div>
|
||||
@@ -137,7 +137,7 @@
|
||||
{#if sunset}
|
||||
<div>
|
||||
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
Sunset
|
||||
{m.label_sunset()}
|
||||
</dt>
|
||||
<dd class="font-semibold tabular-nums">{sunset}</dd>
|
||||
</div>
|
||||
@@ -145,12 +145,14 @@
|
||||
{#if daylight}
|
||||
<div>
|
||||
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
Daylight
|
||||
{m.label_daylight()}
|
||||
</dt>
|
||||
<dd class="font-semibold tabular-nums">
|
||||
{daylight}
|
||||
{#if sunshine != null}
|
||||
<span class="font-medium text-muted-foreground">· {sunshine}% sun</span>
|
||||
<span class="font-medium text-muted-foreground"
|
||||
>· {m.sunshine_share({ percent: sunshine })}</span
|
||||
>
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
@@ -158,7 +160,7 @@
|
||||
{#if finite(uv)}
|
||||
<div>
|
||||
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
UV index
|
||||
{m.label_uv_index()}
|
||||
</dt>
|
||||
<dd class="font-semibold tabular-nums">
|
||||
{uv.toFixed(1)}
|
||||
@@ -169,7 +171,7 @@
|
||||
{#if moonrise}
|
||||
<div>
|
||||
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
Moonrise
|
||||
{m.label_moonrise()}
|
||||
</dt>
|
||||
<dd class="font-semibold tabular-nums">{moonrise}</dd>
|
||||
</div>
|
||||
@@ -177,7 +179,7 @@
|
||||
{#if moonset}
|
||||
<div>
|
||||
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
Moonset
|
||||
{m.label_moonset()}
|
||||
</dt>
|
||||
<dd class="font-semibold tabular-nums">{moonset}</dd>
|
||||
</div>
|
||||
@@ -196,7 +198,7 @@
|
||||
</svg>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
Moon
|
||||
{m.label_moon()}
|
||||
</div>
|
||||
<div class="truncate font-semibold">
|
||||
{moonPhaseName(phase!)}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date';
|
||||
|
||||
import { groupHover, setGroupHover } from '$lib/charts';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import { getTempStyle } from '../../utils/colors';
|
||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||
@@ -402,7 +403,7 @@
|
||||
>
|
||||
<h3 class="text-base font-bold">
|
||||
{formatZoned(selectedDay, data.timezone, 'EEEE')}
|
||||
<span class="font-semibold text-muted-foreground">– hourly</span>
|
||||
<span class="font-semibold text-muted-foreground">– {m.hourly_heading()}</span>
|
||||
<span
|
||||
class="ms-2 rounded-full bg-muted px-2 py-0.5 align-middle text-[10px] font-semibold text-muted-foreground"
|
||||
>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { ChartContainer, downloadChartsPng } from '$lib/components/charts';
|
||||
|
||||
import { CanvasChart, groupRange } from '$lib/charts';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||
import ChartCustomizer from './ChartCustomizer.svelte';
|
||||
@@ -137,11 +138,13 @@
|
||||
let rangePresets = $derived.by(() => {
|
||||
const isToday = isSameDayInZone(now, selectedDay, data.timezone);
|
||||
return [
|
||||
{ label: 'Today', apply: () => setRangeDays(new Date(), 1) },
|
||||
...(isToday ? [] : [{ label: 'Selected day', apply: () => setRangeDays(selectedDay, 1) }]),
|
||||
{ label: '3 days', apply: () => setRangeDays(new Date(), 3) },
|
||||
{ label: '5 days', apply: () => setRangeDays(new Date(), 5) },
|
||||
{ label: 'All', apply: () => showFullRange() }
|
||||
{ label: m.range_today(), apply: () => setRangeDays(new Date(), 1) },
|
||||
...(isToday
|
||||
? []
|
||||
: [{ label: m.range_selected_day(), apply: () => setRangeDays(selectedDay, 1) }]),
|
||||
{ label: m.range_3_days(), apply: () => setRangeDays(new Date(), 3) },
|
||||
{ label: m.range_5_days(), apply: () => setRangeDays(new Date(), 5) },
|
||||
{ label: m.range_all(), apply: () => showFullRange() }
|
||||
];
|
||||
});
|
||||
|
||||
@@ -215,7 +218,7 @@
|
||||
<section class="mt-8" transition:fade={{ duration: 200 }}>
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 class="text-lg font-bold">
|
||||
Meteograms
|
||||
{m.meteograms_heading()}
|
||||
<span class="font-semibold text-muted-foreground">
|
||||
– {formatZoned(selectedDay, data.timezone, 'EEEE')}{getRelativeDayLabel(
|
||||
selectedDay,
|
||||
@@ -231,12 +234,12 @@
|
||||
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]"
|
||||
>Ctrl</kbd
|
||||
>
|
||||
+ scroll to zoom
|
||||
{m.meteograms_zoom_hint_end()}
|
||||
</span>
|
||||
<div
|
||||
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-xs font-semibold"
|
||||
role="group"
|
||||
aria-label="Chart time range"
|
||||
aria-label={m.range_group_aria()}
|
||||
>
|
||||
{#each rangePresets as preset (preset.label)}
|
||||
<button
|
||||
@@ -262,7 +265,7 @@
|
||||
>
|
||||
<path stroke-linecap="round" d="M4 6h16M4 12h16M4 18h16M8 4v4m8 2v4M6 16v4" />
|
||||
</svg>
|
||||
Customize
|
||||
{m.meteograms_customize()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -316,7 +319,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v6h6M20 20v-6h-6" />
|
||||
<path stroke-linecap="round" d="M20 10a8 8 0 0 0-14-4M4 14a8 8 0 0 0 14 4" />
|
||||
</svg>
|
||||
Reset zoom
|
||||
{m.reset_zoom()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import { type WeatherModelGroup, modelGroups } from '../../options';
|
||||
|
||||
interface Props {
|
||||
@@ -15,7 +17,7 @@
|
||||
selectedModel,
|
||||
onModelChange,
|
||||
groups = modelGroups,
|
||||
label = 'Weather model'
|
||||
label = m.model_weather()
|
||||
}: Props = $props();
|
||||
|
||||
let model = $derived(
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
/**
|
||||
* Turns a day's hourly forecast into a short written summary - the kind of
|
||||
* sentence a person would actually say about the weather, rather than another
|
||||
* table of numbers. Everything here is derived from the same data the charts
|
||||
* plot, so the wording can never disagree with them.
|
||||
* table of numbers. Everything is derived from the same data the charts plot,
|
||||
* so the wording can never disagree with them.
|
||||
*
|
||||
* Every sentence is a whole message, not a string built from glued-together
|
||||
* fragments: word order, prepositions and agreement differ per language, so
|
||||
* each locale owns its full sentence and only receives the values.
|
||||
*/
|
||||
import { formatZoned } from '$lib/utils/date';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import {
|
||||
type WeatherUnits,
|
||||
getPrecipUnit,
|
||||
@@ -30,6 +36,17 @@ const CATEGORY_RANK: Record<Category, number> = {
|
||||
thunder: 7
|
||||
};
|
||||
|
||||
const CATEGORY_MESSAGE: Record<Category, () => string> = {
|
||||
clear: m.cond_clear,
|
||||
fair: m.cond_fair,
|
||||
cloudy: m.cond_cloudy,
|
||||
fog: m.cond_fog,
|
||||
drizzle: m.cond_drizzle,
|
||||
rain: m.cond_rain,
|
||||
snow: m.cond_snow,
|
||||
thunder: m.cond_thunder
|
||||
};
|
||||
|
||||
/** WMO weather code → condition family. */
|
||||
function categoryOf(code: number): Category {
|
||||
if (code >= 95) return 'thunder';
|
||||
@@ -45,29 +62,18 @@ function categoryOf(code: number): Category {
|
||||
return 'clear';
|
||||
}
|
||||
|
||||
const CATEGORY_PHRASE: Record<Category, string> = {
|
||||
clear: 'clear',
|
||||
fair: 'partly cloudy',
|
||||
cloudy: 'overcast',
|
||||
fog: 'foggy',
|
||||
drizzle: 'drizzly',
|
||||
rain: 'wet',
|
||||
snow: 'snowy',
|
||||
thunder: 'stormy'
|
||||
};
|
||||
|
||||
interface Period {
|
||||
label: string;
|
||||
message: () => string;
|
||||
/** Inclusive start hour, exclusive end hour (local). */
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
const PERIODS: Period[] = [
|
||||
{ label: 'overnight', from: 0, to: 6 },
|
||||
{ label: 'this morning', from: 6, to: 12 },
|
||||
{ label: 'this afternoon', from: 12, to: 18 },
|
||||
{ label: 'this evening', from: 18, to: 24 }
|
||||
{ message: m.period_overnight, from: 0, to: 6 },
|
||||
{ message: m.period_morning, from: 6, to: 12 },
|
||||
{ message: m.period_afternoon, from: 12, to: 18 },
|
||||
{ message: m.period_evening, from: 18, to: 24 }
|
||||
];
|
||||
|
||||
export interface NarrativeInput {
|
||||
@@ -79,8 +85,6 @@ export interface NarrativeInput {
|
||||
/** The day being described. */
|
||||
day: Date;
|
||||
units: WeatherUnits;
|
||||
/** True when `day` is today, which changes the wording to the present tense. */
|
||||
isToday: boolean;
|
||||
}
|
||||
|
||||
const finite = (v: number | null | undefined): v is number => v != null && Number.isFinite(v);
|
||||
@@ -137,7 +141,7 @@ function capitalise(s: string): string {
|
||||
* paragraph). Returns an empty list when the day has no usable data.
|
||||
*/
|
||||
export function buildDayNarrative(input: NarrativeInput): string[] {
|
||||
const { hourly, hourlyDates, daily, dailyDates, timezone, day, units, isToday } = input;
|
||||
const { hourly, hourlyDates, daily, dailyDates, timezone, day, units } = input;
|
||||
|
||||
const idx = hoursOfDay(hourlyDates, day, timezone);
|
||||
if (idx.length === 0) return [];
|
||||
@@ -151,11 +155,13 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
|
||||
const precipUnit = getPrecipUnit(units);
|
||||
const at = (arr: number[] | undefined, i: number) => (arr ? arr[i] : undefined);
|
||||
const hourOf = (i: number) => Number(formatZoned(hourlyDates[i], timezone, 'H'));
|
||||
const temp = (v: number) => `${v.toFixed(0)}${tempUnit}`;
|
||||
const speed = (v: number) => `${v.toFixed(0)} ${windUnit}`;
|
||||
|
||||
const sentences: string[] = [];
|
||||
|
||||
// ─── How the sky behaves through the day ────────────────────────────────────
|
||||
const segments: { label: string; category: Category }[] = [];
|
||||
const segments: { period: Period; category: Category }[] = [];
|
||||
for (const period of PERIODS) {
|
||||
const inPeriod = idx.filter((i) => {
|
||||
const h = hourOf(i);
|
||||
@@ -163,37 +169,47 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
|
||||
});
|
||||
if (inPeriod.length < 2) continue;
|
||||
const cat = dominantCategory(inPeriod.map((i) => hourly.weather_code?.[i]).filter(finite));
|
||||
if (cat) segments.push({ label: period.label, category: cat });
|
||||
if (cat) segments.push({ period, category: cat });
|
||||
}
|
||||
|
||||
if (segments.length > 0) {
|
||||
// collapse neighbouring periods that share a description
|
||||
const runs: { labels: string[]; category: Category }[] = [];
|
||||
const runs: { period: Period; category: Category }[] = [];
|
||||
for (const seg of segments) {
|
||||
const last = runs[runs.length - 1];
|
||||
if (last && last.category === seg.category) last.labels.push(seg.label);
|
||||
else runs.push({ labels: [seg.label], category: seg.category });
|
||||
if (!last || last.category !== seg.category) runs.push(seg);
|
||||
}
|
||||
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]) })));
|
||||
} else if (runs.length === 2) {
|
||||
sentences.push(
|
||||
`${capitalise(CATEGORY_PHRASE[runs[0].category])} ${isToday ? 'all day' : 'throughout the day'}.`
|
||||
capitalise(
|
||||
m.forecast_sky_two({
|
||||
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
|
||||
// Four clauses is a mouthful: keep the opening, the first change and
|
||||
// where the day ends up.
|
||||
const kept = runs.length > 3 ? [runs[0], runs[1], runs[runs.length - 1]] : runs;
|
||||
const parts = kept.map((run, i) => {
|
||||
const phrase = CATEGORY_PHRASE[run.category];
|
||||
const when = run.labels[0];
|
||||
if (i === 0) return `${capitalise(phrase)} ${when}`;
|
||||
// only the first change gets a verb; later ones read as a list
|
||||
if (i > 1) return `then ${phrase} ${when}`;
|
||||
const prev = kept[i - 1].category;
|
||||
if (CATEGORY_RANK[run.category] > CATEGORY_RANK[prev]) return `turning ${phrase} ${when}`;
|
||||
return `${run.category === 'clear' || run.category === 'fair' ? 'clearing to' : 'easing to'} ${phrase} ${when}`;
|
||||
});
|
||||
sentences.push(`${parts.join(', ')}.`);
|
||||
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()
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,15 +219,12 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
|
||||
const high = Math.max(...temps);
|
||||
const low = Math.min(...temps);
|
||||
const feels = idx.map((i) => hourly.apparent_temperature?.[i]).filter(finite);
|
||||
let sentence = `Highs near ${high.toFixed(0)}${tempUnit}, down to ${low.toFixed(0)}${tempUnit}`;
|
||||
if (feels.length > 0) {
|
||||
const feelsHigh = Math.max(...feels);
|
||||
const delta = feelsHigh - high;
|
||||
if (Math.abs(delta) >= 3) {
|
||||
sentence += `, though it will feel more like ${feelsHigh.toFixed(0)}${tempUnit}`;
|
||||
}
|
||||
}
|
||||
sentences.push(`${sentence}.`);
|
||||
const feelsHigh = feels.length > 0 ? Math.max(...feels) : null;
|
||||
sentences.push(
|
||||
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) })
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Precipitation ──────────────────────────────────────────────────────────
|
||||
@@ -225,7 +238,7 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
|
||||
|
||||
if (total >= wetThreshold) {
|
||||
// name the window carrying most of the total
|
||||
let bestLabel = '';
|
||||
let bestPeriod: Period | null = null;
|
||||
let bestAmount = 0;
|
||||
for (const period of PERIODS) {
|
||||
const amount = idx
|
||||
@@ -235,22 +248,19 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
|
||||
.reduce((a, b) => a + b, 0);
|
||||
if (amount > bestAmount) {
|
||||
bestAmount = amount;
|
||||
bestLabel = period.label;
|
||||
bestPeriod = period;
|
||||
}
|
||||
}
|
||||
const amountText = `${total.toFixed(total < 10 ? 1 : 0)} ${precipUnit}`;
|
||||
const share = bestAmount / total;
|
||||
const amount = `${total.toFixed(total < 10 ? 1 : 0)} ${precipUnit}`;
|
||||
sentences.push(
|
||||
bestLabel && share >= 0.5
|
||||
? `Around ${amountText} of precipitation, most of it ${bestLabel}.`
|
||||
: `Around ${amountText} of precipitation spread through the day.`
|
||||
bestPeriod && bestAmount / total >= 0.5
|
||||
? m.forecast_precip_window({ amount, when: bestPeriod.message() })
|
||||
: m.forecast_precip_spread({ amount })
|
||||
);
|
||||
} else if (peakProb >= 30) {
|
||||
sentences.push(
|
||||
`Mostly dry, with up to a ${Math.round(peakProb)}% chance of catching a shower.`
|
||||
);
|
||||
sentences.push(m.forecast_precip_chance({ percent: Math.round(peakProb) }));
|
||||
} else {
|
||||
sentences.push('Staying dry.');
|
||||
sentences.push(m.forecast_precip_dry());
|
||||
}
|
||||
|
||||
// ─── Wind ───────────────────────────────────────────────────────────────────
|
||||
@@ -260,18 +270,32 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
|
||||
const dir = dayIndex >= 0 ? at(daily.winddirection_10m_dominant, dayIndex) : undefined;
|
||||
const gusts = idx.map((i) => hourly.wind_gusts_10m?.[i]).filter(finite);
|
||||
const maxGust = gusts.length > 0 ? Math.max(...gusts) : 0;
|
||||
const from = finite(dir) ? ` from the ${getWindDirectionLabel(dir)}` : '';
|
||||
let sentence = `Wind${from} up to ${maxWind.toFixed(0)} ${windUnit}`;
|
||||
if (maxGust > maxWind * 1.4) sentence += `, gusting ${maxGust.toFixed(0)}`;
|
||||
sentences.push(`${sentence}.`);
|
||||
const gusty = maxGust > maxWind * 1.4;
|
||||
// below ~5 km/h (or the equivalent in other units) there is nothing to say
|
||||
const calm = maxWind < (windUnit === 'm/s' ? 1.5 : windUnit === 'kn' ? 3 : 5);
|
||||
|
||||
if (calm && !gusty) {
|
||||
sentences.push(m.forecast_calm());
|
||||
} 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) })
|
||||
);
|
||||
} else {
|
||||
sentences.push(
|
||||
gusty
|
||||
? m.forecast_wind_gusts({ speed: speed(maxWind), gust: speed(maxGust) })
|
||||
: m.forecast_wind({ speed: speed(maxWind) })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── UV ─────────────────────────────────────────────────────────────────────
|
||||
const uv = dayIndex >= 0 ? at(daily.uv_index_max, dayIndex) : undefined;
|
||||
if (finite(uv) && uv >= 6) {
|
||||
sentences.push(
|
||||
`UV peaks at ${uv.toFixed(0)} - ${uvLabel(uv).toLowerCase()}, so cover up around midday.`
|
||||
);
|
||||
sentences.push(m.forecast_uv({ value: uv.toFixed(0), label: uvLabel(uv).toLowerCase() }));
|
||||
}
|
||||
|
||||
return sentences;
|
||||
@@ -279,11 +303,11 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
|
||||
|
||||
/** WHO exposure category for a UV index value. */
|
||||
export function uvLabel(uv: number): string {
|
||||
if (uv < 3) return 'Low';
|
||||
if (uv < 6) return 'Moderate';
|
||||
if (uv < 8) return 'High';
|
||||
if (uv < 11) return 'Very high';
|
||||
return 'Extreme';
|
||||
if (uv < 3) return m.uv_low();
|
||||
if (uv < 6) return m.uv_moderate();
|
||||
if (uv < 8) return m.uv_high();
|
||||
if (uv < 11) return m.uv_very_high();
|
||||
return m.uv_extreme();
|
||||
}
|
||||
|
||||
/** Tailwind text colour matching the WHO UV bands. */
|
||||
@@ -298,14 +322,14 @@ export function uvColorClass(uv: number): string {
|
||||
/** Name of the lunar phase for a 0-1 fraction (0 and 1 are new moon). */
|
||||
export function moonPhaseName(phase: number): string {
|
||||
const p = ((phase % 1) + 1) % 1;
|
||||
if (p < 0.03 || p >= 0.97) return 'New moon';
|
||||
if (p < 0.22) return 'Waxing crescent';
|
||||
if (p < 0.28) return 'First quarter';
|
||||
if (p < 0.47) return 'Waxing gibbous';
|
||||
if (p < 0.53) return 'Full moon';
|
||||
if (p < 0.72) return 'Waning gibbous';
|
||||
if (p < 0.78) return 'Last quarter';
|
||||
return 'Waning crescent';
|
||||
if (p < 0.03 || p >= 0.97) return m.moon_new();
|
||||
if (p < 0.22) return m.moon_waxing_crescent();
|
||||
if (p < 0.28) return m.moon_first_quarter();
|
||||
if (p < 0.47) return m.moon_waxing_gibbous();
|
||||
if (p < 0.53) return m.moon_full();
|
||||
if (p < 0.72) return m.moon_waning_gibbous();
|
||||
if (p < 0.78) return m.moon_last_quarter();
|
||||
return m.moon_waning_crescent();
|
||||
}
|
||||
|
||||
/** Illuminated fraction of the disc, 0 at new moon and 1 at full. */
|
||||
|
||||
Reference in New Issue
Block a user