feat(report): nav scrollspy + offset-jump, чат в плавающее окно справа (#958)
Навигация: - AnalysisSidebarNav подсвечивает активную секцию через IntersectionObserver (rootMargin -72px/-60% — активная полоса ниже sticky-шапки) + aria-current. - Переход по пунктам — абсолютный window.scrollTo с offset 80px вместо scrollIntoView: попадает точно даже после async-подгрузки §6 (высоты сдвигаются во время smooth-scroll → раньше «не прыгало»). Чат: - ChatPanel больше не вмонтирован внизу колонки. Новый ChatDock оборачивает его в fixed сворачиваемое окно справа-снизу (лончер «Спросить по участку», Esc / X закрывают) — доступен при любом скролле, едет со скролом (position: fixed). - Хром дока — только close-бар, чтобы не дублировать собственный заголовок ChatPanel (без вложенных карточек). ChatPanel не тронут.
This commit is contained in:
parent
de4704f6cd
commit
185cf81d83
2 changed files with 286 additions and 52 deletions
|
|
@ -1,10 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useRef, 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";
|
||||||
|
|
||||||
import { ChatPanel } from "@/components/site-finder/ChatPanel";
|
import { ChatDock } from "@/components/site-finder/ChatDock";
|
||||||
import { GateVerdictBanner } from "@/components/site-finder/GateVerdictBanner";
|
import { GateVerdictBanner } from "@/components/site-finder/GateVerdictBanner";
|
||||||
import { HorizonSelector } from "@/components/site-finder/HorizonSelector";
|
import { HorizonSelector } from "@/components/site-finder/HorizonSelector";
|
||||||
import { Section1ParcelInfo } from "@/components/site-finder/analysis/Section1ParcelInfo";
|
import { Section1ParcelInfo } from "@/components/site-finder/analysis/Section1ParcelInfo";
|
||||||
|
|
@ -222,11 +222,13 @@ export function AnalysisPageContent({ cad }: Props) {
|
||||||
Seeded из анализа (geom + financial_estimate), считается по
|
Seeded из анализа (geom + financial_estimate), считается по
|
||||||
запросу; result lazy-mounted (Leaflet). */}
|
запросу; result lazy-mounted (Leaflet). */}
|
||||||
<Section7Concept analysis={analysis} />
|
<Section7Concept analysis={analysis} />
|
||||||
|
|
||||||
{/* Chat — grounded parcel-chat over the §22 forecast (#958) */}
|
|
||||||
<ChatPanel cadNum={cad} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Chat — grounded parcel-chat over the §22 forecast (#958). Docked
|
||||||
|
right-side floating panel (position: fixed), доступен при любом
|
||||||
|
скролле; mount once, placement in the tree is irrelevant. */}
|
||||||
|
<ChatDock cadNum={cad} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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 AnalysisSidebarNav() {
|
||||||
function scrollTo(id: string) {
|
// Scrollspy: highlight whichever top-level section is currently near the top
|
||||||
const el = document.getElementById(id);
|
// of the viewport. Default to the first so something is lit before any scroll.
|
||||||
if (el) el.scrollIntoView({ behavior: "smooth" });
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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 (
|
return (
|
||||||
<nav
|
<nav
|
||||||
|
|
@ -436,64 +502,77 @@ function AnalysisSidebarNav() {
|
||||||
>
|
>
|
||||||
{group.label}
|
{group.label}
|
||||||
</div>
|
</div>
|
||||||
{group.items.map((item) => (
|
{group.items.map((item) => {
|
||||||
<div key={item.id}>
|
const isActive = activeId === item.id;
|
||||||
<button
|
return (
|
||||||
type="button"
|
<div key={item.id}>
|
||||||
onClick={() => scrollTo(item.id)}
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
width: "100%",
|
|
||||||
textAlign: "left",
|
|
||||||
padding: "8px 16px",
|
|
||||||
background: "none",
|
|
||||||
border: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--fg-primary, #111111)",
|
|
||||||
transition: "background 0.1s",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background =
|
|
||||||
"var(--bg-card-alt, #FAFBFC)";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = "none";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
{item.children?.map((child) => (
|
|
||||||
<button
|
<button
|
||||||
key={child.id}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => scrollTo(child.id)}
|
onClick={() => scrollToSection(item.id)}
|
||||||
|
aria-current={isActive ? "true" : undefined}
|
||||||
style={{
|
style={{
|
||||||
display: "block",
|
display: "block",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
padding: "6px 16px 6px 28px",
|
padding: "8px 16px",
|
||||||
background: "none",
|
background: isActive
|
||||||
|
? "var(--accent-soft, #DBEAFE)"
|
||||||
|
: "none",
|
||||||
border: "none",
|
border: "none",
|
||||||
|
borderLeft: isActive
|
||||||
|
? "3px solid var(--accent, #1D4ED8)"
|
||||||
|
: "3px solid transparent",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontSize: 12,
|
fontSize: 13,
|
||||||
color: "var(--fg-secondary, #5B6066)",
|
fontWeight: isActive ? 600 : 500,
|
||||||
transition: "background 0.1s",
|
color: isActive
|
||||||
|
? "var(--accent, #1D4ED8)"
|
||||||
|
: "var(--fg-primary, #111111)",
|
||||||
|
transition: "background 0.1s, color 0.1s",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.background =
|
if (!isActive) {
|
||||||
"var(--bg-card-alt, #FAFBFC)";
|
e.currentTarget.style.background =
|
||||||
|
"var(--bg-card-alt, #FAFBFC)";
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.background = "none";
|
if (!isActive) e.currentTarget.style.background = "none";
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{child.label}
|
{item.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
{item.children?.map((child) => (
|
||||||
</div>
|
<button
|
||||||
))}
|
key={child.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => scrollToSection(child.id)}
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
padding: "6px 16px 6px 28px",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--fg-secondary, #5B6066)",
|
||||||
|
transition: "background 0.1s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background =
|
||||||
|
"var(--bg-card-alt, #FAFBFC)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = "none";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{child.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
155
frontend/src/components/site-finder/ChatDock.tsx
Normal file
155
frontend/src/components/site-finder/ChatDock.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ChatDock — floating right-docked wrapper around <ChatPanel> 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: <ChatPanel> already renders its own bordered <section>
|
||||||
|
* 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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
aria-label="Открыть чат по участку"
|
||||||
|
aria-expanded={false}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
right: 24,
|
||||||
|
bottom: 24,
|
||||||
|
zIndex: 1000,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "12px 16px",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--fg-on-dark, #E2E8F0)",
|
||||||
|
background: "var(--accent, #1D4ED8)",
|
||||||
|
border: "1px solid transparent",
|
||||||
|
borderRadius: 12,
|
||||||
|
cursor: "pointer",
|
||||||
|
boxShadow: "0 8px 24px rgba(15, 23, 42, 0.18)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MessageSquare size={20} strokeWidth={1.5} aria-hidden />
|
||||||
|
Спросить по участку
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Expanded: floating popover panel ─────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Чат по участку"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
right: 24,
|
||||||
|
bottom: 24,
|
||||||
|
zIndex: 1000,
|
||||||
|
width: "min(420px, calc(100vw - 32px))",
|
||||||
|
height: "min(640px, calc(100vh - 120px))",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
background: "var(--bg-card, #FFFFFF)",
|
||||||
|
border: "1px solid var(--border-card, #E6E8EC)",
|
||||||
|
borderRadius: 12,
|
||||||
|
boxShadow: "0 16px 40px rgba(15, 23, 42, 0.24)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Thin header bar — close affordance only. The title lives in ChatPanel's
|
||||||
|
own header below, so there's no duplicate heading / nested card. */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderBottom: "1px solid var(--border-soft, #EEF0F3)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
aria-label="Закрыть чат"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
padding: 0,
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 8,
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--fg-secondary, #5B6066)",
|
||||||
|
transition: "background 0.1s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = "var(--bg-card-alt, #FAFBFC)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = "none";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={20} strokeWidth={1.5} aria-hidden />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable body — ChatPanel renders normally (its own card + header). */}
|
||||||
|
<div style={{ flex: 1, overflowY: "auto" }}>
|
||||||
|
<ChatPanel cadNum={cadNum} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue