day swap animation

This commit is contained in:
Vincent van der Wal
2026-08-01 14:37:25 +02:00
parent fe961a27ae
commit 9806396476
6 changed files with 646 additions and 18 deletions
@@ -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>