better cards, local weather codes

This commit is contained in:
Vincent van der Wal
2026-08-01 10:11:06 +02:00
parent 315d4e2bdc
commit 2f86d86165
7 changed files with 344 additions and 126 deletions
@@ -1,10 +1,11 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { onMount } from 'svelte';
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes';
import { precipIsSignificant, windIsSignificant } from './significance';
import { type FetchedDaily, type WeatherUnits } from './types';
interface Props {
@@ -30,63 +31,38 @@
}: Props = $props();
// ─── Scroll-driven collapse (full → compact) ────────────────────────────────
// 0 = full cards (at the top of the page), 1 = compact square strip (stuck).
// The progress sentinel sits ABOVE the sticky strip, so the strip shrinking
// (which reflows the table BELOW it) never feeds back into the measurement.
let progress = $state(0);
// The strip collapses from full cards to a compact strip as it sticks. All
// sizing derives from a single registered custom property `--strip-p` (0 =
// full, 1 = compact):
//
// * Browsers with CSS scroll-driven animations (Chrome/Edge 115+, Safari 26+)
// scrub `--strip-p` natively from a view-timeline on the sentinel below —
// no JS runs during scroll at all, so there is no rAF frame-lag or jitter.
// * Everything else (Firefox, older Safari) snaps between the two states
// with a short CSS transition instead: an IntersectionObserver on the same
// sentinel toggles `.compact`. No per-frame scroll handler anywhere.
let sentinelEl = $state<HTMLDivElement>();
let stripScrollEl = $state<HTMLDivElement>();
let daysWrapEl = $state<HTMLDivElement>();
// Resolved in onMount (client only) so this never touches `window` during the
// prerender of city pages.
let scrollParent: HTMLElement | Window | null = $state(null);
const clamp = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));
function findScrollParent(el: HTMLElement | null): HTMLElement | Window {
let node = el?.parentElement ?? null;
while (node) {
const oy = getComputedStyle(node).overflowY;
if (oy === 'auto' || oy === 'scroll') return node;
node = node.parentElement;
}
return window;
}
// Distance (px) over which the collapse plays out once the strip sticks.
const RANGE = 120;
let ticking = false;
function measure() {
ticking = false;
if (!sentinelEl || !scrollParent) return;
const topRef = scrollParent instanceof Window ? 0 : scrollParent.getBoundingClientRect().top;
const top = sentinelEl.getBoundingClientRect().top - topRef;
progress = clamp(-top / RANGE);
}
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(measure);
}
let needsSnapFallback = $state(false);
let compact = $state(false);
onMount(() => {
scrollParent = findScrollParent(sentinelEl ?? null);
const target: EventTarget = scrollParent;
target.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll, { passive: true });
measure();
return () => {
target.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
};
});
// Re-measure once data (and therefore the strip height) changes.
$effect(() => {
void daily;
tick().then(measure);
const scrubSupported =
CSS.supports('animation-timeline: view()') && CSS.supports('timeline-scope: none');
if (scrubSupported || !sentinelEl) return;
needsSnapFallback = true;
// Collapse once more than half of the sentinel band has scrolled past the
// top; expands again on the same boundary (the transition smooths both).
const io = new IntersectionObserver(
(entries) => {
const e = entries[entries.length - 1];
compact = e.intersectionRatio < 0.5;
},
{ threshold: [0.25, 0.5, 0.75] }
);
io.observe(sentinelEl);
return () => io.disconnect();
});
// Start scrolled so the "Past" button sits just off the left edge (revealed by
@@ -105,17 +81,21 @@
wrap.getBoundingClientRect().left - scroll.getBoundingClientRect().left - 12;
});
});
// All progress-driven sizing lives in CSS (via the single `--p` custom property
// set on the strip), so a scroll frame writes ONE value instead of re-patching
// height/padding/opacity inline styles on every cell.
</script>
<!-- progress sentinel: 0-height marker just above the sticky strip -->
<div bind:this={sentinelEl} aria-hidden="true"></div>
<!-- progress sentinel: an invisible 120px band (taking no layout space) just
above the sticky strip. Its exit across the scrollport top drives the
collapse — via view-timeline where supported, IntersectionObserver
otherwise. It sits above the strip so the strip shrinking (which reflows
the table below) never feeds back into the measurement. -->
<div bind:this={sentinelEl} class="sentinel" aria-hidden="true"></div>
<div class="daystrip sticky -top-3 z-30 -mx-3 md:hidden" style="--p:{progress}">
<div class="flex gap-1.5 overflow-x-auto px-3 py-2" bind:this={stripScrollEl}>
<div
class="daystrip sticky -top-3 z-30 -mx-3 md:hidden"
class:js-snap={needsSnapFallback}
class:compact
>
<div class="strip-row flex overflow-x-auto px-3 py-2" bind:this={stripScrollEl}>
{#if daily}
{#if canExtendPast && onExtendPast}
<button
@@ -137,15 +117,19 @@
</button>
{/if}
<div class="flex gap-1.5" bind:this={daysWrapEl}>
<div class="strip-days flex" bind:this={daysWrapEl}>
{#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 precipSum = daily.daily.precipitation_sum[index]}
{@const windMax = daily.daily.windspeed_10m_max[index]}
{@const maxStyle = getTempStyle(tempMax, String(units.temperature_unit))}
{@const lowPrecip = !precipIsSignificant(precipSum, String(units.precipitation_unit))}
{@const lowWind = !windIsSignificant(windMax, null, String(units.wind_speed_unit))}
{#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)}
<button
type="button"
@@ -156,19 +140,29 @@
onclick={() => onSelectDay(time, index)}
>
<span
class="text-[11px] font-semibold tracking-wide {selected ? 'text-primary' : ''}"
class="text-[11px] font-semibold tracking-wide whitespace-nowrap {selected
? 'text-primary'
: ''}"
>
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}<span
class="date-inline align-baseline font-medium tabular-nums text-muted-foreground"
>&hairsp;{formatZoned(time, daily.timezone, 'd')}</span
>
</span>
<span class="rel-label overflow-hidden text-[9px] leading-none text-muted-foreground">
<span
class="rel-label overflow-hidden text-[9px] leading-[1.25] whitespace-nowrap text-muted-foreground"
>
{getRelativeDayLabel(time, daily.timezone)}
</span>
<div class="relative">
<svg class="day-icon fill-foreground">
<use
xlink:href="/images/weather-icons/{getWeatherIconName(wCode, true)}.svg#Layer_1"
xlink:href="/images/weather-icons/{getWeatherIconName(
dayCode,
true
)}.svg#Layer_1"
></use>
</svg>
<!-- night companion icon, present in the full view, fades as it collapses -->
@@ -177,7 +171,7 @@
>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(
wCode,
nightCode,
false
)}.svg#Layer_1"
></use>
@@ -186,12 +180,12 @@
<div class="flex items-baseline gap-1 leading-none">
<span
class="rounded-md px-1.5 py-0.5 text-[12px] font-extrabold tabular-nums"
class="temp-max rounded-md py-0.5 font-extrabold tabular-nums"
style="background-color:{maxStyle.bg};color:{maxStyle.fg}"
>
{tempMax.toFixed(0)}°
</span>
<span class="text-[11px] font-semibold tabular-nums text-muted-foreground">
<span class="temp-min font-semibold tabular-nums text-muted-foreground">
{tempMin.toFixed(0)}°
</span>
</div>
@@ -199,13 +193,13 @@
<div
class="detail-row flex w-full flex-col items-center gap-0.5 overflow-hidden text-[10px] tabular-nums text-muted-foreground"
>
<span class="inline-flex items-center gap-1">
<span class="inline-flex items-center gap-1 {lowPrecip ? 'opacity-40' : ''}">
<svg class="fill-sky-500" width="13" height="13">
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
</svg>
{Number(precipSum ?? 0).toFixed(precipSum >= 10 ? 0 : 1)}
</span>
<span class="inline-flex items-center gap-1">
<span class="inline-flex items-center gap-1 {lowWind ? 'opacity-40' : ''}">
<svg class="fill-muted-foreground" width="13" height="13">
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
</svg>
@@ -241,29 +235,51 @@
</div>
<style>
/* Everything below is derived from a single custom property `--p` (0 = full
cards, 1 = compact strip) set on `.daystrip` once per scroll frame. The
browser resolves the calc()s natively, so a scroll frame is one property
write instead of dozens of inline-style patches across every cell. */
/* `--strip-p` is registered so it interpolates: scroll-driven keyframes scrub
it continuously, and the snap fallback's `transition` eases it 0 ↔ 1.
Everything below derives from it via calc(), so a scroll frame is one
property update resolved natively by the browser — no JS, no inline-style
patches across cells. */
@property --strip-p {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
/* Invisible 120px collapse band: the distance over which the collapse plays
out. The negative margin removes it from layout so nothing shifts. */
.sentinel {
height: 120px;
margin-bottom: -120px;
pointer-events: none;
}
.daystrip {
/* tunables */
--cell-w: 64px;
--cell-h-full: 134px;
--cell-h-min: 64px;
--icon-full: 40px;
--icon-min: 20px;
--pt-full: 6px;
--pt-min: 1px;
--cell-w-full: 76px;
--cell-w-min: 56px;
--cell-h-full: 146px;
--cell-h-min: 66px;
--icon-full: 44px;
--icon-min: 21px;
--pt-full: 7px;
--pt-min: 2px;
--gap-full: 8px;
--gap-min: 4px;
--strip-p: 0;
/* progress-derived (--k is the "fullness": 1 when full, 0 when compact) */
--k: calc(1 - var(--p));
--k: calc(1 - var(--strip-p));
--cell-w: calc(var(--cell-w-min) + (var(--cell-w-full) - var(--cell-w-min)) * var(--k));
--cell-h: calc(var(--cell-h-min) + (var(--cell-h-full) - var(--cell-h-min)) * var(--k));
--icon: calc(var(--icon-min) + (var(--icon-full) - var(--icon-min)) * var(--k));
--pt: calc(var(--pt-min) + (var(--pt-full) - var(--pt-min)) * var(--k));
--rel: clamp(0, calc(1 - var(--p) * 2), 1);
--detail: clamp(0, calc(1 - var(--p) * 1.6), 1);
--gap: calc(var(--gap-min) + (var(--gap-full) - var(--gap-min)) * var(--k));
--rel: clamp(0, calc(1 - var(--strip-p) * 2), 1);
--detail: clamp(0, calc(1 - var(--strip-p) * 1.6), 1);
/* bar background/divider fade in once it starts collapsing */
--chrome: clamp(0, calc(var(--p) / 0.35), 1);
--chrome: clamp(0, calc(var(--strip-p) / 0.35), 1);
/* Opaque-background fade only — no backdrop-filter blur or animated
box-shadow (both are very expensive to repaint every scroll frame on
@@ -278,11 +294,48 @@
transform: translateZ(0);
}
/* Scrub path: the sentinel's exit across the scrollport top maps directly to
--strip-p 0→1 (the page wrapper hoists the timeline name via
`timeline-scope` so this sibling can reference it). */
@supports (animation-timeline: view()) and (timeline-scope: none) {
.sentinel {
view-timeline: --daystrip-sentinel block;
}
.daystrip {
animation: strip-collapse linear both;
animation-timeline: --daystrip-sentinel;
animation-range: exit 0% exit 100%;
}
}
@keyframes strip-collapse {
to {
--strip-p: 1;
}
}
/* Snap fallback: ease between the two end states instead of scrubbing.
(Browsers too old to register --strip-p simply switch instantly.) */
.daystrip.js-snap {
transition: --strip-p 0.28s ease;
}
.daystrip.js-snap.compact {
--strip-p: 1;
}
@media (prefers-reduced-motion: reduce) {
.daystrip.js-snap {
transition: none;
}
}
.strip-row,
.strip-days {
gap: var(--gap);
}
.strip-cell,
.strip-side {
width: var(--cell-w);
height: var(--cell-h);
/* size tracks scroll exactly (no transition); only the tap highlight eases */
/* size tracks the collapse exactly; only the tap highlight eases */
transition:
border-color 0.15s,
background-color 0.15s;
@@ -299,6 +352,19 @@
height: calc(var(--icon) * 0.44);
opacity: var(--rel);
}
/* Day-of-month slides in next to the weekday as the cards collapse
("MON" → "MON 12"), replacing the relative label that fades out. */
.date-inline {
display: inline-block;
overflow: hidden;
white-space: nowrap;
/* overflow≠visible makes an inline-block's baseline its bottom edge, which
would render the digits superscript; text-bottom restores baseline
alignment (same font size as the weekday, so descents match). */
vertical-align: text-bottom;
max-width: calc(20px * (1 - var(--rel)));
opacity: calc(1 - var(--rel));
}
.rel-label {
opacity: var(--rel);
max-height: calc(14px * var(--rel));
@@ -307,6 +373,13 @@
opacity: var(--detail);
max-height: calc(42px * var(--detail));
}
.temp-max {
font-size: calc(11px + 1px * var(--k));
padding-inline: calc(3px + 3px * var(--k));
}
.temp-min {
font-size: calc(10px + 1px * var(--k));
}
.daystrip :global(.overflow-x-auto) {
scrollbar-width: none;