fix(report): instant nav jump + scroll-position scrollspy (#958)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m0s
CI / openapi-codegen-check (pull_request) Successful in 1m55s

Скриншот-верификация PR #2104 с интеракциями вскрыла два бага навигации:

- Прыжок не работал: window.scrollTo({behavior:"smooth"}) — no-op на этой
  тяжёлой странице (Leaflet-карты / async §6 / lazy-чарты дают непрерывный
  reflow, отменяющий smooth-анимацию → страница оставалась на месте). Это и
  есть исходная жалоба «навигация не прыгает» (оригинал тоже был smooth).
  → behavior:"auto" (instant): надёжно, читается как настоящий прыжок.

- Scrollspy не трекал: узкая IntersectionObserver-полоса (-72px/-60%) пуста для
  нижней/высокой секции → if(next) держал старое значение (на §7 активной
  оставалась «1. Информация»). → scroll-position подход (rAF-throttled): активна
  последняя секция, чья верхушка прошла offset-линию; на низу страницы форсим
  последнюю секцию. Детерминированно, никогда не «пусто».
This commit is contained in:
Light1YT 2026-06-30 15:42:28 +05:00
parent 02f49e795c
commit 2a900cf571

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);
}; };
}, []); }, []);