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