// scroll-rail.jsx — Homepage "transit tracker" // A sleek public-transit style rail down the left gutter of the assembled // homepage. Station dots sit STATICALLY beside each section's heading; a // vehicle dot rides the line as the page scrolls and "arrives" at each // station (dot fills + pulses) when its section reaches the viewport. // // Globals consumed: React. // Exposes: HomeScrollRail. function HomeScrollRail({stations}) { const anchorRef = React.useRef(null); const scrollerRef = React.useRef(null); const geomRef = React.useRef({firstY: 0, lastY: 0, arrivalOff: 0}); // Layout-space geometry (unscaled px). Recomputed on resize / tweak / load. const [geo, setGeo] = React.useState({railTop: 140, stops: []}); // Vehicle position along the line, in layout px (document space). const [veh, setVeh] = React.useState(0); // Bumped each time we arrive at a new station — remounts the pulse ring. const [pulseKey, setPulseKey] = React.useState(0); React.useEffect(() => { const anchor = anchorRef.current; if (!anchor) return; // Nearest scrollable ancestor (the artboard Frame). let sc = anchor.parentElement; while (sc) { const s = getComputedStyle(sc); if (s.overflowY === 'auto' || s.overflowY === 'scroll') break; sc = sc.parentElement; } const useWindow = !sc; const scroller = useWindow ? window : sc; scrollerRef.current = scroller; const host = sc || document; const readScrollTop = () => useWindow ? window.scrollY : sc.scrollTop; // Full re-measure: header bottom + each station's heading position. // The artboard is rendered inside a transform:scale() wrapper, so rect // deltas are SCALED — divide by scale to get layout px. const measure = () => { const clientH = useWindow ? window.innerHeight : sc.clientHeight; const scTop = readScrollTop(); const scRect = useWindow ? {top: 0, height: clientH} : sc.getBoundingClientRect(); const scale = (scRect.height / clientH) || 1; const toLayoutY = (clientTop) => (clientTop - scRect.top) / scale + scTop; // Header bottom — the line starts just below it. const headerEl = host.querySelector('header'); let headerBottom = 0; if (headerEl) headerBottom = toLayoutY(headerEl.getBoundingClientRect().bottom); const stops = stations.map(st => { const wrap = host.querySelector(`[data-station-id="${st.id}"]`); let y = 0; if (wrap) { const h = wrap.querySelector('h1, h2, h3'); if (h) { const r = h.getBoundingClientRect(); y = toLayoutY(r.top) + (r.height / scale) / 2; // center on heading } else { y = toLayoutY(wrap.getBoundingClientRect().top) + 64; // start of block } } return {...st, y}; }); const firstY = stops.length ? stops[0].y : 0; const lastY = stops.length ? stops[stops.length - 1].y : clientH; const railTop = Math.round(headerBottom + 18); const arrivalOff = clientH * 0.32; geomRef.current = {firstY, lastY, arrivalOff}; setGeo({railTop, stops}); setVeh(Math.max(firstY, Math.min(lastY, scTop + arrivalOff))); }; let raf = 0; const onScroll = () => { if (raf) return; raf = requestAnimationFrame(() => { raf = 0; const {firstY, lastY, arrivalOff} = geomRef.current; setVeh(Math.max(firstY, Math.min(lastY, readScrollTop() + arrivalOff))); }); }; measure(); const t1 = setTimeout(measure, 350); const t2 = setTimeout(measure, 1400); scroller.addEventListener('scroll', onScroll, {passive: true}); window.addEventListener('resize', measure); window.addEventListener('aagu:tweaks-changed', measure); return () => { scroller.removeEventListener('scroll', onScroll); window.removeEventListener('resize', measure); window.removeEventListener('aagu:tweaks-changed', measure); clearTimeout(t1); clearTimeout(t2); if (raf) cancelAnimationFrame(raf); }; }, [stations]); const {railTop, stops} = geo; const lastY = stops.length ? stops[stops.length - 1].y : railTop; // Which station have we most recently reached? let active = 0; for (let i = 0; i < stops.length; i++) { if (veh >= stops[i].y - 2) active = i; } // Pulse whenever the active station advances. const prevActive = React.useRef(-1); React.useEffect(() => { if (!stops.length) return; if (prevActive.current !== active) { if (prevActive.current !== -1) setPulseKey(k => k + 1); prevActive.current = active; } }, [active, stops.length]); const goTo = (s) => { const scroller = scrollerRef.current; if (!scroller) return; const useWindow = scroller === window; const clientH = useWindow ? window.innerHeight : scroller.clientHeight; const arrivalOff = clientH * 0.32; const top = Math.max(0, s.y - arrivalOff); if (useWindow) window.scrollTo({top, behavior: 'smooth'}); else scroller.scrollTo({top, behavior: 'smooth'}); }; const activeY = stops[active] ? stops[active].y : railTop; return (
); } Object.assign(window, {HomeScrollRail});