diff --git a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx index f18da37a..8d4cc818 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx @@ -123,24 +123,12 @@ export function AnalysisPageContent({ cad }: Props) {
- {/* Non-destructive entry into the new cockpit interface (Site Finder v2). */} - - Site Finder v2 (beta) → - . - * - * Loading / error states are themed in the scoped v2 module so they never leak - * cockpit styles to the rest of the app. - */ - -import Link from "next/link"; - -import styles from "./v2.module.css"; -import { - useParcelAnalyzeQuery, - useParcelForecastQuery, -} from "@/lib/site-finder-api"; -import type { ParcelAnalysis } from "@/types/site-finder"; -import { V2Cockpit } from "@/components/site-finder/ptica-v2/V2Cockpit"; - -interface Props { - cad: string; -} - -export function V2PageContent({ cad }: Props) { - // Horizon fixed at the default 12 mo — the cockpit is a single analysis view - // (the horizon selector lives on the heavy analysis / Scenarios page). - const { data, isLoading, error } = useParcelAnalyzeQuery(cad, 12); - const { data: forecastData } = useParcelForecastQuery(cad); - const forecastReport = - forecastData?.status === "ready" ? forecastData.report : undefined; - - if (isLoading) { - return ( -
-
-
-
Анализируем участок {cad}…
-
-
-
- ); - } - - if (error || !data) { - const msg = - error instanceof Error ? error.message : "Ошибка загрузки анализа"; - return ( -
-
-
-
-
{msg}
- - ← Вернуться к Site Finder - -
-
-
-
- ); - } - - const analysis = data as unknown as ParcelAnalysis; - - return ; -} diff --git a/frontend/src/app/site-finder/analysis/[cad]/v2/page.tsx b/frontend/src/app/site-finder/analysis/[cad]/v2/page.tsx deleted file mode 100644 index 7c046a87..00000000 --- a/frontend/src/app/site-finder/analysis/[cad]/v2/page.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Suspense } from "react"; -import type { Metadata } from "next"; - -import { V2PageContent } from "./V2PageContent"; - -// ── Metadata ────────────────────────────────────────────────────────────────── - -interface PageProps { - params: Promise<{ cad: string }>; -} - -// Next.js delivers `cad` URL-encoded (":" → "%3A"); decode once at the page -// boundary so downstream hooks/components see the canonical cad and re-encode -// exactly once when building API URLs (mirrors the sibling analysis page.tsx). -export async function generateMetadata({ - params, -}: PageProps): Promise { - const { cad: cadRaw } = await params; - const cad = decodeURIComponent(cadRaw); - return { - title: `ПТИЦА v2 · ${cad}`, - description: `Кокпит анализа участка ${cad} — Site Finder v2`, - }; -} - -// ── Page ────────────────────────────────────────────────────────────────────── - -export default async function SiteFinderV2Page({ params }: PageProps) { - const { cad: cadRaw } = await params; - const cad = decodeURIComponent(cadRaw); - - return ( - - Загрузка кокпита… -
- } - > - - - ); -} diff --git a/frontend/src/app/site-finder/analysis/[cad]/v2/v2.module.css b/frontend/src/app/site-finder/analysis/[cad]/v2/v2.module.css deleted file mode 100644 index 02bff111..00000000 --- a/frontend/src/app/site-finder/analysis/[cad]/v2/v2.module.css +++ /dev/null @@ -1,1992 +0,0 @@ -/* - * Site Finder v2 — «ПТИЦА» cockpit, ported 1:1 from the approved static - * prototype (ptica-v2/index.html). EVERY token and rule is scoped under - * `.v2Root[data-theme="dark"]` / `[data-theme="light"]` so the cockpit theme - * NEVER leaks into the rest of the (light) application — the wrapper carries the - * class AND the data-theme attribute. - * - * Fonts come from the globally-wired next/font CSS vars (--font-inter / - * --font-plex-mono — see app/layout.tsx); no new font deps. - */ - -/* ===================== SCOPED TOKENS ===================== */ -.v2Root { - --font-ui: - var(--font-inter), "Inter", "Manrope", -apple-system, "Segoe UI", system-ui, - sans-serif; - --font-mono: - var(--font-plex-mono), "IBM Plex Mono", "Roboto Mono", ui-monospace, - monospace; - - --radius-xs: 4px; - --radius-sm: 8px; - --radius-md: 12px; - --radius-lg: 16px; - --line: 1px; - --line-strong: 1.5px; - - --accent-blue: #2f6f9f; - --accent-cyan: #7fd0ee; - --accent-cyan-strong: #8ed3ff; - --accent-green: #38c172; - --accent-green-soft: #2faa63; - --accent-yellow: #e0a93b; - --accent-red: #d2655b; - --accent-purple: #9b80d6; - --accent-orange: #e46c35; - - --res-electric: #e6b23c; - --res-water: #4d9bd7; - --res-sewer: #9b80d6; - --res-heat: #e46c35; - --res-gas: #d2655b; - --res-poi: #3fa77d; - - --gauge-track: 9px; -} - -.v2Root[data-theme="dark"] { - --bg: #060f16; - --bg-2: #08131c; - --grid-line: rgba(120, 165, 190, 0.06); - --surface: rgba(11, 26, 36, 0.72); - --surface-2: rgba(9, 22, 31, 0.62); - --surface-strong: rgba(12, 30, 42, 0.94); - --surface-muted: rgba(26, 52, 67, 0.55); - --surface-inset: rgba(4, 12, 18, 0.6); - --text: #dcebf2; - --text-strong: #f2fafe; - --text-muted: #8ba6b3; - --text-soft: #5f7886; - --border: rgba(150, 192, 214, 0.18); - --border-strong: rgba(150, 192, 214, 0.4); - --border-faint: rgba(150, 192, 214, 0.1); - --map-bg: #0a1820; - --glow: rgba(110, 200, 240, 0.28); - --glow-strong: rgba(110, 200, 240, 0.55); - --shadow: 0 18px 70px rgba(0, 0, 0, 0.5); - --verdict: var(--accent-green); - --gauge-good: var(--accent-green); - --chip-bg: rgba(20, 42, 56, 0.6); - --topbar-bg: rgba(7, 16, 23, 0.9); -} - -.v2Root[data-theme="light"] { - --bg: #eaeef2; - --bg-2: #e4e9ee; - --grid-line: rgba(70, 110, 135, 0.07); - --surface: #ffffff; - --surface-2: rgba(255, 255, 255, 0.85); - --surface-strong: #ffffff; - --surface-muted: rgba(222, 231, 238, 0.7); - --surface-inset: rgba(232, 238, 243, 0.8); - --text: #102132; - --text-strong: #06141f; - --text-muted: #51616f; - --text-soft: #768493; - --border: rgba(40, 70, 95, 0.2); - --border-strong: rgba(40, 70, 95, 0.36); - --border-faint: rgba(40, 70, 95, 0.1); - --map-bg: #d9e4ea; - --glow: rgba(70, 140, 180, 0.2); - --glow-strong: rgba(70, 140, 180, 0.4); - --shadow: - 0 1px 2px rgba(16, 24, 40, 0.06), 0 10px 28px rgba(40, 68, 92, 0.09); - --verdict: #1f9d57; - --gauge-good: #1f9d57; - --chip-bg: rgba(225, 234, 241, 0.85); - --topbar-bg: rgba(255, 255, 255, 0.92); -} - -/* ===================== ROOT / BASE ===================== */ -.v2Root { - position: relative; - width: 100%; - min-height: 100vh; - font-family: var(--font-ui); - background: var(--bg); - color: var(--text); - -webkit-font-smoothing: antialiased; - font-size: 12px; - line-height: 1.3; - font-variant-numeric: tabular-nums; -} -.v2Root :where(button) { - font: inherit; - cursor: pointer; - color: inherit; - border: none; - background: none; -} -.v2Root svg { - display: block; -} -.mono { - font-family: var(--font-mono); -} -.muted { - color: var(--text-muted); -} -.soft { - color: var(--text-soft); -} -.dash { - color: var(--text-soft); - font-family: var(--font-mono); -} - -.app { - width: 100%; - height: 100vh; - display: grid; - grid-template-rows: 56px 1fr 26px; - background: - linear-gradient(var(--grid-line) 1px, transparent 1px) 0 0 / 34px 34px, - linear-gradient(90deg, var(--grid-line) 1px, transparent 1px) 0 0 / 34px 34px, - radial-gradient(1200px 700px at 30% -10%, var(--bg-2), var(--bg)); - position: relative; - overflow: hidden; -} - -.lbl { - font-family: var(--font-mono); - font-size: 9px; - letter-spacing: 0.13em; - text-transform: uppercase; - color: var(--text-muted); - font-weight: 500; -} -.sectionTitle { - font-family: var(--font-mono); - font-size: 10px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--text-strong); - font-weight: 600; -} -.sectionSub { - font-family: var(--font-mono); - font-size: 9px; - letter-spacing: 0.13em; - text-transform: uppercase; - color: var(--text-soft); - font-weight: 500; -} - -/* ===================== TOPBAR ===================== */ -.topbar { - grid-row: 1; - display: grid; - grid-template-columns: 300px 1fr 300px; - align-items: center; - border-bottom: 1px solid var(--border); - background: var(--topbar-bg); - padding: 0 18px; - backdrop-filter: blur(6px); - z-index: 10; -} -.brand { - display: flex; - align-items: center; - gap: 11px; - text-decoration: none; - color: inherit; -} -.brandLogo { - width: 30px; - height: 30px; - color: var(--accent-cyan); - flex: 0 0 auto; -} -.brandLogo svg { - width: 100%; - height: 100%; - filter: drop-shadow(0 0 6px var(--glow)); -} -.brandName { - font-family: var(--font-mono); - font-weight: 600; - font-size: 18px; - letter-spacing: 0.34em; - color: var(--text-strong); -} -.brandSub { - font-size: 7.5px; - letter-spacing: 0.05em; - color: var(--text-soft); - text-transform: uppercase; - margin-top: 2px; - line-height: 1.25; - max-width: 200px; -} -.tabs { - display: flex; - justify-content: center; - align-items: center; - gap: 30px; - height: 100%; -} -.tab { - font-family: var(--font-mono); - font-size: 11px; - letter-spacing: 0.14em; - text-transform: uppercase; - color: var(--text-soft); - font-weight: 500; - position: relative; - height: 100%; - display: flex; - align-items: center; -} -.tabActive { - color: var(--text-strong); -} -.tabActive::after { - content: ""; - position: absolute; - left: 0; - right: 0; - bottom: 0; - height: 2px; - background: var(--accent-cyan); - box-shadow: 0 0 8px var(--glow-strong); -} -.topbarRight { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 14px; -} -.clock { - text-align: right; -} -.clock .time { - font-family: var(--font-mono); - font-size: 15px; - font-weight: 500; - color: var(--text-strong); - letter-spacing: 0.04em; -} -.clock .date { - font-family: var(--font-mono); - font-size: 8.5px; - color: var(--text-soft); - letter-spacing: 0.06em; - margin-top: 1px; -} -.ver { - font-family: var(--font-mono); - font-size: 8.5px; - letter-spacing: 0.08em; - color: var(--accent-cyan); - padding: 3px 7px; - border: 1px solid var(--border); - border-radius: var(--radius-xs); - background: var(--surface-2); -} -.tbIcons { - display: flex; - gap: 6px; -} -.tbIc { - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; - border: 1px solid var(--border); - border-radius: var(--radius-xs); - color: var(--text-muted); - background: var(--surface-2); -} -.tbIc svg { - width: 13px; - height: 13px; -} - -/* ===================== BODY GRID ===================== */ -.body { - grid-row: 2; - display: grid; - grid-template-columns: 76px 1fr; - min-height: 0; -} - -/* rail */ -.rail { - display: flex; - flex-direction: column; - align-items: center; - border-right: 1px solid var(--border); - background: var(--surface-2); - padding: 10px 0; - gap: 2px; -} -.railItem { - width: 100%; - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - padding: 9px 0; - color: var(--text-soft); - position: relative; -} -.railItem svg { - width: 19px; - height: 19px; -} -.railItem .rlbl { - font-family: var(--font-mono); - font-size: 7px; - letter-spacing: 0.06em; - text-transform: uppercase; -} -.railItemActive { - color: var(--accent-cyan); -} -.railItemActive::before { - content: ""; - position: absolute; - left: 0; - top: 6px; - bottom: 6px; - width: 2px; - background: var(--accent-cyan); - box-shadow: 0 0 8px var(--glow-strong); -} -.railSpacer { - flex: 1; -} - -/* content area */ -.content { - display: grid; - min-width: 0; - min-height: 0; - gap: 8px; - padding: 8px; - grid-template-rows: - minmax(0, 2.18fr) minmax(0, 1.18fr) minmax(0, 1.3fr) - minmax(0, 1fr); - overflow-y: auto; -} - -/* card primitive */ -.card { - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - display: flex; - flex-direction: column; - min-width: 0; - min-height: 0; - position: relative; - overflow: hidden; -} -.cardH { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 11px 7px; - border-bottom: 1px solid var(--border-faint); - flex: 0 0 auto; -} -.cardH .ttl { - display: flex; - align-items: baseline; - gap: 8px; - min-width: 0; -} -.cardB { - padding: 9px 11px; - flex: 1; - min-height: 0; -} -.more { - font-family: var(--font-mono); - font-size: 8.5px; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--accent-cyan); - white-space: nowrap; - display: flex; - align-items: center; - gap: 3px; -} -.moreRow { - text-align: right; - margin-top: 6px; -} -.moreRowAuto { - text-align: right; - margin-top: auto; -} - -/* bracket corners (light only). bc1 = bottom-left, bc2 = bottom-right; the two - top corners are the ::before / ::after of the card itself. */ -.brk::before, -.brk::after, -.bc1, -.bc2 { - content: ""; - position: absolute; - width: 15px; - height: 15px; - pointer-events: none; - z-index: 3; -} -.v2Root[data-theme="light"] .brk::before { - top: -1px; - left: -1px; - border-top: 2px solid var(--border-strong); - border-left: 2px solid var(--border-strong); -} -.v2Root[data-theme="light"] .brk::after { - top: -1px; - right: -1px; - border-top: 2px solid var(--border-strong); - border-right: 2px solid var(--border-strong); -} -.v2Root[data-theme="light"] .brk .bc1 { - bottom: -1px; - left: -1px; - border-bottom: 2px solid var(--border-strong); - border-left: 2px solid var(--border-strong); -} -.v2Root[data-theme="light"] .brk .bc2 { - bottom: -1px; - right: -1px; - border-bottom: 2px solid var(--border-strong); - border-right: 2px solid var(--border-strong); -} - -/* rows */ -.row1 { - display: grid; - grid-template-columns: 1.74fr 1.06fr 0.86fr; - gap: 8px; - min-height: 0; -} -.row2 { - display: grid; - grid-template-columns: repeat(6, 1fr); - gap: 8px; - min-height: 0; -} -.row3 { - display: grid; - grid-template-columns: 1fr 1fr 1.25fr 1fr; - gap: 8px; - min-height: 0; -} -.row4 { - display: grid; - grid-template-columns: 1.55fr 1.05fr 1.1fr; - gap: 8px; - min-height: 0; -} - -/* ===================== MAP ===================== */ -.mapCard { - padding: 0; -} -/* The Leaflet map fills the card; PticaMapInner brings its own internal - overlay/controls (reused plumbing). */ -.mapMount { - position: absolute; - inset: 0; - border-radius: var(--radius-sm); - overflow: hidden; -} -.mapLoading { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - background: var(--map-bg); - color: var(--text-soft); - font-family: var(--font-mono); - font-size: 10px; - letter-spacing: 0.1em; - text-transform: uppercase; -} -.mapHead { - position: absolute; - top: 9px; - left: 11px; - z-index: 5; - display: flex; - align-items: baseline; - gap: 8px; - pointer-events: none; -} -.mapHead .ttl { - font-family: var(--font-mono); - font-size: 10px; - letter-spacing: 0.14em; - text-transform: uppercase; - color: var(--text-strong); - font-weight: 600; - text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6); -} - -/* ===================== PASSPORT ===================== */ -.passport .cardB { - display: flex; - flex-direction: column; - gap: 0; - padding: 0; -} -.ppId { - display: flex; - align-items: center; - gap: 8px; - padding: 9px 11px; - border-bottom: 1px solid var(--border-faint); -} -.ppId .v { - font-family: var(--font-mono); - font-size: 14px; - color: var(--text-strong); - letter-spacing: 0.02em; -} -.ppId .cp { - width: 14px; - height: 14px; - color: var(--text-soft); - margin-left: auto; -} -.ppGrid { - display: grid; - grid-template-columns: 1fr 1fr; -} -.ppCell { - padding: 9px 11px; - border-bottom: 1px solid var(--border-faint); -} -.ppCell:nth-child(odd) { - border-right: 1px solid var(--border-faint); -} -.ppCell .k { - font-family: var(--font-mono); - font-size: 8px; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--text-soft); - margin-bottom: 4px; -} -.ppCell .vv { - font-size: 12px; - color: var(--text); - font-weight: 500; - line-height: 1.3; -} -.ppCell .vv.big { - font-family: var(--font-mono); - font-size: 13px; -} -.ppCellWide { - grid-column: span 2; - border-right: none; -} -.statusOn { - color: var(--accent-green); - font-family: var(--font-mono); - letter-spacing: 0.06em; - text-transform: uppercase; - font-size: 12px; -} - -/* ===================== RIGHT COLUMN row1 ===================== */ -.colStack { - display: grid; - grid-template-rows: 1.35fr 1fr; - gap: 8px; - min-height: 0; -} -.gaugeCard .cardB { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 6px; -} -.gaugeWrap { - position: relative; - width: 118px; - height: 118px; -} -.gaugeWrap svg { - width: 100%; - height: 100%; - transform: rotate(-90deg); -} -.gaugeCenter { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; -} -.gaugeCenter .pct { - font-family: var(--font-mono); - font-size: 30px; - font-weight: 600; - line-height: 1; -} -.gaugeCenter .pct.mutedG { - color: var(--text-soft); -} -.gaugeCenter .cap { - font-family: var(--font-mono); - font-size: 9px; - letter-spacing: 0.1em; - text-transform: uppercase; - margin-top: 3px; - text-align: center; -} -.gaugeFoot { - font-family: var(--font-mono); - font-size: 8px; - color: var(--text-soft); - letter-spacing: 0.06em; -} - -.invest .cardB { - display: flex; - flex-direction: column; - gap: 8px; -} -.invest .scoreRow { - display: flex; - align-items: center; - justify-content: space-between; -} -.invest .score { - font-family: var(--font-mono); - font-size: 26px; - font-weight: 600; - color: var(--text-strong); -} -.invest .score .u { - font-size: 13px; - color: var(--text-soft); -} -.invest .score.dashed { - color: var(--text-soft); -} -.invest .spark { - height: 26px; - color: var(--accent-cyan); - opacity: 0.8; -} -.invest .spark svg { - height: 100%; - width: auto; -} -.invest .prRow { - display: flex; - gap: 10px; -} -.invest .pr { - flex: 1; -} -.invest .pr .k { - font-family: var(--font-mono); - font-size: 8px; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--text-soft); - margin-bottom: 3px; -} -.invest .pr .vv { - font-size: 13px; - font-weight: 600; - color: var(--accent-green); -} -.invest .pr .vv.amb { - color: var(--accent-yellow); -} -.invest .pr .vv.none { - color: var(--text-soft); - font-family: var(--font-mono); -} - -/* ===================== ROW2 dev scan ===================== */ -.kv { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; - padding: 3.5px 0; -} -.kv .k { - font-family: var(--font-mono); - font-size: 8.5px; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--text-soft); - white-space: nowrap; -} -.kv .v { - font-size: 11px; - color: var(--text); - font-weight: 500; - text-align: right; - font-variant-numeric: tabular-nums; -} -.kv .v.mono { - font-family: var(--font-mono); -} -.kv .v.dash { - color: var(--text-soft); - font-family: var(--font-mono); -} -.engRow { - display: flex; - align-items: center; - justify-content: space-between; - gap: 6px; - padding: 3px 0; - font-size: 9.5px; -} -.engRow .nm { - font-family: var(--font-mono); - font-size: 8.5px; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--text); - display: flex; - align-items: center; - gap: 6px; -} -.engRow .nm .sd { - width: 7px; - height: 7px; - border-radius: 50%; - flex: 0 0 auto; - box-shadow: 0 0 5px currentColor; -} -.engRow .st { - display: flex; - align-items: center; - gap: 6px; -} -.engRow .st .stt { - font-size: 9.5px; - font-weight: 500; -} -.engRow .st .dm { - font-family: var(--font-mono); - font-size: 9px; - color: var(--text-soft); - min-width: 46px; - text-align: right; -} -.ok { - color: var(--accent-green); -} -.warnc { - color: var(--accent-orange); -} -.badge { - font-family: var(--font-mono); - font-size: 7.5px; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--accent-yellow); - border: 1px solid var(--accent-yellow); - border-radius: 3px; - padding: 1px 5px; - opacity: 0.85; -} -.badge.soon { - color: var(--accent-cyan); - border-color: var(--accent-cyan); -} -.note { - font-family: var(--font-mono); - font-size: 8px; - letter-spacing: 0.04em; - color: var(--text-soft); - margin-top: 3px; -} -.emptyState { - font-family: var(--font-mono); - font-size: 9px; - letter-spacing: 0.04em; - color: var(--text-soft); - text-align: center; - padding: 10px 0; -} - -/* small risk gauge in row2 */ -.minigauge { - position: relative; - width: 74px; - height: 74px; - margin: 0 auto; -} -.minigauge svg { - width: 100%; - height: 100%; - transform: rotate(-90deg); -} -.minigauge .c { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; -} -.minigauge .c .p { - font-family: var(--font-mono); - font-size: 18px; - font-weight: 600; - color: var(--accent-cyan); -} -.riskCard .cardB { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 4px; -} -.riskCard .rk { - font-family: var(--font-mono); - font-size: 8px; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--text-soft); -} - -/* ===================== ROW3 ===================== */ -.oksPlan { - flex: 1; - display: flex; - gap: 9px; - min-height: 0; -} -.oksMini { - flex: 0 0 96px; - background: var(--surface-inset); - border: 1px solid var(--border-faint); - border-radius: var(--radius-xs); - position: relative; - overflow: hidden; -} -.oksMini svg { - position: absolute; - inset: 0; - width: 100%; - height: 100%; -} -.oksKv { - flex: 1; - display: flex; - flex-direction: column; - justify-content: center; -} -.dpGrid { - display: grid; - grid-template-columns: 1fr 1fr 1fr; - gap: 7px 10px; - flex: 1; - align-content: center; -} -.dpCell .k { - font-family: var(--font-mono); - font-size: 8px; - letter-spacing: 0.07em; - text-transform: uppercase; - color: var(--text-soft); - margin-bottom: 3px; -} -.dpCell .v { - font-family: var(--font-mono); - font-size: 15px; - font-weight: 600; - color: var(--text-strong); - white-space: nowrap; -} -.dpCell .v .u { - font-size: 10px; - color: var(--text-soft); -} -.dpCell .v.dash { - color: var(--text-soft); -} -.dpCellWide { - grid-column: span 3; - border-top: 1px solid var(--border-faint); - padding-top: 6px; - margin-top: 2px; -} - -/* product card */ -.chips { - display: flex; - gap: 5px; - margin-bottom: 9px; - flex-wrap: wrap; -} -.chip { - font-family: var(--font-mono); - font-size: 8.5px; - letter-spacing: 0.04em; - padding: 3px 8px; - border: 1px solid var(--border); - border-radius: var(--radius-xs); - color: var(--text-soft); - background: var(--surface-2); -} -.chipActive { - color: var(--accent-cyan); - border-color: var(--accent-cyan); - background: var(--chip-bg); - box-shadow: 0 0 8px var(--glow); -} -.chipDim { - opacity: 0.45; -} -.aptrow { - display: flex; - align-items: center; - gap: 8px; - padding: 2.5px 0; -} -.aptrow .nm { - font-family: var(--font-mono); - font-size: 8.5px; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--text-soft); - width: 34px; - flex: 0 0 auto; -} -.aptrow .track { - flex: 1; - height: 6px; - background: var(--surface-inset); - border-radius: 3px; - overflow: hidden; -} -.aptrow .track i { - display: block; - height: 100%; - background: var(--accent-cyan); - opacity: 0.85; - border-radius: 3px; -} -.aptrow .pc { - font-family: var(--font-mono); - font-size: 9px; - color: var(--text-soft); - width: 30px; - text-align: right; -} -.qgTitle { - font-family: var(--font-mono); - font-size: 7.5px; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--text-soft); - margin-bottom: 5px; -} -.dimBars .track i { - background: var(--text-soft); - opacity: 0.3; -} - -/* insolation */ -.insoCard .cardB { - display: flex; - align-items: center; - gap: 12px; -} -.insoIso { - flex: 0 0 110px; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - color: var(--text-soft); -} -.insoIso svg { - width: 108px; - height: 96px; -} -.insoTxt { - flex: 1; -} -.insoTxt .d { - font-size: 10px; - color: var(--text-soft); - margin-top: 6px; - line-height: 1.4; - max-width: 150px; -} - -/* ===================== ROW4 ===================== */ -.finCard .cardB { - display: flex; - align-items: stretch; - gap: 14px; -} -.finMain { - display: flex; - gap: 18px; - flex: 1; - align-items: center; - flex-wrap: wrap; -} -.finBig .k { - font-family: var(--font-mono); - font-size: 8px; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--text-soft); - margin-bottom: 4px; -} -.finBig .v { - font-family: var(--font-mono); - font-size: 25px; - font-weight: 600; - color: var(--text-strong); - line-height: 1; -} -.finBig .v.dashed { - color: var(--text-soft); -} -.finBig .u { - font-family: var(--font-mono); - font-size: 9px; - color: var(--text-soft); - margin-top: 3px; - letter-spacing: 0.06em; -} -.finSep { - width: 1px; - background: var(--border-faint); - align-self: stretch; -} -.finRatios { - display: flex; - gap: 16px; - align-items: center; -} -.finRatios .r .k { - font-family: var(--font-mono); - font-size: 8px; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--text-soft); - margin-bottom: 4px; -} -.finRatios .r .v { - font-family: var(--font-mono); - font-size: 18px; - font-weight: 600; - color: var(--text-strong); -} -.finRatios .r .v.dashed { - color: var(--text-soft); -} -.buyGauge { - flex: 0 0 auto; - display: flex; - flex-direction: column; - align-items: center; - gap: 3px; - border-left: 1px solid var(--border-faint); - padding-left: 14px; -} - -/* legal */ -.legalGrid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 0 14px; - flex: 1; - align-content: start; -} -.legalRow { - display: flex; - align-items: baseline; - justify-content: space-between; - padding: 4px 0; - border-bottom: 1px solid var(--border-faint); -} -.legalRow .k { - font-family: var(--font-mono); - font-size: 9px; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--text-soft); -} -.legalRow .v { - font-size: 11px; - color: var(--text); - font-weight: 500; -} -.legalRow .v.no { - color: var(--accent-green); -} -.legalRow .v.dash { - color: var(--text-soft); - font-family: var(--font-mono); -} - -/* verdict */ -.verdictCard .cardB { - display: flex; - align-items: center; - gap: 16px; -} -.verdictBig { - flex: 0 0 auto; - display: flex; - flex-direction: column; - justify-content: center; -} -.verdictBig .v { - font-family: var(--font-mono); - font-size: 34px; - font-weight: 700; - letter-spacing: 0.04em; - color: var(--verdict); - line-height: 1; - text-shadow: 0 0 14px var(--glow); -} -.verdictBig .v.warn { - color: var(--accent-yellow); -} -.verdictBig .v.bad { - color: var(--accent-red); -} -.verdictBig .note { - margin-top: 8px; -} -.verdictCheck { - flex: 1; - display: flex; - flex-direction: column; - gap: 0; - min-width: 0; -} -.vchk { - display: flex; - align-items: center; - gap: 8px; - padding: 4px 0; - border-bottom: 1px solid var(--border-faint); - font-size: 10.5px; -} -.vchk svg { - width: 14px; - height: 14px; - flex: 0 0 auto; -} -.vchk.okRow { - color: var(--text); -} -.vchk.okRow svg { - color: var(--accent-green); -} -.vchk.warnRow svg { - color: var(--accent-orange); -} -.vchk.warnRow { - color: var(--text); -} -.vchk.badRow svg { - color: var(--accent-red); -} -.vchk.badRow { - color: var(--text); -} - -/* ===================== FOOTER ===================== */ -.footer { - grid-row: 3; - display: flex; - align-items: center; - justify-content: space-between; - border-top: 1px solid var(--border); - background: var(--topbar-bg); - padding: 0 18px; - font-family: var(--font-mono); - font-size: 8.5px; - letter-spacing: 0.06em; - color: var(--text-soft); - text-transform: uppercase; -} -.footer .left { - display: flex; - gap: 10px; - align-items: center; -} -.footer .left b { - color: var(--text-muted); - font-weight: 600; -} -.footDot { - color: var(--text-soft); - opacity: 0.6; -} -.footer .right { - color: var(--text-soft); -} - -/* ===================== TOAST ===================== */ -.toast { - position: fixed; - left: 50%; - bottom: 64px; - z-index: 90; - transform: translateX(-50%) translateY(14px); - background: var(--surface-strong); - border: 1px solid var(--border-strong); - border-radius: var(--radius-sm); - padding: 11px 18px; - font-family: var(--font-mono); - font-size: 11px; - letter-spacing: 0.04em; - color: var(--text-strong); - box-shadow: var(--shadow); - opacity: 0; - visibility: hidden; - transition: - opacity 0.2s, - transform 0.2s, - visibility 0.2s; -} -.toastShow { - opacity: 1; - visibility: visible; - transform: translateX(-50%) translateY(0); -} - -/* theme toggle */ -.themeToggle { - position: fixed; - right: 14px; - bottom: 38px; - z-index: 50; - width: 38px; - height: 38px; - border-radius: 50%; - background: var(--surface-strong); - border: 1px solid var(--border-strong); - color: var(--accent-cyan); - display: flex; - align-items: center; - justify-content: center; - box-shadow: var(--shadow); - font-size: 16px; -} - -/* ===================== STATE SCREENS ===================== */ -.stateScreen { - grid-row: 2; - display: flex; - align-items: center; - justify-content: center; - padding: 40px; -} -.stateBox { - font-family: var(--font-mono); - font-size: 11px; - letter-spacing: 0.08em; - color: var(--text-muted); - text-transform: uppercase; -} -.stateError { - font-family: var(--font-mono); - font-size: 12px; - color: var(--accent-red); - margin-bottom: 12px; -} -.stateLink { - font-family: var(--font-mono); - font-size: 10px; - letter-spacing: 0.06em; - color: var(--accent-cyan); - text-transform: uppercase; - text-decoration: none; -} - -/* ===================== OVERLAY (shared scrim) ===================== */ -.scrim { - position: fixed; - inset: 0; - z-index: 60; - background: rgba(2, 8, 12, 0.55); - opacity: 0; - visibility: hidden; - transition: - opacity 0.22s ease, - visibility 0.22s ease; - backdrop-filter: blur(1.5px); -} -.v2Root[data-theme="light"] .scrim { - background: rgba(30, 50, 70, 0.32); -} -.scrimOpen { - opacity: 1; - visibility: visible; -} - -/* ===================== DRAWER ===================== */ -.drawer { - position: fixed; - top: 0; - right: 0; - bottom: 0; - z-index: 70; - width: 480px; - max-width: 92vw; - background: var(--surface-strong); - border-left: 1px solid var(--border-strong); - box-shadow: -24px 0 70px rgba(0, 0, 0, 0.42); - transform: translateX(100%); - transition: transform 0.26s cubic-bezier(0.4, 0, 0.2, 1); - display: flex; - flex-direction: column; - background-image: - linear-gradient(var(--grid-line) 1px, transparent 1px), - linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); - background-size: - 34px 34px, - 34px 34px; -} -.v2Root[data-theme="light"] .drawer { - box-shadow: -18px 0 50px rgba(40, 68, 92, 0.18); -} -.drawerOpen { - transform: translateX(0); -} -.drawerH { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 10px; - padding: 16px 18px 13px; - border-bottom: 1px solid var(--border); - flex: 0 0 auto; - background: var(--topbar-bg); -} -.drawerH .dhLeft { - min-width: 0; -} -.drawerH .dhEyebrow { - font-family: var(--font-mono); - font-size: 8.5px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--accent-cyan); - margin-bottom: 5px; -} -.drawerH .dhTitle { - font-family: var(--font-mono); - font-size: 15px; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--text-strong); - font-weight: 600; - line-height: 1.15; -} -.drawerH .dhSum { - font-size: 11px; - color: var(--text-muted); - margin-top: 7px; - line-height: 1.45; - max-width: 380px; -} -.drawerClose { - flex: 0 0 auto; - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - border: 1px solid var(--border); - border-radius: var(--radius-xs); - background: var(--surface-2); - color: var(--text-muted); - font-size: 15px; - line-height: 1; -} -.drawerClose:hover { - color: var(--text-strong); - border-color: var(--border-strong); -} -.drawerB { - flex: 1; - min-height: 0; - overflow-y: auto; - padding: 16px 18px 22px; -} -.drawerB::-webkit-scrollbar { - width: 7px; -} -.drawerB::-webkit-scrollbar-thumb { - background: var(--border-strong); - border-radius: 4px; -} - -/* drawer content primitives */ -.dsec { - margin-bottom: 18px; -} -.dsec:last-child { - margin-bottom: 0; -} -.dsecT { - font-family: var(--font-mono); - font-size: 9px; - letter-spacing: 0.14em; - text-transform: uppercase; - color: var(--text-soft); - margin-bottom: 9px; - display: flex; - align-items: center; - gap: 8px; -} -.dsecT::after { - content: ""; - flex: 1; - height: 1px; - background: var(--border-faint); -} -.drow { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 14px; - padding: 7px 0; - border-bottom: 1px solid var(--border-faint); -} -.drow:last-child { - border-bottom: none; -} -.drow .k { - font-family: var(--font-mono); - font-size: 9.5px; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--text-soft); - flex: 0 0 auto; - max-width: 48%; -} -.drow .v { - font-size: 12px; - color: var(--text); - font-weight: 500; - text-align: right; - font-variant-numeric: tabular-nums; - line-height: 1.35; -} -.drow .v.mono { - font-family: var(--font-mono); -} -.drow .v.dash { - color: var(--text-soft); - font-family: var(--font-mono); -} -.drow .v.ok { - color: var(--verdict); -} -.drow .v.warnc { - color: var(--accent-orange); -} -.dnote { - font-family: var(--font-mono); - font-size: 8.5px; - letter-spacing: 0.04em; - color: var(--text-soft); - margin-top: 10px; - line-height: 1.5; -} -.dsource { - font-family: var(--font-mono); - font-size: 8.5px; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--text-soft); - margin-top: 16px; - padding-top: 12px; - border-top: 1px solid var(--border-faint); - display: flex; - align-items: center; - gap: 7px; -} -.dsource svg { - width: 12px; - height: 12px; - flex: 0 0 auto; - opacity: 0.8; -} -.dbadge { - display: inline-block; - font-family: var(--font-mono); - font-size: 8px; - letter-spacing: 0.08em; - text-transform: uppercase; - padding: 2px 7px; - border-radius: 3px; - border: 1px solid var(--accent-yellow); - color: var(--accent-yellow); -} -.dbadge.cyan { - border-color: var(--accent-cyan); - color: var(--accent-cyan); -} -.dbadge.green { - border-color: var(--verdict); - color: var(--verdict); -} -.dbadgeRight { - margin-left: auto; -} - -/* drawer data table */ -.dtable { - width: 100%; - border-collapse: collapse; - font-variant-numeric: tabular-nums; -} -.dtable th, -.dtable td { - text-align: right; - padding: 6px 6px; - border-bottom: 1px solid var(--border-faint); - white-space: nowrap; -} -.dtable th { - font-family: var(--font-mono); - font-size: 7.5px; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--text-soft); - font-weight: 500; -} -.dtable td { - font-size: 10px; - color: var(--text); -} -.dtable th:first-child, -.dtable td:first-child { - text-align: left; -} -.dtable td.mono { - font-family: var(--font-mono); -} -.dtable tr.me td { - color: var(--accent-cyan); -} -.dtable td.dash { - color: var(--text-soft); - font-family: var(--font-mono); -} - -/* factor bars */ -.dfac { - margin: 9px 0; -} -.dfacH { - display: flex; - justify-content: space-between; - font-family: var(--font-mono); - font-size: 9px; - color: var(--text-soft); - text-transform: uppercase; - letter-spacing: 0.04em; - margin-bottom: 4px; -} -.dfacH b { - color: var(--text); - font-weight: 500; -} -.dfacTrack { - height: 7px; - background: var(--surface-inset); - border-radius: 4px; - overflow: hidden; -} -.dfacTrack i { - display: block; - height: 100%; - border-radius: 4px; -} - -/* drawer export buttons */ -.dexport { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 9px; -} -.dbtn { - display: flex; - align-items: center; - gap: 9px; - padding: 11px 12px; - border: 1px solid var(--border); - border-radius: var(--radius-xs); - background: var(--surface-2); - color: var(--text); - font-family: var(--font-mono); - font-size: 10px; - letter-spacing: 0.04em; - text-transform: uppercase; - text-align: left; -} -.dbtn:hover { - border-color: var(--accent-cyan); - color: var(--accent-cyan); - box-shadow: 0 0 10px var(--glow); -} -.dbtn svg { - width: 15px; - height: 15px; - flex: 0 0 auto; - opacity: 0.85; -} -.dbtn .ds { - font-size: 7.5px; - color: var(--text-soft); - text-transform: none; - letter-spacing: 0; -} -.dbtn b { - font-weight: 600; - display: block; -} - -/* ===================== CHAT ===================== */ -.chatFab { - position: fixed; - right: 14px; - bottom: 84px; - z-index: 55; - width: 46px; - height: 46px; - border-radius: 50%; - background: var(--accent-blue); - border: 1px solid var(--accent-cyan); - color: #fff; - display: flex; - align-items: center; - justify-content: center; - box-shadow: - 0 8px 28px rgba(0, 0, 0, 0.4), - 0 0 14px var(--glow-strong); -} -.chatFab svg { - width: 21px; - height: 21px; -} -.chatPanel { - position: fixed; - right: 0; - top: 0; - bottom: 0; - z-index: 75; - width: 380px; - max-width: 90vw; - background: var(--surface-strong); - border-left: 1px solid var(--border-strong); - box-shadow: -24px 0 70px rgba(0, 0, 0, 0.42); - transform: translateX(110%); - transition: transform 0.26s cubic-bezier(0.4, 0, 0.2, 1); - display: flex; - flex-direction: column; -} -.v2Root[data-theme="light"] .chatPanel { - box-shadow: -18px 0 50px rgba(40, 68, 92, 0.18); -} -.chatPanelOpen { - transform: translateX(0); -} -.chatH { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - padding: 14px 16px 12px; - border-bottom: 1px solid var(--border); - background: var(--topbar-bg); - flex: 0 0 auto; -} -.chatH .chL { - display: flex; - align-items: center; - gap: 9px; - min-width: 0; -} -.chatH .chIc { - width: 26px; - height: 26px; - border-radius: 50%; - background: var(--accent-blue); - display: flex; - align-items: center; - justify-content: center; - color: #fff; - flex: 0 0 auto; -} -.chatH .chIc svg { - width: 14px; - height: 14px; -} -.chatH .chT { - font-family: var(--font-mono); - font-size: 11px; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--text-strong); - font-weight: 600; -} -.chatH .chS { - font-family: var(--font-mono); - font-size: 8px; - letter-spacing: 0.06em; - color: var(--text-soft); - margin-top: 2px; -} -.chatMsgs { - flex: 1; - min-height: 0; - overflow-y: auto; - padding: 16px; - display: flex; - flex-direction: column; - gap: 12px; -} -.chatMsgs::-webkit-scrollbar { - width: 7px; -} -.chatMsgs::-webkit-scrollbar-thumb { - background: var(--border-strong); - border-radius: 4px; -} -.msg { - max-width: 84%; - font-size: 12px; - line-height: 1.5; - padding: 10px 12px; - border-radius: 12px; - white-space: pre-wrap; -} -.msgBot { - align-self: flex-start; - background: var(--surface-muted); - border: 1px solid var(--border-faint); - color: var(--text); - border-top-left-radius: 3px; -} -.msgUser { - align-self: flex-end; - background: var(--accent-blue); - color: #fff; - border-top-right-radius: 3px; -} -.chatChips { - display: flex; - flex-wrap: wrap; - gap: 7px; - padding: 0 16px 12px; - flex: 0 0 auto; -} -.chatChip { - font-family: var(--font-mono); - font-size: 9.5px; - letter-spacing: 0.02em; - padding: 6px 11px; - border: 1px solid var(--border); - border-radius: 14px; - color: var(--text-muted); - background: var(--surface-2); -} -.chatChip:hover { - border-color: var(--accent-cyan); - color: var(--accent-cyan); -} -.chatInput { - display: flex; - gap: 8px; - padding: 12px 14px; - border-top: 1px solid var(--border); - background: var(--topbar-bg); - flex: 0 0 auto; -} -.chatInput input { - flex: 1; - background: var(--surface-inset); - border: 1px solid var(--border); - border-radius: var(--radius-xs); - color: var(--text); - font-family: var(--font-ui); - font-size: 12px; - padding: 9px 11px; - outline: none; -} -.chatInput input:focus { - border-color: var(--accent-cyan); -} -.chatInput input::placeholder { - color: var(--text-soft); -} -.chatSend { - flex: 0 0 auto; - width: 38px; - border: 1px solid var(--accent-cyan); - border-radius: var(--radius-xs); - background: var(--accent-blue); - color: #fff; - display: flex; - align-items: center; - justify-content: center; -} -.chatSend svg { - width: 16px; - height: 16px; -} -.chatTyping { - align-self: flex-start; - display: flex; - gap: 4px; - padding: 11px 13px; - background: var(--surface-muted); - border: 1px solid var(--border-faint); - border-radius: 12px; - border-top-left-radius: 3px; -} -.chatTyping i { - width: 5px; - height: 5px; - border-radius: 50%; - background: var(--text-soft); - animation: tdot 1s infinite; -} -.chatTyping i:nth-child(2) { - animation-delay: 0.2s; -} -.chatTyping i:nth-child(3) { - animation-delay: 0.4s; -} -@keyframes tdot { - 0%, - 60%, - 100% { - opacity: 0.3; - transform: translateY(0); - } - 30% { - opacity: 1; - transform: translateY(-3px); - } -} - -/* ===================================================================== - * MAP — per-theme title + resource legend overlay - * (matches prototype `ptica-v2/index.html` `.map-head` + `.legend .lg-res`) - * ===================================================================== */ - -/* Per-theme map title — both spans rendered, CSS shows one. Mirrors the - prototype `.tnum-d` / `.tnum-l` toggle. */ -.mapTitleDark, -.mapTitleLight { - display: none; -} -.v2Root[data-theme="dark"] .mapTitleDark { - display: inline; -} -.v2Root[data-theme="light"] .mapTitleLight { - display: inline; -} - -/* ===================================================================== - * LIGHT THEME — targeted contrast polish - * Tokens stay; just bump the value-side text and the cyan accents that - * appear washed-out on white. Dark theme is untouched. - * ===================================================================== */ - -/* kv-row: value text on white needs darker ink. */ -.v2Root[data-theme="light"] .kv .v { - color: var(--text-strong); -} - -/* Инженерия status-row: distance was --text-soft (#768493 ≈ AA-borderline - at 12px on white); push to --text-muted (#51616f) for solid AA. */ -.v2Root[data-theme="light"] .engRow .st .dm { - color: var(--text-muted); -} -/* Status-dot glow on white reads as a halo — soften it. */ -.v2Root[data-theme="light"] .engRow .nm .sd { - box-shadow: none; -} - -/* Map-legend value column — same readability bump on white. */ -.v2Root[data-theme="light"] .v2Root[data-theme="light"] .v2Root[data-theme="light"] /* Map title (light): drop the dark text-shadow that makes thin uppercase - look smudgy on a satellite-light map base. */ -.v2Root[data-theme="light"] .mapHead .ttl { - text-shadow: 0 1px 2px rgba(255, 255, 255, 0.6); - color: var(--text-strong); -} - -/* Cards `Подробнее →` — cyan on white is faint. Swap to the deeper blue. */ -.v2Root[data-theme="light"] .more { - color: var(--accent-blue); -} - -/* Drawer eyebrow (cyan in dark) — use blue on white for AA. */ -.v2Root[data-theme="light"] .drawerH .dhEyebrow { - color: var(--accent-blue); -} - -/* Topbar version-chip: cyan text on a white pill is illegible. */ -.v2Root[data-theme="light"] .ver { - color: var(--accent-blue); - border-color: var(--border-strong); - background: var(--surface); -} -/* Topbar clock — separator-like vertical line between time + date is faint; - strengthen the date line and the chip border for visible structure. */ -.v2Root[data-theme="light"] .clock .date { - color: var(--text-muted); -} - -/* Active tab underline — cyan + glow on white reads as a pale smudge. - Swap to brand blue + drop the glow halo. */ -.v2Root[data-theme="light"] .tabActive::after { - background: var(--accent-blue); - box-shadow: none; -} -/* Rail active marker — same treatment. */ -.v2Root[data-theme="light"] .railItemActive { - color: var(--accent-blue); -} -.v2Root[data-theme="light"] .railItemActive::before { - background: var(--accent-blue); - box-shadow: none; -} - -/* Generic note / drawer note / source — bump from --text-soft to --text-muted - on white so the caveat copy stays readable but still secondary. */ -.v2Root[data-theme="light"] .note, -.v2Root[data-theme="light"] .dnote, -.v2Root[data-theme="light"] .dsource { - color: var(--text-muted); -} - -/* Section separator line inside drawer (`::after` on .dsecT) — barely visible - on white at 10% alpha; strengthen the border-faint reach within drawer. */ -.v2Root[data-theme="light"] .dsecT::after, -.v2Root[data-theme="light"] .drow { - border-color: rgba(40, 70, 95, 0.16); -} diff --git a/frontend/src/components/site-finder/ptica-v2/V2BottomGrid.tsx b/frontend/src/components/site-finder/ptica-v2/V2BottomGrid.tsx deleted file mode 100644 index 71694f53..00000000 --- a/frontend/src/components/site-finder/ptica-v2/V2BottomGrid.tsx +++ /dev/null @@ -1,201 +0,0 @@ -"use client"; - -/** - * V2BottomGrid — row 4 (prototype `.row4`): Investment Clearance (финмодель) · - * Legal Status · Site Verdict. Финмодель bignums are honest «—» (после финмодели - * §23); the buy-signal gauge reads the §22 deficit_index when ready. Legal Status - * surfaces REAL ЗОУИТ / красные линии / gate, «—» for the rest. Site Verdict is - * the JTBD plaque + checklist derived from the gate verdict + real signals. - */ - -import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css"; -import type { ParcelAnalysis } from "@/types/site-finder"; -import type { ForecastReport } from "@/types/forecast"; -import { - adaptInvestmentClearance, - adaptBuySignalGauge, - adaptLegalStatus, - adaptSiteVerdict, -} from "@/components/site-finder/ptica/ptica-adapt"; -import type { - PticaSiteVerdict, - PticaVerdictCheckItem, -} from "@/components/site-finder/ptica/ptica-adapt"; -import { V2Gauge } from "@/components/site-finder/ptica-v2/V2Gauge"; -import { V2CheckIcon, V2WarnIcon } from "@/components/site-finder/ptica-v2/V2Icons"; -import type { V2DrawerKey } from "@/components/site-finder/ptica-v2/v2-drawer-registry"; - -const VERDICT_CLASS: Record = { - good: "", - warn: styles.warn, - bad: styles.bad, -}; - -function CheckRow({ item }: { item: PticaVerdictCheckItem }) { - const cls = - item.tone === "ok" - ? styles.okRow - : item.tone === "warn" - ? styles.warnRow - : styles.badRow; - return ( -
- {item.tone === "ok" ? : } - {item.text} -
- ); -} - -interface Props { - analysis: ParcelAnalysis; - forecastReport?: ForecastReport; - onOpenDrawer: (key: V2DrawerKey) => void; -} - -export function V2BottomGrid({ analysis, forecastReport, onOpenDrawer }: Props) { - const clearance = adaptInvestmentClearance(); - const buy = adaptBuySignalGauge(forecastReport); - const legal = adaptLegalStatus(analysis); - const verdict = adaptSiteVerdict(analysis, forecastReport); - - const buyColor = - buy.gauge.value == null - ? "var(--text-soft)" - : buy.gauge.tone === "good" - ? "var(--accent-green)" - : buy.gauge.tone === "bad" - ? "var(--accent-red)" - : "var(--accent-yellow)"; - - return ( -
- {/* Investment Clearance */} -
- - -
-
- Investment Clearance - финансовая модель -
- -
-
-
- {clearance.bignums - .filter((b) => b.unit) - .map((b) => ( -
-
{b.label}
-
- {b.value} -
-
{b.unit}
-
- ))} -
-
- {clearance.bignums - .filter((b) => !b.unit) - .map((b) => ( -
-
{b.label}
-
- {b.value} -
-
- ))} -
-
-
-
- Buy signal -
-
- -
- - {buy.gauge.value == null ? "—" : `${buy.gauge.value}%`} - -
-
-
- {buy.word} -
-
-
-
- - {/* Legal Status */} -
-
-
- Legal Status -
-
-
-
- {legal.rows.map((r) => ( -
- {r.k} - - {r.field.value} - -
- ))} -
-
- -
-
-
- - {/* Site Verdict */} -
- - -
-
- Site Verdict -
-
-
-
-
- {verdict.word} -
-
-
- {verdict.checklist.map((item, i) => ( - - ))} -
-
-
-
- ); -} diff --git a/frontend/src/components/site-finder/ptica-v2/V2Chat.tsx b/frontend/src/components/site-finder/ptica-v2/V2Chat.tsx deleted file mode 100644 index e804fb87..00000000 --- a/frontend/src/components/site-finder/ptica-v2/V2Chat.tsx +++ /dev/null @@ -1,224 +0,0 @@ -"use client"; - -/** - * V2Chat — floating chat panel (prototype `.chat-panel`). Grounded, local - * keyword answerer over the SAME ptica-adapt view-models the cockpit renders, so - * the answers never invent numbers: площадь/регламент/рынок/инженерия/риски are - * read straight from the adapters (REAL or honest «—»). No HTTP — this mirrors - * the prototype's grounded chat; the full LLM chat lives on the heavy analysis - * page. - */ - -import { useMemo, useState } from "react"; - -import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css"; -import type { ParcelAnalysis } from "@/types/site-finder"; -import type { ForecastReport } from "@/types/forecast"; -import { - adaptPassport, - adaptUrbanCard, - adaptMarketCard, - adaptEngineeringCard, - adaptRiskGauge, - adaptBuildabilityGauge, - adaptInvestScore, -} from "@/components/site-finder/ptica/ptica-adapt"; - -interface ChatMsg { - text: string; - who: "bot" | "user"; -} - -const CHIPS = [ - "Какая площадь?", - "Можно ли строить?", - "Какая цена в районе?", - "Какие риски?", -]; - -function joinReal(label: string, value: string, isReal: boolean): string { - return isReal ? `${label}: ${value}.` : `${label}: данных пока нет (—).`; -} - -interface Props { - analysis: ParcelAnalysis; - forecastReport?: ForecastReport; - open: boolean; - onClose: () => void; -} - -export function V2Chat({ analysis, forecastReport, open, onClose }: Props) { - const passport = useMemo(() => adaptPassport(analysis), [analysis]); - const urban = useMemo(() => adaptUrbanCard(analysis), [analysis]); - const market = useMemo(() => adaptMarketCard(analysis), [analysis]); - const engineering = useMemo(() => adaptEngineeringCard(analysis), [analysis]); - const risk = useMemo(() => adaptRiskGauge(analysis), [analysis]); - const build = useMemo(() => adaptBuildabilityGauge(analysis), [analysis]); - const invest = useMemo( - () => adaptInvestScore(analysis, forecastReport), - [analysis, forecastReport], - ); - - const answer = useMemo(() => { - return (q: string): string => { - const t = q.toLowerCase(); - const has = (...ws: string[]) => ws.some((w) => t.includes(w)); - - if (has("площад", "гектар", "га")) { - return joinReal("Площадь участка", passport.area.value, passport.area.isReal); - } - if (has("строит", "застрой", "ксит", "высот", "плотн", "регламент", "зона")) { - const zone = urban.rows.find((r) => r.key === "Зона")?.field; - const far = urban.rows.find((r) => r.key === "Плотность (КСИТ)")?.field; - const height = urban.rows.find((r) => r.key === "Пред. высота")?.field; - const buildPct = build.value == null ? "—" : `${build.value}%`; - return ( - `${joinReal("Зона", zone?.value ?? "—", zone?.isReal ?? false)} ` + - `${joinReal("Плотность (КСИТ)", far?.value ?? "—", far?.isReal ?? false)} ` + - `${joinReal("Предельная высота", height?.value ?? "—", height?.isReal ?? false)} ` + - `Buildability ${buildPct} (${build.footnote ?? "предв."}).` - ); - } - if (has("цен", "медиан", "рынок", "стоим", "спрос")) { - const median = market.rows.find((r) => r.key === "Медиана цены")?.field; - const demand = market.rows.find((r) => r.key === "Уровень спроса")?.field; - const comp = market.rows.find((r) => r.key === "Конкурентов")?.field; - return ( - `${joinReal("Медиана цены в районе", median?.value ?? "—", median?.isReal ?? false)} ` + - `${joinReal("Уровень спроса", demand?.value ?? "—", demand?.isReal ?? false)} ` + - `${joinReal("Конкурентов", comp?.value ?? "—", comp?.isReal ?? false)}` - ); - } - if (has("риск")) { - const pct = risk.value == null ? "—" : `${risk.value}%`; - return `Сводный риск ${pct} — ${risk.label} (${risk.footnote ?? "предв."}).`; - } - if (has("инженер", "сет", "подключ", "тепло", "вод", "электр", "газ", "канализ")) { - if (engineering.rows.length === 0) { - return "Данных по инженерным сетям пока нет (—)."; - } - const parts = engineering.rows.map( - (r) => `${r.key} ${r.field.value}`, - ); - return `Расстояния до точек подключения: ${parts.join(", ")}.`; - } - if (has("инвест", "скор", "оценк", "потенциал")) { - return ( - `${joinReal("Инвест-скор", invest.score.value, invest.score.isReal)} ` + - `${joinReal("Сигнал покупки", invest.buySignal.value, invest.buySignal.isReal)}` - ); - } - return ( - "Я отвечаю по данным анализа участка: площадь, градрегламент, рынок, " + - "инженерия, риски и инвест-скор. Где данных нет — отвечаю честным «—»." - ); - }; - }, [passport, urban, market, engineering, risk, build, invest]); - - const [msgs, setMsgs] = useState([ - { - who: "bot", - text: `Здравствуйте! Я помощник по участку ${analysis.cad_num}. Спросите про площадь, регламент, рынок, инженерию или риски.`, - }, - ]); - const [typing, setTyping] = useState(false); - const [input, setInput] = useState(""); - - function send(q: string) { - const question = q.trim(); - if (!question) return; - setMsgs((m) => [...m, { who: "user", text: question }]); - setInput(""); - setTyping(true); - window.setTimeout(() => { - setTyping(false); - setMsgs((m) => [...m, { who: "bot", text: answer(question) }]); - }, 420); - } - - return ( - - ); -} diff --git a/frontend/src/components/site-finder/ptica-v2/V2Cockpit.tsx b/frontend/src/components/site-finder/ptica-v2/V2Cockpit.tsx deleted file mode 100644 index 819e80fa..00000000 --- a/frontend/src/components/site-finder/ptica-v2/V2Cockpit.tsx +++ /dev/null @@ -1,304 +0,0 @@ -"use client"; - -/** - * V2Cockpit — the composed «ПТИЦА» cockpit (prototype #app). Owns: - * - theme (dark/light) with a floating ☾/☀ toggle + localStorage persistence - * (SSR-guarded), scoped via `data-theme` on `.v2Root`, - * - the open-drawer key (rail + card "ПОДРОБНЕЕ" triggers), deep-linked via - * `?drawer=` so drawers are linkable / back-button friendly, - * - the chat panel open-state + a shared scrim backing both drawer and chat, - * - the 4-row cockpit grid (map+passport+gauges / 6 scan cards / lower / bottom). - * - * All rendered values flow through the ptica-adapt layer — REAL where /analyze - * provides it, honest «—» otherwise. No fabricated numbers. - */ - -import { useCallback, useEffect, useState } from "react"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; - -import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css"; -import type { ParcelAnalysis } from "@/types/site-finder"; -import type { ForecastReport } from "@/types/forecast"; -import { - adaptUrbanCard, - adaptRestrictionsCard, - adaptEngineeringCard, - adaptMarketCard, - adaptEconomyCard, - adaptEnvironmentCard, -} from "@/components/site-finder/ptica/ptica-adapt"; -import { V2Topbar } from "@/components/site-finder/ptica-v2/V2Topbar"; -import { V2Rail } from "@/components/site-finder/ptica-v2/V2Rail"; -import { V2Map } from "@/components/site-finder/ptica-v2/V2Map"; -import { V2Passport } from "@/components/site-finder/ptica-v2/V2Passport"; -import { V2RightColumn } from "@/components/site-finder/ptica-v2/V2RightColumn"; -import { V2ScanCard } from "@/components/site-finder/ptica-v2/V2ScanCard"; -import { V2RiskCard } from "@/components/site-finder/ptica-v2/V2RiskCard"; -import { V2LowerGrid } from "@/components/site-finder/ptica-v2/V2LowerGrid"; -import { V2BottomGrid } from "@/components/site-finder/ptica-v2/V2BottomGrid"; -import { V2Drawer } from "@/components/site-finder/ptica-v2/V2Drawer"; -import { V2Chat } from "@/components/site-finder/ptica-v2/V2Chat"; -import { - asV2DrawerKey, - getV2DrawerEntry, - type V2DrawerKey, -} from "@/components/site-finder/ptica-v2/v2-drawer-registry"; - -type Theme = "dark" | "light"; -const THEME_KEY = "ptica-v2-theme"; - -interface Props { - analysis: ParcelAnalysis; - forecastReport?: ForecastReport; -} - -export function V2Cockpit({ analysis, forecastReport }: Props) { - // ── Theme (scoped, localStorage-persisted, SSR-guarded) ───────────────────── - const [theme, setTheme] = useState("dark"); - useEffect(() => { - if (typeof window === "undefined") return; - try { - const saved = window.localStorage.getItem(THEME_KEY); - if (saved === "light" || saved === "dark") setTheme(saved); - } catch { - // ignore storage errors - } - }, []); - const toggleTheme = useCallback(() => { - setTheme((prev) => { - const next: Theme = prev === "dark" ? "light" : "dark"; - try { - window.localStorage.setItem(THEME_KEY, next); - } catch { - // ignore storage errors - } - return next; - }); - }, []); - - // ── Drawer open-state + deep-link (?drawer=) ─────────────────────────── - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - const [openDrawer, setOpenDrawer] = useState(null); - const [chatOpen, setChatOpen] = useState(false); - const [toast, setToast] = useState(null); - - const drawerParam = searchParams.get("drawer"); - useEffect(() => { - setOpenDrawer(asV2DrawerKey(drawerParam)); - }, [drawerParam]); - - const syncUrl = useCallback( - (key: V2DrawerKey | null) => { - const params = new URLSearchParams(searchParams.toString()); - if (key) params.set("drawer", key); - else params.delete("drawer"); - const qs = params.toString(); - router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); - }, - [router, pathname, searchParams], - ); - - const handleOpenDrawer = useCallback( - (key: V2DrawerKey) => { - setChatOpen(false); - setOpenDrawer(key); - syncUrl(key); - }, - [syncUrl], - ); - const handleCloseDrawer = useCallback(() => { - setOpenDrawer(null); - syncUrl(null); - }, [syncUrl]); - - const openChat = useCallback(() => { - setOpenDrawer(null); - syncUrl(null); - setChatOpen(true); - }, [syncUrl]); - const closeChat = useCallback(() => setChatOpen(false), []); - - // ESC closes drawer + chat - useEffect(() => { - function onKey(e: KeyboardEvent) { - if (e.key === "Escape") { - setOpenDrawer(null); - setChatOpen(false); - syncUrl(null); - } - } - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [syncUrl]); - - // Toast auto-dismiss - useEffect(() => { - if (!toast) return; - const id = window.setTimeout(() => setToast(null), 2400); - return () => window.clearTimeout(id); - }, [toast]); - - const handleExport = useCallback((fmt: string) => { - setToast(`Экспорт «${fmt}» доступен на странице глубокого анализа`); - }, []); - - const drawerEntry = openDrawer - ? getV2DrawerEntry(openDrawer, analysis, forecastReport, handleExport) - : null; - - const scrimOpen = Boolean(openDrawer) || chatOpen; - - // ── Scan-card view-models (ptica-adapt) ───────────────────────────────────── - const urban = adaptUrbanCard(analysis); - const restrictions = adaptRestrictionsCard(analysis); - const engineering = adaptEngineeringCard(analysis); - const market = adaptMarketCard(analysis); - const economy = adaptEconomyCard(); - const environment = adaptEnvironmentCard(analysis); - - return ( -
-
- - -
- - -
- {/* ROW 1 — map · passport · gauges */} -
- - - -
- - {/* ROW 2 — 6 scan cards */} -
- - - - - - -
- - {/* ROW 3 — ОКС · potential · product · insolation */} - - - {/* ROW 4 — clearance · legal · verdict */} - - - {/* Среда · экология — honest atmospheric scan (not in the prototype - 4-row grid but ported from the adapter so the data isn't dropped). */} -
- -
-
-
- -
-
- - МОДЕЛЬ ОЦЕНКИ: ПТИЦА · Site Finder v2 - - · - - УЧАСТОК: {analysis.cad_num} - -
-
- НЕ ЯВЛЯЕТСЯ ПУБЛИЧНОЙ ОФЕРТОЙ · ИНФОРМАЦИЯ НОСИТ ОЦЕНОЧНЫЙ ХАРАКТЕР -
-
-
- - {/* Theme toggle */} - - - {/* Chat FAB */} - - - {/* Shared scrim */} -
{ - handleCloseDrawer(); - closeChat(); - }} - aria-hidden="true" - /> - - {/* Drawer */} - - {drawerEntry?.content} - - - {/* Chat */} - - - {/* Toast */} - {toast && ( -
{toast}
- )} -
- ); -} diff --git a/frontend/src/components/site-finder/ptica-v2/V2Drawer.tsx b/frontend/src/components/site-finder/ptica-v2/V2Drawer.tsx deleted file mode 100644 index 39ef3d5c..00000000 --- a/frontend/src/components/site-finder/ptica-v2/V2Drawer.tsx +++ /dev/null @@ -1,49 +0,0 @@ -"use client"; - -/** - * V2Drawer — right slide-in panel (prototype `.drawer`, 480px). Controlled by - * `open`; renders the eyebrow / title / summary header + scrollable body. The - * shared scrim is owned by V2Cockpit (so it can also back the chat panel). - */ - -import type { ReactNode } from "react"; - -import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css"; - -interface Props { - open: boolean; - eyebrow?: string; - title: string; - sum?: string; - onClose: () => void; - children: ReactNode; -} - -export function V2Drawer({ open, eyebrow, title, sum, onClose, children }: Props) { - return ( - - ); -} diff --git a/frontend/src/components/site-finder/ptica-v2/V2DrawerPrimitives.tsx b/frontend/src/components/site-finder/ptica-v2/V2DrawerPrimitives.tsx deleted file mode 100644 index 6b593bc6..00000000 --- a/frontend/src/components/site-finder/ptica-v2/V2DrawerPrimitives.tsx +++ /dev/null @@ -1,95 +0,0 @@ -"use client"; - -/** - * V2DrawerPrimitives — shared drawer-content building blocks (prototype `.dsec` / - * `.drow` / `.dsource` / `.dfac`). Each consumes the ptica-adapt view-models so - * REAL data renders normally and placeholders render muted with their caption as - * a title tooltip. - */ - -import type { ReactNode } from "react"; - -import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css"; -import type { - DrawerKvRow, - PticaFactorBar, -} from "@/components/site-finder/ptica/ptica-adapt"; -import { V2ShieldIcon } from "@/components/site-finder/ptica-v2/V2Icons"; - -export function DSec({ - title, - badge, - children, -}: { - title: string; - badge?: ReactNode; - children: ReactNode; -}) { - return ( -
-
- {title} - {badge} -
- {children} -
- ); -} - -export function DRow({ row }: { row: DrawerKvRow }) { - return ( -
- {row.k} - - {row.field.value} - -
- ); -} - -export function DRows({ rows }: { rows: DrawerKvRow[] }) { - return ( - <> - {rows.map((r) => ( - - ))} - - ); -} - -const BAR_COLOR: Record = { - good: "var(--accent-green)", - bad: "var(--accent-red)", - neutral: "var(--accent-cyan)", -}; - -export function DFactor({ bar }: { bar: PticaFactorBar }) { - const pct = Math.max(0, Math.min(100, bar.pct)); - return ( -
-
- {bar.name} - {bar.value} -
-
- -
-
- ); -} - -export function DNote({ children }: { children: ReactNode }) { - return
{children}
; -} - -export function DSource({ children }: { children: ReactNode }) { - return ( -
- - {children} -
- ); -} diff --git a/frontend/src/components/site-finder/ptica-v2/V2Gauge.tsx b/frontend/src/components/site-finder/ptica-v2/V2Gauge.tsx deleted file mode 100644 index 84eece96..00000000 --- a/frontend/src/components/site-finder/ptica-v2/V2Gauge.tsx +++ /dev/null @@ -1,72 +0,0 @@ -"use client"; - -/** - * V2Gauge — SVG progress ring (prototype `.gauge-wrap` / `.minigauge`). Renders a - * faint track + a colored arc whose length maps a 0..100 value. `null` value → - * muted "—" center (honest no-data, never a fake percentage). Tone selects the - * arc color via the scoped CSS tokens. - * - * Geometry mirrors the prototype: r=50 on a 120-box (big), r=30 on a 74-box - * (mini), r=26 on a 66-box (buy). dashoffset = circumference × (1 − value/100). - */ - -import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css"; -import type { PticaGauge } from "@/components/site-finder/ptica/ptica-adapt"; - -type Size = "big" | "mini" | "buy"; - -const GEOM: Record = { - big: { box: 120, r: 50, stroke: 9 }, - mini: { box: 74, r: 30, stroke: 7 }, - buy: { box: 66, r: 26, stroke: 7 }, -}; - -// Tone → stroke color (map exception: Leaflet/SVG can't read CSS custom props in -// stroke attr reliably across themes, so we reference the var() here directly). -const TONE_STROKE: Record = { - good: "var(--accent-green)", - warn: "var(--accent-yellow)", - bad: "var(--accent-red)", - neutral: "var(--accent-cyan)", -}; - -interface Props { - gauge: PticaGauge; - size?: Size; -} - -export function V2Gauge({ gauge, size = "big" }: Props) { - const { box, r, stroke } = GEOM[size]; - const c = box / 2; - const circ = 2 * Math.PI * r; - const pct = gauge.value == null ? 0 : Math.max(0, Math.min(100, gauge.value)); - const offset = circ * (1 - pct / 100); - const color = gauge.value == null ? "var(--text-soft)" : TONE_STROKE[gauge.tone]; - - return ( - - ); -} diff --git a/frontend/src/components/site-finder/ptica-v2/V2Icons.tsx b/frontend/src/components/site-finder/ptica-v2/V2Icons.tsx deleted file mode 100644 index dceb7815..00000000 --- a/frontend/src/components/site-finder/ptica-v2/V2Icons.tsx +++ /dev/null @@ -1,53 +0,0 @@ -"use client"; - -/** - * V2Icons — inline SVG glyphs ported 1:1 from the prototype (ptica-v2/index.html). - * Kept in one module so the topbar / rail / drawers / chat share identical marks. - */ - -import type { ReactNode } from "react"; - -export function V2BirdMark(): ReactNode { - return ( - - ); -} - -export function V2CheckIcon(): ReactNode { - return ( - - - - ); -} - -export function V2WarnIcon(): ReactNode { - return ( - - - - - ); -} - -export function V2ShieldIcon(): ReactNode { - return ( - - - - ); -} - -export function V2CopyIcon(): ReactNode { - return ( - - - - - ); -} diff --git a/frontend/src/components/site-finder/ptica-v2/V2LowerGrid.tsx b/frontend/src/components/site-finder/ptica-v2/V2LowerGrid.tsx deleted file mode 100644 index d12c4b5e..00000000 --- a/frontend/src/components/site-finder/ptica-v2/V2LowerGrid.tsx +++ /dev/null @@ -1,201 +0,0 @@ -"use client"; - -/** - * V2LowerGrid — row 3 (prototype `.row3`): ОКС · Development Potential · - * Recommended Product · Инсоляционная матрица. ОКС is an honest empty-state (no - * реестр ОКС in /analyze); Potential reads REAL ёмкость from геометрия + НСПД; - * Product reads the §22 product_tz (class + quartile mix) or shows «после - * прогноза»; Insolation stays «скоро» (not computed yet). - */ - -import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css"; -import type { ParcelAnalysis } from "@/types/site-finder"; -import type { ForecastReport } from "@/types/forecast"; -import { - adaptOksLowerCard, - adaptPotentialCard, - adaptProductDrawer, -} from "@/components/site-finder/ptica/ptica-adapt"; -import type { V2DrawerKey } from "@/components/site-finder/ptica-v2/v2-drawer-registry"; - -interface Props { - analysis: ParcelAnalysis; - forecastReport?: ForecastReport; - onOpenDrawer: (key: V2DrawerKey) => void; -} - -export function V2LowerGrid({ analysis, forecastReport, onOpenDrawer }: Props) { - const oks = adaptOksLowerCard(); - const potential = adaptPotentialCard(analysis); - const product = adaptProductDrawer(forecastReport); - - return ( -
- {/* ОКС */} -
-
-
- ОКС - объекты кап. стр-ва -
-
-
-
-
- - - -
-
- {oks.rows.map((r) => ( -
- {r.k} - - {r.field.value} - -
- ))} -
-
-
- -
-
-
- - {/* Development Potential */} -
-
-
- Development Potential -
-
-
-
- {potential.metrics.map((m) => ( -
-
{m.label}
-
- {m.value} - {m.unit && {m.unit}} -
-
- ))} -
-
- -
-
-
- - {/* Recommended Product */} -
-
-
- Recommended Product - продуктовая стратегия -
-
-
-
- Класс - - {product.objClass.value} - -
- {product.available && product.mix.length > 0 ? ( - <> -
Квартирография
-
- {product.mix.map((m) => ( -
- {m.bucket} - - - - {m.pct}% -
- ))} -
- - ) : ( -
после прогноза (Сценарии)
- )} -
- -
-
-
- - {/* Insolation matrix — «скоро» */} -
-
-
- Инсоляционная матрица -
-
-
-
- - - - - - -
-
- СКОРО -
- Расчёт инсоляции и КЕО в следующей версии -
-
-
-
-
- ); -} diff --git a/frontend/src/components/site-finder/ptica-v2/V2Map.tsx b/frontend/src/components/site-finder/ptica-v2/V2Map.tsx deleted file mode 100644 index b9b60a38..00000000 --- a/frontend/src/components/site-finder/ptica-v2/V2Map.tsx +++ /dev/null @@ -1,65 +0,0 @@ -"use client"; - -/** - * V2Map — the cockpit map card. Reuses the existing rich Leaflet plumbing - * (PticaMapInner — parcel polygon, POI / ЖК / pipeline glyph pins, ЗОУИТ zones, - * connection-point lines, custom POI, dark/satellite tiles) via a SINGLE shared - * implementation loaded through dynamic(ssr:false) so Leaflet never touches the - * server. Connection points + custom POIs are fetched here (same TanStack hooks - * as PticaMap) and threaded into the inner map. - * - * The inner map brings its own overlay/legend/controls + base-layer toggle - * (Спутник | Схема) and dark CartoDB tiles, matching the prototype map block. - * The map title chip is rendered here over the card per the prototype `.map-head`. - */ - -import dynamic from "next/dynamic"; - -import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css"; -import type { ParcelAnalysis } from "@/types/site-finder"; -import { useConnectionPoints } from "@/hooks/useConnectionPoints"; -import { useCustomPois } from "@/hooks/useCustomPois"; - -const PticaMapInner = dynamic( - () => import("@/components/site-finder/ptica/PticaMapInner"), - { - ssr: false, - loading: () =>
Загрузка карты…
, - }, -); - -interface Props { - analysis: ParcelAnalysis; -} - -export function V2Map({ analysis }: Props) { - const cad = analysis.cad_num; - const { data: connectionPoints } = useConnectionPoints(cad); - const { data: customPois } = useCustomPois(cad); - - return ( -
- - -
- -
- {/* Map title — text differs per theme; only one span is shown at a time - via the [data-theme="..."] selectors in v2.module.css (mirrors the - prototype `.tnum-d` / `.tnum-l` toggle pattern). */} -
- - СПУТНИК · СВЕЖИЙ СНИМОК - - - СПУТНИК · СХЕМА ПОДКЛЮЧЕНИЙ - -
-
- ); -} diff --git a/frontend/src/components/site-finder/ptica-v2/V2Passport.tsx b/frontend/src/components/site-finder/ptica-v2/V2Passport.tsx deleted file mode 100644 index d86d7c93..00000000 --- a/frontend/src/components/site-finder/ptica-v2/V2Passport.tsx +++ /dev/null @@ -1,90 +0,0 @@ -"use client"; - -/** - * V2Passport — «Паспорт участка» card (prototype `.passport`). Renders REAL ЕГРН - * fields via adaptPassport; absent fields show the honest «—» placeholder. The - * "ПОДРОБНЕЕ →" link opens the passport drawer. - */ - -import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css"; -import type { ParcelAnalysis } from "@/types/site-finder"; -import { adaptPassport } from "@/components/site-finder/ptica/ptica-adapt"; -import type { PticaField } from "@/components/site-finder/ptica/ptica-adapt"; -import { V2CopyIcon } from "@/components/site-finder/ptica-v2/V2Icons"; -import type { V2DrawerKey } from "@/components/site-finder/ptica-v2/v2-drawer-registry"; - -function Cell({ - label, - field, - wide, - big, -}: { - label: string; - field: PticaField; - wide?: boolean; - big?: boolean; -}) { - return ( -
-
{label}
-
- {field.value} -
-
- ); -} - -interface Props { - analysis: ParcelAnalysis; - onOpenDrawer: (key: V2DrawerKey) => void; -} - -export function V2Passport({ analysis, onOpenDrawer }: Props) { - const p = adaptPassport(analysis); - - return ( -
-
-
- Паспорт участка -
- -
-
-
- ID - {p.cadNum} - -
-
- - - - - -
-
Статус
-
- {p.status.value} -
-
- -
-
-
- ); -} diff --git a/frontend/src/components/site-finder/ptica-v2/V2Rail.tsx b/frontend/src/components/site-finder/ptica-v2/V2Rail.tsx deleted file mode 100644 index ca99aa57..00000000 --- a/frontend/src/components/site-finder/ptica-v2/V2Rail.tsx +++ /dev/null @@ -1,132 +0,0 @@ -"use client"; - -/** - * V2Rail — left icon rail (prototype `.rail`). Each item opens its detail drawer - * via the shared `onOpenDrawer` callback (V2 drawer registry keys). The currently - * open drawer key highlights the matching rail item (prototype `.rail-on`). - */ - -import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css"; -import type { V2DrawerKey } from "@/components/site-finder/ptica-v2/v2-drawer-registry"; - -interface RailItem { - key: string; - label: string; - drawer: V2DrawerKey; - icon: React.ReactNode; -} - -const RAIL: RailItem[] = [ - { - key: "parcel", - label: "Участок", - drawer: "parcel", - icon: ( - - - - - ), - }, - { - key: "potential", - label: "Потенциал", - drawer: "potential", - icon: ( - - - - - ), - }, - { - key: "product", - label: "Продукт", - drawer: "product", - icon: ( - - - - - ), - }, - { - key: "economics", - label: "Экономика", - drawer: "economics", - icon: ( - - - - ), - }, - { - key: "risks", - label: "Риски", - drawer: "risks", - icon: ( - - - - - - ), - }, - { - key: "compare", - label: "Сравнение", - drawer: "compare", - icon: ( - - - - ), - }, - { - key: "reports", - label: "Отчёты", - drawer: "reports", - icon: ( - - - - - ), - }, -]; - -interface Props { - openDrawer: V2DrawerKey | null; - onOpenDrawer: (key: V2DrawerKey) => void; -} - -export function V2Rail({ openDrawer, onOpenDrawer }: Props) { - return ( - - ); -} diff --git a/frontend/src/components/site-finder/ptica-v2/V2RightColumn.tsx b/frontend/src/components/site-finder/ptica-v2/V2RightColumn.tsx deleted file mode 100644 index 558a4f33..00000000 --- a/frontend/src/components/site-finder/ptica-v2/V2RightColumn.tsx +++ /dev/null @@ -1,146 +0,0 @@ -"use client"; - -/** - * V2RightColumn — row-1 right stack (prototype `.col-stack`): the Buildability - * gauge (SVG ring) over the Инвест-скор block. Both honest: the gauge shows a - * muted «—» when there is no data; the invest-score shows «—» until the §22 - * forecast surfaces the overall_score. Values flow through ptica-adapt. - */ - -import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css"; -import type { ParcelAnalysis } from "@/types/site-finder"; -import type { ForecastReport } from "@/types/forecast"; -import { - adaptBuildabilityGauge, - adaptInvestScore, -} from "@/components/site-finder/ptica/ptica-adapt"; -import type { PticaValueTone } from "@/components/site-finder/ptica/ptica-adapt"; -import { V2Gauge } from "@/components/site-finder/ptica-v2/V2Gauge"; -import type { V2DrawerKey } from "@/components/site-finder/ptica-v2/v2-drawer-registry"; - -const TONE_CLASS: Record = { - good: "", - warn: "amb", - none: "none", -}; - -// Static spark bars (decorative only — prototype `.spark`). -const SPARK = [12, 16, 20, 15, 22, 17, 24]; - -interface Props { - analysis: ParcelAnalysis; - forecastReport?: ForecastReport; - onOpenDrawer: (key: V2DrawerKey) => void; -} - -export function V2RightColumn({ analysis, forecastReport, onOpenDrawer }: Props) { - const gauge = adaptBuildabilityGauge(analysis); - const invest = adaptInvestScore(analysis, forecastReport); - const pct = gauge.value == null ? "—" : `${gauge.value}%`; - const gaugeColor = - gauge.value == null - ? "var(--text-soft)" - : gauge.tone === "good" - ? "var(--accent-green)" - : gauge.tone === "warn" - ? "var(--accent-yellow)" - : gauge.tone === "bad" - ? "var(--accent-red)" - : "var(--accent-cyan)"; - - return ( -
- {/* Buildability gauge */} -
- - -
-
- Buildability Index -
-
-
-
- -
- - {pct} - - - {gauge.label} - -
-
- {gauge.footnote && ( - {gauge.footnote} - )} -
-
- - {/* Invest score */} -
-
-
- Инвест-скор -
- -
-
-
-
- {invest.score.value} - /10 -
-
- - {SPARK.map((h, i) => ( - - ))} - -
-
-
-
-
Потенциал
-
- {invest.potential.value} -
-
-
-
Риск
-
- {invest.risk.value} -
-
-
-
-
-
- ); -} diff --git a/frontend/src/components/site-finder/ptica-v2/V2RiskCard.tsx b/frontend/src/components/site-finder/ptica-v2/V2RiskCard.tsx deleted file mode 100644 index 935316bc..00000000 --- a/frontend/src/components/site-finder/ptica-v2/V2RiskCard.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"use client"; - -/** - * V2RiskCard — the «Риски» mini-gauge card (prototype `.risk-card`). Renders the - * derived risk proxy (adaptRiskGauge, «предв.») as a small SVG ring + label; - * muted «—» when there is no data. Opens the risks drawer. - */ - -import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css"; -import type { ParcelAnalysis } from "@/types/site-finder"; -import { adaptRiskGauge } from "@/components/site-finder/ptica/ptica-adapt"; -import { V2Gauge } from "@/components/site-finder/ptica-v2/V2Gauge"; -import type { V2DrawerKey } from "@/components/site-finder/ptica-v2/v2-drawer-registry"; - -interface Props { - analysis: ParcelAnalysis; - onOpenDrawer: (key: V2DrawerKey) => void; -} - -export function V2RiskCard({ analysis, onOpenDrawer }: Props) { - const gauge = adaptRiskGauge(analysis); - const pct = gauge.value == null ? "—" : `${gauge.value}%`; - const color = - gauge.value == null - ? "var(--text-soft)" - : gauge.tone === "good" - ? "var(--accent-green)" - : gauge.tone === "warn" - ? "var(--accent-yellow)" - : "var(--accent-red)"; - - return ( -
-
-
- Риски -
-
-
-
Сводный риск
-
- -
- - {pct} - -
-
-
- {gauge.label} -
- -
-
- ); -} diff --git a/frontend/src/components/site-finder/ptica-v2/V2ScanCard.tsx b/frontend/src/components/site-finder/ptica-v2/V2ScanCard.tsx deleted file mode 100644 index 3219335c..00000000 --- a/frontend/src/components/site-finder/ptica-v2/V2ScanCard.tsx +++ /dev/null @@ -1,116 +0,0 @@ -"use client"; - -/** - * V2ScanCard — generic «scan» card for row 2 (prototype `.row2 .card`). Renders a - * PticaScanCard view-model: a title (+ optional badge), kv-rows OR status-rows - * (dot + state, used by Инженерия), an empty-state, and a "ПОДРОБНЕЕ →" link to - * the card's drawer. All values come from the ptica-adapt layer (REAL or honest - * «—»). - */ - -import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css"; -import type { - PticaScanCard, - PticaScanRow, - PticaStatusTone, -} from "@/components/site-finder/ptica/ptica-adapt"; -import type { V2DrawerKey } from "@/components/site-finder/ptica-v2/v2-drawer-registry"; - -const STATUS_COLOR: Record = { - ok: "var(--accent-green)", - warn: "var(--accent-orange)", - bad: "var(--accent-red)", -}; - -const STATUS_LABEL: Record = { - ok: "Доступно", - warn: "Огранич.", - bad: "Нет", -}; - -const STATUS_CLASS: Record = { - ok: styles.ok, - warn: styles.warnc, - bad: styles.warnc, -}; - -function KvRow({ row }: { row: PticaScanRow }) { - return ( -
- {row.key} - - {row.field.value} - -
- ); -} - -function StatusRow({ row }: { row: PticaScanRow }) { - const tone = row.status ?? "ok"; - return ( -
- - - - - {STATUS_LABEL[tone]} - - - {row.field.value} - - -
- ); -} - -interface Props { - card: PticaScanCard; - drawer: V2DrawerKey; - onOpenDrawer: (key: V2DrawerKey) => void; -} - -export function V2ScanCard({ card, drawer, onOpenDrawer }: Props) { - return ( -
-
-
- {card.title} - {card.badge && {card.badge}} -
-
-
- {card.rows.length === 0 ? ( -
- {card.emptyState ?? "Нет данных"} -
- ) : ( - card.rows.map((row) => - row.status ? ( - - ) : ( - - ), - ) - )} -
- -
-
-
- ); -} diff --git a/frontend/src/components/site-finder/ptica-v2/V2Topbar.tsx b/frontend/src/components/site-finder/ptica-v2/V2Topbar.tsx deleted file mode 100644 index bb373281..00000000 --- a/frontend/src/components/site-finder/ptica-v2/V2Topbar.tsx +++ /dev/null @@ -1,104 +0,0 @@ -"use client"; - -/** - * V2Topbar — cockpit top bar (prototype `.topbar`): ПТИЦА wordmark (links to - * Site Finder), 4 режимных tabs (visual — the cockpit is a single analysis view), - * a live clock + version chip + two icon buttons. - * - * The clock is mounted-only (state gated by an effect) to avoid an SSR/client - * hydration mismatch; the effect drives a UI clock — no HTTP — which is the - * sanctioned use of useEffect here. - */ - -import { useEffect, useState } from "react"; -import Link from "next/link"; - -import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css"; -import { V2BirdMark } from "@/components/site-finder/ptica-v2/V2Icons"; - -const TABS = [ - "Анализ участка", - "Сценарии", - "Отчёты", - "Сравнение", -] as const; - -interface Props { - cad: string; -} - -export function V2Topbar({ cad }: Props) { - const [now, setNow] = useState<{ time: string; date: string } | null>(null); - - useEffect(() => { - function tick() { - const d = new Date(); - const pad = (n: number) => String(n).padStart(2, "0"); - setNow({ - time: `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`, - date: `${d.getFullYear()}.${pad(d.getMonth() + 1)}.${pad(d.getDate())}`, - }); - } - tick(); - const id = window.setInterval(tick, 1000); - return () => window.clearInterval(id); - }, []); - - return ( -
- - -
-
ПТИЦА
-
- платформа территориального -
- информационно-цифрового анализа -
-
- - - - -
-
-
- {now?.time ?? "—"} -
-
- {now?.date ?? "—"} -
-
-
SITE FINDER v2
-
- - - - - - -
-
-
- ); -} diff --git a/frontend/src/components/site-finder/ptica-v2/v2-drawer-registry.tsx b/frontend/src/components/site-finder/ptica-v2/v2-drawer-registry.tsx deleted file mode 100644 index c3b2e997..00000000 --- a/frontend/src/components/site-finder/ptica-v2/v2-drawer-registry.tsx +++ /dev/null @@ -1,465 +0,0 @@ -"use client"; - -/** - * v2-drawer-registry — the 11 detail drawers of the v2 cockpit (prototype - * `DRAWERS`). Each entry = { rail?, eyebrow, title, sum, content }. Content is - * built from the ptica-adapt drawer view-models so REAL data renders and absent - * fields stay honest «—». Mirrors the prototype 1:1 in copy + layout; demo - * numbers from the prototype are NOT hard-coded — values come from /analyze. - */ - -import type { ReactNode } from "react"; - -import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css"; -import type { ParcelAnalysis } from "@/types/site-finder"; -import type { ForecastReport } from "@/types/forecast"; -import { - adaptPassportDrawer, - adaptPotentialDrawer, - adaptBuildabilityDrawer, - adaptProductDrawer, - adaptEconomyDrawer, - adaptRisksDrawer, - adaptRestrictionsDrawer, - adaptEngineeringDrawer, - adaptMarketDrawer, - adaptOksDrawer, -} from "@/components/site-finder/ptica/ptica-adapt"; -import { - DSec, - DRow, - DRows, - DFactor, - DNote, - DSource, -} from "@/components/site-finder/ptica-v2/V2DrawerPrimitives"; - -export const V2_DRAWER_KEYS = [ - "parcel", - "potential", - "product", - "economics", - "risks", - "compare", - "reports", - "restrictions", - "engineering", - "market", - "oks", -] as const; - -export type V2DrawerKey = (typeof V2_DRAWER_KEYS)[number]; - -export function asV2DrawerKey( - value: string | null | undefined, -): V2DrawerKey | null { - if (!value) return null; - return (V2_DRAWER_KEYS as readonly string[]).includes(value) - ? (value as V2DrawerKey) - : null; -} - -export interface V2DrawerEntry { - eyebrow: string; - title: string; - sum: string; - content: ReactNode; -} - -// ── Reports / export buttons (demo — actual export is in the heavy analysis page) ─ - -const EXPORT_FORMATS: Array<{ id: string; sub: string }> = [ - { id: "PDF", sub: "сводный паспорт" }, - { id: "CSV", sub: "таблица метрик" }, - { id: "DOCX §22", sub: "пояснит. записка" }, - { id: "PPTX", sub: "презентация" }, -]; - -function ReportsContent({ onExport }: { onExport: (fmt: string) => void }) { - return ( - <> - -
- {EXPORT_FORMATS.map((f) => ( - - ))} -
-
- - Полная выгрузка отчётов доступна на странице глубокого анализа участка. - - Источник: генератор отчётов ПТИЦА - - ); -} - -// ── Compare (advisory — single-parcel cockpit; full сравнение в отдельном модуле) ─ - -function CompareContent({ cad }: { cad: string }) { - return ( - <> - - - - - Сравнение с участками-кандидатами доступно в модуле «Сравнение» Site - Finder — выберите несколько участков, чтобы сопоставить их по ёмкости, - рынку и риску. - - Источник: реестр кандидатов Site Finder - - ); -} - -// ── Registry ────────────────────────────────────────────────────────────────── - -export function getV2DrawerEntry( - key: V2DrawerKey, - analysis: ParcelAnalysis, - forecastReport: ForecastReport | undefined, - onExport: (fmt: string) => void, -): V2DrawerEntry { - switch (key) { - case "parcel": { - const d = adaptPassportDrawer(analysis); - return { - eyebrow: "01 · ЕГРН", - title: "Паспорт участка", - sum: d.summary, - content: ( - <> - - - - {d.zouitOverlaps.length > 0 && ( - - {d.zouitCount} зон - - } - > - {d.zouitOverlaps.map((z, i) => ( - - ))} - - )} - Источник: ЕГРН · Росреестр · НСПД - - ), - }; - } - case "potential": { - const cap = adaptPotentialDrawer(analysis); - const build = adaptBuildabilityDrawer(analysis); - return { - eyebrow: "02 · ГРАДРЕГЛАМЕНТ", - title: "Потенциал застройки", - sum: cap.summary, - content: ( - <> - - - - - {build.gauge.value == null ? "—" : `${build.gauge.value}%`} · - предв. - - } - > - {build.factors.map((f) => ( - - ))} - - {build.summary} - Источник: ПЗЗ / НСПД · расчёт ПТИЦА - - ), - }; - } - case "product": { - const d = adaptProductDrawer(forecastReport); - return { - eyebrow: "03 · ПРОДУКТ", - title: "Рекомендованный продукт", - sum: d.summary, - content: ( - <> - - - - {d.available && d.mix.length > 0 && ( - - {d.mix.map((m, i) => ( - - ))} - - )} - {d.usp.length > 0 && ( - - {d.usp.map((u, i) => ( - - ))} - - )} - - {d.available - ? "Класс и квартирография — рекомендация прогноза §22 (advisory)." - : "Продуктовая стратегия формируется в прогнозе §22 — откройте «Сценарии»."} - - Источник: продуктовая модель ПТИЦА - - ), - }; - } - case "economics": { - const d = adaptEconomyDrawer(); - return { - eyebrow: "04 · ФИНМОДЕЛЬ", - title: "Экономика проекта", - sum: d.summary, - content: ( - <> - - - - {d.summary} - Источник: финмодель ПТИЦА (предв.) - - ), - }; - } - case "risks": { - const d = adaptRisksDrawer(analysis); - return { - eyebrow: "05 · РИСКИ", - title: "Риски участка", - sum: d.summary, - content: ( - <> - - {d.gauge.value == null ? "—" : `${d.gauge.value}%`} ·{" "} - {d.gauge.label} - - } - > - {d.breakdown.map((f) => ( - - ))} - - - - - - {d.summary} - Источник: гейт-модель ПТИЦА (предв.) - - ), - }; - } - case "compare": - return { - eyebrow: "06 · СРАВНЕНИЕ", - title: "Сравнение участков", - sum: "Сопоставление участков-кандидатов по ключевым метрикам.", - content: , - }; - case "reports": - return { - eyebrow: "07 · ЭКСПОРТ", - title: "Отчёты и экспорт", - sum: "Выгрузка аналитики по участку.", - content: , - }; - case "restrictions": { - const d = adaptRestrictionsDrawer(analysis); - return { - eyebrow: "ОГРАНИЧЕНИЯ", - title: "Ограничения и ЗОУИТ", - sum: d.summary, - content: ( - <> - - {d.statusRows.map((r, i) => ( - - ))} - - {d.zouitRows.length > 0 && ( - - {d.zouitRows.map((z, i) => ( - - ))} - - )} - {d.summary} - Источник: ИСОГД · ЕГРН · НСПД - - ), - }; - } - case "engineering": { - const d = adaptEngineeringDrawer(analysis); - return { - eyebrow: "ИНЖЕНЕРИЯ", - title: "Инженерная обеспеченность", - sum: d.summary, - content: ( - <> - {d.hasData ? ( - <> - - {d.rows.map((r, i) => ( - - ))} - - - {d.bars.map((b) => ( - - ))} - - - ) : ( - -
- Нет данных по инженерным сетям -
-
- )} - {d.summary} - Источник: ГКХ · сети РСО · НСПД - - ), - }; - } - case "market": { - const d = adaptMarketDrawer(analysis); - return { - eyebrow: "РЫНОК", - title: "Рынок района", - sum: d.summary, - content: ( - <> - - - - - - {d.competitors.length > 0 && ( - - - - - - - - - - - - {d.competitors.map((c, i) => ( - - - - - - - ))} - -
ЖККлассКв.Дист.
{c.name}{c.cls}{c.flats}{c.distance}
-
- )} - {d.summary} - Источник: рыночная аналитика ПТИЦА - - ), - }; - } - case "oks": { - const d = adaptOksDrawer(); - return { - eyebrow: "ОКС", - title: "Объекты кап. строительства", - sum: d.summary, - content: ( - <> - -
- Нет данных реестра ОКС (ЕГРН) -
-
- {d.summary} - Источник: ЕГРН (ОКС) - - ), - }; - } - } -}