feat(site-finder): ПТИЦА dark cockpit — increment 1 (shell + hero + Development Scan)
All checks were successful
CI / changes (pull_request) Successful in 5s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 55s
CI / openapi-codegen-check (pull_request) Successful in 1m57s

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.
This commit is contained in:
Light1YT 2026-06-20 15:29:53 +05:00
parent a425f2f200
commit 05eb70eb62
15 changed files with 1946 additions and 1 deletions

View file

@ -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 (
<html lang="ru">
<html lang="ru" className={plexMono.variable}>
<body>
<Providers>
<RouteGuard>{children}</RouteGuard>

View file

@ -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<number>(12);
const [tab, setTab] = useState<PticaTab>("analysis");
const { data, isLoading, error } = useParcelAnalyzeQuery(cad, horizon);
// ── Loading ────────────────────────────────────────────────────────────────
if (isLoading) {
return (
<div className={styles.pticaRoot} data-theme="dark">
<div className={styles.stateScreen}>
<div className={styles.stateBox}>Анализируем участок {cad}</div>
</div>
</div>
);
}
// ── Error ──────────────────────────────────────────────────────────────────
if (error || !data) {
const msg =
error instanceof Error ? error.message : "Ошибка загрузки анализа";
return (
<div className={styles.pticaRoot} data-theme="dark">
<div className={styles.stateScreen}>
<div>
<div className={styles.stateError}>{msg}</div>
<Link href="/site-finder" className={styles.stateLink}>
Вернуться к Site Finder
</Link>
</div>
</div>
</div>
);
}
// 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 (
<div className={styles.pticaRoot} data-theme="dark">
<PticaShell activeTab={tab} onTabChange={setTab}>
{tab === "analysis" ? (
<>
<PticaHero analysis={analysis} />
<DevelopmentScan analysis={analysis} />
</>
) : (
<PticaPlaceholderPanel
label="В разработке"
hint="Раздел появится в следующем релизе платформы ПТИЦА."
/>
)}
</PticaShell>
</div>
);
}

View file

@ -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<Metadata> {
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 (
<Suspense
fallback={
<div
// Inline hex intentionally duplicates --bg / --text-muted: the scoped
// CSS-module tokens live under .pticaRoot[data-theme="dark"] and aren't
// reachable from this server-rendered Suspense fallback.
style={{
minHeight: "100vh",
display: "grid",
placeItems: "center",
background: "#060f16",
color: "#8ba6b3",
fontSize: 14,
}}
>
Загрузка ПТИЦА
</div>
}
>
<PticaPageContent cad={cad} />
</Suspense>
);
}

View file

@ -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;
}
}

View file

@ -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<PticaGauge["tone"], string> = {
good: styles.gaugeGood,
warn: styles.gaugeWarn,
bad: styles.gaugeBad,
neutral: styles.gaugeNeutral,
};
const NUM_CLASS: Record<PticaGauge["tone"], string> = {
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 (
<div className={styles.gaugePanel}>
<div
className={styles.cardTitle}
style={{ fontSize: "9.5px", textAlign: "center" }}
>
{title}
</div>
<div className={styles.gauge}>
<svg viewBox="0 0 120 120" aria-hidden="true">
<circle
className={styles.gaugeTrack}
cx="60"
cy="60"
r={RADIUS}
strokeWidth="9"
/>
{value != null && (
<circle
className={`${styles.gaugeProg} ${PROG_CLASS[tone]}`}
cx="60"
cy="60"
r={RADIUS}
strokeWidth="9"
strokeDasharray={CIRCUMFERENCE}
strokeDashoffset={dashOffset}
/>
)}
</svg>
<div className={styles.gaugeCenter}>
<div className={`${styles.gaugeNum} ${NUM_CLASS[tone]}`}>
{value != null ? `${value}%` : "—"}
</div>
<div className={styles.gaugeLab}>{label}</div>
</div>
</div>
{footnote && <div className={styles.gaugeFootnote}>{footnote}</div>}
</div>
);
}

View file

@ -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 (
<>
<div id="scan" className={styles.sectionLabel}>
Development Scan · <b>градостроительный анализ</b>
</div>
<section className={styles.scanGrid}>
<ScanCard card={adaptUrbanCard(analysis)} />
<ScanCard card={adaptRestrictionsCard(analysis)} />
<ScanCard card={adaptEngineeringCard(analysis)} />
<ScanCard card={adaptMarketCard(analysis)} />
<div id="economy">
<ScanCard card={adaptEconomyCard()} />
</div>
<div
id="risks"
className={`${styles.card} ${styles.scanCard}`}
style={{ alignItems: "center" }}
>
<h3 className={styles.cardTitle} style={{ alignSelf: "flex-start" }}>
Риски
</h3>
<BuildabilityGauge gauge={riskGauge} title="Сводный риск" />
</div>
<ScanCard card={adaptEnvironmentCard()} />
<ScanCard card={adaptOksCard()} />
</section>
</>
);
}

View file

@ -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 (
<div className={styles.scoreStack}>
<div>
<div className={styles.statLine}>
<span className={styles.k}>Инвест-скор</span>
</div>
<div className={styles.investScore}>
{inv.score.isReal ? (
<>
<span className={styles.investBig}>{inv.score.value}</span>
<span className={styles.investSmall}>/ 10</span>
</>
) : (
<span className={`${styles.investBig} ${styles.investPlaceholder}`}>
</span>
)}
</div>
{!inv.score.isReal && inv.score.caption && (
<span className={styles.fieldCaption}>{inv.score.caption}</span>
)}
</div>
<div className={styles.statLine}>
<span className={styles.k}>Потенциал</span>
<span className={styles.v} title={inv.potential.caption}>
{inv.potential.value}
</span>
</div>
<div className={styles.statLine}>
<span className={styles.k}>Риск</span>
<span className={styles.v} title={inv.risk.caption}>
{inv.risk.value}
</span>
</div>
<div className={styles.statLine}>
<span className={styles.k}>Buy Signal</span>
<span className={styles.badge} title={inv.buySignal.caption}>
{inv.buySignal.isReal ? inv.buySignal.value : "после прогноза"}
</span>
</div>
</div>
);
}

View file

@ -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 (
<div className={`${styles.kv} ${wide ? styles.kvWide : ""}`}>
<span className={styles.kvK}>{label}</span>
<span
className={`${styles.kvV} ${field.isReal ? "" : styles.kvPlaceholder}`}
>
{field.value}
</span>
{field.caption && !field.isReal && (
<span className={styles.kvCaption}>{field.caption}</span>
)}
</div>
);
}
export function ParcelPassportCard({ analysis }: Props) {
const p: PticaPassport = adaptPassport(analysis);
return (
<div className={styles.panel}>
<div className={styles.cardHead}>
<h3 className={styles.cardTitle}>Паспорт участка</h3>
</div>
<div className={styles.kvGrid}>
<div className={`${styles.kv} ${styles.kvWide}`}>
<span className={styles.kvK}>Кадастровый номер</span>
<span className={`${styles.kvV} ${styles.mono}`}>{p.cadNum}</span>
</div>
<KvCell label="Район" field={p.district} />
<KvCell label="Площадь" field={p.area} />
<KvCell label="Адрес" field={p.address} wide />
<KvCell label="Категория земель" field={p.landCategory} />
<KvCell label="ВРИ" field={p.vri} />
<KvCell label="Статус" field={p.status} />
<KvCell label="Обременения" field={p.encumbrances} />
</div>
</div>
);
}

View file

@ -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 (
<section id="passport" className={styles.hero}>
<PticaMap geojson={analysis.geom_geojson} />
<ParcelPassportCard analysis={analysis} />
<div id="potential" className={`${styles.panel} ${styles.scorePanel}`}>
<BuildabilityGauge gauge={buildGauge} title="Buildability Index" />
<InvestScoreBlock analysis={analysis} />
</div>
</section>
);
}

View file

@ -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: () => <div className={styles.mapLoading}>Загрузка карты</div>,
},
);
interface Props {
geojson: unknown;
}
export function PticaMap({ geojson }: Props) {
return (
<div className={styles.mapCard}>
<PticaMapInner geojson={geojson} />
</div>
);
}

View file

@ -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 (
<div className={styles.mapMount}>
<MapContainer center={center} zoom={geom ? 15 : 11} scrollWheelZoom>
<TileLayer
attribution='&copy; <a href="https://carto.com/attributions">CARTO</a> · &copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>'
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
/>
{geom && (
<GeoJSON
data={geom}
style={{
// = --accent-cyan (Leaflet style objects can't read CSS vars)
color: "#7fd0ee",
weight: 2.5,
fillColor: "#7fd0ee",
fillOpacity: 0.18,
}}
/>
)}
</MapContainer>
</div>
);
}

View file

@ -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 (
<div className={`${styles.panel} ${styles.placeholderPanel}`}>
<div className={styles.soon}>{label}</div>
{hint && <p>{hint}</p>}
</div>
);
}

View file

@ -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 }) => (
<svg className={className} viewBox="0 0 40 40" fill="none" aria-hidden="true">
<path
d="M6 15 C14 13 18 17 20 23 C22 17 26 13 34 15 C27 17 23 22 20 30 C17 22 13 17 6 15 Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
);
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<string | null>(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 (
<div className={styles.shell}>
{/* ── Left rail ───────────────────────────────────────────────── */}
<aside className={styles.rail}>
<div className={styles.brand}>
<BirdMark className={styles.brandMark} />
</div>
<nav className={styles.nav} aria-label="Разделы анализа">
{RAIL.map((item) => (
<button
key={item.anchor}
type="button"
className={styles.navItem}
onClick={() => scrollToAnchor(item.anchor)}
>
<span>{item.label}</span>
</button>
))}
</nav>
<div className={styles.navFoot}>
<div className={styles.mono} suppressHydrationWarning>
DATA
<br />
{clock ?? "—"}
</div>
</div>
</aside>
{/* ── Main column ─────────────────────────────────────────────── */}
<div className={styles.main}>
<header className={styles.topbar}>
<div className={styles.wordmark}>
<BirdMark className={styles.bird} />
<div>
<div className={styles.wordmarkTitle}>ПТИЦА</div>
<div className={styles.wordmarkSub}>
платформа территориального
<br />
информационно-цифрового анализа
</div>
</div>
</div>
<nav className={styles.tabs} aria-label="Режимы">
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
className={`${styles.tab} ${
activeTab === tab.id ? styles.tabActive : ""
}`}
aria-current={activeTab === tab.id ? "page" : undefined}
onClick={() => onTabChange(tab.id)}
>
{tab.label}
</button>
))}
</nav>
<div className={styles.sysbar}>
<div className={styles.clock} suppressHydrationWarning>
<b>{clock ?? "—"}</b>
</div>
</div>
</header>
<div className={styles.canvas}>{children}</div>
</div>
</div>
);
}

View file

@ -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 (
<div className={`${styles.card} ${styles.scanCard}`}>
<h3 className={styles.cardTitle}>
{title}
{badge && <span className={styles.cardTitleSub}>{badge}</span>}
</h3>
{emptyState ? (
<div className={styles.emptyState}>{emptyState}</div>
) : (
rows.map((row) => {
const placeholder = !row.field.isReal;
return (
<div
key={row.key}
className={`${styles.kvrow} ${
placeholder ? styles.kvrowPlaceholder : ""
}`}
>
<span className={styles.k}>{row.key}</span>
<span className={styles.v} title={row.field.caption}>
{row.field.value}
</span>
</div>
);
})
)}
<button
type="button"
className={styles.detailBtn}
disabled
aria-disabled="true"
title="Детализация — в следующем релизе"
>
Подробнее
</button>
</div>
);
}

View file

@ -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<string, string> = {
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];