This repository has been archived on 2026-08-10. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
drizzli/src/routes/weather/week/[location]/DailyCards.svelte
T
2026-07-25 10:51:21 +02:00

401 lines
15 KiB
Svelte

<script lang="ts">
import { fade } from 'svelte/transition';
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes';
import { type FetchedDaily, type WeatherUnits, getWindArrowRotation } from './types';
interface Props {
daily: FetchedDaily | null;
selectedDay: Date;
units: WeatherUnits;
onSelectDay: (date: Date, index: number) => void;
/** Offer a button after the last day to load the model's longer range */
canExtend?: boolean;
onExtend?: () => void;
/** Offer a button before the first day to load recent past days */
canExtendPast?: boolean;
onExtendPast?: () => void;
}
let {
daily,
selectedDay,
units,
onSelectDay,
canExtend = false,
onExtend,
canExtendPast = false,
onExtendPast
}: Props = $props();
// The "past days" button sits before the first card but starts scrolled out
// of view — the user reveals it by scrolling left. Re-hide only when the
// dataset (location) changes, not on every re-render.
let scrollEl = $state<HTMLDivElement>();
let cardsWrapEl = $state<HTMLDivElement>();
let hiddenForRef: FetchedDaily | null = null;
// Show the scrollbar only briefly while actively scrolling (a constant thin
// gutter is reserved so revealing the thumb never shifts layout).
let scrolling = $state(false);
let scrollHideTimer: ReturnType<typeof setTimeout> | undefined;
function onScroll() {
scrolling = true;
clearTimeout(scrollHideTimer);
scrollHideTimer = setTimeout(() => (scrolling = false), 700);
}
$effect(() => {
const d = daily;
if (!d || !canExtendPast || !scrollEl || !cardsWrapEl || hiddenForRef === d) return;
hiddenForRef = d;
const el = scrollEl;
const wrap = cardsWrapEl;
// defer to after layout so the measured positions and scroll width are final
requestAnimationFrame(() => {
// scroll so the first day card sits exactly at the content edge (aligned
// with the page hero), leaving the "past days" button off to the left
el.scrollLeft += wrap.getBoundingClientRect().left - el.getBoundingClientRect().left - 12;
});
});
function getDaylightSeconds(index: number): number {
if (!daily) return 0;
const sunriseTs = daily.daily.sunrise[index];
const sunsetTs = daily.daily.sunset[index];
if (!sunriseTs || !sunsetTs) return 0;
return Math.max(0, sunsetTs - sunriseTs);
}
function getSunshinePercent(sunshineSeconds: number | null, daylightSeconds: number): number {
if (!sunshineSeconds || daylightSeconds <= 0) return 0;
return Math.min(100, (sunshineSeconds / daylightSeconds) * 100);
}
function getSunshineColor(sunshineSeconds: number | null, daylightSeconds: number): string {
if (daylightSeconds <= 0) return '#d1d5db';
const ratio = (sunshineSeconds ?? 0) / daylightSeconds;
if (ratio >= 0.7) return '#f59e0b';
if (ratio >= 0.45) return '#fbbf24';
if (ratio >= 0.1) return '#fcd34d';
return '#d1d5db';
}
// ─── "Is this metric worth highlighting?" thresholds ────────────────────────
// Below these, the sun / precip / wind bits are greyed out so a card at a
// glance only emphasises what's actually notable that day.
function sunIsSignificant(sunshineSeconds: number | null, daylightSeconds: number): boolean {
if (daylightSeconds <= 0) return false;
return (sunshineSeconds ?? 0) / daylightSeconds >= 0.1;
}
function precipIsSignificant(sum: number | null, unit: string): boolean {
const min = unit === 'mm' ? 0.1 : 0.005; // anything above a trace
return (sum ?? 0) >= min;
}
function windIsSignificant(speed: number | null, gust: number | null, unit: string): boolean {
// separate bars: sustained wind ~ a light breeze (~12 km/h), gusts a bit
// higher (~22 km/h). If EITHER is met, the whole wind readout is coloured.
const windMin = unit === 'ms' ? 3 : unit === 'mph' ? 7 : unit === 'kn' ? 6 : 12;
const gustMin = unit === 'ms' ? 6 : unit === 'mph' ? 14 : unit === 'kn' ? 12 : 22;
const s = speed != null && !isNaN(speed) ? speed : -Infinity;
const g = gust != null && !isNaN(gust) ? gust : -Infinity;
return s >= windMin || g >= gustMin;
}
</script>
<!-- Shared filter: erodes the filled weather glyphs slightly so their
lines read a touch thinner at large sizes (radius = how much to shave) -->
<svg aria-hidden="true" width="0" height="0" class="absolute">
<defs>
<filter id="thin-day-icon" x="-10%" y="-10%" width="120%" height="120%">
<feMorphology operator="erode" radius="0.45" />
</filter>
</defs>
</svg>
<div in:fade out:fade class="mb-6 min-h-[260px]">
<!-- negative margin + matching padding: the scroll box gains room so a
lifted/scaled/shadowed card is never clipped, while the first card still
lines up with the page content edge -->
<div
bind:this={scrollEl}
class="day-scroll -mx-3 flex gap-2 overflow-x-auto px-3 pt-5 pb-11"
class:scrolling
onscroll={onScroll}
>
{#if daily}
{#if canExtendPast && onExtendPast}
<button
type="button"
class="flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground"
onclick={onExtendPast}
aria-label="Load recent past days"
>
<svg
class="h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8 7V3m8 4V3M4 11h16M5 21h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M14 13l-3 3 3 3" />
</svg>
<span class="text-center text-[11px] leading-tight font-semibold">
Past<br />3 days
</span>
</button>
{/if}
<!-- the cards fill at least the viewport so the row overflows past the
"past days" button (letting it scroll out of view even on wide
screens). On md+ we also reserve room so the "load more" button stays
visible; on mobile it's simply reached by scrolling (never clipped). -->
<div
bind:this={cardsWrapEl}
class="cards-fill flex gap-2"
style="--fill-reserve: {canExtend ? '6.5rem' : '0rem'}"
>
{#each daily.dailyDates as time, index (index)}
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
{@const tempMax = daily.daily.temperature_2m_max[index]}
{@const tempMin = daily.daily.temperature_2m_min[index]}
{@const wCode = daily.daily.weather_code[index]}
{@const sunDuration = daily.daily.sunshine_duration[index]}
{@const daylightSec = getDaylightSeconds(index)}
{@const sunColor = getSunshineColor(sunDuration, daylightSec)}
{@const sunPct = getSunshinePercent(sunDuration, daylightSec)}
{@const precipSum = daily.daily.precipitation_sum[index]}
{@const windMax = daily.daily.windspeed_10m_max[index]}
{@const gustMax = daily.daily.windgusts_10m_max[index]}
{@const windDir = daily.daily.winddirection_10m_dominant[index]}
{@const unit = String(units.temperature_unit)}
{@const maxStyle = getTempStyle(tempMax, unit)}
{@const lowSun = !sunIsSignificant(sunDuration, daylightSec)}
{@const lowPrecip = !precipIsSignificant(precipSum, String(units.precipitation_unit))}
{@const lowWind = !windIsSignificant(windMax, gustMax, String(units.wind_speed_unit))}
{#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)}
<button
class="day-card group relative flex w-35 shrink-0 cursor-pointer flex-col items-center gap-1 rounded-2xl border px-2 pt-3 pb-2.5 transition-all duration-200 ease-out will-change-transform motion-reduce:transition-none
{selected
? 'z-10 -translate-y-1 scale-[1.04] border-primary bg-primary/10 shadow-xl ring-2 ring-primary/60'
: 'border-border/60 bg-card shadow-xs hover:z-10 hover:-translate-y-1 hover:scale-[1.02] hover:border-border hover:shadow-lg'}"
aria-pressed={selected}
onclick={() => onSelectDay(time, index)}
>
<!-- Day label -->
<span
class="text-[13px] font-semibold tracking-wider {selected ? 'text-primary' : ''}"
>
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
</span>
<span
class="-mt-1 text-[11px] {selected
? 'font-medium text-primary/80'
: 'text-muted-foreground'}"
>
{getRelativeDayLabel(time, daily.timezone)}
</span>
<!-- Weather icon: large day with a night badge in the corner -->
<div class="relative my-1 px-3 -ml-2.5">
<svg
class="day-icon fill-foreground"
width="100px"
height="100px"
style="filter: url(#thin-day-icon)"
>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(wCode, true)}.svg#Layer_1"
></use>
</svg>
<svg
class="night-icon absolute -right-2 -bottom-1 rounded-full bg-card fill-foreground/60 p-0.5 ring-1 ring-border/60"
width="42px"
height="42px"
>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(
wCode,
false
)}.svg#Layer_1"
></use>
</svg>
</div>
<!-- Temperature max/min -->
<div class="flex items-baseline gap-1.5">
<span
class="ml-1 rounded-xl px-5 py-1.5 text-xl font-extrabold tabular-nums"
style="background-color: {maxStyle.bg}; color: {maxStyle.fg}"
>
{tempMax.toFixed(0)}°
</span>
<span class="text-lg font-semibold tabular-nums text-muted-foreground">
{tempMin.toFixed(0)}°
</span>
</div>
<!-- Details -->
<div class="mt-1.5 flex w-full flex-col gap-1 border-t border-border/50 px-1 pt-1.5">
<!-- Sunshine -->
<div class="flex w-full items-center gap-1.5 {lowSun ? 'opacity-45' : ''}">
<svg class="shrink-0" width="20px" height="20px" style="fill: {sunColor}">
<use xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"></use>
</svg>
<div class="h-1 flex-1 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full transition-all"
style="width: {sunPct}%; background-color: {sunColor}"
></div>
</div>
<span class="text-[10px] font-medium tabular-nums text-muted-foreground">
{Number((sunDuration ?? 0) / 3600).toFixed(0)}h
</span>
</div>
<!-- Precipitation + wind -->
<div
class="flex w-full items-center justify-center gap-2.5 text-[11px] tabular-nums text-foreground/80"
>
<span
class="inline-flex items-center gap-0.5 {lowPrecip
? 'text-muted-foreground/50'
: ''}"
>
<svg
class="shrink-0 {lowPrecip
? 'fill-muted-foreground/40'
: 'fill-foreground/70'}"
width="23px"
height="23px"
>
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
</svg>
{Number(precipSum ?? 0).toFixed(
precipSum >= 10 ? 0 : 1
)}{units.precipitation_unit === 'mm' ? ' mm' : "'"}
</span>
<span
class="inline-flex items-center gap-0.5 {lowWind
? 'text-muted-foreground/50'
: ''}"
>
{#if windDir != null && !isNaN(windDir)}
<span
class="inline-flex shrink-0 -mr-2"
style="transform: {getWindArrowRotation(windDir)}"
>
<svg
class={lowWind ? 'fill-muted-foreground/40' : 'fill-foreground/70'}
width="40px"
height="40px"
>
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"
></use>
</svg>
</span>
{:else}
<svg
class="shrink-0 -mr-2 {lowWind
? 'fill-muted-foreground/40'
: 'fill-foreground/70'}"
width="40px"
height="40px"
>
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
</svg>
{/if}
{windMax?.toFixed(0) ?? '-'}<span class="opacity-70"
>-{gustMax?.toFixed(0) ?? '-'}</span
>
</span>
</div>
</div>
</button>
{/if}
{/each}
</div>
{#if canExtend && onExtend}
<button
type="button"
class="flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground"
onclick={onExtend}
aria-label="Load the longer-range forecast"
>
<svg
class="h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8 7V3m8 4V3M4 11h16M5 21h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 14v4m-2-2h4" />
</svg>
<span class="text-center text-[11px] leading-tight font-semibold">
Load<br />15 days
</span>
</button>
{/if}
{/if}
</div>
</div>
<style>
/* Reserve a constant thin scrollbar gutter (no layout shift), but keep the
thumb invisible until the user is actively scrolling. */
.day-scroll {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
.day-scroll.scrolling {
scrollbar-color: color-mix(in oklab, var(--color-border) 85%, transparent) transparent;
}
.day-scroll::-webkit-scrollbar {
height: 8px;
}
.day-scroll::-webkit-scrollbar-thumb {
border-radius: 4px;
background: transparent;
transition: background 0.2s;
}
.day-scroll.scrolling::-webkit-scrollbar-thumb {
background: color-mix(in oklab, var(--color-border) 85%, transparent);
}
/* The cards group fills the viewport so the row overflows past the side
buttons. On md+ we also reserve room so the "load more" button stays in
view; on mobile it keeps its full width after the cards (reached by
scrolling) and is never clipped. */
.cards-fill {
min-width: 100%;
}
@media (min-width: 768px) {
.cards-fill {
min-width: calc(100% - var(--fill-reserve, 0rem));
}
}
/* Mobile: keep the exact desktop layout, just scale the whole card down. */
@media (max-width: 768px) {
.day-card {
zoom: 0.72;
}
}
</style>