animation strip

This commit is contained in:
Vincent van der Wal
2026-08-01 17:13:44 +02:00
parent d7fe0bafc8
commit 49e011c20b
5 changed files with 47 additions and 21 deletions
+5
View File
@@ -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';
@@ -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
@@ -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
});
@@ -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
});
@@ -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<HTMLDivElement>();
let stripEl = $state<HTMLDivElement>();
let stripScrollEl = $state<HTMLDivElement>();
let daysWrapEl = $state<HTMLDivElement>();
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 @@
<div bind:this={sentinelEl} class="sentinel" aria-hidden="true"></div>
<div
bind:this={stripEl}
class="daystrip sticky -top-3 z-30 -mx-3 lg:-top-6 lg:-mx-8 mt-1"
class:js-snap={needsSnapFallback}
class:compact