fix(report): instant nav jump + scroll-position scrollspy (#958) #2105

Merged
bot-backend merged 1 commit from fix/report-nav-jump-scrollspy into main 2026-06-30 10:45:34 +00:00

View file

@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useQueryClient } from "@tanstack/react-query";
@ -414,65 +414,65 @@ const TOP_LEVEL_IDS: string[] = NAV_GROUPS.flatMap((g) =>
);
// Sticky chrome offset (header + breadcrumb). The jump lands the section title
// just below this; matches scrollMarginTop used on the section cards.
// just below this; the scrollspy uses the same line to pick the active section.
const SCROLL_OFFSET = 80;
// Robust jump: bare scrollIntoView({behavior:"smooth"}) can land short when
// async content (§6 forecast loads ~20-40s later) shifts heights mid-animation,
// and it ignores the sticky offset. Compute an absolute target relative to the
// document and scroll there, accounting for the sticky chrome.
// Jump to a section. behavior:"auto" (INSTANT), NOT smooth: on this heavy report
// (Leaflet maps, async §6 forecast, lazy charts) the continuous reflow silently
// cancels a smooth window.scrollTo — it never moved («навигация не прыгает»).
// Instant is reliable and reads as a real jump. Absolute document target +
// SCROLL_OFFSET clears the sticky chrome and is robust to async height shifts.
function scrollToSection(id: string) {
const el = document.getElementById(id);
if (!el) return;
const top = el.getBoundingClientRect().top + window.scrollY - SCROLL_OFFSET;
window.scrollTo({ top, behavior: "smooth" });
window.scrollTo({ top, behavior: "auto" });
}
function AnalysisSidebarNav() {
// Scrollspy: highlight whichever top-level section is currently near the top
// of the viewport. Default to the first so something is lit before any scroll.
// Scrollspy: the active section is the LAST one whose top has scrolled past the
// offset line (deterministic, never "empty" — unlike a narrow IntersectionObserver
// band, which left the last/at-bottom section un-lit). Default to the first so
// something is lit before any scroll.
const [activeId, setActiveId] = useState<string>(TOP_LEVEL_IDS[0]);
const observerRef = useRef<IntersectionObserver | null>(null);
useEffect(() => {
// SSR / unsupported-env guard.
if (
typeof window === "undefined" ||
typeof IntersectionObserver === "undefined"
) {
return;
}
if (typeof window === "undefined") return;
const targets = TOP_LEVEL_IDS.map((id) =>
document.getElementById(id),
).filter((el): el is HTMLElement => el !== null);
if (targets.length === 0) return;
// Track the set of currently-intersecting sections; the active one is the
// first (topmost in scroll order) still inside the band. rootMargin pushes
// the band's top down past the sticky chrome and shrinks its bottom so the
// "active" section is the one near the top of the viewport — not whatever
// merely peeks in from below.
const visible = new Set<string>();
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) visible.add(entry.target.id);
else visible.delete(entry.target.id);
let raf = 0;
const compute = () => {
raf = 0;
// At the page bottom the last section can be too short to reach the offset
// line — force it active so the last nav item lights up. (2px tolerance.)
const atBottom =
window.innerHeight + window.scrollY >=
document.documentElement.scrollHeight - 2;
if (atBottom) {
setActiveId(TOP_LEVEL_IDS[TOP_LEVEL_IDS.length - 1]);
return;
}
// Otherwise: the last section whose top has passed the offset line.
let current = TOP_LEVEL_IDS[0];
for (const id of TOP_LEVEL_IDS) {
const el = document.getElementById(id);
if (el && el.getBoundingClientRect().top - SCROLL_OFFSET <= 1) {
current = id;
}
const next = TOP_LEVEL_IDS.find((id) => visible.has(id));
if (next) setActiveId(next);
},
{ root: null, rootMargin: "-72px 0px -60% 0px", threshold: 0 },
);
}
setActiveId(current);
};
// rAF-throttle: at most one O(7) measurement per frame, never mid-frame jank.
const onScroll = () => {
if (raf) return;
raf = window.requestAnimationFrame(compute);
};
targets.forEach((el) => observer.observe(el));
observerRef.current = observer;
window.addEventListener("scroll", onScroll, { passive: true });
compute(); // initial state
return () => {
observer.disconnect();
observerRef.current = null;
window.removeEventListener("scroll", onScroll);
if (raf) window.cancelAnimationFrame(raf);
};
}, []);