c8168564bb
Replace Home-cloned section grammar with a numbered left rail (Bodoni numerals, vertical spine, mono marginalia), an asymmetric content column, and SVG waveform dividers. Adds a degrade-safe IntersectionObserver interop. Copy verbatim.
66 lines
2.4 KiB
TypeScript
66 lines
2.4 KiB
TypeScript
/**
|
|
* About-page rail active-numeral highlight.
|
|
*
|
|
* The Liner Notes layout carries one oversized Bodoni numeral per movement in a
|
|
* persistent left rail. This module lights the numeral of whichever movement is
|
|
* currently in view by toggling `.is-active` on the movement element; the CSS does
|
|
* the colour transition. Pure progressive enhancement — the numerals render
|
|
* statically in low-opacity navy without this, so a load failure or a no-JS client
|
|
* degrades to a still-legible page.
|
|
*
|
|
* One observer at a time, re-pointed on each `observe` call. The active movement is
|
|
* the one nearest the top of the viewport among those currently intersecting, which
|
|
* keeps a single numeral lit during the scroll rather than flickering between
|
|
* adjacent movements at the boundary.
|
|
*/
|
|
|
|
let observer: IntersectionObserver | null = null;
|
|
let observed: Element[] = [];
|
|
|
|
function refreshActive(): void {
|
|
let best: Element | null = null;
|
|
let bestTop = Number.POSITIVE_INFINITY;
|
|
|
|
for (const el of observed) {
|
|
const rect = el.getBoundingClientRect();
|
|
const viewportH = window.innerHeight || document.documentElement.clientHeight;
|
|
// In view at all (any overlap with the viewport).
|
|
const inView = rect.bottom > 0 && rect.top < viewportH;
|
|
if (!inView) continue;
|
|
// Prefer the movement whose top is closest to (but not far below) the fold.
|
|
const distance = Math.abs(rect.top);
|
|
if (distance < bestTop) {
|
|
bestTop = distance;
|
|
best = el;
|
|
}
|
|
}
|
|
|
|
for (const el of observed) {
|
|
el.classList.toggle('is-active', el === best);
|
|
}
|
|
}
|
|
|
|
export function observe(...elements: Element[]): void {
|
|
unobserve();
|
|
observed = elements.filter(Boolean);
|
|
if (observed.length === 0) return;
|
|
|
|
// IntersectionObserver only tells us *that* visibility changed; the actual
|
|
// "which is nearest the fold" decision is recomputed from live rects so the
|
|
// choice stays correct mid-scroll.
|
|
observer = new IntersectionObserver(() => refreshActive(), {
|
|
threshold: [0, 0.25, 0.5, 0.75, 1],
|
|
});
|
|
for (const el of observed) observer.observe(el);
|
|
|
|
// Seed once so the first movement lights before any scroll.
|
|
refreshActive();
|
|
}
|
|
|
|
export function unobserve(): void {
|
|
observer?.disconnect();
|
|
observer = null;
|
|
for (const el of observed) el.classList.remove('is-active');
|
|
observed = [];
|
|
}
|