221 lines
7.5 KiB
Svelte
221 lines
7.5 KiB
Svelte
<script lang="ts">
|
||
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
|
||
|
||
import * as m from '$lib/paraglide/messages';
|
||
|
||
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 sentences = $derived(
|
||
buildDayNarrative({
|
||
hourly: data.hourly,
|
||
hourlyDates: data.hourlyDates,
|
||
daily: daily.daily,
|
||
dailyDates: daily.dailyDates,
|
||
timezone,
|
||
day: selectedDay,
|
||
units
|
||
})
|
||
);
|
||
|
||
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 min = Math.round((daylightSeconds % 3600) / 60);
|
||
return m.daylight_hours({ hours: h, minutes: String(min).padStart(2, '0') });
|
||
});
|
||
|
||
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={m.summary_heading()}>
|
||
<!-- flush with the screen edges on phones, a contained card from md up -->
|
||
<div
|
||
class="-mx-3 overflow-hidden border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border"
|
||
>
|
||
<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">
|
||
– {m.summary_heading()}{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">
|
||
{m.summary_no_data()}
|
||
</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">
|
||
{m.label_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">
|
||
{m.label_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">
|
||
{m.label_daylight()}
|
||
</dt>
|
||
<dd class="font-semibold tabular-nums">
|
||
{daylight}
|
||
{#if sunshine != null}
|
||
<span class="font-medium text-muted-foreground"
|
||
>· {m.sunshine_share({ percent: sunshine })}</span
|
||
>
|
||
{/if}
|
||
</dd>
|
||
</div>
|
||
{/if}
|
||
{#if finite(uv)}
|
||
<div>
|
||
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||
{m.label_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">
|
||
{m.label_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">
|
||
{m.label_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">
|
||
{m.label_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>
|