303 lines
10 KiB
Svelte
303 lines
10 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);
|
||
|
||
// ─── Narrative clamp (phones only) ──────────────────────────────────────────
|
||
// The narrative runs anywhere from two to nine lines depending on the day,
|
||
// which on a phone means the whole page below it moves as soon as the data
|
||
// lands. Below md the text is clamped to five lines and gets a toggle, and
|
||
// the block reserves those five lines plus the toggle row whatever the
|
||
// length - so the card is the same height before and after the fetch, and the
|
||
// skeleton can match it exactly. From md up nothing is clamped.
|
||
|
||
let expanded = $state(false);
|
||
let textEl = $state<HTMLParagraphElement>();
|
||
let overflows = $state(false);
|
||
|
||
$effect(() => {
|
||
sentences; // re-measure when the day (and so the text) changes
|
||
const el = textEl;
|
||
if (!el || expanded) return; // measuring while open would always say "fits"
|
||
|
||
const measure = () => {
|
||
// only clamped below md, where the toggle is the only way to see the rest
|
||
const clamped = window.matchMedia('(max-width: 767px)').matches;
|
||
overflows = clamped && el.scrollHeight - el.clientHeight > 1;
|
||
};
|
||
measure();
|
||
|
||
const observer = new ResizeObserver(measure);
|
||
observer.observe(el);
|
||
return () => observer.disconnect();
|
||
});
|
||
</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">
|
||
<div class="narrative">
|
||
{#if sentences.length > 0}
|
||
<p
|
||
bind:this={textEl}
|
||
class="text-[15px] leading-relaxed text-foreground"
|
||
class:clamped={!expanded}
|
||
>
|
||
{sentences.join(' ')}
|
||
</p>
|
||
{:else}
|
||
<p class="text-[15px] leading-relaxed text-muted-foreground">
|
||
{m.summary_no_data()}
|
||
</p>
|
||
{/if}
|
||
|
||
<!-- Always occupies its row on phones, even when the text fits: a
|
||
toggle that appears only sometimes is itself a layout shift. -->
|
||
<button
|
||
type="button"
|
||
class="toggle mt-1 cursor-pointer text-[13px] font-semibold text-primary hover:underline"
|
||
class:invisible={!overflows && !expanded}
|
||
aria-hidden={!overflows && !expanded}
|
||
tabindex={!overflows && !expanded ? -1 : 0}
|
||
aria-expanded={expanded}
|
||
onclick={() => (expanded = !expanded)}
|
||
>
|
||
{expanded ? m.summary_read_less() : m.summary_read_more()}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 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>
|
||
|
||
<style>
|
||
/* Phones: exactly five lines, reserved whether the text is long or short, so
|
||
the card's height never depends on the forecast that lands. 15px text at
|
||
leading-relaxed (1.625) → 5 lines = 8.125em. */
|
||
.clamped {
|
||
display: -webkit-box;
|
||
-webkit-box-orient: vertical;
|
||
-webkit-line-clamp: 5;
|
||
line-clamp: 5;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.narrative p {
|
||
min-height: 8.125em;
|
||
}
|
||
|
||
/* md and up: no clamp, no reserved height, no toggle. */
|
||
@media (min-width: 768px) {
|
||
.clamped {
|
||
display: block;
|
||
overflow: visible;
|
||
}
|
||
|
||
.narrative p {
|
||
min-height: 0;
|
||
}
|
||
|
||
.toggle {
|
||
display: none;
|
||
}
|
||
}
|
||
</style>
|