419 lines
16 KiB
Svelte
419 lines
16 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 { precipIsSignificant, sunIsSignificant, windIsSignificant } from './significance';
|
|
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 fully off to the
|
|
// left. The scroll's pl-3 keeps the lifted/scaled card from being clipped,
|
|
// so subtract it here to land the card on the content edge.
|
|
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';
|
|
}
|
|
</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
|
|
transition:fade={{ duration: 200 }}
|
|
class="-mx-3 mb-1 flex min-h-47.5 items-stretch gap-2 px-3 md:mb-6 md:min-h-65"
|
|
>
|
|
<!-- A horizontally-scrolling rail (past button + day cards) plus a "load more"
|
|
card pinned to the right so it's always visible on every screen. The
|
|
matching top/bottom padding gives a lifted/scaled card room so it's never
|
|
clipped, while the first card still lines up with the page content edge. -->
|
|
<div
|
|
bind:this={scrollEl}
|
|
class="day-scroll -ml-3 flex min-w-0 flex-1 gap-2 overflow-x-auto pt-3 pb-3 pl-3 md:pt-5 md:pb-11"
|
|
class:scrolling
|
|
onscroll={onScroll}
|
|
>
|
|
{#if daily}
|
|
{#if canExtendPast && onExtendPast}
|
|
<button
|
|
type="button"
|
|
class="mr-4 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 -->
|
|
<div bind:this={cardsWrapEl} class="cards-fill flex gap-2">
|
|
{#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 dayCode = daily.dayCodes?.[index] ?? wCode}
|
|
{@const nightCode = daily.nightCodes?.[index] ?? wCode}
|
|
{@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-[0_5px_14px_-4px_rgba(0,0,0,0.4)] ring-2 ring-primary/60 md:shadow-xl'
|
|
: '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(
|
|
dayCode,
|
|
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(
|
|
nightCode,
|
|
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}
|
|
|
|
<!-- Mobile: the "load more" card is the last item in the SAME flex row
|
|
as the cards (reached by scrolling); keeping it in this container
|
|
avoids the zoom/flex mis-position seen when it was a sibling. On
|
|
md+ it's hidden here and the pinned sibling below is shown. -->
|
|
{#if canExtend && onExtend}
|
|
<button
|
|
type="button"
|
|
class="day-card-btn flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 self-stretch 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 md:hidden"
|
|
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}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Desktop: "load more" card pinned to the right of the scroll rail (a
|
|
sibling, not inside the scroll) so it's always visible and full-height. -->
|
|
{#if daily && canExtend && onExtend}
|
|
<button
|
|
type="button"
|
|
class="mt-2 mb-3 hidden w-20 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 self-stretch 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 md:mt-5 md:mb-11 md:flex md:w-24"
|
|
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}
|
|
</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;
|
|
/* Soft fade at the right edge (only) so cards dissolve into the page
|
|
margin instead of being hard-cut as they scroll off. */
|
|
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent);
|
|
mask-image: linear-gradient(to right, #000 calc(100% - 32px), 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>
|