day swap animation
This commit is contained in:
@@ -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<Category, number> = {
|
||||
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<Category, string> = {
|
||||
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<Category, number>();
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user