animation strip
This commit is contained in:
@@ -6,6 +6,11 @@
|
|||||||
* Writes use `replaceState` rather than `goto`, so mirroring state never adds a
|
* 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
|
* history entry or re-runs a load - the back button still means "the page
|
||||||
* before", not "the previous day I clicked".
|
* 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 { browser } from '$app/environment';
|
||||||
import { replaceState } from '$app/navigation';
|
import { replaceState } from '$app/navigation';
|
||||||
|
|||||||
@@ -94,7 +94,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const model = params.models?.[0];
|
const model = params.models?.[0];
|
||||||
if (!mounted || !model) return;
|
if (!mounted || !model) return;
|
||||||
syncSearchParams($page.url, { model });
|
syncSearchParams(get(page).url, { model });
|
||||||
});
|
});
|
||||||
|
|
||||||
// components persist across refetches; entries are null while unmounted
|
// components persist across refetches; entries are null while unmounted
|
||||||
|
|||||||
@@ -123,7 +123,7 @@
|
|||||||
const models = params.models;
|
const models = params.models;
|
||||||
const vars = params.hourly;
|
const vars = params.hourly;
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
syncSearchParams($page.url, {
|
syncSearchParams(get(page).url, {
|
||||||
models: models?.length ? models.join(',') : null,
|
models: models?.length ? models.join(',') : null,
|
||||||
vars: vars?.length ? vars.join(',') : null
|
vars: vars?.length ? vars.join(',') : null
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -185,9 +185,9 @@
|
|||||||
|
|
||||||
const target = Date.parse(`${param}T12:00:00Z`);
|
const target = Date.parse(`${param}T12:00:00Z`);
|
||||||
if (Number.isNaN(target)) return;
|
if (Number.isNaN(target)) return;
|
||||||
const today = new Date();
|
const now = new Date();
|
||||||
today.setUTCHours(12, 0, 0, 0);
|
const todayNoon = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), 12);
|
||||||
const offset = Math.round((target - today.getTime()) / 86_400_000);
|
const offset = Math.round((target - todayNoon) / 86_400_000);
|
||||||
|
|
||||||
if (offset < -3 || offset > 15) return; // out of reach: stay on today
|
if (offset < -3 || offset > 15) return; // out of reach: stay on today
|
||||||
wantedDay = param;
|
wantedDay = param;
|
||||||
@@ -215,7 +215,7 @@
|
|||||||
const isToday = fetchedDaily
|
const isToday = fetchedDaily
|
||||||
? dayKey === formatZoned(new Date(), fetchedDaily.timezone, 'yyyy-MM-dd')
|
? dayKey === formatZoned(new Date(), fetchedDaily.timezone, 'yyyy-MM-dd')
|
||||||
: false;
|
: false;
|
||||||
syncSearchParams($page.url, {
|
syncSearchParams(get(page).url, {
|
||||||
day: isToday ? null : dayKey,
|
day: isToday ? null : dayKey,
|
||||||
model: model && model !== 'best_match' ? model : null
|
model: model && model !== 'best_match' ? model : null
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -62,6 +62,7 @@
|
|||||||
// with a short CSS transition instead: an IntersectionObserver on the same
|
// with a short CSS transition instead: an IntersectionObserver on the same
|
||||||
// sentinel toggles `.compact`. No per-frame scroll handler anywhere.
|
// sentinel toggles `.compact`. No per-frame scroll handler anywhere.
|
||||||
let sentinelEl = $state<HTMLDivElement>();
|
let sentinelEl = $state<HTMLDivElement>();
|
||||||
|
let stripEl = $state<HTMLDivElement>();
|
||||||
let stripScrollEl = $state<HTMLDivElement>();
|
let stripScrollEl = $state<HTMLDivElement>();
|
||||||
let daysWrapEl = $state<HTMLDivElement>();
|
let daysWrapEl = $state<HTMLDivElement>();
|
||||||
let needsSnapFallback = $state(false);
|
let needsSnapFallback = $state(false);
|
||||||
@@ -71,21 +72,40 @@
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
const scrubSupported =
|
const scrubSupported =
|
||||||
CSS.supports('animation-timeline: view()') && CSS.supports('timeline-scope: none');
|
CSS.supports('animation-timeline: view()') && CSS.supports('timeline-scope: none');
|
||||||
if (scrubSupported || !sentinelEl) return;
|
if (scrubSupported || !sentinelEl || !stripEl) return;
|
||||||
needsSnapFallback = true;
|
|
||||||
// `stuck` fires the moment the strip pins (turns the bar opaque before any
|
// Without scroll-driven animations (Firefox, older Safari) the collapse used
|
||||||
// content can slide under it); `compact` snaps the cells once more than
|
// to snap between the two states at a threshold, which reads as a jump next
|
||||||
// half of the sentinel band has scrolled past. The transitions smooth both.
|
// to the smooth scrub everywhere else. Drive `--strip-p` from the scroll
|
||||||
const io = new IntersectionObserver(
|
// position instead, so the cells shrink with the scroll exactly as they do
|
||||||
(entries) => {
|
// natively. One rAF-coalesced write per frame, no layout thrash.
|
||||||
const e = entries[entries.length - 1];
|
const sentinel = sentinelEl;
|
||||||
stuck = e.intersectionRatio < 0.97;
|
const strip = stripEl;
|
||||||
compact = e.intersectionRatio < 0.5;
|
const scroller = sentinel.closest('main');
|
||||||
},
|
|
||||||
{ threshold: [0.25, 0.5, 0.75, 0.97] }
|
let frame = 0;
|
||||||
);
|
const update = () => {
|
||||||
io.observe(sentinelEl);
|
frame = 0;
|
||||||
return () => io.disconnect();
|
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
|
// 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={sentinelEl} class="sentinel" aria-hidden="true"></div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
bind:this={stripEl}
|
||||||
class="daystrip sticky -top-3 z-30 -mx-3 lg:-top-6 lg:-mx-8 mt-1"
|
class="daystrip sticky -top-3 z-30 -mx-3 lg:-top-6 lg:-mx-8 mt-1"
|
||||||
class:js-snap={needsSnapFallback}
|
class:js-snap={needsSnapFallback}
|
||||||
class:compact
|
class:compact
|
||||||
|
|||||||
Reference in New Issue
Block a user