/** * 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; }