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 != 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 ─────────────────────────────────────────────── */}
+
+
+
+
+
+
ПТИЦА
+
+ платформа территориального
+
+ информационно-цифрового анализа
+
+
+
+
+ {TABS.map((tab) => (
+ onTabChange(tab.id)}
+ >
+ {tab.label}
+
+ ))}
+
+
+
+
+
{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];