From 2a900cf5713d9d42eab92bc072464e364f2143c9 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Tue, 30 Jun 2026 15:42:28 +0500 Subject: [PATCH] fix(report): instant nav jump + scroll-position scrollspy (#958) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Скриншот-верификация 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-линию; на низу страницы форсим последнюю секцию. Детерминированно, никогда не «пусто». --- .../analysis/[cad]/AnalysisPageContent.tsx | 86 +++++++++---------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx index 5573133c..fff7b7ee 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx @@ -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(TOP_LEVEL_IDS[0]); - const observerRef = useRef(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(); - - 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); }; }, []);