"use client"; import { useEffect, useRef, useState } from "react"; import Link from "next/link"; import { useQueryClient } from "@tanstack/react-query"; 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"; import { Section2NetworksUtilities } from "@/components/site-finder/analysis/Section2NetworksUtilities"; import { Section3SettingsAndCompetitors } from "@/components/site-finder/analysis/Section3SettingsAndCompetitors"; import { Section4Estimate } from "@/components/site-finder/analysis/Section4Estimate"; import { Section5Atmosphere } from "@/components/site-finder/analysis/Section5Atmosphere"; import { Section6Forecast } from "@/components/site-finder/analysis/Section6Forecast"; import { Section7Concept } from "@/components/site-finder/analysis/Section7Concept"; import { adaptEgrn, useParcelAnalyzeQuery } from "@/lib/site-finder-api"; import type { ParcelAnalysis } from "@/types/site-finder"; // ── Props ───────────────────────────────────────────────────────────────────── interface Props { cad: string; } // ── Page Content (client — needs TanStack Query) ─────────────────────────────── export function AnalysisPageContent({ cad }: Props) { const [horizon, setHorizon] = useState(12); const queryClient = useQueryClient(); const { data, isLoading, isFetching, error } = useParcelAnalyzeQuery( cad, horizon, ); // Changing the horizon re-runs /analyze (expensive ~10-30s), which re-enqueues // the async §22 forecast (recompute ~30-180s). Invalidating ["parcel-forecast", // cad] forces Section 6's poll to re-fetch; until the new run is ready it may // still show the previous run's emphasis. The selectedHorizon prop passed to // Section6Forecast gives an instant client-side target highlight regardless — // that's intended. function handleHorizonChange(h: number) { setHorizon(h); void queryClient.invalidateQueries({ queryKey: ["parcel-forecast", cad] }); } // ── Loading ──────────────────────────────────────────────────────────────── if (isLoading) { return (
Анализируем участок {cad}…
); } // ── Error ────────────────────────────────────────────────────────────────── if (error || !data) { const msg = error instanceof Error ? error.message : "Ошибка загрузки анализа"; return (
{msg}
Вернуться к Site Finder
); } // ── Results layout ───────────────────────────────────────────────────────── // Cast to ParcelAnalysis (superset of ParcelAnalyzeResponse) — both describe // the same /analyze endpoint; Section3 requires the richer type. const analysis = data as unknown as ParcelAnalysis; const districtName = analysis.district?.district_name ?? "—"; // Площадь крошки — из ТОГО ЖЕ источника, что KPI «Площадь» и ЕГРН-таблица в // Section1: ЕГРН-area (egrn_block.area_m2 через adaptEgrn), а НЕ geometry-area // (geometry_suitability.area_ha считается по геометрии кадастрового квартала и // расходится с зарегистрированной площадью участка — давало 6.61 vs 11.68 га). const egrnAreaM2 = adaptEgrn(analysis.egrn, cad)?.area_m2; const areaStr = egrnAreaM2 != null && Number.isFinite(egrnAreaM2) && egrnAreaM2 > 0 ? `${(egrnAreaM2 / 10000).toFixed(2)} га` : null; return (
{/* Breadcrumb */} {/* Controls row — horizon selector drives the analyze + forecast queries */}
{/* Page layout: sidebar (240px) + main scroll. На планшете/телефоне .gd-grid-sidebar схлопывает грид в одну колонку (issue #66). */}
{/* ── Sidebar nav ───────────────────────────────────────────────── */} {/* ── Main scroll area ──────────────────────────────────────────── */}
{/* Вердикт-баннер участка — выше всех секций (#1741). */} {/* ── Группа «Участок» ──────────────────────────────────────── */} {/* 1. Информация — IMPLEMENTED in A5 */} {/* 2. Оценка участка — IMPLEMENTED in A10 */} {/* 3. Сети и точки подключения — IMPLEMENTED in A6 */} {/* ── Группа «Стройка и рынок» ──────────────────────────────── */} {/* 4. Рынок и конкуренты — IMPLEMENTED in A7 */} {/* 5. Атмосфера — IMPLEMENTED in A11 */} {/* Статус пересчёта прогноза при смене горизонта (#1744) — нейтральная серая строка вместо молчаливого disabled. */} {isFetching && (
Пересчитываем прогноз на {horizon} мес…
)} {/* 6. Прогноз и рекомендация — IMPLEMENTED in 958-B3 (§22 forecast) */} {/* 7. Концепция — генеративный дизайн застройки (#1965 Stage 1). Seeded из анализа (geom + financial_estimate), считается по запросу; result lazy-mounted (Leaflet). */}
{/* Chat — grounded parcel-chat over the §22 forecast (#958). Docked right-side floating panel (position: fixed), доступен при любом скролле; mount once, placement in the tree is irrelevant. */}
); } // ── Page heading (h1 a11y) ───────────────────────────────────────────────────── // // Единственный

страницы анализа (#1926 a11y): присутствует во ВСЕХ состояниях // (loading / error / success), не только в success — иначе SR-пользователь на этапе // загрузки/ошибки остаётся без заголовка верхнего уровня. district дописывается // когда известен (success). Стиль = h1-токен (22/28 weight 600, left-align). function PageHeading({ cad, districtName, }: { cad: string; districtName?: string; }) { const hasDistrict = districtName != null && districtName !== "—"; return (

Анализ участка {cad} {hasDistrict ? ` · ${districtName}` : ""}

); } // ── Breadcrumb ───────────────────────────────────────────────────────────────── function Breadcrumb({ cad, districtName, areaStr, }: { cad: string; districtName?: string; areaStr?: string | null; }) { const parts = [cad]; if (districtName && districtName !== "—") parts.push(districtName); if (areaStr) parts.push(areaStr); return (
Site Finder / {parts.map((part, i) => ( {i > 0 && ( · )} {part} ))} · анализ
); } // ── Group divider ──────────────────────────────────────────────────────────── // // Плашка-разделитель между смысловыми группами секций (#1741): тёмный фон // --bg-headline + светлый uppercase-label --fg-on-dark, паттерн headline-bar. function GroupDivider({ label }: { label: string }) { return (
{label}
); } // ── Sidebar nav ──────────────────────────────────────────────────────────────── // // Две группы с групп-хедерами (#1741). Нумерация секций сквозная 1–6 под новый // порядок: «Участок» → 1-3, «Стройка и рынок» → 4-6. interface NavItem { id: string; label: string; children?: { id: string; label: string }[]; } interface NavGroup { label: string; items: NavItem[]; } const NAV_GROUPS: NavGroup[] = [ { label: "Участок", items: [ { id: "section-1", label: "1. Информация" }, { id: "section-4", label: "2. Оценка участка" }, { id: "section-2", label: "3. Сети и точки подключения" }, ], }, { label: "Стройка и рынок", items: [ { id: "section-3", label: "4. Рынок и конкуренты", children: [ { id: "section-3-1", label: "4.1 Настройки выборки" }, { id: "section-3-2", label: "4.2 Планировки" }, { id: "section-3-3", label: "4.3 Остатки и скорость" }, ], }, { id: "section-5", label: "5. Атмосфера" }, { id: "section-6", label: "6. Прогноз и рекомендация", children: [ { id: "section-6-1", label: "6.1 Прогноз по горизонтам" }, { id: "section-6-2", label: "6.2 Сценарии" }, { id: "section-6-3", label: "6.3 Уверенность" }, { id: "section-6-4", label: "6.4 Рекомендация по продукту" }, { id: "section-6-5", label: "6.5 Прозрачность скоринга" }, { id: "section-6-6", label: "6.6 Будущее предложение и конкуренты" }, ], }, { id: "section-7", label: "7. Концепция" }, ], }, ]; // 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() { // 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 ( ); } // ── Section placeholder ──────────────────────────────────────────────────────── function SectionPlaceholder({ number, title, note, }: { number: string; title: string; note: string; }) { return ( <>
{number}. {title}
{note}
); }