const SPECTRUM_VERSION = Date.now(); function SpectrumGraph() { const { locale, t } = useI18n(); const sectionRef = React.useRef(null); const iframeRef = React.useRef(null); const readyRef = React.useRef(false); const lastTRef = React.useRef(-1); const [isMobile, setIsMobile] = React.useState( typeof window !== 'undefined' && window.matchMedia('(max-width: 820px)').matches ); React.useEffect(() => { const mq = window.matchMedia('(max-width: 820px)'); const onChange = () => setIsMobile(mq.matches); mq.addEventListener?.('change', onChange); return () => mq.removeEventListener?.('change', onChange); }, []); // Eight Sleep–style scroll-pin: the section is N×100vh tall, a sticky child // pins the iframe to the viewport, and we drive horizontal pan via the // page's own scroll progress. No body lock, no wheel hijack — native scroll. React.useEffect(() => { if (isMobile) return; const onReady = (e) => { const d = e.data; if (d && d.type === 'spectrum-ready') { readyRef.current = true; send(); // sync immediately } }; window.addEventListener('message', onReady); let raf = 0; const send = () => { const sec = sectionRef.current; const iframe = iframeRef.current; if (!sec || !iframe || !iframe.contentWindow) return; const r = sec.getBoundingClientRect(); const vh = window.innerHeight; const span = sec.offsetHeight - vh; let t = span > 0 ? (-r.top) / span : 0; if (t < 0) t = 0; else if (t > 1) t = 1; // Intro/outro hold: keep scene 0 pinned and readable for the first // ~18% of the scroll before panning starts, and let the final scene // rest for the last 8% before the section unpins. const HOLD_IN = 0.18, HOLD_OUT = 0.08; t = (t - HOLD_IN) / (1 - HOLD_IN - HOLD_OUT); if (t < 0) t = 0; else if (t > 1) t = 1; // Avoid spamming postMessage when t hasn't changed meaningfully. if (Math.abs(t - lastTRef.current) < 0.0008) return; lastTRef.current = t; try { iframe.contentWindow.postMessage({ type: 'pan-progress', t }, '*'); } catch (_) {} }; const onScroll = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(send); }; window.addEventListener('scroll', onScroll, { passive: true }); window.addEventListener('resize', onScroll); // Initial push in case iframe loads after first scroll send(); return () => { window.removeEventListener('scroll', onScroll); window.removeEventListener('resize', onScroll); window.removeEventListener('message', onReady); cancelAnimationFrame(raf); }; }, [isMobile]); if (isMobile) return ; return (
{/* Pinned intro — kept visible above the graph on desktop (mirrors mobile) */}

{t('spectrum.heroTitle').replace(/\n/g, ' ')}

{t('spectrum.heroBody')}