feat(site-finder): ПТИЦА dark cockpit — increment 1 (shell + hero + Development Scan)
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:
parent
a425f2f200
commit
05eb70eb62
15 changed files with 1946 additions and 1 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
55
frontend/src/app/site-finder/analysis/[cad]/ptica/page.tsx
Normal file
55
frontend/src/app/site-finder/analysis/[cad]/ptica/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/site-finder/ptica/PticaHero.tsx
Normal file
35
frontend/src/components/site-finder/ptica/PticaHero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
frontend/src/components/site-finder/ptica/PticaMap.tsx
Normal file
31
frontend/src/components/site-finder/ptica/PticaMap.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
frontend/src/components/site-finder/ptica/PticaMapInner.tsx
Normal file
123
frontend/src/components/site-finder/ptica/PticaMapInner.tsx
Normal 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='© <a href="https://carto.com/attributions">CARTO</a> · © <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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
152
frontend/src/components/site-finder/ptica/PticaShell.tsx
Normal file
152
frontend/src/components/site-finder/ptica/PticaShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
frontend/src/components/site-finder/ptica/ScanCard.tsx
Normal file
61
frontend/src/components/site-finder/ptica/ScanCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
369
frontend/src/components/site-finder/ptica/ptica-adapt.ts
Normal file
369
frontend/src/components/site-finder/ptica/ptica-adapt.ts
Normal 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];
|
||||
Loading…
Add table
Reference in a new issue