From 49e011c20b805696c529649cd40e0fd18af93dff Mon Sep 17 00:00:00 2001 From: Vincent van der Wal Date: Sat, 1 Aug 2026 17:13:44 +0200 Subject: [PATCH] animation strip --- src/lib/utils/url-state.ts | 5 ++ .../weather/14-day/[location]/+page.svelte | 2 +- .../weather/compare/[location]/+page.svelte | 2 +- .../weather/week/[location]/+page.svelte | 8 +-- .../week/[location]/DailyStripSticky.svelte | 51 +++++++++++++------ 5 files changed, 47 insertions(+), 21 deletions(-) diff --git a/src/lib/utils/url-state.ts b/src/lib/utils/url-state.ts index fb84bf8..52397be 100644 --- a/src/lib/utils/url-state.ts +++ b/src/lib/utils/url-state.ts @@ -6,6 +6,11 @@ * Writes use `replaceState` rather than `goto`, so mirroring state never adds a * history entry or re-runs a load - the back button still means "the page * before", not "the previous day I clicked". + * + * Callers must pass the URL *untracked* (`get(page).url`, not `$page.url`). + * Reading it reactively inside the same effect that writes it creates a loop: + * replaceState publishes a new URL, the effect re-runs, writes again - which + * Svelte eventually kills with `effect_update_depth_exceeded`, hanging the page. */ import { browser } from '$app/environment'; import { replaceState } from '$app/navigation'; diff --git a/src/routes/weather/14-day/[location]/+page.svelte b/src/routes/weather/14-day/[location]/+page.svelte index a8f0f20..a409d86 100644 --- a/src/routes/weather/14-day/[location]/+page.svelte +++ b/src/routes/weather/14-day/[location]/+page.svelte @@ -94,7 +94,7 @@ $effect(() => { const model = params.models?.[0]; if (!mounted || !model) return; - syncSearchParams($page.url, { model }); + syncSearchParams(get(page).url, { model }); }); // components persist across refetches; entries are null while unmounted diff --git a/src/routes/weather/compare/[location]/+page.svelte b/src/routes/weather/compare/[location]/+page.svelte index cc5d0be..ab0834b 100644 --- a/src/routes/weather/compare/[location]/+page.svelte +++ b/src/routes/weather/compare/[location]/+page.svelte @@ -123,7 +123,7 @@ const models = params.models; const vars = params.hourly; if (!mounted) return; - syncSearchParams($page.url, { + syncSearchParams(get(page).url, { models: models?.length ? models.join(',') : null, vars: vars?.length ? vars.join(',') : null }); diff --git a/src/routes/weather/week/[location]/+page.svelte b/src/routes/weather/week/[location]/+page.svelte index 1a22cbf..c1a4145 100644 --- a/src/routes/weather/week/[location]/+page.svelte +++ b/src/routes/weather/week/[location]/+page.svelte @@ -185,9 +185,9 @@ const target = Date.parse(`${param}T12:00:00Z`); if (Number.isNaN(target)) return; - const today = new Date(); - today.setUTCHours(12, 0, 0, 0); - const offset = Math.round((target - today.getTime()) / 86_400_000); + const now = new Date(); + const todayNoon = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), 12); + const offset = Math.round((target - todayNoon) / 86_400_000); if (offset < -3 || offset > 15) return; // out of reach: stay on today wantedDay = param; @@ -215,7 +215,7 @@ const isToday = fetchedDaily ? dayKey === formatZoned(new Date(), fetchedDaily.timezone, 'yyyy-MM-dd') : false; - syncSearchParams($page.url, { + syncSearchParams(get(page).url, { day: isToday ? null : dayKey, model: model && model !== 'best_match' ? model : null }); diff --git a/src/routes/weather/week/[location]/DailyStripSticky.svelte b/src/routes/weather/week/[location]/DailyStripSticky.svelte index ae9d88b..7f64bba 100644 --- a/src/routes/weather/week/[location]/DailyStripSticky.svelte +++ b/src/routes/weather/week/[location]/DailyStripSticky.svelte @@ -62,6 +62,7 @@ // with a short CSS transition instead: an IntersectionObserver on the same // sentinel toggles `.compact`. No per-frame scroll handler anywhere. let sentinelEl = $state(); + let stripEl = $state(); let stripScrollEl = $state(); let daysWrapEl = $state(); let needsSnapFallback = $state(false); @@ -71,21 +72,40 @@ onMount(() => { const scrubSupported = CSS.supports('animation-timeline: view()') && CSS.supports('timeline-scope: none'); - if (scrubSupported || !sentinelEl) return; - needsSnapFallback = true; - // `stuck` fires the moment the strip pins (turns the bar opaque before any - // content can slide under it); `compact` snaps the cells once more than - // half of the sentinel band has scrolled past. The transitions smooth both. - const io = new IntersectionObserver( - (entries) => { - const e = entries[entries.length - 1]; - stuck = e.intersectionRatio < 0.97; - compact = e.intersectionRatio < 0.5; - }, - { threshold: [0.25, 0.5, 0.75, 0.97] } - ); - io.observe(sentinelEl); - return () => io.disconnect(); + if (scrubSupported || !sentinelEl || !stripEl) return; + + // Without scroll-driven animations (Firefox, older Safari) the collapse used + // to snap between the two states at a threshold, which reads as a jump next + // to the smooth scrub everywhere else. Drive `--strip-p` from the scroll + // position instead, so the cells shrink with the scroll exactly as they do + // natively. One rAF-coalesced write per frame, no layout thrash. + const sentinel = sentinelEl; + const strip = stripEl; + const scroller = sentinel.closest('main'); + + let frame = 0; + const update = () => { + frame = 0; + const band = sentinel.getBoundingClientRect(); + const top = scroller ? scroller.getBoundingClientRect().top : 0; + const height = band.height || 1; + const progress = Math.min(1, Math.max(0, (top - band.top) / height)); + strip.style.setProperty('--strip-p', String(progress)); + strip.style.setProperty('--stuck', progress > 0.02 ? '1' : '0'); + }; + const schedule = () => { + if (!frame) frame = requestAnimationFrame(update); + }; + + update(); + const target: EventTarget = scroller ?? window; + target.addEventListener('scroll', schedule, { passive: true }); + window.addEventListener('resize', schedule); + return () => { + if (frame) cancelAnimationFrame(frame); + target.removeEventListener('scroll', schedule); + window.removeEventListener('resize', schedule); + }; }); // Start scrolled so the "Past" button sits just off the left edge (revealed by @@ -129,6 +149,7 @@