fix(report): instant nav jump + scroll-position scrollspy (#958)
Скриншот-верификация 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:
parent
02f49e795c
commit
2a900cf571
1 changed files with 43 additions and 43 deletions
|
|
@ -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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue