'use client'; import { useEffect, useState } from 'react'; export interface NavSection { /** `id` of the element on the page this entry jumps to. */ id: string; label: string; } interface SectionNavProps { /** * The sections, in page order. Pass a value with a stable identity (a module * constant, or memoized) — a fresh array each render re-subscribes the * observer on every render. */ sections: NavSection[]; } /** * In-page jump menu. * * The rankings page stacks three boards below a full-height chart, far enough * down that readers were not finding the per-challenge and * per-frequency/horizon ones at all. This lists them all above the fold. * * One component, two layouts: a rail in the left gutter from `lg` up, and a * strip of chips across the top below it, where there is no gutter to put a * rail in. Both stick while the reader scrolls the sections past them. */ export default function SectionNav({ sections }: SectionNavProps) { const [activeId, setActiveId] = useState(sections[0]?.id ?? ''); useEffect(() => { const elements = sections .map((section) => document.getElementById(section.id)) .filter((element): element is HTMLElement => element !== null); if (elements.length === 0) return; // Ids currently in the band, kept across callbacks: an IntersectionObserver // reports only what *changed*, so the full picture has to be accumulated. const inBand = new Set(); const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) inBand.add(entry.target.id); else inBand.delete(entry.target.id); }); // Later sections win: scrolling down, the one that just arrived at the // top of the screen is the one the reader has moved to. for (let i = sections.length - 1; i >= 0; i--) { if (inBand.has(sections[i].id)) { setActiveId(sections[i].id); return; } } }, // Squeeze the root to a band across the top fifth of the viewport, so // "in view" means "at the top of the screen". Against the whole viewport // a board as tall as these would stay active long after the next one had // scrolled into place. { rootMargin: '0px 0px -80% 0px' } ); elements.forEach((element) => observer.observe(element)); return () => observer.disconnect(); }, [sections]); return ( ); }