past weather
This commit is contained in:
@@ -35,18 +35,31 @@
|
||||
// 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 pastBtnEl = $state<HTMLButtonElement>();
|
||||
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 || !pastBtnEl || hiddenForRef === d) return;
|
||||
if (!d || !canExtendPast || !scrollEl || !cardsWrapEl || hiddenForRef === d) return;
|
||||
hiddenForRef = d;
|
||||
const btn = pastBtnEl.getBoundingClientRect();
|
||||
const cont = scrollEl.getBoundingClientRect();
|
||||
// scroll so the button's right edge sits just past the left edge (a small
|
||||
// gap of extra margin keeps the first day card from hugging the edge)
|
||||
scrollEl.scrollLeft += btn.right - cont.left + 6;
|
||||
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 {
|
||||
@@ -112,13 +125,13 @@
|
||||
lines up with the page content edge -->
|
||||
<div
|
||||
bind:this={scrollEl}
|
||||
class="-mx-3 flex gap-2 overflow-x-auto px-3 pt-4 pb-8"
|
||||
style="scrollbar-width: thin"
|
||||
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
|
||||
bind:this={pastBtnEl}
|
||||
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}
|
||||
@@ -143,157 +156,175 @@
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#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
|
||||
<!-- 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'}"
|
||||
? '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)}
|
||||
>
|
||||
{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">
|
||||
<!-- Day label -->
|
||||
<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}"
|
||||
class="text-[13px] font-semibold tracking-wider {selected ? 'text-primary' : ''}"
|
||||
>
|
||||
{tempMax.toFixed(0)}°
|
||||
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
|
||||
</span>
|
||||
<span class="text-lg font-semibold tabular-nums text-muted-foreground">
|
||||
{tempMin.toFixed(0)}°
|
||||
<span
|
||||
class="-mt-1 text-[11px] {selected
|
||||
? 'font-medium text-primary/80'
|
||||
: 'text-muted-foreground'}"
|
||||
>
|
||||
{getRelativeDayLabel(time, daily.timezone)}
|
||||
</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>
|
||||
<!-- 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>
|
||||
<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
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
{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'
|
||||
: ''}"
|
||||
<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"
|
||||
>
|
||||
{#if windDir != null && !isNaN(windDir)}
|
||||
<span
|
||||
class="inline-flex shrink-0 -mr-2"
|
||||
style="transform: {getWindArrowRotation(windDir)}"
|
||||
<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={lowWind ? 'fill-muted-foreground/40' : 'fill-foreground/70'}
|
||||
class="shrink-0 -mr-2 {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>
|
||||
<use xlink:href="/images/weather-icons/wi-strong-wind.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"
|
||||
{/if}
|
||||
{windMax?.toFixed(0) ?? '-'}<span class="opacity-70"
|
||||
>-{gustMax?.toFixed(0) ?? '-'}</span
|
||||
>
|
||||
<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>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if canExtend && onExtend}
|
||||
<button
|
||||
@@ -326,6 +357,40 @@
|
||||
</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 {
|
||||
|
||||
Reference in New Issue
Block a user