day swap animation
This commit is contained in:
@@ -208,6 +208,17 @@ export interface WeekDailyData {
|
|||||||
windspeed_10m_max: number[];
|
windspeed_10m_max: number[];
|
||||||
windgusts_10m_max: number[];
|
windgusts_10m_max: number[];
|
||||||
winddirection_10m_dominant: 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 {
|
export interface WeekForecastResult {
|
||||||
@@ -361,7 +372,13 @@ const WEEK_DAILY_VARS = [
|
|||||||
'precipitation_sum',
|
'precipitation_sum',
|
||||||
'wind_speed_10m_max',
|
'wind_speed_10m_max',
|
||||||
'wind_gusts_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;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -463,6 +480,17 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
|
|||||||
const sunriseVar = dailyBlock.variables(3)!;
|
const sunriseVar = dailyBlock.variables(3)!;
|
||||||
const sunsetVar = dailyBlock.variables(4)!;
|
const sunsetVar = dailyBlock.variables(4)!;
|
||||||
|
|
||||||
|
// Optional tail variables: a model that doesn't carry them yields fewer
|
||||||
|
// entries, so read them defensively instead of asserting.
|
||||||
|
const dailyAt = (i: number): number[] => {
|
||||||
|
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 = {
|
const daily: WeekDailyData = {
|
||||||
weather_code: getValues(dailyBlock.variables(0)!),
|
weather_code: getValues(dailyBlock.variables(0)!),
|
||||||
temperature_2m_max: getValues(dailyBlock.variables(1)!),
|
temperature_2m_max: getValues(dailyBlock.variables(1)!),
|
||||||
@@ -473,7 +501,13 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
|
|||||||
precipitation_sum: getValues(dailyBlock.variables(6)!),
|
precipitation_sum: getValues(dailyBlock.variables(6)!),
|
||||||
windspeed_10m_max: getValues(dailyBlock.variables(7)!),
|
windspeed_10m_max: getValues(dailyBlock.variables(7)!),
|
||||||
windgusts_10m_max: getValues(dailyBlock.variables(8)!),
|
windgusts_10m_max: getValues(dailyBlock.variables(8)!),
|
||||||
winddirection_10m_dominant: getValues(dailyBlock.variables(9)!)
|
winddirection_10m_dominant: getValues(dailyBlock.variables(9)!),
|
||||||
|
daylight_duration: dailyAt(10),
|
||||||
|
uv_index_max: dailyAt(11),
|
||||||
|
precipitation_probability_max: dailyAt(12),
|
||||||
|
moonrise: dailyInt64At(13),
|
||||||
|
moonset: dailyInt64At(14),
|
||||||
|
moon_phase: dailyAt(15)
|
||||||
};
|
};
|
||||||
|
|
||||||
const daylightBands = buildDaylightBands(daily.sunrise, daily.sunset);
|
const daylightBands = buildDaylightBands(daily.sunrise, daily.sunset);
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Fades a block back in whenever the value passed to it changes - used to make
|
||||||
|
* a day switch visible in the hourly table, the written forecast and the
|
||||||
|
* meteograms.
|
||||||
|
*
|
||||||
|
* It animates the existing node instead of remounting it: the meteograms hold
|
||||||
|
* canvas charts with their own zoom state, and rebuilding those on every day
|
||||||
|
* change would be both expensive and lossy.
|
||||||
|
*/
|
||||||
|
export function daySwap(node: HTMLElement, key: unknown) {
|
||||||
|
let current = key;
|
||||||
|
|
||||||
|
const play = () => {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
storedVariablePrefs
|
storedVariablePrefs
|
||||||
} from '$lib/stores/settings';
|
} from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import { formatZoned } from '$lib/utils/date';
|
||||||
|
import { daySwap } from '$lib/utils/day-swap';
|
||||||
import { buildLocationRoute } from '$lib/utils/location';
|
import { buildLocationRoute } from '$lib/utils/location';
|
||||||
|
|
||||||
import { ChartContainer } from '$lib/components/charts';
|
import { ChartContainer } from '$lib/components/charts';
|
||||||
@@ -27,6 +29,7 @@
|
|||||||
import { defaultParameters } from '../../options';
|
import { defaultParameters } from '../../options';
|
||||||
import { computeDayNightWeatherCodes } from '../../utils/weather-codes';
|
import { computeDayNightWeatherCodes } from '../../utils/weather-codes';
|
||||||
import DailyStripSticky from './DailyStripSticky.svelte';
|
import DailyStripSticky from './DailyStripSticky.svelte';
|
||||||
|
import DaySummary from './DaySummary.svelte';
|
||||||
import HourlyTable from './HourlyTable.svelte';
|
import HourlyTable from './HourlyTable.svelte';
|
||||||
import MeteogramCharts from './MeteogramCharts.svelte';
|
import MeteogramCharts from './MeteogramCharts.svelte';
|
||||||
import ModelSelector from './ModelSelector.svelte';
|
import ModelSelector from './ModelSelector.svelte';
|
||||||
@@ -59,13 +62,11 @@
|
|||||||
// arrives (no layout shift)
|
// arrives (no layout shift)
|
||||||
let enabledChartCount = $derived($storedChartLayout.filter((p) => p.variables.length > 0).length);
|
let enabledChartCount = $derived($storedChartLayout.filter((p) => p.variables.length > 0).length);
|
||||||
|
|
||||||
// Height the hourly table will occupy once it renders, so its placeholder
|
// The hourly table is a header row plus one row per enabled variable, so the
|
||||||
// reserves exactly that and nothing below it jumps. The table is a header row
|
// placeholder below reserves exactly that and nothing under it jumps when the
|
||||||
// plus one row per enabled variable, so the count drives the estimate.
|
// real table arrives.
|
||||||
const TABLE_HEADER_PX = 96;
|
|
||||||
const TABLE_ROW_PX = 57;
|
const TABLE_ROW_PX = 57;
|
||||||
let enabledTableRows = $derived(Object.values($storedVariablePrefs.table).filter(Boolean).length);
|
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
|
// Request only the hourly variables the table rows and meteograms actually
|
||||||
// show, so unused variables are never fetched. weather_code is always
|
// show, so unused variables are never fetched. weather_code is always
|
||||||
@@ -129,6 +130,13 @@
|
|||||||
let fetchedHourly: FetchedHourly | null = $state(null);
|
let fetchedHourly: FetchedHourly | null = $state(null);
|
||||||
let fetchedDaily: FetchedDaily | 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
|
// Charts intentionally keep their current range: they show the full week
|
||||||
// unless the user narrows it via the range presets or Ctrl+scroll.
|
// unless the user narrows it via the range presets or Ctrl+scroll.
|
||||||
const switchDay = (date: Date) => {
|
const switchDay = (date: Date) => {
|
||||||
@@ -320,14 +328,16 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{#if fetchedHourly && fetchedDaily}
|
{#if fetchedHourly && fetchedDaily}
|
||||||
<HourlyTable
|
<div use:daySwap={selectedDayKey}>
|
||||||
data={fetchedHourly}
|
<HourlyTable
|
||||||
daily={fetchedDaily}
|
data={fetchedHourly}
|
||||||
{selectedDay}
|
daily={fetchedDaily}
|
||||||
units={params}
|
{selectedDay}
|
||||||
locationName={location.name ?? ''}
|
units={params}
|
||||||
onCustomize={() => (variableSidebarOpen = true)}
|
locationName={location.name ?? ''}
|
||||||
/>
|
onCustomize={() => (variableSidebarOpen = true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Mirrors the real table: same header bar and the same body height,
|
<!-- Mirrors the real table: same header bar and the same body height,
|
||||||
so the heading doesn't pop in and nothing below moves.
|
so the heading doesn't pop in and nothing below moves.
|
||||||
@@ -347,12 +357,35 @@
|
|||||||
<div class="h-8 w-20 animate-pulse rounded-lg bg-muted"></div>
|
<div class="h-8 w-20 animate-pulse rounded-lg bg-muted"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="animate-pulse bg-card" style="height: {tableSkeletonHeight}px"></div>
|
<!-- one placeholder per row the real table will render, so the body
|
||||||
|
reads as a loading table rather than a blank panel -->
|
||||||
|
<div class="divide-y divide-border/50">
|
||||||
|
{#each { length: enabledTableRows } as _, i (i)}
|
||||||
|
<div class="flex items-center gap-4 px-4" style="height: {TABLE_ROW_PX}px">
|
||||||
|
<div class="h-3.5 w-14 shrink-0 animate-pulse rounded bg-muted"></div>
|
||||||
|
<div class="h-3.5 flex-1 animate-pulse rounded bg-muted/70"></div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if fetchedHourly && fetchedDaily}
|
||||||
|
<div use:daySwap={selectedDayKey}>
|
||||||
|
<DaySummary data={fetchedHourly} daily={fetchedDaily} {selectedDay} units={params} />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- same footprint as the written forecast, so it doesn't shove the
|
||||||
|
meteograms down when it arrives -->
|
||||||
|
<section class="mt-6" in:fade={{ duration: 200 }}>
|
||||||
|
<div class="h-52 animate-pulse rounded-2xl border border-border/70 bg-card sm:h-40"></div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if fetchedHourly}
|
{#if fetchedHourly}
|
||||||
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
|
<div use:daySwap={selectedDayKey}>
|
||||||
|
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- reserve the exact chart area height before the first fetch resolves,
|
<!-- reserve the exact chart area height before the first fetch resolves,
|
||||||
header row included -->
|
header row included -->
|
||||||
|
|||||||
@@ -401,7 +401,7 @@
|
|||||||
<div class="strip-days flex">
|
<div class="strip-days flex">
|
||||||
{#each SKELETON_CELLS as i (i)}
|
{#each SKELETON_CELLS as i (i)}
|
||||||
<div
|
<div
|
||||||
class="strip-cell shrink-0 animate-pulse rounded-xl border border-border/50 bg-muted/60"
|
class="strip-cell shrink-0 animate-pulse rounded-xl border border-border/60 bg-muted"
|
||||||
></div>
|
></div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildDayNarrative,
|
||||||
|
moonIllumination,
|
||||||
|
moonPhaseName,
|
||||||
|
uvColorClass,
|
||||||
|
uvLabel
|
||||||
|
} from './forecast-text';
|
||||||
|
|
||||||
|
import type { FetchedDaily, FetchedHourly, WeatherUnits } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: FetchedHourly;
|
||||||
|
daily: FetchedDaily;
|
||||||
|
selectedDay: Date;
|
||||||
|
units: WeatherUnits;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data, daily, selectedDay, units }: Props = $props();
|
||||||
|
|
||||||
|
const finite = (v: number | null | undefined): v is number => v != null && Number.isFinite(v);
|
||||||
|
|
||||||
|
let timezone = $derived(daily.timezone);
|
||||||
|
let dayKey = $derived(formatZoned(selectedDay, timezone, 'yyyy-MM-dd'));
|
||||||
|
let dayIndex = $derived(
|
||||||
|
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({
|
||||||
|
hourly: data.hourly,
|
||||||
|
hourlyDates: data.hourlyDates,
|
||||||
|
daily: daily.daily,
|
||||||
|
dailyDates: daily.dailyDates,
|
||||||
|
timezone,
|
||||||
|
day: selectedDay,
|
||||||
|
units,
|
||||||
|
isToday
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = (arr: number[] | undefined, i: number): number | undefined =>
|
||||||
|
arr && i >= 0 ? arr[i] : undefined;
|
||||||
|
|
||||||
|
/** Unix seconds → local clock time; 0 means "never happens today". */
|
||||||
|
const clock = (seconds: number | undefined): string | null =>
|
||||||
|
finite(seconds) && seconds > 0
|
||||||
|
? formatZoned(new Date(seconds * 1000), timezone, 'HH:mm')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
let sunrise = $derived(clock(value(daily.daily.sunrise, dayIndex)));
|
||||||
|
let sunset = $derived(clock(value(daily.daily.sunset, dayIndex)));
|
||||||
|
let moonrise = $derived(clock(value(daily.daily.moonrise, dayIndex)));
|
||||||
|
let moonset = $derived(clock(value(daily.daily.moonset, dayIndex)));
|
||||||
|
let uv = $derived(value(daily.daily.uv_index_max, dayIndex));
|
||||||
|
let phase = $derived(value(daily.daily.moon_phase, dayIndex));
|
||||||
|
|
||||||
|
// Prefer the reported daylight duration; fall back to sunset − sunrise.
|
||||||
|
let daylightSeconds = $derived.by(() => {
|
||||||
|
const reported = value(daily.daily.daylight_duration, dayIndex);
|
||||||
|
if (finite(reported) && reported > 0) return reported;
|
||||||
|
const rise = value(daily.daily.sunrise, dayIndex);
|
||||||
|
const set = value(daily.daily.sunset, dayIndex);
|
||||||
|
return finite(rise) && finite(set) && set > rise ? set - rise : NaN;
|
||||||
|
});
|
||||||
|
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`;
|
||||||
|
});
|
||||||
|
|
||||||
|
let sunshine = $derived.by(() => {
|
||||||
|
const s = value(daily.daily.sunshine_duration, dayIndex);
|
||||||
|
if (!finite(s) || !finite(daylightSeconds) || daylightSeconds <= 0) return null;
|
||||||
|
return Math.round((s / daylightSeconds) * 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Moon disc ──────────────────────────────────────────────────────────────
|
||||||
|
// The terminator is an ellipse whose width tracks the phase: full circle at
|
||||||
|
// new moon, flat at the quarters, and back out again towards full.
|
||||||
|
const R = 9;
|
||||||
|
let litPath = $derived.by(() => {
|
||||||
|
if (!finite(phase)) return null;
|
||||||
|
const p = ((phase % 1) + 1) % 1;
|
||||||
|
const k = Math.cos(2 * Math.PI * p);
|
||||||
|
const rx = Math.abs(k) * R;
|
||||||
|
const waxing = p < 0.5;
|
||||||
|
// outer edge of the lit half, then the terminator back to the top
|
||||||
|
const outerSweep = waxing ? 1 : 0;
|
||||||
|
const innerSweep = k >= 0 ? (waxing ? 0 : 1) : waxing ? 1 : 0;
|
||||||
|
return `M 0 ${-R} A ${R} ${R} 0 0 ${outerSweep} 0 ${R} A ${rx} ${R} 0 0 ${innerSweep} 0 ${-R} Z`;
|
||||||
|
});
|
||||||
|
let illumination = $derived(finite(phase) ? Math.round(moonIllumination(phase) * 100) : null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="mt-6" aria-label="Written forecast">
|
||||||
|
<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')
|
||||||
|
? ''
|
||||||
|
: ` (${relLabel})`}
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 px-4 py-3.5 lg:grid-cols-[minmax(0,1fr)_auto] lg:gap-6">
|
||||||
|
{#if sentences.length > 0}
|
||||||
|
<p class="text-[15px] leading-relaxed text-foreground">
|
||||||
|
{sentences.join(' ')}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-[15px] leading-relaxed text-muted-foreground">
|
||||||
|
No hourly detail available for this day.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Sun, moon and UV: the numbers the sentence above deliberately leaves out -->
|
||||||
|
<dl
|
||||||
|
class="grid grid-cols-2 gap-x-5 gap-y-2.5 text-sm sm:grid-cols-3 lg:w-80 lg:grid-cols-2 lg:border-l lg:border-border/60 lg:pl-6"
|
||||||
|
>
|
||||||
|
{#if sunrise}
|
||||||
|
<div>
|
||||||
|
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||||||
|
Sunrise
|
||||||
|
</dt>
|
||||||
|
<dd class="font-semibold tabular-nums">{sunrise}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if sunset}
|
||||||
|
<div>
|
||||||
|
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||||||
|
Sunset
|
||||||
|
</dt>
|
||||||
|
<dd class="font-semibold tabular-nums">{sunset}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if daylight}
|
||||||
|
<div>
|
||||||
|
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||||||
|
Daylight
|
||||||
|
</dt>
|
||||||
|
<dd class="font-semibold tabular-nums">
|
||||||
|
{daylight}
|
||||||
|
{#if sunshine != null}
|
||||||
|
<span class="font-medium text-muted-foreground">· {sunshine}% sun</span>
|
||||||
|
{/if}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if finite(uv)}
|
||||||
|
<div>
|
||||||
|
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||||||
|
UV index
|
||||||
|
</dt>
|
||||||
|
<dd class="font-semibold tabular-nums">
|
||||||
|
{uv.toFixed(1)}
|
||||||
|
<span class="font-medium {uvColorClass(uv)}">{uvLabel(uv)}</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if moonrise}
|
||||||
|
<div>
|
||||||
|
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||||||
|
Moonrise
|
||||||
|
</dt>
|
||||||
|
<dd class="font-semibold tabular-nums">{moonrise}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if moonset}
|
||||||
|
<div>
|
||||||
|
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||||||
|
Moonset
|
||||||
|
</dt>
|
||||||
|
<dd class="font-semibold tabular-nums">{moonset}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if litPath}
|
||||||
|
<div class="col-span-2 flex items-center gap-2.5 sm:col-span-3 lg:col-span-2">
|
||||||
|
<svg
|
||||||
|
class="h-6 w-6 shrink-0"
|
||||||
|
viewBox="-12 -12 24 24"
|
||||||
|
role="img"
|
||||||
|
aria-label={moonPhaseName(phase!)}
|
||||||
|
>
|
||||||
|
<circle r={R} class="fill-muted-foreground/45" />
|
||||||
|
<path d={litPath} class="fill-amber-200 dark:fill-amber-100" />
|
||||||
|
<circle r={R} fill="none" class="stroke-border" stroke-width="0.75" />
|
||||||
|
</svg>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||||||
|
Moon
|
||||||
|
</div>
|
||||||
|
<div class="truncate font-semibold">
|
||||||
|
{moonPhaseName(phase!)}
|
||||||
|
{#if illumination != null}
|
||||||
|
<span class="font-medium text-muted-foreground tabular-nums"
|
||||||
|
>· {illumination}%</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -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