diff --git a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx index ebfc1943..5573133c 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx @@ -1,10 +1,10 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import Link from "next/link"; import { useQueryClient } from "@tanstack/react-query"; -import { ChatPanel } from "@/components/site-finder/ChatPanel"; +import { ChatDock } from "@/components/site-finder/ChatDock"; import { GateVerdictBanner } from "@/components/site-finder/GateVerdictBanner"; import { HorizonSelector } from "@/components/site-finder/HorizonSelector"; import { Section1ParcelInfo } from "@/components/site-finder/analysis/Section1ParcelInfo"; @@ -222,11 +222,13 @@ export function AnalysisPageContent({ cad }: Props) { Seeded из анализа (geom + financial_estimate), считается по запросу; result lazy-mounted (Leaflet). */} - - {/* Chat — grounded parcel-chat over the §22 forecast (#958) */} - + + {/* Chat — grounded parcel-chat over the §22 forecast (#958). Docked + right-side floating panel (position: fixed), доступен при любом + скролле; mount once, placement in the tree is irrelevant. */} + ); } @@ -404,11 +406,75 @@ const NAV_GROUPS: NavGroup[] = [ }, ]; +// Top-level section anchors in scroll order — observed by the scrollspy. +// Children (3.x / 6.x) are intentionally NOT observed: their parent stays lit +// while the user is anywhere inside the section, which reads cleaner. +const TOP_LEVEL_IDS: string[] = NAV_GROUPS.flatMap((g) => + g.items.map((i) => i.id), +); + +// Sticky chrome offset (header + breadcrumb). The jump lands the section title +// just below this; matches scrollMarginTop used on the section cards. +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. +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" }); +} + function AnalysisSidebarNav() { - function scrollTo(id: string) { - const el = document.getElementById(id); - if (el) el.scrollIntoView({ behavior: "smooth" }); - } + // 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. + 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; + } + + 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); + } + const next = TOP_LEVEL_IDS.find((id) => visible.has(id)); + if (next) setActiveId(next); + }, + { root: null, rootMargin: "-72px 0px -60% 0px", threshold: 0 }, + ); + + targets.forEach((el) => observer.observe(el)); + observerRef.current = observer; + + return () => { + observer.disconnect(); + observerRef.current = null; + }; + }, []); return ( diff --git a/frontend/src/components/site-finder/ChatDock.tsx b/frontend/src/components/site-finder/ChatDock.tsx new file mode 100644 index 00000000..d6fee1c7 --- /dev/null +++ b/frontend/src/components/site-finder/ChatDock.tsx @@ -0,0 +1,155 @@ +"use client"; + +/** + * ChatDock — floating right-docked wrapper around for the Site + * Finder analysis report (#958 follow-up). + * + * The chat used to be mounted inline at the bottom of the report; that meant the + * user had to scroll all the way down to ask anything. ChatDock keeps it + * always reachable: a fixed launcher (bottom-right) opens a fixed popover panel + * that stays put while the report scrolls. + * + * Open/close: + * - launcher click → toggles the panel open + * - close button (X) → closes + * - Esc → closes (keydown listener active only while open, cleaned up) + * + * Double-card avoidance: already renders its own bordered
+ * card with the «Чат по участку» header. So the dock's own chrome is just a thin + * header bar carrying the X close button (NO duplicate title) — ChatPanel's own + * header provides the title inside the scrollable body. No nested boxes. + * + * box-shadow is allowed here (floating popover / launcher), unlike in-page cards. + */ + +import { useEffect, useState } from "react"; +import { MessageSquare, X } from "lucide-react"; + +import { ChatPanel } from "@/components/site-finder/ChatPanel"; + +// ── Props ───────────────────────────────────────────────────────────────────── + +interface Props { + cadNum: string; +} + +// ── ChatDock ─────────────────────────────────────────────────────────────────── + +export function ChatDock({ cadNum }: Props) { + const [open, setOpen] = useState(false); + + // Esc closes the panel (listener active only while open). + useEffect(() => { + if (!open) return; + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [open]); + + // ── Collapsed: floating launcher ───────────────────────────────────────────── + + if (!open) { + return ( + + ); + } + + // ── Expanded: floating popover panel ───────────────────────────────────────── + + return ( +
+ {/* Thin header bar — close affordance only. The title lives in ChatPanel's + own header below, so there's no duplicate heading / nested card. */} +
+ +
+ + {/* Scrollable body — ChatPanel renders normally (its own card + header). */} +
+ +
+
+ ); +}