animation strip
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user