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"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useQueryClient } from "@tanstack/react-query"; 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 // 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; const SCROLL_OFFSET = 80;
// Robust jump: bare scrollIntoView({behavior:"smooth"}) can land short when // Jump to a section. behavior:"auto" (INSTANT), NOT smooth: on this heavy report
// async content (§6 forecast loads ~20-40s later) shifts heights mid-animation, // (Leaflet maps, async §6 forecast, lazy charts) the continuous reflow silently
// and it ignores the sticky offset. Compute an absolute target relative to the // cancels a smooth window.scrollTo — it never moved («навигация не прыгает»).
// document and scroll there, accounting for the sticky chrome. // 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) { function scrollToSection(id: string) {
const el = document.getElementById(id); const el = document.getElementById(id);
if (!el) return; if (!el) return;
const top = el.getBoundingClientRect().top + window.scrollY - SCROLL_OFFSET; const top = el.getBoundingClientRect().top + window.scrollY - SCROLL_OFFSET;
window.scrollTo({ top, behavior: "smooth" }); window.scrollTo({ top, behavior: "auto" });
} }
function AnalysisSidebarNav() { function AnalysisSidebarNav() {
// Scrollspy: highlight whichever top-level section is currently near the top // Scrollspy: the active section is the LAST one whose top has scrolled past the
// of the viewport. Default to the first so something is lit before any scroll. // 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 [activeId, setActiveId] = useState<string>(TOP_LEVEL_IDS[0]);
const observerRef = useRef<IntersectionObserver | null>(null);
useEffect(() => { useEffect(() => {
// SSR / unsupported-env guard. if (typeof window === "undefined") return;
if (
typeof window === "undefined" ||
typeof IntersectionObserver === "undefined"
) {
return;
}
const targets = TOP_LEVEL_IDS.map((id) => let raf = 0;
document.getElementById(id), const compute = () => {
).filter((el): el is HTMLElement => el !== null); raf = 0;
if (targets.length === 0) return; // 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.)
// Track the set of currently-intersecting sections; the active one is the const atBottom =
// first (topmost in scroll order) still inside the band. rootMargin pushes window.innerHeight + window.scrollY >=
// the band's top down past the sticky chrome and shrinks its bottom so the document.documentElement.scrollHeight - 2;
// "active" section is the one near the top of the viewport — not whatever if (atBottom) {
// merely peeks in from below. setActiveId(TOP_LEVEL_IDS[TOP_LEVEL_IDS.length - 1]);
const visible = new Set<string>(); return;
}
const observer = new IntersectionObserver( // Otherwise: the last section whose top has passed the offset line.
(entries) => { let current = TOP_LEVEL_IDS[0];
for (const entry of entries) { for (const id of TOP_LEVEL_IDS) {
if (entry.isIntersecting) visible.add(entry.target.id); const el = document.getElementById(id);
else visible.delete(entry.target.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); setActiveId(current);
}, };
{ root: null, rootMargin: "-72px 0px -60% 0px", threshold: 0 }, // 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)); window.addEventListener("scroll", onScroll, { passive: true });
observerRef.current = observer; compute(); // initial state
return () => { return () => {
observer.disconnect(); window.removeEventListener("scroll", onScroll);
observerRef.current = null; if (raf) window.cancelAnimationFrame(raf);
}; };
}, []); }, []);