deck: virtualize to a 3-scene mount window (active ± 1)

Previously every scene rendered at all times — paint, layout, and
the per-scene widgets all ran in parallel. Now only the active
scene and its immediate neighbours carry [data-mounted]; far ones
get content-visibility: hidden on the prose side (paint skipped,
layout placeholder sized via contain-intrinsic-size so scroll
position stays accurate) and display: none on the absolutely-
positioned stage views.

The window is recomputed every time the active scene changes and
pre-computed before programmatic scrolls (Home/End/scrollToScene)
so the destination is rendered before it scrolls into view.

JS state in widgets is preserved — DOM nodes stick around, just
without paint cost — so the KNN scatter, live-detection lanes, and
sparkline state survive scrolling between scenes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Max Gorog 2026-05-08 15:19:46 -05:00
parent 3b3bdab9df
commit 997c399cf9
3 changed files with 50 additions and 2 deletions

View file

@ -1228,6 +1228,25 @@ html, body { overflow-anchor: none; }
stays as scroll-margin for the prose column. */
padding: 4rem 0.5rem 4rem 2rem;
}
/* Scene virtualization (mount window: active ± 1)
Only three scenes are "mounted" at a time the active one, plus its
immediate neighbours. Far scenes get content-visibility: hidden which
skips paint and rasterization while preserving layout (so the
scrollbar position and IntersectionObserver geometry stay correct).
contain-intrinsic-size declares the placeholder height so the
document doesn't reflow as scenes mount/unmount on scroll.
For the absolutely-positioned stage views (which all overlap in the
same box), far ones can use display: none outright they don't
contribute to layout flow either way. */
.scene[data-stage]:not([data-mounted]) {
content-visibility: hidden;
contain-intrinsic-size: 1px 100vh;
}
.stage-view[data-view]:not([data-mounted]) {
display: none;
}
.scene .prose {
width: var(--prose-w); max-width: calc(100vw - 4rem);
padding: 2.25rem 2.5rem 2.25rem 5rem;

View file

@ -98,6 +98,28 @@
const fab = document.getElementById('next-fab');
sceneTotalEl.textContent = String(scenes.length);
// Mount window: the active scene plus its immediate neighbours
// get [data-mounted]; everything else is skipped from paint via
// CSS (content-visibility: hidden on .scene, display: none on
// the matching .stage-view). The window is recomputed every time
// the active scene changes — and pre-computed before any
// programmatic scroll so the destination is ready to render
// before it scrolls into view.
function updateMountedRange(idx) {
for (let i = 0; i < scenes.length; i++) {
const inWindow = Math.abs(i - idx) <= 1;
const scene = scenes[i];
const view = stageViews.get(scene.dataset.stage);
if (inWindow) {
scene.setAttribute('data-mounted', '');
if (view) view.setAttribute('data-mounted', '');
} else {
scene.removeAttribute('data-mounted');
if (view) view.removeAttribute('data-mounted');
}
}
}
let activeIdx = -1;
function setActiveIdx(idx) {
if (idx === activeIdx) return;
@ -110,6 +132,7 @@
scenes[activeIdx].removeAttribute('data-active');
}
activeIdx = idx;
updateMountedRange(idx);
sceneIdxEl.textContent = String(idx + 1);
const name = scenes[idx].dataset.stage;
const view = stageViews.get(name);
@ -142,6 +165,12 @@
function scrollToScene(idx) {
idx = Math.max(0, Math.min(scenes.length - 1, idx));
if (idx === activeIdx) return;
// Pre-mount the destination's window before initiating the smooth
// scroll. Without this, an Home/End jump scrolls past unmounted
// scenes that are content-visibility: hidden, and the target only
// mounts once activation flips at the end of the animation —
// showing a flash of empty space mid-scroll.
updateMountedRange(idx);
scenes[idx].scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function next() { scrollToScene(activeIdx + 1); }

View file

@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CIS490 — live</title>
<link rel="stylesheet" href="/static/dashboard.css?v=94174956">
<link rel="stylesheet" href="/static/dashboard.css?v=8675cea9">
</head>
<body>
<!-- SVG filter defs for the lava-lamp goo effect. Width/height 0
@ -647,6 +647,6 @@
</article>
</div>
<script src="/static/dashboard.js?v=a33c0771"></script>
<script src="/static/dashboard.js?v=7c6859eb"></script>
</body>
</html>