feat(ptica): complete cockpit lower-grid + bottom-grid sections
Port the prototype's missing sections — the React cockpit had only hero +
Development Scan, so the deployed page was incomplete («поплывшее»). Add:
- lower-grid: ОКС · Development Potential · Recommended Product (квартирография)
· Инсоляционная матрица (opens 3D);
- bottom-grid: Investment Clearance · Buy Signal · Legal Status · Site Verdict
(«Приобретать/Нельзя/Нужна проверка» plaque + checklist).
Move ОКС + Инсоляция out of DevelopmentScan → scan = 7 cards (prototype IA).
Data honesty: Development Potential (real КСИТ/площадь/плотность), Recommended
Product (forecast product_tz), Buy Signal + Site Verdict (forecast deficit +
gate_verdict, real, three-state), Legal (zouit/red_lines/gate). Investment
Clearance all «—» «после финмодели (§23)» — no fabricated numbers. Typed
{value,isReal,caption} adapters; scoped dark CSS; forecast-pending safe.
Cleanup: drop orphaned adaptOksCard/adaptInsolationCard; add .metric/.bignum.
This commit is contained in:
parent
4205ecb483
commit
24b5e6047f
15 changed files with 1441 additions and 54 deletions
|
|
@ -33,6 +33,8 @@ 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 { PticaLowerGrid } from "@/components/site-finder/ptica/cockpit/PticaLowerGrid";
|
||||
import { PticaBottomGrid } from "@/components/site-finder/ptica/cockpit/PticaBottomGrid";
|
||||
import { PticaScenarios } from "@/components/site-finder/ptica/scenarios/PticaScenarios";
|
||||
import { PticaReports } from "@/components/site-finder/ptica/reports/PticaReports";
|
||||
import { PticaCompare } from "@/components/site-finder/ptica/compare/PticaCompare";
|
||||
|
|
@ -146,6 +148,16 @@ export function PticaPageContent({ cad }: Props) {
|
|||
analysis={analysis}
|
||||
onOpenDrawer={handleOpenDrawer}
|
||||
/>
|
||||
<PticaLowerGrid
|
||||
analysis={analysis}
|
||||
forecastReport={forecastReport}
|
||||
onOpenDrawer={handleOpenDrawer}
|
||||
/>
|
||||
<PticaBottomGrid
|
||||
analysis={analysis}
|
||||
forecastReport={forecastReport}
|
||||
onOpenDrawer={handleOpenDrawer}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{tab === "scenarios" && (
|
||||
|
|
|
|||
|
|
@ -656,7 +656,7 @@
|
|||
}
|
||||
.scanGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.scanCard {
|
||||
|
|
@ -1879,7 +1879,11 @@
|
|||
}
|
||||
@media (max-width: 1200px) {
|
||||
.scanGrid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.lowerGrid,
|
||||
.bottomGrid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.forecastGrid,
|
||||
.reportGrid {
|
||||
|
|
@ -1894,7 +1898,9 @@
|
|||
display: none;
|
||||
}
|
||||
.hero,
|
||||
.scanGrid {
|
||||
.scanGrid,
|
||||
.lowerGrid,
|
||||
.bottomGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.scorePanel {
|
||||
|
|
@ -2167,3 +2173,309 @@
|
|||
border-radius: 999px;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
/* ===================== LOWER GRID (cockpit) =====================
|
||||
Ported from prototype .lower-grid: ОКС · Development Potential ·
|
||||
Recommended Product · Инсоляционная матрица. */
|
||||
.lowerGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.05fr 1.05fr 1.15fr 0.95fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* shared detail-btn that needs top separation inside flow cards */
|
||||
.detailBtnSpaced {
|
||||
margin-top: 10px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* ОКС — isometric building visual + empty-state */
|
||||
.isoBox {
|
||||
height: 84px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 2px 0 10px;
|
||||
}
|
||||
.isoBox svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.isoFaceLeft {
|
||||
fill: rgba(210, 101, 91, 0.22);
|
||||
stroke: var(--accent-red);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.isoFaceTop {
|
||||
fill: rgba(210, 101, 91, 0.13);
|
||||
stroke: var(--accent-red);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.isoFaceRight {
|
||||
fill: rgba(210, 101, 91, 0.3);
|
||||
stroke: var(--accent-red);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
/* Development Potential — metric tiles */
|
||||
.potentialGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 9px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
/* grid-item wrapper (label + value stack); min-width:0 stops long values
|
||||
blowing out the grid track. */
|
||||
.metric {
|
||||
min-width: 0;
|
||||
}
|
||||
.metricLabel {
|
||||
font-size: 8.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-soft);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.metricValue {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.metricValueLg {
|
||||
font-size: 22px;
|
||||
}
|
||||
.metricValuePlaceholder {
|
||||
color: var(--text-soft);
|
||||
font-weight: 500;
|
||||
}
|
||||
.metricUnit {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
/* Recommended Product — mix-bar head (квартирография / доля) */
|
||||
.mixHead {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 8.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-soft);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Инсоляционная матрица — faint preview massing + ПРЕВЬЮ placeholder */
|
||||
.isoBoxPreview {
|
||||
height: 74px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 2px 0 10px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.isoBoxPreview svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.isoPreviewLeft {
|
||||
fill: rgba(150, 192, 214, 0.1);
|
||||
stroke: var(--border-strong);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.isoPreviewTop {
|
||||
fill: rgba(150, 192, 214, 0.06);
|
||||
stroke: var(--border-strong);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.isoPreviewRight {
|
||||
fill: rgba(150, 192, 214, 0.14);
|
||||
stroke: var(--border-strong);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.isoPreviewEdge {
|
||||
stroke: var(--border);
|
||||
stroke-width: 0.6;
|
||||
}
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.placeholderSoon {
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-soft);
|
||||
font-weight: 600;
|
||||
}
|
||||
.placeholder p {
|
||||
font-size: 10px;
|
||||
color: var(--text-soft);
|
||||
margin: 0;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
/* ===================== BOTTOM GRID (cockpit) =====================
|
||||
Ported from prototype .bottom-grid: Investment Clearance · Buy Signal ·
|
||||
Legal Status · Site Verdict. */
|
||||
.bottomGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 0.62fr 0.86fr 1.2fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Investment Clearance — bignum row */
|
||||
.bignumRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.bignum {
|
||||
min-width: 0;
|
||||
}
|
||||
.bignumLabel {
|
||||
font-size: 8.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-soft);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.bignumValue {
|
||||
font-size: 30px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--text-strong);
|
||||
line-height: 1;
|
||||
}
|
||||
.bignumValuePlaceholder {
|
||||
color: var(--text-soft);
|
||||
}
|
||||
.bignumUnit {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.bignumCaption {
|
||||
margin: 12px 0 0;
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
/* Buy Signal — centered gauge */
|
||||
.buySignalCard {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.buySignalTitle {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* Legal Status — Нет/значение grid */
|
||||
.legalGrid {
|
||||
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;
|
||||
}
|
||||
.legalCell {
|
||||
background: var(--surface-2);
|
||||
padding: 9px 11px;
|
||||
}
|
||||
.legalKey {
|
||||
display: block;
|
||||
font-size: 8.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-soft);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.legalValue {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.legalValuePlaceholder {
|
||||
color: var(--text-soft);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Site Verdict — headline plaque + checklist */
|
||||
.verdictCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.verdictSplit {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
}
|
||||
.verdictLead {
|
||||
text-align: center;
|
||||
}
|
||||
.verdictLeadLabel {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-soft);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.verdictWord {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
text-shadow: 0 0 22px var(--glow);
|
||||
}
|
||||
.verdictWordGood {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
.verdictWordWarn {
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
.verdictWordBad {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
.checklist {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
.checkItem {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.checkIc {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: none;
|
||||
}
|
||||
.checkItemOk .checkIc {
|
||||
color: var(--accent-green);
|
||||
border: var(--line-strong) solid currentColor;
|
||||
border-radius: 50%;
|
||||
padding: 2px;
|
||||
}
|
||||
.checkItemWarn .checkIc {
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
.checkItemBad .checkIc {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,9 +41,11 @@ export function BuildabilityGauge({ gauge, title }: Props) {
|
|||
|
||||
return (
|
||||
<div className={styles.gaugePanel}>
|
||||
{title && (
|
||||
<div className={`${styles.cardTitle} ${styles.cardTitleGauge}`}>
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.gauge}>
|
||||
<svg viewBox="0 0 120 120" aria-hidden="true">
|
||||
<circle
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* DevelopmentScan — "Development Scan" section: a responsive grid of ScanCards
|
||||
* DevelopmentScan — "Development Scan" section: a 7-card grid of ScanCards
|
||||
* (Градостроительство / Ограничения / Инженерия / Рынок / Экономика / Риски /
|
||||
* Среда + ОКС). Every card's real-vs-placeholder content comes from the adapter.
|
||||
* Среда), matching the prototype scan-grid. ОКС and Инсоляция live in the
|
||||
* lower-grid (PticaLowerGrid). Every card's real-vs-placeholder content comes
|
||||
* from the adapter.
|
||||
*
|
||||
* Each card maps to its drawer key and opens the matching detail drawer via the
|
||||
* `onOpenDrawer` callback threaded down from PticaPageContent.
|
||||
|
|
@ -20,8 +22,6 @@ import {
|
|||
adaptMarketCard,
|
||||
adaptEconomyCard,
|
||||
adaptEnvironmentCard,
|
||||
adaptOksCard,
|
||||
adaptInsolationCard,
|
||||
adaptRiskGauge,
|
||||
} from "@/components/site-finder/ptica/ptica-adapt";
|
||||
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
|
||||
|
|
@ -89,14 +89,8 @@ export function DevelopmentScan({ analysis, onOpenDrawer }: Props) {
|
|||
drawerKey="environment"
|
||||
onOpen={onOpenDrawer}
|
||||
/>
|
||||
<ScanCard card={adaptOksCard()} drawerKey="oks" onOpen={onOpenDrawer} />
|
||||
{/* Инсоляция · 3D-масса — opens the future3d/insolation drawer (3D
|
||||
massing + shadow preview) so the 3D module is discoverable here. */}
|
||||
<ScanCard
|
||||
card={adaptInsolationCard(analysis)}
|
||||
drawerKey="insolation"
|
||||
onOpen={onOpenDrawer}
|
||||
/>
|
||||
{/* ОКС and Инсоляция · 3D-масса live in the lower-grid (PticaLowerGrid)
|
||||
per the prototype, keeping the scan at 7 cards. */}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* BuySignalCard — bottom-grid «Buy Signal» gauge. Derived from the §22 forecast
|
||||
* exec-summary deficit_index (same logic as the invest-score buy-signal): >0.05
|
||||
* приобретать / <−0.05 пропустить / else наблюдать. Advisory (§22). While the
|
||||
* forecast is pending the gauge shows the honest «после прогноза» placeholder.
|
||||
*/
|
||||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
import type { ForecastReport } from "@/types/forecast";
|
||||
import { BuildabilityGauge } from "@/components/site-finder/ptica/BuildabilityGauge";
|
||||
import { adaptBuySignalGauge } from "@/components/site-finder/ptica/ptica-adapt";
|
||||
|
||||
interface Props {
|
||||
forecastReport?: ForecastReport;
|
||||
}
|
||||
|
||||
export function BuySignalCard({ forecastReport }: Props) {
|
||||
const { gauge } = adaptBuySignalGauge(forecastReport);
|
||||
|
||||
return (
|
||||
<div className={`${styles.card} ${styles.buySignalCard}`}>
|
||||
<h3 className={`${styles.cardTitle} ${styles.buySignalTitle}`}>
|
||||
Buy Signal
|
||||
</h3>
|
||||
<BuildabilityGauge gauge={gauge} title="" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* DevelopmentPotentialCard — lower-grid «Development Potential» card. Renders the
|
||||
* REAL ёмкость-метрики (площадь · КСИТ · плотность · высота · пятно · продаваемая)
|
||||
* from `adaptPotentialCard` (geometry + НСПД-регламент, post-#1847) as the
|
||||
* prototype's `.potential-grid` tiles, honest «—» where an input is absent.
|
||||
* "Подробнее" opens the potential drawer.
|
||||
*/
|
||||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||
import { adaptPotentialCard } from "@/components/site-finder/ptica/ptica-adapt";
|
||||
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
|
||||
|
||||
interface Props {
|
||||
analysis: ParcelAnalysis;
|
||||
onOpenDrawer: (key: DrawerKey) => void;
|
||||
}
|
||||
|
||||
export function DevelopmentPotentialCard({ analysis, onOpenDrawer }: Props) {
|
||||
const { metrics } = adaptPotentialCard(analysis);
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.cardHead}>
|
||||
<h3 className={styles.cardTitle}>Development Potential</h3>
|
||||
</div>
|
||||
|
||||
<div className={styles.potentialGrid}>
|
||||
{metrics.map((m) => (
|
||||
<div key={m.label} className={styles.metric}>
|
||||
<div className={styles.metricLabel}>{m.label}</div>
|
||||
<div
|
||||
className={`${styles.metricValue} ${m.lead ? styles.metricValueLg : ""} ${
|
||||
m.isReal ? "" : styles.metricValuePlaceholder
|
||||
}`}
|
||||
title={m.caption}
|
||||
>
|
||||
{m.value}
|
||||
{m.unit && <span className={styles.metricUnit}>{m.unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.detailBtn}
|
||||
onClick={() => onOpenDrawer("potential")}
|
||||
title="Открыть детализацию потенциала"
|
||||
>
|
||||
Подробнее
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* InsolationCard — lower-grid «Инсоляционная матрица» preview card. Ports the
|
||||
* prototype's faint isometric massing visual + «ПРЕВЬЮ» state; «Открыть 3D»
|
||||
* opens the insolation / future3d drawer (the 3D massing + shadow module).
|
||||
*/
|
||||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
|
||||
|
||||
interface Props {
|
||||
onOpenDrawer: (key: DrawerKey) => void;
|
||||
}
|
||||
|
||||
export function InsolationCard({ onOpenDrawer }: Props) {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.cardHead}>
|
||||
<h3 className={styles.cardTitle}>Инсоляционная матрица</h3>
|
||||
<span className={styles.openTag}>превью</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.isoBoxPreview}>
|
||||
<svg viewBox="0 0 200 74" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M60 50 L100 30 L100 8 L60 28 Z"
|
||||
className={styles.isoPreviewLeft}
|
||||
/>
|
||||
<path
|
||||
d="M100 30 L140 50 L140 28 L100 8 Z"
|
||||
className={styles.isoPreviewTop}
|
||||
/>
|
||||
<path
|
||||
d="M60 50 L100 70 L140 50 L100 30 Z"
|
||||
className={styles.isoPreviewRight}
|
||||
/>
|
||||
<path d="M100 30 L100 70" className={styles.isoPreviewEdge} />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className={styles.placeholder}>
|
||||
<div className={styles.placeholderSoon}>ПРЕВЬЮ</div>
|
||||
<p>Тени по времени суток — в 3D-модуле массы застройки.</p>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.detailBtn}
|
||||
onClick={() => onOpenDrawer("insolation")}
|
||||
title="Открыть 3D-модуль"
|
||||
>
|
||||
Открыть 3D
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* InvestmentClearance — bottom-grid «Investment Clearance» card. The §23 finance
|
||||
* model is NOT in the backend, so every bignum (GDV / Cost / Profit / ROI / IRR)
|
||||
* is an honest «—» placeholder with a «после финмодели (§23)» caption — NO
|
||||
* fabricated numbers. The bignum layout matches the prototype. "Подробнее" opens
|
||||
* the finance drawer.
|
||||
*/
|
||||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
import { adaptInvestmentClearance } from "@/components/site-finder/ptica/ptica-adapt";
|
||||
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
|
||||
|
||||
interface Props {
|
||||
onOpenDrawer: (key: DrawerKey) => void;
|
||||
}
|
||||
|
||||
export function InvestmentClearance({ onOpenDrawer }: Props) {
|
||||
const { bignums, caption } = adaptInvestmentClearance();
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.cardHead}>
|
||||
<h3 className={styles.cardTitle}>
|
||||
Investment Clearance
|
||||
<span className={styles.cardTitleSub}>финансовая модель</span>
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.detailBtn}
|
||||
onClick={() => onOpenDrawer("finance")}
|
||||
title="Открыть финансовую модель"
|
||||
>
|
||||
Подробнее
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.bignumRow}>
|
||||
{bignums.map((b) => (
|
||||
<div key={b.label} className={styles.bignum}>
|
||||
<div className={styles.bignumLabel}>{b.label}</div>
|
||||
<div
|
||||
className={`${styles.bignumValue} ${
|
||||
b.isReal ? "" : styles.bignumValuePlaceholder
|
||||
}`}
|
||||
title={b.caption}
|
||||
>
|
||||
{b.value}
|
||||
</div>
|
||||
{b.unit && <span className={styles.bignumUnit}>{b.unit}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className={styles.bignumCaption}>{caption}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* LegalStatus — bottom-grid «Legal Status» card. Renders ОКН / сервитуты /
|
||||
* красные линии / аренда / ЗОУИТ / ограничения оборота as Нет/значение rows from
|
||||
* `adaptLegalStatus`: REAL where /analyze provides it (ЗОУИТ count, красные
|
||||
* линии, gate blockers), honest «—» for the rest. "Подробнее" opens the legal
|
||||
* drawer.
|
||||
*/
|
||||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||
import { adaptLegalStatus } from "@/components/site-finder/ptica/ptica-adapt";
|
||||
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
|
||||
|
||||
interface Props {
|
||||
analysis: ParcelAnalysis;
|
||||
onOpenDrawer: (key: DrawerKey) => void;
|
||||
}
|
||||
|
||||
export function LegalStatus({ analysis, onOpenDrawer }: Props) {
|
||||
const { rows } = adaptLegalStatus(analysis);
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.cardHead}>
|
||||
<h3 className={styles.cardTitle}>Legal Status</h3>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.detailBtn}
|
||||
onClick={() => onOpenDrawer("legal")}
|
||||
title="Открыть правовой статус"
|
||||
>
|
||||
Подробнее
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.legalGrid}>
|
||||
{rows.map((row) => (
|
||||
<div key={row.k} className={styles.legalCell}>
|
||||
<span className={styles.legalKey}>{row.k}</span>
|
||||
<span
|
||||
className={`${styles.legalValue} ${
|
||||
row.field.isReal ? "" : styles.legalValuePlaceholder
|
||||
}`}
|
||||
title={row.field.caption}
|
||||
>
|
||||
{row.field.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* OksCard — lower-grid «Объекты кап. строительства · ОКС» card. The backend does
|
||||
* not surface ОКС on the parcel, so the card shows the prototype's isometric
|
||||
* building visual + an honest empty-state «Нет данных» (no fabricated counts).
|
||||
* The "Подробнее" button opens the oks drawer.
|
||||
*/
|
||||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
import { adaptOksLowerCard } from "@/components/site-finder/ptica/ptica-adapt";
|
||||
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
|
||||
|
||||
interface Props {
|
||||
onOpenDrawer: (key: DrawerKey) => void;
|
||||
}
|
||||
|
||||
export function OksCard({ onOpenDrawer }: Props) {
|
||||
const card = adaptOksLowerCard();
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.cardHead}>
|
||||
<h3 className={styles.cardTitle}>
|
||||
Объекты кап. строительства
|
||||
<span className={styles.cardTitleSub}>ОКС</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className={styles.isoBox}>
|
||||
<svg viewBox="0 0 200 84" fill="none" aria-hidden="true">
|
||||
<g opacity="0.9">
|
||||
<path
|
||||
d="M40 56 L70 42 L70 22 L40 36 Z"
|
||||
className={styles.isoFaceLeft}
|
||||
/>
|
||||
<path
|
||||
d="M70 42 L92 52 L92 32 L70 22 Z"
|
||||
className={styles.isoFaceTop}
|
||||
/>
|
||||
<path
|
||||
d="M40 56 L62 66 L92 52 L70 42 Z"
|
||||
className={styles.isoFaceRight}
|
||||
/>
|
||||
<path
|
||||
d="M100 50 L120 40 L120 26 L100 36 Z"
|
||||
className={styles.isoFaceLeft}
|
||||
/>
|
||||
<path
|
||||
d="M120 40 L138 48 L138 34 L120 26 Z"
|
||||
className={styles.isoFaceTop}
|
||||
/>
|
||||
<path
|
||||
d="M100 50 L118 58 L138 48 L120 40 Z"
|
||||
className={styles.isoFaceRight}
|
||||
/>
|
||||
<path
|
||||
d="M146 56 L162 47 L162 36 L146 45 Z"
|
||||
className={styles.isoFaceLeft}
|
||||
/>
|
||||
<path
|
||||
d="M146 56 L160 63 L176 54 L162 47 Z"
|
||||
className={styles.isoFaceRight}
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className={styles.emptyState}>{card.emptyState}</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.detailBtn} ${styles.detailBtnSpaced}`}
|
||||
onClick={() => onOpenDrawer("oks")}
|
||||
title="Открыть детализацию ОКС"
|
||||
>
|
||||
Подробнее
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* PticaBottomGrid — the prototype's `.bottom-grid` row, rendered below the lower
|
||||
* grid: Investment Clearance · Buy Signal · Legal Status · Site Verdict. Each
|
||||
* card wires to REAL /analyze + §22 data with honest placeholders.
|
||||
*/
|
||||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||
import type { ForecastReport } from "@/types/forecast";
|
||||
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
|
||||
import { InvestmentClearance } from "./InvestmentClearance";
|
||||
import { BuySignalCard } from "./BuySignalCard";
|
||||
import { LegalStatus } from "./LegalStatus";
|
||||
import { SiteVerdictCard } from "./SiteVerdictCard";
|
||||
|
||||
interface Props {
|
||||
analysis: ParcelAnalysis;
|
||||
forecastReport?: ForecastReport;
|
||||
onOpenDrawer: (key: DrawerKey) => void;
|
||||
}
|
||||
|
||||
export function PticaBottomGrid({
|
||||
analysis,
|
||||
forecastReport,
|
||||
onOpenDrawer,
|
||||
}: Props) {
|
||||
return (
|
||||
<section className={styles.bottomGrid}>
|
||||
<InvestmentClearance onOpenDrawer={onOpenDrawer} />
|
||||
<BuySignalCard forecastReport={forecastReport} />
|
||||
<LegalStatus analysis={analysis} onOpenDrawer={onOpenDrawer} />
|
||||
<SiteVerdictCard analysis={analysis} forecastReport={forecastReport} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* PticaLowerGrid — the prototype's `.lower-grid` row, rendered below the
|
||||
* Development Scan: ОКС · Development Potential · Recommended Product ·
|
||||
* Инсоляционная матрица. Each card wires to REAL /analyze + §22 data with honest
|
||||
* placeholders and opens its detail drawer via `onOpenDrawer`.
|
||||
*/
|
||||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||
import type { ForecastReport } from "@/types/forecast";
|
||||
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
|
||||
import { OksCard } from "./OksCard";
|
||||
import { DevelopmentPotentialCard } from "./DevelopmentPotentialCard";
|
||||
import { RecommendedProductCard } from "./RecommendedProductCard";
|
||||
import { InsolationCard } from "./InsolationCard";
|
||||
|
||||
interface Props {
|
||||
analysis: ParcelAnalysis;
|
||||
forecastReport?: ForecastReport;
|
||||
onOpenDrawer: (key: DrawerKey) => void;
|
||||
}
|
||||
|
||||
export function PticaLowerGrid({
|
||||
analysis,
|
||||
forecastReport,
|
||||
onOpenDrawer,
|
||||
}: Props) {
|
||||
return (
|
||||
<section className={styles.lowerGrid}>
|
||||
<OksCard onOpenDrawer={onOpenDrawer} />
|
||||
<DevelopmentPotentialCard
|
||||
analysis={analysis}
|
||||
onOpenDrawer={onOpenDrawer}
|
||||
/>
|
||||
<RecommendedProductCard
|
||||
forecastReport={forecastReport}
|
||||
onOpenDrawer={onOpenDrawer}
|
||||
/>
|
||||
<InsolationCard onOpenDrawer={onOpenDrawer} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* RecommendedProductCard — lower-grid «Recommended Product» card. Renders the
|
||||
* recommended класс (tag) + квартирография mix bars from the §22
|
||||
* `forecastReport.product_tz`, reusing the exact bar logic of the Scenarios-tab
|
||||
* `RecommendedProduct` (доля when an explicit `pct` mix exists, else
|
||||
* |deficit_index| as signal strength). While the forecast is pending → the
|
||||
* honest «после прогноза» placeholder. "Подробнее" opens the product drawer.
|
||||
*/
|
||||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
import type { ForecastReport, ProductMixEntry } from "@/types/forecast";
|
||||
import {
|
||||
DEFICIT_BALANCE_EPS,
|
||||
deficitTone,
|
||||
fmtNum,
|
||||
} from "@/components/site-finder/ptica/scenarios/scenario-helpers";
|
||||
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
|
||||
|
||||
interface Props {
|
||||
/** §22 forecast report — quartirografiya comes from `product_tz`. */
|
||||
forecastReport?: ForecastReport;
|
||||
onOpenDrawer: (key: DrawerKey) => void;
|
||||
}
|
||||
|
||||
const TONE_CLASS = {
|
||||
good: styles.hbarFillGood,
|
||||
bad: styles.hbarFillBad,
|
||||
warn: styles.hbarFillBad,
|
||||
neutral: styles.hbarFillNeutral,
|
||||
} as const;
|
||||
|
||||
interface MixBar {
|
||||
key: string;
|
||||
label: string;
|
||||
widthPct: number;
|
||||
valueStr: string;
|
||||
tone: "good" | "warn" | "bad" | "neutral";
|
||||
}
|
||||
|
||||
function buildBars(mix: ProductMixEntry[]): {
|
||||
bars: MixBar[];
|
||||
byShare: boolean;
|
||||
} {
|
||||
const rows = mix.filter((m) => m.pct != null || m.deficit_index != null);
|
||||
const byShare = rows.some((m) => m.pct != null);
|
||||
const bars = rows.map((m, i): MixBar => {
|
||||
if (m.pct != null) {
|
||||
const pct = Math.max(0, Math.min(100, m.pct));
|
||||
return {
|
||||
key: `${m.bucket ?? "?"}-${m.obj_class ?? "?"}-${i}`,
|
||||
label: m.bucket ?? "формат",
|
||||
widthPct: pct,
|
||||
valueStr: `${fmtNum(pct, pct % 1 === 0 ? 0 : 1)}%`,
|
||||
tone: "neutral",
|
||||
};
|
||||
}
|
||||
const di = m.deficit_index ?? 0;
|
||||
const balanced = Math.abs(di) < DEFICIT_BALANCE_EPS;
|
||||
return {
|
||||
key: `${m.bucket ?? "?"}-${m.obj_class ?? "?"}-${i}`,
|
||||
label: m.bucket ?? "формат",
|
||||
widthPct: Math.max(0, Math.min(100, Math.abs(di) * 100)),
|
||||
valueStr: `${di > 0 ? "+" : ""}${fmtNum(di, 2)}`,
|
||||
tone: balanced ? "neutral" : deficitTone(di),
|
||||
};
|
||||
});
|
||||
return { bars, byShare };
|
||||
}
|
||||
|
||||
function capitalize(text: string): string {
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
|
||||
export function RecommendedProductCard({
|
||||
forecastReport,
|
||||
onOpenDrawer,
|
||||
}: Props) {
|
||||
const tz = forecastReport?.product_tz;
|
||||
const hasProduct =
|
||||
tz != null && (tz.obj_class != null || tz.mix.length > 0 || !!tz.summary);
|
||||
const { bars, byShare } = tz
|
||||
? buildBars(tz.mix)
|
||||
: { bars: [], byShare: false };
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.cardHead}>
|
||||
<h3 className={styles.cardTitle}>
|
||||
Recommended Product
|
||||
<span className={styles.cardTitleSub}>продуктовая стратегия</span>
|
||||
</h3>
|
||||
{hasProduct && tz?.obj_class && (
|
||||
<span className={styles.tag}>{capitalize(tz.obj_class)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasProduct && bars.length > 0 ? (
|
||||
<>
|
||||
<div className={styles.mixHead}>
|
||||
<span>Квартирография</span>
|
||||
<span>{byShare ? "доля" : "сигнал"}</span>
|
||||
</div>
|
||||
<div className={styles.hbars}>
|
||||
{bars.map((b) => (
|
||||
<div key={b.key} className={styles.hbar}>
|
||||
<span className={styles.hbarName}>{b.label}</span>
|
||||
<div className={styles.hbarTrack}>
|
||||
<div
|
||||
className={`${styles.hbarFill} ${TONE_CLASS[b.tone]}`}
|
||||
style={{ width: `${b.widthPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.hbarValue}>{b.valueStr}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!byShare && (
|
||||
<p className={styles.panelHint}>
|
||||
Длина полосы — сила сигнала по индексу дефицита формата: зелёный —
|
||||
недонасыщенность, красный — затоварка.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.emptyState}>после прогноза (Сценарии)</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.detailBtn} ${styles.detailBtnSpaced}`}
|
||||
onClick={() => onOpenDrawer("product")}
|
||||
title="Открыть детализацию продукта"
|
||||
>
|
||||
Подробнее
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* SiteVerdictCard — bottom-grid «Site Verdict» plaque: the JTBD «стоит ли брать
|
||||
* участок» answer. The big verdict word comes from gate_verdict
|
||||
* (can_build_mkd → Приобретать/Нельзя/Нужна проверка); the checklist is derived
|
||||
* from REAL signals (рыночный спрос, потенциал застройки, инженерия) and the gate
|
||||
* blockers/warnings. Ports the prototype's `.verdict-card`/`.verdict-split`.
|
||||
*/
|
||||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||
import type { ForecastReport } from "@/types/forecast";
|
||||
import {
|
||||
adaptSiteVerdict,
|
||||
type PticaVerdictCheckItem,
|
||||
} from "@/components/site-finder/ptica/ptica-adapt";
|
||||
|
||||
interface Props {
|
||||
analysis: ParcelAnalysis;
|
||||
forecastReport?: ForecastReport;
|
||||
}
|
||||
|
||||
const WORD_TONE_CLASS = {
|
||||
good: styles.verdictWordGood,
|
||||
warn: styles.verdictWordWarn,
|
||||
bad: styles.verdictWordBad,
|
||||
} as const;
|
||||
|
||||
const ITEM_TONE_CLASS: Record<PticaVerdictCheckItem["tone"], string> = {
|
||||
ok: styles.checkItemOk,
|
||||
warn: styles.checkItemWarn,
|
||||
bad: styles.checkItemBad,
|
||||
};
|
||||
|
||||
function CheckIcon({ tone }: { tone: PticaVerdictCheckItem["tone"] }) {
|
||||
if (tone === "ok") {
|
||||
return (
|
||||
<svg
|
||||
className={styles.checkIc}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M5 13l4 4L19 7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
// warn / bad → triangle exclamation
|
||||
return (
|
||||
<svg
|
||||
className={styles.checkIc}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M12 4l8 14H4L12 4z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 10v4M12 16.5v.4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SiteVerdictCard({ analysis, forecastReport }: Props) {
|
||||
const { word, tone, checklist } = adaptSiteVerdict(analysis, forecastReport);
|
||||
|
||||
return (
|
||||
<div className={`${styles.card} ${styles.verdictCard}`}>
|
||||
<div className={styles.verdictSplit}>
|
||||
<div className={styles.verdictLead}>
|
||||
<div className={styles.verdictLeadLabel}>Site Verdict</div>
|
||||
<div className={`${styles.verdictWord} ${WORD_TONE_CLASS[tone]}`}>
|
||||
{word}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.checklist}>
|
||||
{checklist.map((item, i) => (
|
||||
<div
|
||||
key={`${item.text}-${i}`}
|
||||
className={`${styles.checkItem} ${ITEM_TONE_CLASS[item.tone]}`}
|
||||
>
|
||||
<CheckIcon tone={item.tone} />
|
||||
{item.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -465,42 +465,7 @@ export function adaptEnvironmentCard(): PticaScanCard {
|
|||
};
|
||||
}
|
||||
|
||||
/** ОКС — empty-state for INCREMENT 1. */
|
||||
export function adaptOksCard(): PticaScanCard {
|
||||
return {
|
||||
title: "ОКС",
|
||||
rows: [],
|
||||
emptyState: "Нет данных",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Инсоляция · 3D-масса — entry card for the future3d / insolation drawer (the 3D
|
||||
* massing + shadow preview). Buildability % is REAL (shared gauge); the rest are
|
||||
* honest placeholders until the massing volumetrics land. The card exists so the
|
||||
* 3D module is discoverable from the analysis grid, not only via the map tool.
|
||||
*/
|
||||
export function adaptInsolationCard(a: ParcelAnalysis): PticaScanCard {
|
||||
const build = adaptBuildabilityGauge(a);
|
||||
return {
|
||||
title: "Инсоляция · 3D-масса",
|
||||
badge: "3D",
|
||||
rows: [
|
||||
{
|
||||
key: "Buildability",
|
||||
field:
|
||||
build.value != null && build.isReal
|
||||
? real(`${Math.round(build.value)}%`)
|
||||
: placeholder("после расчёта потенциала"),
|
||||
},
|
||||
{
|
||||
key: "Норматив инсоляции",
|
||||
field: placeholder("после 3D-расчёта теней"),
|
||||
},
|
||||
{ key: "Затенённость", field: placeholder("после 3D-расчёта теней") },
|
||||
],
|
||||
};
|
||||
}
|
||||
// (ОКС + Инсоляция moved to the lower-grid — see cockpit/OksCard + InsolationCard.)
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// DRAWER-LEVEL ADAPTERS (PR#4) — typed view-models, real-vs-placeholder centralized
|
||||
|
|
@ -1584,6 +1549,444 @@ export function adaptOksDrawer(): PticaOksDrawer {
|
|||
};
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// COCKPIT LOWER-GRID / BOTTOM-GRID SECTIONS — typed view-models for the cards
|
||||
// rendered below DevelopmentScan (porting the prototype `.lower-grid` /
|
||||
// `.bottom-grid`). Same {value, isReal, caption} honesty contract: REAL where the
|
||||
// backend provides it, honest "—"/placeholder otherwise. No fabricated numbers.
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ── ОКС (lower-grid card) — empty-state «Нет данных» ───────────────────────────
|
||||
|
||||
export interface PticaOksLowerCard {
|
||||
rows: DrawerKvRow[];
|
||||
/** When false the card shows the honest empty-state instead of rows. */
|
||||
hasData: boolean;
|
||||
emptyState: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ОКС on the parcel are not surfaced by /analyze, so this is an honest
|
||||
* empty-state card. The isometric building visual is rendered by the React
|
||||
* component; this adapter only carries the (currently empty) data + message.
|
||||
*/
|
||||
export function adaptOksLowerCard(): PticaOksLowerCard {
|
||||
return {
|
||||
rows: [
|
||||
{ k: "ОКСы на участке", field: notReal("нет реестра ОКС (ЕГРН)") },
|
||||
{ k: "Общая площадь", field: notReal("нет реестра ОКС (ЕГРН)") },
|
||||
{ k: "Статус", field: notReal("нет реестра ОКС (ЕГРН)") },
|
||||
],
|
||||
hasData: false,
|
||||
emptyState: "Нет данных",
|
||||
};
|
||||
}
|
||||
|
||||
// ── Development Potential (lower-grid card) — REAL (post-#1847) ─────────────────
|
||||
|
||||
/** One big-metric tile: a label, a value (split unit) + real/placeholder flag. */
|
||||
export interface PticaPotentialMetric {
|
||||
label: string;
|
||||
value: string;
|
||||
unit?: string;
|
||||
isReal: boolean;
|
||||
caption?: string;
|
||||
/** Larger type for the two headline tiles (площадь, КСИТ). */
|
||||
lead?: boolean;
|
||||
}
|
||||
|
||||
export interface PticaPotentialCard {
|
||||
metrics: PticaPotentialMetric[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Development Potential — REAL ёмкость-метрики из геометрии + НСПД-регламента,
|
||||
* mirroring `adaptPotentialDrawer` but as the compact lower-grid tiles. Площадь
|
||||
* участка (geometry), КСИТ (max_far), плотность = площадь×КСИТ, предельная
|
||||
* высота, пятно = площадь×max_building_pct, продаваемая ≈ ёмкость по КСИТ.
|
||||
* Honest «—» (placeholder) where an input is absent.
|
||||
*/
|
||||
export function adaptPotentialCard(a: ParcelAnalysis): PticaPotentialCard {
|
||||
const areaHa = a.geometry_suitability?.area_ha;
|
||||
const areaM2 = parcelAreaM2(a);
|
||||
const z = a.nspd_zoning;
|
||||
const maxFar = z?.max_far;
|
||||
const maxHeight = z?.max_height_m;
|
||||
const buildingPct = z?.max_building_pct;
|
||||
|
||||
const areaMetric: PticaPotentialMetric =
|
||||
areaM2 != null
|
||||
? {
|
||||
label: "Площадь участка",
|
||||
value: formatInt(areaM2),
|
||||
unit: "м²",
|
||||
isReal: true,
|
||||
lead: true,
|
||||
}
|
||||
: {
|
||||
label: "Площадь участка",
|
||||
value: PLACEHOLDER,
|
||||
isReal: false,
|
||||
caption: "нет геометрии",
|
||||
lead: true,
|
||||
};
|
||||
|
||||
const farMetric: PticaPotentialMetric =
|
||||
maxFar != null
|
||||
? {
|
||||
label: "КСИТ",
|
||||
value: formatFar(maxFar),
|
||||
isReal: true,
|
||||
caption: "НСПД",
|
||||
lead: true,
|
||||
}
|
||||
: {
|
||||
label: "КСИТ",
|
||||
value: PLACEHOLDER,
|
||||
isReal: false,
|
||||
caption: SRC_NSPD_ZONING,
|
||||
lead: true,
|
||||
};
|
||||
|
||||
const densityMetric: PticaPotentialMetric =
|
||||
maxFar != null && areaM2 != null
|
||||
? {
|
||||
label: "Плотность (КСИТ)",
|
||||
value: formatInt(areaM2 * maxFar),
|
||||
unit: "м²",
|
||||
isReal: true,
|
||||
caption: `площадь × КСИТ ${formatFar(maxFar)}`,
|
||||
}
|
||||
: {
|
||||
label: "Плотность (КСИТ)",
|
||||
value: PLACEHOLDER,
|
||||
isReal: false,
|
||||
caption: SRC_NSPD_ZONING,
|
||||
};
|
||||
|
||||
const heightMetric: PticaPotentialMetric =
|
||||
maxHeight != null
|
||||
? {
|
||||
label: "Предельная высота",
|
||||
value: formatInt(maxHeight),
|
||||
unit: "м",
|
||||
isReal: true,
|
||||
caption: "НСПД",
|
||||
}
|
||||
: {
|
||||
label: "Предельная высота",
|
||||
value: PLACEHOLDER,
|
||||
isReal: false,
|
||||
caption: SRC_NSPD_ZONING,
|
||||
};
|
||||
|
||||
const spotMetric: PticaPotentialMetric =
|
||||
areaM2 != null && buildingPct != null
|
||||
? {
|
||||
label: "Пятно застройки",
|
||||
value: formatInt(areaM2 * (buildingPct / 100)),
|
||||
unit: "м²",
|
||||
isReal: true,
|
||||
caption: `${formatInt(buildingPct)} % площади · НСПД`,
|
||||
}
|
||||
: {
|
||||
label: "Пятно застройки",
|
||||
value: PLACEHOLDER,
|
||||
isReal: false,
|
||||
caption: SRC_NSPD_ZONING,
|
||||
};
|
||||
|
||||
const sellableMetric: PticaPotentialMetric =
|
||||
maxFar != null && areaM2 != null
|
||||
? {
|
||||
label: "Продаваемая",
|
||||
value: `≈ ${formatInt(areaM2 * maxFar)}`,
|
||||
unit: "м²",
|
||||
isReal: true,
|
||||
caption: "оценка по КСИТ-ёмкости · без МОП/паркинга",
|
||||
}
|
||||
: {
|
||||
label: "Продаваемая",
|
||||
value: PLACEHOLDER,
|
||||
isReal: false,
|
||||
caption: "после расчёта потенциала",
|
||||
};
|
||||
|
||||
return {
|
||||
metrics: [
|
||||
areaMetric,
|
||||
farMetric,
|
||||
densityMetric,
|
||||
heightMetric,
|
||||
spotMetric,
|
||||
sellableMetric,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ── Investment Clearance (bottom-grid) — finance bignums (PLACEHOLDER, §23) ─────
|
||||
|
||||
export interface PticaClearanceBignum {
|
||||
label: string;
|
||||
value: string;
|
||||
unit?: string;
|
||||
isReal: boolean;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
export interface PticaInvestmentClearance {
|
||||
bignums: PticaClearanceBignum[];
|
||||
caption: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Investment Clearance — GDV/COST/PROFIT + ROI/IRR. The §23 finance model is NOT
|
||||
* in the backend, so EVERY value is an honest placeholder «—» (NO fabricated
|
||||
* numbers). The bignum layout matches the prototype; the caption explains why.
|
||||
*/
|
||||
export function adaptInvestmentClearance(): PticaInvestmentClearance {
|
||||
const fin = "после финмодели (§23)";
|
||||
return {
|
||||
bignums: [
|
||||
{
|
||||
label: "GDV · выручка",
|
||||
value: PLACEHOLDER,
|
||||
unit: "млрд ₽",
|
||||
isReal: false,
|
||||
caption: fin,
|
||||
},
|
||||
{
|
||||
label: "Cost · затраты",
|
||||
value: PLACEHOLDER,
|
||||
unit: "млрд ₽",
|
||||
isReal: false,
|
||||
caption: fin,
|
||||
},
|
||||
{
|
||||
label: "Profit · прибыль",
|
||||
value: PLACEHOLDER,
|
||||
unit: "млрд ₽",
|
||||
isReal: false,
|
||||
caption: fin,
|
||||
},
|
||||
{ label: "ROI", value: PLACEHOLDER, isReal: false, caption: fin },
|
||||
{ label: "IRR", value: PLACEHOLDER, isReal: false, caption: fin },
|
||||
],
|
||||
caption: "после финмодели (§23)",
|
||||
};
|
||||
}
|
||||
|
||||
// ── Buy Signal (bottom-grid) — gauge from §22 forecast deficit_index ───────────
|
||||
|
||||
export interface PticaBuySignalCard {
|
||||
gauge: PticaGauge;
|
||||
/** The verdict word shown under the gauge (Приобретать / Наблюдать / Пропустить). */
|
||||
word: string;
|
||||
available: boolean;
|
||||
caption: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buy Signal — derived from the §22 forecast exec-summary deficit_index, the SAME
|
||||
* logic as `adaptInvestScore`'s buySignal (>0.05 приобретать / <−0.05 пропустить /
|
||||
* else наблюдать). The gauge value maps the (signed) deficit_index onto 0..100 for
|
||||
* the visual; advisory (§22). While the forecast is pending → «после прогноза».
|
||||
*/
|
||||
export function adaptBuySignalGauge(
|
||||
report?: ForecastReport,
|
||||
): PticaBuySignalCard {
|
||||
if (!report) {
|
||||
return {
|
||||
gauge: {
|
||||
value: null,
|
||||
label: "после прогноза",
|
||||
tone: "neutral",
|
||||
isReal: false,
|
||||
footnote: "Scenarios §22",
|
||||
},
|
||||
word: PLACEHOLDER,
|
||||
available: false,
|
||||
caption: "после прогноза (Scenarios)",
|
||||
};
|
||||
}
|
||||
|
||||
const di = report.exec_summary.key_numbers.deficit_index;
|
||||
// Map signed deficit_index (roughly −0.5..+0.5) onto a 0..100 gauge value.
|
||||
const gaugeValue =
|
||||
di == null ? 50 : Math.max(0, Math.min(100, Math.round(50 + di * 100)));
|
||||
|
||||
let tone: PticaGauge["tone"] = "neutral";
|
||||
let word = "Наблюдать";
|
||||
if (di != null) {
|
||||
if (di > 0.05) {
|
||||
tone = "good";
|
||||
word = "Приобретать";
|
||||
} else if (di < -0.05) {
|
||||
tone = "bad";
|
||||
word = "Пропустить";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
gauge: {
|
||||
value: gaugeValue,
|
||||
label: word,
|
||||
tone,
|
||||
isReal: true,
|
||||
footnote: "advisory · §22",
|
||||
},
|
||||
word,
|
||||
available: true,
|
||||
caption: "advisory · §22",
|
||||
};
|
||||
}
|
||||
|
||||
// ── Legal Status (bottom-grid) — ОКН / сервитуты / красные линии / ЗОУИТ ────────
|
||||
|
||||
export interface PticaLegalStatus {
|
||||
rows: DrawerKvRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Legal Status — REAL where /analyze provides it (ЗОУИТ count, красные линии,
|
||||
* gate verdict label), honest placeholder for the rest (ОКН / сервитуты /
|
||||
* аренда / ограничения оборота / обременения — not in the dump). Mirrors the
|
||||
* prototype's Legal table (Нет / значение rows).
|
||||
*/
|
||||
export function adaptLegalStatus(a: ParcelAnalysis): PticaLegalStatus {
|
||||
const zouit = a.nspd_zouit_overlaps ?? null;
|
||||
const redLines = a.nspd_red_lines ?? null;
|
||||
const gate = a.gate_verdict;
|
||||
|
||||
const rows: DrawerKvRow[] = [
|
||||
{
|
||||
k: "ЗОУИТ",
|
||||
field:
|
||||
zouit != null
|
||||
? real(formatInt(zouit.length))
|
||||
: notReal("источник НСПД"),
|
||||
},
|
||||
{
|
||||
k: "Красные линии",
|
||||
field:
|
||||
redLines != null
|
||||
? real(redLines.length > 0 ? formatInt(redLines.length) : "Нет")
|
||||
: notReal("источник НСПД-граддок"),
|
||||
},
|
||||
{ k: "ОКН", field: notReal("источник НСПД") },
|
||||
{ k: "Сервитуты", field: notReal("источник ЕГРН") },
|
||||
{ k: "Аренда", field: notReal("источник ЕГРН") },
|
||||
{
|
||||
k: "Огранич. оборота",
|
||||
field: gate
|
||||
? real(
|
||||
gate.blockers.length > 0
|
||||
? `${formatInt(gate.blockers.length)} блок.`
|
||||
: "Нет",
|
||||
)
|
||||
: notReal("источник ЕГРН"),
|
||||
},
|
||||
];
|
||||
|
||||
return { rows };
|
||||
}
|
||||
|
||||
// ── Site Verdict (bottom-grid) — headline plaque + checklist ───────────────────
|
||||
|
||||
export interface PticaVerdictCheckItem {
|
||||
text: string;
|
||||
tone: "ok" | "warn" | "bad";
|
||||
}
|
||||
|
||||
export interface PticaSiteVerdict {
|
||||
/** The big verdict word (Приобретать / Можно / Нельзя / Нужна проверка). */
|
||||
word: string;
|
||||
tone: "good" | "warn" | "bad";
|
||||
checklist: PticaVerdictCheckItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Site Verdict — the JTBD «стоит ли брать участок» answer. The verdict word comes
|
||||
* from gate_verdict.can_build_mkd (true → «Приобретать» green, false → «Нельзя»
|
||||
* red, "unknown" → «Нужна проверка» amber). The checklist is derived from REAL
|
||||
* signals where available (рыночный спрос via median price, потенциал застройки
|
||||
* via max_far, инженерная обеспеченность via utilities count) and falls back to
|
||||
* the gate blockers/warnings.
|
||||
*/
|
||||
export function adaptSiteVerdict(
|
||||
a: ParcelAnalysis,
|
||||
report?: ForecastReport,
|
||||
): PticaSiteVerdict {
|
||||
const gate = a.gate_verdict;
|
||||
|
||||
let word = "Нужна проверка";
|
||||
let tone: PticaSiteVerdict["tone"] = "warn";
|
||||
if (gate) {
|
||||
if (gate.can_build_mkd === true) {
|
||||
word = "Приобретать";
|
||||
tone = "good";
|
||||
} else if (gate.can_build_mkd === false) {
|
||||
word = "Нельзя";
|
||||
tone = "bad";
|
||||
}
|
||||
}
|
||||
|
||||
const checklist: PticaVerdictCheckItem[] = [];
|
||||
|
||||
// Рыночный спрос — derived from §22 deficit_index when ready, else from the
|
||||
// presence of a district median (proxy «рынок отслеживается»).
|
||||
const di = report?.exec_summary.key_numbers.deficit_index ?? null;
|
||||
if (di != null) {
|
||||
if (di > 0.05) {
|
||||
checklist.push({ text: "Высокий спрос", tone: "ok" });
|
||||
checklist.push({ text: "Дефицит предложения", tone: "ok" });
|
||||
} else if (di < -0.05) {
|
||||
checklist.push({ text: "Признаки затоварки рынка", tone: "warn" });
|
||||
} else {
|
||||
checklist.push({ text: "Рынок сбалансирован", tone: "warn" });
|
||||
}
|
||||
} else if (a.district?.median_price_per_m2 != null) {
|
||||
checklist.push({ text: "Рынок района отслеживается", tone: "ok" });
|
||||
}
|
||||
|
||||
// Потенциал застройки — REAL when КСИТ flows from НСПД.
|
||||
if (a.nspd_zoning?.max_far != null) {
|
||||
checklist.push({ text: "Потенциал застройки (КСИТ)", tone: "ok" });
|
||||
} else {
|
||||
checklist.push({ text: "Потенциал застройки не рассчитан", tone: "warn" });
|
||||
}
|
||||
|
||||
// Инженерная обеспеченность — REAL from utilities count.
|
||||
const utilCount = a.utilities?.summary?.length ?? 0;
|
||||
if (utilCount >= 3) {
|
||||
checklist.push({ text: "Хорошая инженерная обеспеченность", tone: "ok" });
|
||||
} else if (utilCount > 0) {
|
||||
checklist.push({
|
||||
text: "Частичная инженерная обеспеченность",
|
||||
tone: "warn",
|
||||
});
|
||||
} else {
|
||||
checklist.push({ text: "Нет данных по инженерии", tone: "warn" });
|
||||
}
|
||||
|
||||
// Gate blockers/warnings — surface the real risk items.
|
||||
if (gate) {
|
||||
for (const b of gate.blockers.slice(0, 2)) {
|
||||
checklist.push({ text: b.detail || b.code, tone: "bad" });
|
||||
}
|
||||
for (const w of gate.warnings.slice(0, 2)) {
|
||||
checklist.push({ text: w.detail || w.code, tone: "warn" });
|
||||
}
|
||||
}
|
||||
|
||||
// Always show at least one item so the plaque never reads empty.
|
||||
if (checklist.length === 0) {
|
||||
checklist.push({ text: "Недостаточно данных для чек-листа", tone: "warn" });
|
||||
}
|
||||
|
||||
return { word, tone, checklist };
|
||||
}
|
||||
|
||||
// ── Map geometry ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** EKB fallback center (lat, lon). */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue