From 05eb70eb624395c362b16e2d62369ce903797c40 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 20 Jun 2026 15:29:53 +0500 Subject: [PATCH] =?UTF-8?q?feat(site-finder):=20=D0=9F=D0=A2=D0=98=D0=A6?= =?UTF-8?q?=D0=90=20dark=20cockpit=20=E2=80=94=20increment=201=20(shell=20?= =?UTF-8?q?+=20hero=20+=20Development=20Scan)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New non-destructive route /site-finder/analysis/[cad]/ptica rendering the existing /analyze data in the «ПТИЦА» operator-terminal cockpit. The light analysis page, Section1-6, globals.css and package.json are untouched. PR#1 scope: app-shell (topbar + 4 tabs + left rail), hero (dark Leaflet map + parcel passport KV-grid + Buildability radial gauge + invest-score block), and the Development Scan card grid — wired to useParcelAnalyzeQuery / ParcelAnalysis. Data honesty: real fields render live (passport egrn.*, district, utilities.summary, median_price, pipeline_24mo); absent fields show muted placeholders with source captions (buildability/risk = derived proxy «предв.»; invest/buy-signal «после прогноза»; КСИТ/height/ОКС/economy «—») via typed ptica-adapt.ts — no fake numbers presented as live. Dark theme is a CSS-module scoped under .pticaRoot[data-theme=dark] (cannot leak to the light app); IBM Plex Mono added via next/font (no dep/lockfile change). Leaflet via dynamic(ssr:false). TS strict, no any. Deferred to follow-up PRs: Scenarios (forecast), Reports+Compare tabs, 16 detail drawers, Three.js 3D massing. --- frontend/src/app/layout.tsx | 12 +- .../analysis/[cad]/ptica/PticaPageContent.tsx | 87 +++ .../site-finder/analysis/[cad]/ptica/page.tsx | 55 ++ .../analysis/[cad]/ptica/ptica.module.css | 725 ++++++++++++++++++ .../site-finder/ptica/BuildabilityGauge.tsx | 81 ++ .../site-finder/ptica/DevelopmentScan.tsx | 61 ++ .../site-finder/ptica/InvestScoreBlock.tsx | 65 ++ .../site-finder/ptica/ParcelPassportCard.tsx | 68 ++ .../site-finder/ptica/PticaHero.tsx | 35 + .../components/site-finder/ptica/PticaMap.tsx | 31 + .../site-finder/ptica/PticaMapInner.tsx | 123 +++ .../ptica/PticaPlaceholderPanel.tsx | 22 + .../site-finder/ptica/PticaShell.tsx | 152 ++++ .../components/site-finder/ptica/ScanCard.tsx | 61 ++ .../site-finder/ptica/ptica-adapt.ts | 369 +++++++++ 15 files changed, 1946 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/site-finder/analysis/[cad]/ptica/PticaPageContent.tsx create mode 100644 frontend/src/app/site-finder/analysis/[cad]/ptica/page.tsx create mode 100644 frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css create mode 100644 frontend/src/components/site-finder/ptica/BuildabilityGauge.tsx create mode 100644 frontend/src/components/site-finder/ptica/DevelopmentScan.tsx create mode 100644 frontend/src/components/site-finder/ptica/InvestScoreBlock.tsx create mode 100644 frontend/src/components/site-finder/ptica/ParcelPassportCard.tsx create mode 100644 frontend/src/components/site-finder/ptica/PticaHero.tsx create mode 100644 frontend/src/components/site-finder/ptica/PticaMap.tsx create mode 100644 frontend/src/components/site-finder/ptica/PticaMapInner.tsx create mode 100644 frontend/src/components/site-finder/ptica/PticaPlaceholderPanel.tsx create mode 100644 frontend/src/components/site-finder/ptica/PticaShell.tsx create mode 100644 frontend/src/components/site-finder/ptica/ScanCard.tsx create mode 100644 frontend/src/components/site-finder/ptica/ptica-adapt.ts diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index a8ec550b..6b158408 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,10 +1,20 @@ import type { Metadata } from "next"; +import { IBM_Plex_Mono } from "next/font/google"; import { RouteGuard } from "@/components/auth/RouteGuard"; import "./globals.css"; import { Providers } from "./providers"; +// ПТИЦА cockpit numerals (mono). next/font is bundled — no package.json change. +// Cyrillic + latin subsets so mono digits/labels render in the dark cockpit. +const plexMono = IBM_Plex_Mono({ + subsets: ["latin", "cyrillic"], + weight: ["400", "500", "600"], + variable: "--font-plex-mono", + display: "swap", +}); + export const metadata: Metadata = { title: "GenDesign", description: "Generative Design + Site Finder", @@ -16,7 +26,7 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - + {children} diff --git a/frontend/src/app/site-finder/analysis/[cad]/ptica/PticaPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/ptica/PticaPageContent.tsx new file mode 100644 index 00000000..5242659e --- /dev/null +++ b/frontend/src/app/site-finder/analysis/[cad]/ptica/PticaPageContent.tsx @@ -0,0 +1,87 @@ +"use client"; + +/** + * PticaPageContent — client root of the ПТИЦА cockpit (INCREMENT 1). + * + * Wires the SAME /analyze data as the existing light analysis page via + * useParcelAnalyzeQuery, narrows the response to ParcelAnalysis (the sanctioned + * `as unknown as` pattern — see AnalysisPageContent.tsx:111), and composes the + * dark operator-terminal shell. Only the 'analysis' tab renders content in PR#1; + * Scenarios / Reports / Compare show an honest "В разработке" panel. + * + * The root carries BOTH `.pticaRoot` and `data-theme="dark"` so the scoped dark + * tokens apply here and never leak to the light pages. + */ + +import { useState } from "react"; +import Link from "next/link"; + +import styles from "./ptica.module.css"; +import { useParcelAnalyzeQuery } from "@/lib/site-finder-api"; +import type { ParcelAnalysis } from "@/types/site-finder"; +import { PticaShell } from "@/components/site-finder/ptica/PticaShell"; +import type { PticaTab } from "@/components/site-finder/ptica/PticaShell"; +import { PticaHero } from "@/components/site-finder/ptica/PticaHero"; +import { DevelopmentScan } from "@/components/site-finder/ptica/DevelopmentScan"; +import { PticaPlaceholderPanel } from "@/components/site-finder/ptica/PticaPlaceholderPanel"; + +interface Props { + cad: string; +} + +export function PticaPageContent({ cad }: Props) { + const [horizon] = useState(12); + const [tab, setTab] = useState("analysis"); + const { data, isLoading, error } = useParcelAnalyzeQuery(cad, horizon); + + // ── Loading ──────────────────────────────────────────────────────────────── + if (isLoading) { + return ( +
+
+
Анализируем участок {cad}…
+
+
+ ); + } + + // ── Error ────────────────────────────────────────────────────────────────── + if (error || !data) { + const msg = + error instanceof Error ? error.message : "Ошибка загрузки анализа"; + return ( +
+
+
+
{msg}
+ + ← Вернуться к Site Finder + +
+
+
+ ); + } + + // Narrow to ParcelAnalysis (superset of ParcelAnalyzeResponse) — both describe + // the same /analyze endpoint. Sanctioned pattern (AnalysisPageContent.tsx:111). + const analysis = data as unknown as ParcelAnalysis; + + return ( +
+ + {tab === "analysis" ? ( + <> + + + + ) : ( + + )} + +
+ ); +} diff --git a/frontend/src/app/site-finder/analysis/[cad]/ptica/page.tsx b/frontend/src/app/site-finder/analysis/[cad]/ptica/page.tsx new file mode 100644 index 00000000..75f42f58 --- /dev/null +++ b/frontend/src/app/site-finder/analysis/[cad]/ptica/page.tsx @@ -0,0 +1,55 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; + +import { PticaPageContent } from "./PticaPageContent"; + +// ── Metadata ────────────────────────────────────────────────────────────────── + +interface PageProps { + params: Promise<{ cad: string }>; +} + +// Next.js delivers `cad` URL-encoded (":" -> "%3A"); decode once at the page +// boundary so the cockpit + hooks see the canonical "66:41:0204016:10" (and +// re-encode exactly once when building API URLs). Mirrors the sibling page.tsx. +export async function generateMetadata({ + params, +}: PageProps): Promise { + const { cad: cadRaw } = await params; + const cad = decodeURIComponent(cadRaw); + return { + title: `ПТИЦА · ${cad}`, + description: `Когнитивный анализ участка ${cad} — платформа ПТИЦА`, + }; +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +export default async function PticaPage({ params }: PageProps) { + const { cad: cadRaw } = await params; + const cad = decodeURIComponent(cadRaw); + + return ( + + Загрузка ПТИЦА… + + } + > + + + ); +} diff --git a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css new file mode 100644 index 00000000..b33bff6b --- /dev/null +++ b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css @@ -0,0 +1,725 @@ +/* + * ПТИЦА cockpit — dark "operator terminal" styles (INCREMENT 1). + * + * Ported from the prototype tokens.css + styles.css. Every design token is + * scoped under `.pticaRoot[data-theme="dark"]` (the wrapper carries BOTH the + * class and the attribute) so these dark vars NEVER leak onto the existing + * light analysis pages. Cockpit class styles reference tokens via var(--…). + */ + +/* ===================== SCOPED TOKENS (dark) ===================== */ +.pticaRoot[data-theme="dark"] { + --font-ui: + "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; + + /* accents */ + --accent-blue: #2f6f9f; + --accent-cyan: #7fd0ee; + --accent-cyan-strong: #8ed3ff; + --accent-green: #38c172; + --accent-yellow: #e0a93b; + --accent-red: #d2655b; + --accent-purple: #9b80d6; + --accent-orange: #e46c35; + + --gauge-track: 9px; + + /* dark surface palette */ + --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; + --map-filter: saturate(0.7) contrast(1.12) brightness(0.7); + --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); + + --gauge-good: var(--accent-green); + + /* root paint */ + position: relative; + min-height: 100vh; + font-family: var(--font-ui); + color: var(--text); + font-size: 13px; + line-height: 1.4; + font-variant-numeric: tabular-nums; + -webkit-font-smoothing: antialiased; + background: + linear-gradient(90deg, transparent 31px, var(--grid-line) 32px), + linear-gradient(0deg, transparent 31px, var(--grid-line) 32px), + radial-gradient(circle at 78% -10%, var(--glow), transparent 45%), var(--bg); + background-size: + 32px 32px, + 32px 32px, + 100% 100%, + 100% 100%; +} + +.pticaRoot button { + font: inherit; + cursor: pointer; + color: inherit; +} +.pticaRoot svg { + display: block; +} +.mono { + font-family: var(--font-mono); +} + +/* ===================== APP SHELL ===================== */ +.shell { + display: grid; + grid-template-columns: 96px 1fr; + min-height: 100vh; +} + +/* sidebar rail */ +.rail { + border-right: var(--line) solid var(--border); + background: var(--surface-2); + backdrop-filter: blur(8px); + display: flex; + flex-direction: column; + padding: 16px 10px 12px; + gap: 14px; + position: sticky; + top: 0; + height: 100vh; +} +.brand { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding-bottom: 6px; +} +.brandMark { + width: 40px; + height: 40px; + color: var(--accent-cyan); + filter: drop-shadow(0 0 10px var(--glow)); +} +.nav { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 4px; +} +.navItem { + position: relative; + display: grid; + place-items: center; + gap: 6px; + min-height: 60px; + padding: 8px 4px; + border: var(--line) solid transparent; + border-radius: var(--radius-sm); + color: var(--text-soft); + background: transparent; + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.05em; + text-align: center; + transition: + color 0.15s, + background 0.15s; +} +.navItem svg { + width: 22px; + height: 22px; +} +.navItem:hover { + color: var(--text-muted); + background: var(--surface); +} +.navFoot { + margin-top: auto; + display: grid; + gap: 8px; + justify-items: center; + color: var(--text-soft); +} +.navFoot .mono { + font-size: 8px; + text-align: center; + line-height: 1.5; +} + +/* main column */ +.main { + display: flex; + flex-direction: column; + min-width: 0; +} + +/* topbar */ +.topbar { + position: sticky; + top: 0; + z-index: 20; + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 24px; + padding: 12px 20px; + border-bottom: var(--line) solid var(--border); + background: var(--surface-2); + backdrop-filter: blur(8px); +} +.wordmark { + display: flex; + align-items: center; + gap: 12px; +} +.bird { + width: 26px; + height: 26px; + color: var(--accent-cyan); +} +.wordmarkTitle { + font-weight: 700; + letter-spacing: 0.42em; + font-size: 18px; + color: var(--text-strong); +} +.wordmarkSub { + color: var(--text-soft); + font-size: 8.5px; + line-height: 1.25; + text-transform: uppercase; + letter-spacing: 0.06em; +} +.tabs { + display: flex; + justify-content: center; + gap: 0; +} +.tab { + padding: 8px 22px; + background: transparent; + border: none; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.14em; + font-size: 11px; + border-bottom: 2px solid transparent; +} +.tab + .tab { + border-left: var(--line) solid var(--border); +} +.tab:hover { + color: var(--text); +} +.tabActive { + color: var(--text-strong); + border-bottom-color: var(--accent-cyan); +} +.sysbar { + display: flex; + align-items: center; + gap: 16px; + color: var(--text-muted); +} +.clock { + font-family: var(--font-mono); + font-size: 12px; + text-align: right; + line-height: 1.3; +} +.clock b { + color: var(--text); + font-weight: 600; +} + +/* canvas (scroll area) */ +.canvas { + padding: 14px 18px 24px; + display: flex; + flex-direction: column; + gap: 12px; +} + +/* ===================== PANEL / CARD primitives ===================== */ +.panel, +.card, +.mapCard { + position: relative; + background: var(--surface); + border: var(--line) solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow); + backdrop-filter: blur(6px); +} +.panel, +.card { + padding: 14px 15px; +} +.cardHead { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 12px; +} +.cardTitle { + display: flex; + align-items: baseline; + gap: 8px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--text-strong); + margin: 0; +} +.cardTitleSub { + font-size: 9px; + font-weight: 500; + letter-spacing: 0.08em; + color: var(--text-soft); +} + +.detailBtn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 11px; + border: var(--line) solid var(--border-strong); + background: transparent; + color: var(--text-muted); + border-radius: var(--radius-xs); + text-transform: uppercase; + font-size: 9px; + letter-spacing: 0.08em; + white-space: nowrap; +} +.detailBtn::after { + content: "→"; + font-size: 11px; +} +.detailBtn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +/* ===================== HERO GRID ===================== */ +.hero { + display: grid; + grid-template-columns: 1.7fr 1.02fr 0.62fr; + gap: 12px; +} + +/* map card */ +.mapCard { + min-height: 358px; + overflow: hidden; + background: var(--map-bg); +} +.mapMount { + position: absolute; + inset: 0; + border-radius: inherit; + overflow: hidden; +} +.mapMount :global(.leaflet-container) { + width: 100%; + height: 100%; + background: transparent; + font-family: var(--font-ui); +} +.mapMount :global(.leaflet-tile-pane) { + filter: var(--map-filter); +} +.mapMount :global(.leaflet-control-attribution) { + background: rgba(8, 19, 28, 0.55); + color: var(--text-soft); + font-size: 8px; + padding: 1px 6px; + opacity: 0.6; + backdrop-filter: blur(4px); +} +.mapMount :global(.leaflet-control-attribution a) { + color: var(--text-muted); +} +.mapLoading { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: var(--text-soft); + font-size: 12px; + background: var(--map-bg); +} + +/* passport KV-grid */ +.kvGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--line); + background: var(--border-faint); + border: var(--line) solid var(--border-faint); + border-radius: var(--radius-sm); + overflow: hidden; +} +.kv { + background: var(--surface-2); + padding: 9px 11px; +} +.kvK { + display: block; + font-size: 8.5px; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-soft); + margin-bottom: 3px; +} +.kvV { + font-size: 12.5px; + font-weight: 600; + color: var(--text); +} +.kvV.mono { + font-family: var(--font-mono); + font-size: 12px; +} +.kvWide { + grid-column: span 2; +} +.kvPlaceholder { + color: var(--text-soft); + font-weight: 500; +} +.kvCaption { + display: block; + margin-top: 2px; + font-size: 8px; + text-transform: none; + letter-spacing: 0.02em; + color: var(--text-soft); +} + +/* score panel (hero col 3) */ +.scorePanel { + display: flex; + flex-direction: column; + gap: 10px; +} +.gaugePanel { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} +.scoreStack { + display: flex; + flex-direction: column; + gap: 8px; +} +.statLine { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 11px; +} +.statLine .k { + color: var(--text-soft); + text-transform: uppercase; + letter-spacing: 0.06em; + font-size: 9px; +} +.statLine .v { + font-weight: 600; +} +.investScore { + display: flex; + align-items: baseline; + gap: 6px; +} +.investBig { + font-size: 26px; + font-weight: 700; + color: var(--text-strong); + font-family: var(--font-mono); +} +.investSmall { + color: var(--text-soft); + font-size: 11px; +} +.investPlaceholder { + color: var(--text-soft); +} +.badge { + display: inline-block; + padding: 2px 9px; + border-radius: 999px; + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.06em; + border: var(--line) solid var(--border-strong); + color: var(--text-soft); +} +.fieldCaption { + font-size: 8px; + color: var(--text-soft); + text-transform: none; + letter-spacing: 0.02em; +} + +/* ===================== GAUGE ===================== */ +.gauge { + position: relative; + width: 122px; + height: 122px; + margin: 2px auto; +} +.gauge svg { + width: 100%; + height: 100%; + transform: rotate(-90deg); +} +.gaugeTrack { + fill: none; + stroke: var(--surface-muted); +} +.gaugeProg { + fill: none; + stroke-linecap: round; + filter: drop-shadow(0 0 6px var(--glow)); + transition: stroke-dashoffset 0.9s ease; +} +.gaugeGood { + stroke: var(--accent-green); +} +.gaugeWarn { + stroke: var(--accent-yellow); +} +.gaugeBad { + stroke: var(--accent-red); +} +.gaugeNeutral { + stroke: var(--accent-cyan); +} +.gaugeCenter { + position: absolute; + inset: 0; + display: grid; + place-content: center; + text-align: center; +} +.gaugeNum { + font-size: 30px; + font-weight: 700; + line-height: 1; + color: var(--text-strong); + font-family: var(--font-mono); +} +.gaugeNumGood { + color: var(--accent-green); +} +.gaugeNumWarn { + color: var(--accent-yellow); +} +.gaugeNumBad { + color: var(--accent-red); +} +.gaugeLab { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-muted); + margin-top: 5px; +} +.gaugeFootnote { + font-size: 8px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-soft); +} + +/* ===================== SCAN GRID ===================== */ +.sectionLabel { + display: flex; + align-items: center; + gap: 10px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--text-soft); + margin: 2px 2px -2px; +} +.sectionLabel::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border); +} +.sectionLabel b { + color: var(--accent-cyan); + font-weight: 600; + letter-spacing: 0.12em; +} +.scanGrid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; +} +.scanCard { + padding: 12px 12px 11px; + min-height: 156px; + display: flex; + flex-direction: column; +} +.scanCard .cardTitle { + margin-bottom: 10px; + font-size: 10px; +} +.kvrow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 11px; + padding: 3px 0; +} +.kvrow .k { + color: var(--text-muted); +} +.kvrow .v { + font-weight: 600; + color: var(--text); + text-align: right; +} +.kvrowPlaceholder .v { + color: var(--text-soft); + font-weight: 500; +} +.scanCard .detailBtn { + margin-top: auto; + align-self: flex-start; +} +.emptyState { + display: grid; + place-items: center; + flex: 1; + text-align: center; + color: var(--text-soft); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +/* ===================== PLACEHOLDER PANEL ===================== */ +.placeholderPanel { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + text-align: center; + min-height: 240px; + border-style: dashed; +} +.placeholderPanel .soon { + font-size: 22px; + letter-spacing: 0.12em; + color: var(--text-soft); + font-weight: 700; + text-transform: uppercase; +} +.placeholderPanel p { + font-size: 10px; + color: var(--text-soft); + margin: 0; + max-width: 280px; +} + +/* ===================== STATE SCREENS ===================== */ +.stateScreen { + display: grid; + place-items: center; + min-height: 60vh; + padding: 40px 24px; +} +.stateBox { + text-align: center; + color: var(--text-muted); + font-size: 14px; +} +.stateError { + border: var(--line) solid var(--accent-red); + background: rgba(210, 101, 91, 0.08); + color: var(--accent-red); + border-radius: var(--radius-md); + padding: 16px 20px; +} +.stateLink { + display: inline-block; + margin-top: 16px; + color: var(--accent-cyan); + text-decoration: none; + font-size: 13px; +} + +/* ===================== RESPONSIVE ===================== */ +@media (max-width: 1500px) { + .hero { + grid-template-columns: 1.5fr 1fr; + } + .scorePanel { + grid-column: span 2; + flex-direction: row; + justify-content: space-around; + flex-wrap: wrap; + } +} +@media (max-width: 1200px) { + .scanGrid { + grid-template-columns: repeat(2, 1fr); + } +} +@media (max-width: 820px) { + .shell { + grid-template-columns: 1fr; + } + .rail { + display: none; + } + .hero, + .scanGrid { + grid-template-columns: 1fr; + } + .scorePanel { + flex-direction: column; + } + .tabs { + display: none; + } +} diff --git a/frontend/src/components/site-finder/ptica/BuildabilityGauge.tsx b/frontend/src/components/site-finder/ptica/BuildabilityGauge.tsx new file mode 100644 index 00000000..f08c4f67 --- /dev/null +++ b/frontend/src/components/site-finder/ptica/BuildabilityGauge.tsx @@ -0,0 +1,81 @@ +"use client"; + +/** + * BuildabilityGauge — reusable SVG radial gauge for the cockpit. + * + * Renders a 0..100 value (or a muted track when value is null) with a color + * keyed to the `tone` bucket via CSS-module classes (which resolve to dark + * tokens). Optional footnote (e.g. "предв.") flags soft/preliminary proxies. + */ + +import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; +import type { PticaGauge } from "@/components/site-finder/ptica/ptica-adapt"; + +const RADIUS = 52; +const CIRCUMFERENCE = 2 * Math.PI * RADIUS; + +const PROG_CLASS: Record = { + good: styles.gaugeGood, + warn: styles.gaugeWarn, + bad: styles.gaugeBad, + neutral: styles.gaugeNeutral, +}; + +const NUM_CLASS: Record = { + good: styles.gaugeNumGood, + warn: styles.gaugeNumWarn, + bad: styles.gaugeNumBad, + neutral: "", +}; + +interface Props { + gauge: PticaGauge; + /** Title shown above the gauge (e.g. "Buildability Index"). */ + title: string; +} + +export function BuildabilityGauge({ gauge, title }: Props) { + const { value, label, tone, footnote } = gauge; + const pct = value ?? 0; + const dashOffset = CIRCUMFERENCE * (1 - pct / 100); + + return ( +
+
+ {title} +
+
+ +
+
+ {value != null ? `${value}%` : "—"} +
+
{label}
+
+
+ {footnote &&
{footnote}
} +
+ ); +} diff --git a/frontend/src/components/site-finder/ptica/DevelopmentScan.tsx b/frontend/src/components/site-finder/ptica/DevelopmentScan.tsx new file mode 100644 index 00000000..788172bb --- /dev/null +++ b/frontend/src/components/site-finder/ptica/DevelopmentScan.tsx @@ -0,0 +1,61 @@ +"use client"; + +/** + * DevelopmentScan — "Development Scan" section: a responsive grid of ScanCards + * (Градостроительство / Ограничения / Инженерия / Рынок / Экономика / Риски / + * Среда + ОКС). Every card's real-vs-placeholder content comes from the adapter. + */ + +import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; +import type { ParcelAnalysis } from "@/types/site-finder"; +import { ScanCard } from "@/components/site-finder/ptica/ScanCard"; +import { BuildabilityGauge } from "@/components/site-finder/ptica/BuildabilityGauge"; +import { + adaptUrbanCard, + adaptRestrictionsCard, + adaptEngineeringCard, + adaptMarketCard, + adaptEconomyCard, + adaptEnvironmentCard, + adaptOksCard, + adaptRiskGauge, +} from "@/components/site-finder/ptica/ptica-adapt"; + +interface Props { + analysis: ParcelAnalysis; +} + +export function DevelopmentScan({ analysis }: Props) { + const riskGauge = adaptRiskGauge(analysis); + + return ( + <> +
+ Development Scan · градостроительный анализ +
+
+ + + + +
+ +
+ +
+

+ Риски +

+ +
+ + + +
+ + ); +} diff --git a/frontend/src/components/site-finder/ptica/InvestScoreBlock.tsx b/frontend/src/components/site-finder/ptica/InvestScoreBlock.tsx new file mode 100644 index 00000000..b7bd1225 --- /dev/null +++ b/frontend/src/components/site-finder/ptica/InvestScoreBlock.tsx @@ -0,0 +1,65 @@ +"use client"; + +/** + * InvestScoreBlock — hero score panel (col 3): big /10 invest-score number + a + * buy-signal badge. In INCREMENT 1 the invest-score and buy-signal are + * PLACEHOLDERS (surfaced after the §22 forecast / Scenarios), so the big number + * renders muted "—" with a caption instead of a fabricated value. + */ + +import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; +import type { ParcelAnalysis } from "@/types/site-finder"; +import { adaptInvestScore } from "@/components/site-finder/ptica/ptica-adapt"; + +interface Props { + analysis: ParcelAnalysis; +} + +export function InvestScoreBlock({ analysis }: Props) { + const inv = adaptInvestScore(analysis); + + return ( +
+
+
+ Инвест-скор +
+
+ {inv.score.isReal ? ( + <> + {inv.score.value} + / 10 + + ) : ( + + — + + )} +
+ {!inv.score.isReal && inv.score.caption && ( + {inv.score.caption} + )} +
+ +
+ Потенциал + + {inv.potential.value} + +
+
+ Риск + + {inv.risk.value} + +
+ +
+ Buy Signal + + {inv.buySignal.isReal ? inv.buySignal.value : "после прогноза"} + +
+
+ ); +} diff --git a/frontend/src/components/site-finder/ptica/ParcelPassportCard.tsx b/frontend/src/components/site-finder/ptica/ParcelPassportCard.tsx new file mode 100644 index 00000000..c4a3de52 --- /dev/null +++ b/frontend/src/components/site-finder/ptica/ParcelPassportCard.tsx @@ -0,0 +1,68 @@ +"use client"; + +/** + * ParcelPassportCard — passport KV-grid (hero col 2). Mono cad number; every + * value flows from the adapter view-model, so placeholders render muted "—" + * with their caption rather than fake live data. + */ + +import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; +import type { ParcelAnalysis } from "@/types/site-finder"; +import { adaptPassport } from "@/components/site-finder/ptica/ptica-adapt"; +import type { + PticaField, + PticaPassport, +} from "@/components/site-finder/ptica/ptica-adapt"; + +interface Props { + analysis: ParcelAnalysis; +} + +function KvCell({ + label, + field, + wide, +}: { + label: string; + field: PticaField; + wide?: boolean; +}) { + return ( +
+ {label} + + {field.value} + + {field.caption && !field.isReal && ( + {field.caption} + )} +
+ ); +} + +export function ParcelPassportCard({ analysis }: Props) { + const p: PticaPassport = adaptPassport(analysis); + + return ( +
+
+

Паспорт участка

+
+
+
+ Кадастровый номер + {p.cadNum} +
+ + + + + + + +
+
+ ); +} diff --git a/frontend/src/components/site-finder/ptica/PticaHero.tsx b/frontend/src/components/site-finder/ptica/PticaHero.tsx new file mode 100644 index 00000000..e1d02f2c --- /dev/null +++ b/frontend/src/components/site-finder/ptica/PticaHero.tsx @@ -0,0 +1,35 @@ +"use client"; + +/** + * PticaHero — top of the cockpit: map + passport KV-grid + Buildability gauge + + * invest-score block, in the prototype's hero grid. + */ + +import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; +import type { ParcelAnalysis } from "@/types/site-finder"; +import { PticaMap } from "@/components/site-finder/ptica/PticaMap"; +import { ParcelPassportCard } from "@/components/site-finder/ptica/ParcelPassportCard"; +import { BuildabilityGauge } from "@/components/site-finder/ptica/BuildabilityGauge"; +import { InvestScoreBlock } from "@/components/site-finder/ptica/InvestScoreBlock"; +import { adaptBuildabilityGauge } from "@/components/site-finder/ptica/ptica-adapt"; + +interface Props { + analysis: ParcelAnalysis; +} + +export function PticaHero({ analysis }: Props) { + const buildGauge = adaptBuildabilityGauge(analysis); + + return ( +
+ + + + +
+ + +
+
+ ); +} diff --git a/frontend/src/components/site-finder/ptica/PticaMap.tsx b/frontend/src/components/site-finder/ptica/PticaMap.tsx new file mode 100644 index 00000000..79ad46a6 --- /dev/null +++ b/frontend/src/components/site-finder/ptica/PticaMap.tsx @@ -0,0 +1,31 @@ +"use client"; + +/** + * PticaMap — SSR-safe wrapper around the Leaflet cockpit map. Leaflet touches + * `window`, so PticaMapInner is loaded via dynamic(ssr:false) with a dark + * loading placeholder (never imported into a server component). + */ + +import dynamic from "next/dynamic"; + +import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; + +const PticaMapInner = dynamic( + () => import("@/components/site-finder/ptica/PticaMapInner"), + { + ssr: false, + loading: () =>
Загрузка карты…
, + }, +); + +interface Props { + geojson: unknown; +} + +export function PticaMap({ geojson }: Props) { + return ( +
+ +
+ ); +} diff --git a/frontend/src/components/site-finder/ptica/PticaMapInner.tsx b/frontend/src/components/site-finder/ptica/PticaMapInner.tsx new file mode 100644 index 00000000..eace3f14 --- /dev/null +++ b/frontend/src/components/site-finder/ptica/PticaMapInner.tsx @@ -0,0 +1,123 @@ +"use client"; + +/** + * PticaMapInner — react-leaflet map for the cockpit hero (client-only; loaded + * via dynamic(ssr:false) from PticaMap). CartoDB dark_all tiles + the parcel + * polygon in cyan, centered on the geometry centroid (or EKB when absent). + * + * Mirrors the SiteMap pattern: react-leaflet MapContainer + the Leaflet default- + * icon webpack fix. `geojson` arrives as `unknown` (the wire type of + * ParcelAnalysis.geom_geojson) and is narrowed to a GeoJSON Geometry here. + */ + +import { useEffect } from "react"; +import { MapContainer, TileLayer, GeoJSON } from "react-leaflet"; +import type { Geometry, Position } from "geojson"; +import "leaflet/dist/leaflet.css"; + +import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; +import { EKB_CENTER } from "@/components/site-finder/ptica/ptica-adapt"; + +interface Props { + geojson: unknown; +} + +// ── Narrowing: unknown → GeoJSON Geometry ───────────────────────────────────── + +const GEOMETRY_TYPES = new Set([ + "Point", + "MultiPoint", + "LineString", + "MultiLineString", + "Polygon", + "MultiPolygon", + "GeometryCollection", +]); + +function asGeometry(value: unknown): Geometry | null { + if (value == null || typeof value !== "object") return null; + if (!("type" in value)) return null; + const type = (value as { type: unknown }).type; + if (typeof type !== "string" || !GEOMETRY_TYPES.has(type)) return null; + return value as Geometry; +} + +// ── Centroid (bounding-box center — sufficient for zoom-to fit) ─────────────── + +function flattenCoords(geom: Geometry): Position[] { + switch (geom.type) { + case "Point": + return [geom.coordinates]; + case "MultiPoint": + case "LineString": + return geom.coordinates; + case "MultiLineString": + case "Polygon": + return geom.coordinates.flat(); + case "MultiPolygon": + return geom.coordinates.flat(2); + case "GeometryCollection": + return geom.geometries.flatMap(flattenCoords); + default: + return []; + } +} + +function geomCenter(geom: Geometry): [number, number] { + const coords = flattenCoords(geom); + if (!coords.length) return EKB_CENTER; + let minLat = Infinity; + let maxLat = -Infinity; + let minLon = Infinity; + let maxLon = -Infinity; + for (const [lon, lat] of coords) { + if (lat < minLat) minLat = lat; + if (lat > maxLat) maxLat = lat; + if (lon < minLon) minLon = lon; + if (lon > maxLon) maxLon = lon; + } + return [(minLat + maxLat) / 2, (minLon + maxLon) / 2]; +} + +export default function PticaMapInner({ geojson }: Props) { + // Fix Leaflet default icon paths broken by webpack bundler (mirror SiteMap). + useEffect(() => { + void import("leaflet").then((L) => { + // @ts-expect-error — _getIconUrl is internal Leaflet property + delete L.Icon.Default.prototype._getIconUrl; + L.Icon.Default.mergeOptions({ + iconRetinaUrl: + "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png", + iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png", + shadowUrl: + "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", + }); + }); + }, []); + + const geom = asGeometry(geojson); + const center = geom ? geomCenter(geom) : EKB_CENTER; + + return ( +
+ + + {geom && ( + + )} + +
+ ); +} diff --git a/frontend/src/components/site-finder/ptica/PticaPlaceholderPanel.tsx b/frontend/src/components/site-finder/ptica/PticaPlaceholderPanel.tsx new file mode 100644 index 00000000..a0a088c7 --- /dev/null +++ b/frontend/src/components/site-finder/ptica/PticaPlaceholderPanel.tsx @@ -0,0 +1,22 @@ +"use client"; + +/** + * PticaPlaceholderPanel — honest "В разработке" panel for cockpit tabs/sections + * that aren't wired in INCREMENT 1 (Scenarios / Reports / Compare). + */ + +import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; + +interface Props { + label: string; + hint?: string; +} + +export function PticaPlaceholderPanel({ label, hint }: Props) { + return ( +
+
{label}
+ {hint &&

{hint}

} +
+ ); +} diff --git a/frontend/src/components/site-finder/ptica/PticaShell.tsx b/frontend/src/components/site-finder/ptica/PticaShell.tsx new file mode 100644 index 00000000..976101dd --- /dev/null +++ b/frontend/src/components/site-finder/ptica/PticaShell.tsx @@ -0,0 +1,152 @@ +"use client"; + +/** + * PticaShell — cockpit chrome: sticky topbar (ПТИЦА wordmark + 4 tab buttons, + * active state driven by props) and a left rail of anchor buttons that + * scrollIntoView the matching hero/scan sections, plus a mono timestamp footer. + * + * The timestamp is rendered only after mount (useState gated by an effect) to + * avoid an SSR/client hydration mismatch on the clock value. The effect drives a + * UI clock — no HTTP — which is the allowed use of useEffect here. + */ + +import { useEffect, useState } from "react"; + +import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; + +export type PticaTab = "analysis" | "scenarios" | "reports" | "compare"; + +interface TabDef { + id: PticaTab; + label: string; +} + +const TABS: TabDef[] = [ + { id: "analysis", label: "Анализ участка" }, + { id: "scenarios", label: "Сценарии" }, + { id: "reports", label: "Отчёты" }, + { id: "compare", label: "Сравнение" }, +]; + +interface RailItem { + anchor: string; + label: string; +} + +const RAIL: RailItem[] = [ + { anchor: "passport", label: "Паспорт" }, + { anchor: "potential", label: "Потенциал" }, + { anchor: "scan", label: "Скан" }, + { anchor: "economy", label: "Экономика" }, + { anchor: "risks", label: "Риски" }, + { anchor: "compare-anchor", label: "Сравнение" }, +]; + +const BirdMark = ({ className }: { className: string }) => ( + +); + +interface Props { + activeTab: PticaTab; + onTabChange: (tab: PticaTab) => void; + children: React.ReactNode; +} + +function scrollToAnchor(anchor: string) { + if (typeof document === "undefined") return; + const el = document.getElementById(anchor); + el?.scrollIntoView({ behavior: "smooth", block: "start" }); +} + +export function PticaShell({ activeTab, onTabChange, children }: Props) { + const [clock, setClock] = useState(null); + + useEffect(() => { + function tick() { + const now = new Date(); + const hh = String(now.getHours()).padStart(2, "0"); + const mm = String(now.getMinutes()).padStart(2, "0"); + const ss = String(now.getSeconds()).padStart(2, "0"); + setClock(`${hh}:${mm}:${ss}`); + } + tick(); + const id = window.setInterval(tick, 1000); + return () => window.clearInterval(id); + }, []); + + return ( +
+ {/* ── Left rail ───────────────────────────────────────────────── */} + + + {/* ── Main column ─────────────────────────────────────────────── */} +
+
+
+ +
+
ПТИЦА
+
+ платформа территориального +
+ информационно-цифрового анализа +
+
+
+ +
+
+ {clock ?? "—"} +
+
+
+ +
{children}
+
+
+ ); +} diff --git a/frontend/src/components/site-finder/ptica/ScanCard.tsx b/frontend/src/components/site-finder/ptica/ScanCard.tsx new file mode 100644 index 00000000..a63f234b --- /dev/null +++ b/frontend/src/components/site-finder/ptica/ScanCard.tsx @@ -0,0 +1,61 @@ +"use client"; + +/** + * ScanCard — generic dark cockpit card: title (+ optional badge), metric rows, + * and a "Подробнее" button that is RENDERED but disabled in INCREMENT 1 (drill- + * down detail ships in a later release). + * + * Each row's value flows from a {value, isReal, caption} view-model: placeholder + * fields are rendered muted with their caption so they never read as live data. + */ + +import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; +import type { PticaScanCard } from "@/components/site-finder/ptica/ptica-adapt"; + +interface Props { + card: PticaScanCard; +} + +export function ScanCard({ card }: Props) { + const { title, badge, rows, emptyState } = card; + + return ( +
+

+ {title} + {badge && {badge}} +

+ + {emptyState ? ( +
{emptyState}
+ ) : ( + rows.map((row) => { + const placeholder = !row.field.isReal; + return ( +
+ {row.key} + + {row.field.value} + +
+ ); + }) + )} + + +
+ ); +} diff --git a/frontend/src/components/site-finder/ptica/ptica-adapt.ts b/frontend/src/components/site-finder/ptica/ptica-adapt.ts new file mode 100644 index 00000000..f3f2c9e8 --- /dev/null +++ b/frontend/src/components/site-finder/ptica/ptica-adapt.ts @@ -0,0 +1,369 @@ +/** + * ptica-adapt — PURE typed mappers ParcelAnalysis → cockpit view-models. + * + * INCREMENT 1 contract: render REAL data where the backend provides it, and an + * honest PLACEHOLDER ("—" + caption) where a field is absent or not yet wired. + * Every value the cockpit renders flows through here as a {value, isReal, + * caption?} view-model so the UI never presents a fake hard number as live. + * + * No React, no side effects — just narrowing + formatting. The single source of + * truth for the real-vs-placeholder decision. + */ + +import type { ParcelAnalysis } from "@/types/site-finder"; + +// ── View-model ────────────────────────────────────────────────────────────── + +/** A single field as shown in the cockpit. `caption` annotates placeholders. */ +export interface PticaField { + value: string; + isReal: boolean; + caption?: string; +} + +/** Gauge view-model — value 0..100 (null = no data → render muted track). */ +export interface PticaGauge { + /** 0..100 progress, or null when the metric is unknown. */ + value: number | null; + /** Center caption under the number (e.g. "Можно строить"). */ + label: string; + /** Severity bucket drives the stroke color via CSS tokens. */ + tone: "good" | "warn" | "bad" | "neutral"; + isReal: boolean; + /** Footnote (e.g. "предварительная оценка"). */ + footnote?: string; +} + +/** A titled scan card: metric rows + optional footnote. */ +export interface PticaScanCard { + title: string; + /** Subtitle shown next to the title (e.g. "предв."). */ + badge?: string; + rows: PticaScanRow[]; + /** Empty-state message when there is nothing to render. */ + emptyState?: string; +} + +export interface PticaScanRow { + key: string; + field: PticaField; +} + +const PLACEHOLDER = "—"; + +function real(value: string): PticaField { + return { value, isReal: true }; +} + +function placeholder(caption: string): PticaField { + return { value: PLACEHOLDER, isReal: false, caption }; +} + +// ── Formatting helpers (ru typography) ──────────────────────────────────────── + +function formatInt(n: number): string { + return Math.round(n).toLocaleString("ru-RU"); +} + +/** м² from area_ha (×10000). */ +function areaM2FromHa(areaHa: number): string { + return `${formatInt(areaHa * 10000)} м²`; +} + +function formatHa(areaHa: number): string { + return `${areaHa.toFixed(2)} га`; +} + +function formatRubPerM2(n: number): string { + return `${formatInt(n)} ₽/м²`; +} + +// ── Passport ────────────────────────────────────────────────────────────────── + +export interface PticaPassport { + cadNum: string; + district: PticaField; + area: PticaField; + address: PticaField; + landCategory: PticaField; + vri: PticaField; + status: PticaField; + encumbrances: PticaField; +} + +export function adaptPassport(a: ParcelAnalysis): PticaPassport { + const areaHa = a.geometry_suitability?.area_ha; + const area = + areaHa != null + ? real(`${formatHa(areaHa)} · ${areaM2FromHa(areaHa)}`) + : placeholder("нет геометрии"); + + const districtName = a.district?.district_name; + const address = a.egrn?.address; + const landCategory = a.egrn?.land_category; + const vri = a.egrn?.permitted_use_text; + const status = a.egrn?.parcel_status; + + return { + cadNum: a.cad_num, + district: districtName ? real(districtName) : placeholder("нет данных"), + area, + address: address ? real(address) : placeholder("нет данных ЕГРН"), + landCategory: landCategory + ? real(landCategory) + : placeholder("нет данных ЕГРН"), + vri: vri ? real(vri) : placeholder("нет данных ЕГРН"), + status: status ? real(status) : placeholder("нет данных ЕГРН"), + // Matches the existing analysis page: encumbrances are not surfaced yet. + encumbrances: { value: PLACEHOLDER, isReal: true }, + }; +} + +// ── Buildability gauge (soft proxy — PLACEHOLDER, isReal:false) ──────────────── + +/** + * Derive a soft buildability proxy from the gate verdict + geometry suitability. + * This is NOT a backend score — it's a preliminary heuristic, flagged with a + * "предварительная оценка" footnote so it never reads as a live index. + */ +export function adaptBuildabilityGauge(a: ParcelAnalysis): PticaGauge { + const gate = a.gate_verdict; + const geom = a.geometry_suitability; + + // Gate contributes the bulk; geometry suitability nudges within the band. + let base: number | null = null; + let tone: PticaGauge["tone"] = "neutral"; + let label = "нет данных"; + + if (gate) { + if (gate.can_build_mkd === true) { + base = 78; + tone = "good"; + label = "Можно строить"; + } else if (gate.can_build_mkd === false) { + base = 24; + tone = "bad"; + label = "Нельзя"; + } else { + base = 50; + tone = "warn"; + label = "Нужна проверка"; + } + // Each blocker/warning trims the proxy a little. + base -= gate.blockers.length * 8 + gate.warnings.length * 3; + } + + // Geometry suitability (0..1) shifts the proxy ±10. + if (base != null && geom?.suitability_score != null) { + base += (geom.suitability_score - 0.5) * 20; + } + + const value = + base == null ? null : Math.max(0, Math.min(100, Math.round(base))); + + return { + value, + label, + tone, + isReal: false, + footnote: "предварительная оценка", + }; +} + +/** + * Risk gauge — inverse soft proxy from the same signals. Lower is better. + * PLACEHOLDER ("предв.") until the real risk model is wired. + */ +export function adaptRiskGauge(a: ParcelAnalysis): PticaGauge { + const build = adaptBuildabilityGauge(a); + const value = build.value == null ? null : 100 - build.value; + + let tone: PticaGauge["tone"] = "neutral"; + let label = "нет данных"; + if (value != null) { + if (value <= 33) { + tone = "good"; + label = "Низкий"; + } else if (value <= 66) { + tone = "warn"; + label = "Средний"; + } else { + tone = "bad"; + label = "Высокий"; + } + } + + return { value, label, tone, isReal: false, footnote: "предв." }; +} + +// ── Invest score block (PLACEHOLDER — после прогноза) ───────────────────────── + +export interface PticaInvestScore { + score: PticaField; + potential: PticaField; + risk: PticaField; + buySignal: PticaField; +} + +export function adaptInvestScore(a: ParcelAnalysis): PticaInvestScore { + const risk = adaptRiskGauge(a); + return { + score: placeholder("после прогноза (Scenarios)"), + potential: placeholder("после прогноза (Scenarios)"), + risk: + risk.value != null + ? { value: risk.label, isReal: false, caption: "предв." } + : placeholder("предв."), + buySignal: placeholder("после прогноза (Scenarios)"), + }; +} + +// ── Development Scan cards ───────────────────────────────────────────────────── + +// subtype → ru label for the engineering card. +const UTILITY_LABELS: Record = { + substation: "Подстанция", + pipeline: "Трубопровод", + power_line: "ЛЭП", + water_intake: "Водозабор", + pumping_station: "Насосная станция", +}; + +function utilityLabel(subtype: string): string { + return UTILITY_LABELS[subtype] ?? subtype; +} + +/** Градостроительство — mostly placeholder (НСПД-зонирование / источники). */ +export function adaptUrbanCard(a: ParcelAnalysis): PticaScanCard { + const zoneCode = a.nspd_zoning?.zone_code; + return { + title: "Градостроительство", + rows: [ + { + key: "Зона", + field: zoneCode + ? { value: zoneCode, isReal: true, caption: "НСПД-зонирование" } + : placeholder("источник НСПД-зонирование"), + }, + { + key: "Пред. высота", + field: placeholder("источник НСПД-зонирование"), + }, + { + key: "Плотность (КСИТ)", + field: placeholder("источник НСПД-зонирование"), + }, + { + key: "Пятно застройки", + field: placeholder("после расчёта потенциала"), + }, + ], + }; +} + +/** Ограничения · ЗОУИТ — ЗОУИТ count is real; the rest placeholder. */ +export function adaptRestrictionsCard(a: ParcelAnalysis): PticaScanCard { + const zouit = a.nspd_zouit_overlaps; + return { + title: "Ограничения · ЗОУИТ", + rows: [ + { + key: "ЗОУИТ", + field: + zouit != null + ? real(formatInt(zouit.length)) + : placeholder("нет данных НСПД"), + }, + { key: "Красные линии", field: placeholder("источник НСПД-граддок") }, + { key: "ОКН", field: placeholder("источник НСПД") }, + { key: "Сервитуты", field: placeholder("источник ЕГРН") }, + ], + }; +} + +/** Инженерия — utilities.summary rows (subtype → label + nearest_m). REAL. */ +export function adaptEngineeringCard(a: ParcelAnalysis): PticaScanCard { + const summary = a.utilities?.summary ?? []; + if (summary.length === 0) { + return { + title: "Инженерия", + rows: [], + emptyState: "Нет данных по инженерным сетям", + }; + } + return { + title: "Инженерия", + rows: summary.map((u) => ({ + key: utilityLabel(u.subtype), + field: real(`${formatInt(u.nearest_m)} м`), + })), + }; +} + +/** Рынок — median price + pipeline count are REAL; demand/absorption placeholder. */ +export function adaptMarketCard(a: ParcelAnalysis): PticaScanCard { + const median = a.district?.median_price_per_m2; + const newProjects = a.pipeline_24mo?.objects_count; + return { + title: "Рынок", + rows: [ + { + key: "Медиана цены", + field: + median != null + ? real(formatRubPerM2(median)) + : placeholder("нет данных района"), + }, + { + key: "Новых проектов", + field: + newProjects != null + ? real(formatInt(newProjects)) + : placeholder("нет пайплайна"), + }, + { key: "Поглощение", field: placeholder("после прогноза") }, + { key: "Уровень спроса", field: placeholder("после прогноза") }, + ], + }; +} + +/** Экономика — PLACEHOLDER (предв.), surfaced after the financial model. */ +export function adaptEconomyCard(): PticaScanCard { + return { + title: "Экономика", + badge: "предв.", + rows: [ + { key: "Потенц. выручки", field: placeholder("после финмодели") }, + { key: "Маржа", field: placeholder("после финмодели") }, + { key: "ROI", field: placeholder("после финмодели") }, + { key: "IRR", field: placeholder("после финмодели") }, + ], + }; +} + +/** Среда · экология — PLACEHOLDER for INCREMENT 1. */ +export function adaptEnvironmentCard(): PticaScanCard { + return { + title: "Среда", + badge: "экология", + rows: [ + { key: "Шум", field: placeholder("после атмосферного слоя") }, + { key: "Воздух (AQI)", field: placeholder("после атмосферного слоя") }, + { key: "Зелёных зон", field: placeholder("после атмосферного слоя") }, + ], + }; +} + +/** ОКС — empty-state for INCREMENT 1. */ +export function adaptOksCard(): PticaScanCard { + return { + title: "ОКС", + rows: [], + emptyState: "Нет данных", + }; +} + +// ── Map geometry ────────────────────────────────────────────────────────────── + +/** EKB fallback center (lat, lon). */ +export const EKB_CENTER: [number, number] = [56.838, 60.6];