diff --git a/src/lib/services/weather.ts b/src/lib/services/weather.ts index 8c6eb5d..4299466 100644 --- a/src/lib/services/weather.ts +++ b/src/lib/services/weather.ts @@ -208,6 +208,17 @@ export interface WeekDailyData { windspeed_10m_max: number[]; windgusts_10m_max: number[]; winddirection_10m_dominant: number[]; + // Only the live forecast carries these; the archive-backed views reuse this + // shape without them, so they stay optional. + /** Seconds between sunrise and sunset. */ + daylight_duration?: number[]; + uv_index_max?: number[]; + precipitation_probability_max?: number[]; + /** Unix seconds; 0 on the days the moon doesn't rise / set at all. */ + moonrise?: number[]; + moonset?: number[]; + /** 0 and 1 are new moon, 0.5 is full moon. */ + moon_phase?: number[]; } export interface WeekForecastResult { @@ -361,7 +372,13 @@ const WEEK_DAILY_VARS = [ 'precipitation_sum', 'wind_speed_10m_max', 'wind_gusts_10m_max', - 'wind_direction_10m_dominant' + 'wind_direction_10m_dominant', + 'daylight_duration', + 'uv_index_max', + 'precipitation_probability_max', + 'moonrise', + 'moonset', + 'moon_phase' ] as const; /** @@ -463,6 +480,17 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise { + const v = dailyBlock.variables(i); + return v ? getValues(v) : []; + }; + const dailyInt64At = (i: number): number[] => { + const v = dailyBlock.variables(i); + return v ? getInt64Values(v) : []; + }; + const daily: WeekDailyData = { weather_code: getValues(dailyBlock.variables(0)!), temperature_2m_max: getValues(dailyBlock.variables(1)!), @@ -473,7 +501,13 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + node.animate( + [ + { opacity: 0.15, transform: 'translateY(4px)' }, + { opacity: 1, transform: 'translateY(0)' } + ], + { duration: 280, easing: 'cubic-bezier(0.22, 0.61, 0.36, 1)' } + ); + }; + + return { + update(next: unknown) { + if (next === current) return; + current = next; + play(); + } + }; +} diff --git a/src/routes/weather/week/[location]/+page.svelte b/src/routes/weather/week/[location]/+page.svelte index e616a66..f7869c4 100644 --- a/src/routes/weather/week/[location]/+page.svelte +++ b/src/routes/weather/week/[location]/+page.svelte @@ -12,6 +12,8 @@ storedVariablePrefs } from '$lib/stores/settings'; + import { formatZoned } from '$lib/utils/date'; + import { daySwap } from '$lib/utils/day-swap'; import { buildLocationRoute } from '$lib/utils/location'; import { ChartContainer } from '$lib/components/charts'; @@ -27,6 +29,7 @@ import { defaultParameters } from '../../options'; import { computeDayNightWeatherCodes } from '../../utils/weather-codes'; import DailyStripSticky from './DailyStripSticky.svelte'; + import DaySummary from './DaySummary.svelte'; import HourlyTable from './HourlyTable.svelte'; import MeteogramCharts from './MeteogramCharts.svelte'; import ModelSelector from './ModelSelector.svelte'; @@ -59,13 +62,11 @@ // arrives (no layout shift) let enabledChartCount = $derived($storedChartLayout.filter((p) => p.variables.length > 0).length); - // Height the hourly table will occupy once it renders, so its placeholder - // reserves exactly that and nothing below it jumps. The table is a header row - // plus one row per enabled variable, so the count drives the estimate. - const TABLE_HEADER_PX = 96; + // The hourly table is a header row plus one row per enabled variable, so the + // placeholder below reserves exactly that and nothing under it jumps when the + // real table arrives. const TABLE_ROW_PX = 57; let enabledTableRows = $derived(Object.values($storedVariablePrefs.table).filter(Boolean).length); - let tableSkeletonHeight = $derived(TABLE_HEADER_PX + enabledTableRows * TABLE_ROW_PX); // Request only the hourly variables the table rows and meteograms actually // show, so unused variables are never fetched. weather_code is always @@ -129,6 +130,13 @@ let fetchedHourly: FetchedHourly | null = $state(null); let fetchedDaily: FetchedDaily | null = $state(null); + // Stable per-day key: the fade only replays when the day actually changes, + // not on every clock tick or refetch. + let selectedDayKey = $derived.by(() => { + const fd = fetchedDaily; + return fd ? formatZoned(selectedDay, fd.timezone, 'yyyy-MM-dd') : ''; + }); + // 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) => { @@ -320,14 +328,16 @@ /> {#if fetchedHourly && fetchedDaily} - (variableSidebarOpen = true)} - /> +
+ (variableSidebarOpen = true)} + /> +
{:else} +
+ {#each { length: enabledTableRows } as _, i (i)} +
+
+
+
+ {/each} +
{/if} + {#if fetchedHourly && fetchedDaily} +
+ +
+ {:else} + +
+
+
+ {/if} + {#if fetchedHourly} - +
+ +
{:else} diff --git a/src/routes/weather/week/[location]/DailyStripSticky.svelte b/src/routes/weather/week/[location]/DailyStripSticky.svelte index af3aa1e..2527e23 100644 --- a/src/routes/weather/week/[location]/DailyStripSticky.svelte +++ b/src/routes/weather/week/[location]/DailyStripSticky.svelte @@ -401,7 +401,7 @@
{#each SKELETON_CELLS as i (i)}
{/each}
diff --git a/src/routes/weather/week/[location]/DaySummary.svelte b/src/routes/weather/week/[location]/DaySummary.svelte new file mode 100644 index 0000000..f88fada --- /dev/null +++ b/src/routes/weather/week/[location]/DaySummary.svelte @@ -0,0 +1,215 @@ + + +
+
+
+

+ {formatZoned(selectedDay, timezone, 'EEEE')} + + – in words{relLabel === formatZoned(selectedDay, timezone, 'EEEE') + ? '' + : ` (${relLabel})`} + +

+
+ +
+ {#if sentences.length > 0} +

+ {sentences.join(' ')} +

+ {:else} +

+ No hourly detail available for this day. +

+ {/if} + + +
+ {#if sunrise} +
+
+ Sunrise +
+
{sunrise}
+
+ {/if} + {#if sunset} +
+
+ Sunset +
+
{sunset}
+
+ {/if} + {#if daylight} +
+
+ Daylight +
+
+ {daylight} + {#if sunshine != null} + · {sunshine}% sun + {/if} +
+
+ {/if} + {#if finite(uv)} +
+
+ UV index +
+
+ {uv.toFixed(1)} + {uvLabel(uv)} +
+
+ {/if} + {#if moonrise} +
+
+ Moonrise +
+
{moonrise}
+
+ {/if} + {#if moonset} +
+
+ Moonset +
+
{moonset}
+
+ {/if} + {#if litPath} +
+ + + + + +
+
+ Moon +
+
+ {moonPhaseName(phase!)} + {#if illumination != null} + · {illumination}% + {/if} +
+
+
+ {/if} +
+
+
+
diff --git a/src/routes/weather/week/[location]/forecast-text.ts b/src/routes/weather/week/[location]/forecast-text.ts new file mode 100644 index 0000000..51bd327 --- /dev/null +++ b/src/routes/weather/week/[location]/forecast-text.ts @@ -0,0 +1,315 @@ +/** + * 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. + */ +import { formatZoned } from '$lib/utils/date'; + +import { + type WeatherUnits, + getPrecipUnit, + getTempUnit, + getWindDirectionLabel, + getWindUnit +} from './types'; + +import type { WeekDailyData, WeekHourlyData } from '$lib/services/weather'; + +/** Broad condition families, ordered from calmest to most disruptive. */ +type Category = 'clear' | 'fair' | 'cloudy' | 'fog' | 'drizzle' | 'rain' | 'snow' | 'thunder'; + +const CATEGORY_RANK: Record = { + clear: 0, + fair: 1, + cloudy: 2, + fog: 3, + drizzle: 4, + rain: 5, + snow: 6, + thunder: 7 +}; + +/** WMO weather code → condition family. */ +function categoryOf(code: number): Category { + if (code >= 95) return 'thunder'; + if (code >= 85) return 'snow'; + if (code >= 80) return 'rain'; // rain showers + if (code >= 71) return 'snow'; + if (code >= 66) return 'snow'; // freezing rain reads as wintry + if (code >= 61) return 'rain'; + if (code >= 51) return 'drizzle'; + if (code >= 45) return 'fog'; + if (code === 3) return 'cloudy'; + if (code === 1 || code === 2) return 'fair'; + return 'clear'; +} + +const CATEGORY_PHRASE: Record = { + clear: 'clear', + fair: 'partly cloudy', + cloudy: 'overcast', + fog: 'foggy', + drizzle: 'drizzly', + rain: 'wet', + snow: 'snowy', + thunder: 'stormy' +}; + +interface Period { + label: 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 } +]; + +export interface NarrativeInput { + hourly: WeekHourlyData; + hourlyDates: Date[]; + daily: WeekDailyData; + dailyDates: Date[]; + timezone: string; + /** 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); + +/** Indices of the hourly samples that fall on `day`, in the location's zone. */ +function hoursOfDay(dates: Date[], day: Date, timezone: string): number[] { + const key = formatZoned(day, timezone, 'yyyy-MM-dd'); + const out: number[] = []; + for (let i = 0; i < dates.length; i++) { + if (formatZoned(dates[i], timezone, 'yyyy-MM-dd') === key) out.push(i); + } + return out; +} + +/** The family that best characterises a stretch of hours. */ +function dominantCategory(codes: number[]): Category | null { + if (codes.length === 0) return null; + const counts = new Map(); + for (const code of codes) { + if (!finite(code)) continue; + const cat = categoryOf(code); + counts.set(cat, (counts.get(cat) ?? 0) + 1); + } + if (counts.size === 0) return null; + + // A third of the window under a disruptive sky is what the day is "about", + // even when calmer hours outnumber it. + let best: Category | null = null; + for (const [cat, n] of counts) { + if (n / codes.length < 0.34 && CATEGORY_RANK[cat] < CATEGORY_RANK.drizzle) continue; + if (!best) best = cat; + else if (CATEGORY_RANK[cat] > CATEGORY_RANK[best]) best = cat; + else if (CATEGORY_RANK[cat] === CATEGORY_RANK[best] && n > (counts.get(best) ?? 0)) best = cat; + } + if (best) return best; + + let mode: Category = 'clear'; + let modeCount = -1; + for (const [cat, n] of counts) { + if (n > modeCount) { + mode = cat; + modeCount = n; + } + } + return mode; +} + +function capitalise(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +/** + * 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. + */ +export function buildDayNarrative(input: NarrativeInput): string[] { + const { hourly, hourlyDates, daily, dailyDates, timezone, day, units, isToday } = input; + + const idx = hoursOfDay(hourlyDates, day, timezone); + if (idx.length === 0) return []; + + const dayIndex = dailyDates.findIndex( + (d) => formatZoned(d, timezone, 'yyyy-MM-dd') === formatZoned(day, timezone, 'yyyy-MM-dd') + ); + + const tempUnit = getTempUnit(units); + const windUnit = getWindUnit(units); + 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 sentences: string[] = []; + + // ─── How the sky behaves through the day ──────────────────────────────────── + const segments: { label: string; category: Category }[] = []; + for (const period of PERIODS) { + const inPeriod = idx.filter((i) => { + const h = hourOf(i); + return h >= period.from && h < period.to; + }); + 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 (segments.length > 0) { + // collapse neighbouring periods that share a description + const runs: { labels: string[]; 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 (runs.length === 1) { + sentences.push( + `${capitalise(CATEGORY_PHRASE[runs[0].category])} ${isToday ? 'all day' : 'throughout the day'}.` + ); + } else { + // 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(', ')}.`); + } + } + + // ─── Temperature ──────────────────────────────────────────────────────────── + const temps = idx.map((i) => hourly.temperature_2m?.[i]).filter(finite); + if (temps.length > 0) { + 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}.`); + } + + // ─── Precipitation ────────────────────────────────────────────────────────── + const total = idx + .map((i) => hourly.precipitation?.[i]) + .filter(finite) + .reduce((a, b) => a + b, 0); + const probs = idx.map((i) => hourly.precipitation_probability?.[i]).filter(finite); + const peakProb = probs.length > 0 ? Math.max(...probs) : 0; + const wetThreshold = precipUnit === 'in' ? 0.004 : 0.1; + + if (total >= wetThreshold) { + // name the window carrying most of the total + let bestLabel = ''; + let bestAmount = 0; + for (const period of PERIODS) { + const amount = idx + .filter((i) => hourOf(i) >= period.from && hourOf(i) < period.to) + .map((i) => hourly.precipitation?.[i]) + .filter(finite) + .reduce((a, b) => a + b, 0); + if (amount > bestAmount) { + bestAmount = amount; + bestLabel = period.label; + } + } + const amountText = `${total.toFixed(total < 10 ? 1 : 0)} ${precipUnit}`; + const share = bestAmount / total; + sentences.push( + bestLabel && share >= 0.5 + ? `Around ${amountText} of precipitation, most of it ${bestLabel}.` + : `Around ${amountText} of precipitation spread through the day.` + ); + } else if (peakProb >= 30) { + sentences.push( + `Mostly dry, with up to a ${Math.round(peakProb)}% chance of catching a shower.` + ); + } else { + sentences.push('Staying dry.'); + } + + // ─── Wind ─────────────────────────────────────────────────────────────────── + const winds = idx.map((i) => hourly.windspeed_10m?.[i]).filter(finite); + if (winds.length > 0) { + const maxWind = Math.max(...winds); + 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}.`); + } + + // ─── 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.` + ); + } + + return sentences; +} + +/** 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'; +} + +/** Tailwind text colour matching the WHO UV bands. */ +export function uvColorClass(uv: number): string { + if (uv < 3) return 'text-emerald-600 dark:text-emerald-400'; + if (uv < 6) return 'text-amber-600 dark:text-amber-400'; + if (uv < 8) return 'text-orange-600 dark:text-orange-400'; + if (uv < 11) return 'text-red-600 dark:text-red-400'; + return 'text-fuchsia-600 dark:text-fuchsia-400'; +} + +/** 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'; +} + +/** Illuminated fraction of the disc, 0 at new moon and 1 at full. */ +export function moonIllumination(phase: number): number { + const p = ((phase % 1) + 1) % 1; + return (1 - Math.cos(2 * Math.PI * p)) / 2; +}