gendesign/frontend/src/components/site-finder/ptica/ptica-adapt.ts
Light1YT 24b5e6047f
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m6s
CI / openapi-codegen-check (pull_request) Successful in 2m1s
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.
2026-06-21 00:33:15 +05:00

1993 lines
70 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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,
ParcelAnalysisCompetitor,
} from "@/types/site-finder";
import type { NspdZoning } from "@/types/nspd";
import type { ForecastReport, ProductMixEntry } from "@/types/forecast";
// ── 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");
}
/** Fixed-decimal RU number (used for the invest-score on a /10 scale). */
function fmtNum(value: number, digits = 1): string {
return value.toLocaleString("ru-RU", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
/** м² 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)} ₽/м²`;
}
// ── НСПД-регламент (ПЗЗ) helpers ──────────────────────────────────────────────
/** КСИТ (max_far) as a plain RU number — e.g. "3,5". */
function formatFar(far: number): string {
return fmtNum(far, 1);
}
/**
* Real parcel area in m² when geometry is known (area_ha × 10000); null
* otherwise. The single point at which we turn area_ha into m² for ёмкость.
*/
function parcelAreaM2(a: ParcelAnalysis): number | null {
const areaHa = a.geometry_suitability?.area_ha;
return areaHa != null ? areaHa * 10000 : null;
}
/** Human regulation zone — prefer the readable index ("Ж-2") over reg-number. */
function regulationZone(z: NspdZoning | null | undefined): string | null {
return z?.regulation_zone_index ?? z?.zone_code ?? null;
}
const SRC_NSPD_ZONING = "источник НСПД-зонирование";
// ── 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;
}
/**
* Buy-signal from the §22 forecast exec-summary key numbers. Derived from the
* (signed) deficit_index — the long `verdict` sentence is a paragraph, not a
* badge, so we map the structured KPI instead. >0.05 недонасыщенность →
* «приобретать», <0.05 затоварка → «пропустить», иначе «наблюдать». Flagged
* advisory (the forecast itself is advisory). Independent of the overall score,
* so it surfaces as soon as the report is ready even if scoring is thin.
*/
function buySignalFromForecast(report: ForecastReport): PticaField {
const kn = report.exec_summary.key_numbers;
const di = kn.deficit_index;
if (di == null) {
return { value: "наблюдать", isReal: true, caption: "advisory · §22" };
}
const word =
di > 0.05 ? "приобретать" : di < -0.05 ? "пропустить" : "наблюдать";
return { value: word, isReal: true, caption: "advisory · §22" };
}
/**
* Invest-score view-model. INCREMENT 1: PLACEHOLDER until the §22 forecast is
* ready; PR#2: when `report` is present, the score is overall_score×10 (∈ 0..10)
* and the buy-signal is derived from the exec-summary KPIs. While the forecast is
* still pending (`report` undefined) the «после прогноза» placeholders stand.
*/
export function adaptInvestScore(
a: ParcelAnalysis,
report?: ForecastReport,
): PticaInvestScore {
const risk = adaptRiskGauge(a);
const riskField: PticaField =
risk.value != null
? { value: risk.label, isReal: false, caption: "предв." }
: placeholder("предв.");
const overall =
report?.scoring?.overall ?? report?.exec_summary.key_numbers.overall_score;
// Buy-signal is derived from the deficit_index alone, so it surfaces whenever
// the report is ready — independent of whether the overall score is present.
const buySignal = report
? buySignalFromForecast(report)
: placeholder("после прогноза (Scenarios)");
if (report && overall != null) {
// overall_score ∈ 0..1 → /10 scale (one decimal), advisory.
const scoreOn10 = real(fmtNum(overall * 10, 1));
scoreOn10.caption = "advisory · §22";
return {
score: scoreOn10,
potential: placeholder("после финмодели"),
risk: riskField,
buySignal,
};
}
return {
score: placeholder("после прогноза (Scenarios)"),
potential: placeholder("после прогноза (Scenarios)"),
risk: riskField,
buySignal,
};
}
// ── 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;
}
/**
* Градостроительство — REAL ПЗЗ-регламент when nspd_zoning carries it
* (PR #1847), honest placeholder otherwise. Зона prefers the human index
* ("Ж-2"); КСИТ / высота / плотность light up once the backend field flows.
*/
export function adaptUrbanCard(a: ParcelAnalysis): PticaScanCard {
const z = a.nspd_zoning;
const zone = regulationZone(z);
const maxFar = z?.max_far;
const maxHeight = z?.max_height_m;
const areaM2 = parcelAreaM2(a);
// Плотность (КСИТ) ёмкость = площадь × КСИТ (м²), real only with both inputs.
const densityField =
maxFar != null
? areaM2 != null
? {
value: `${formatInt(areaM2 * maxFar)} м²`,
isReal: true,
caption: `КСИТ ${formatFar(maxFar)} · НСПД`,
}
: { value: formatFar(maxFar), isReal: true, caption: "КСИТ · НСПД" }
: placeholder(SRC_NSPD_ZONING);
return {
title: "Градостроительство",
rows: [
{
key: "Зона",
field: zone
? { value: zone, isReal: true, caption: "НСПД-зонирование" }
: placeholder(SRC_NSPD_ZONING),
},
{
key: "Пред. высота",
field:
maxHeight != null
? real(`${formatInt(maxHeight)} м`)
: placeholder(SRC_NSPD_ZONING),
},
{
key: "Плотность (КСИТ)",
field: densityField,
},
{
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("после атмосферного слоя") },
],
};
}
// (ОКС + Инсоляция moved to the lower-grid — see cockpit/OksCard + InsolationCard.)
// ══════════════════════════════════════════════════════════════════════════════
// DRAWER-LEVEL ADAPTERS (PR#4) — typed view-models, real-vs-placeholder centralized
// ══════════════════════════════════════════════════════════════════════════════
//
// Each drawer consumes a typed view-model below rather than reaching into
// ParcelAnalysis / ForecastReport in the React tree. REAL data is surfaced where
// the backend provides it; absent fields become honest `{value:"—", isReal:false,
// caption}` placeholders — NO fabricated numbers. `notReal()` rows are styled
// muted by the drawer content so they never read as live.
const DASH = "—";
function notReal(caption: string): PticaField {
return { value: DASH, isReal: false, caption };
}
/** A single "Параметр → Значение" row with real/placeholder provenance. */
export interface DrawerKvRow {
k: string;
field: PticaField;
}
function metersField(
m: number | null | undefined,
caption: string,
): PticaField {
return m != null ? real(`${formatInt(m)} м`) : notReal(caption);
}
// ── passport — full ЕГРН / НСПД (REAL) ────────────────────────────────────────
export interface PticaPassportDrawer {
summary: string;
registry: DrawerKvRow[];
zouitCount: number | null;
zouitOverlaps: Array<{ layer: string; name: string; sub: string }>;
}
export function adaptPassportDrawer(a: ParcelAnalysis): PticaPassportDrawer {
const p = adaptPassport(a);
const areaHa = a.geometry_suitability?.area_ha;
const zouit = a.nspd_zouit_overlaps ?? null;
const districtPart = a.district?.district_name
? `, ${a.district.district_name} р`
: "";
const summary =
`Участок ${a.cad_num}${districtPart}. ` +
(a.egrn?.land_category ? `Категория — ${a.egrn.land_category}. ` : "") +
(a.egrn?.permitted_use_text ? `ВРИ — ${a.egrn.permitted_use_text}. ` : "") +
(a.egrn?.parcel_status ? `Статус ЕГРН — ${a.egrn.parcel_status}.` : "");
const registry: DrawerKvRow[] = [
{ k: "Кадастровый номер", field: real(a.cad_num) },
{ k: "Адрес", field: p.address },
{ k: "Район", field: p.district },
{ k: "Площадь", field: p.area },
{
k: "Площадь, га",
field: areaHa != null ? real(formatHa(areaHa)) : notReal("нет геометрии"),
},
{ k: "Категория земель", field: p.landCategory },
{ k: "ВРИ", field: p.vri },
{ k: "Статус ЕГРН", field: p.status },
{
k: "Кадастровая стоимость",
field:
a.egrn?.cadastral_value_rub != null
? real(`${formatInt(a.egrn.cadastral_value_rub)}`)
: notReal("нет данных ЕГРН"),
},
{
k: "Стоимость, ₽/м²",
field:
a.egrn?.cost_per_m2_rub != null
? real(formatRubPerM2(a.egrn.cost_per_m2_rub))
: notReal("нет данных ЕГРН"),
},
{
k: "Дата обновления ЕГРН",
field: a.egrn?.last_egrn_update_date
? real(a.egrn.last_egrn_update_date)
: notReal("нет данных ЕГРН"),
},
{ k: "Обременения", field: { value: DASH, isReal: true } },
];
const zouitOverlaps = (zouit ?? []).map((z) => ({
layer: z.layer,
name: z.name ?? z.type_zone ?? z.category_name ?? "ЗОУИТ",
sub: z.subcategory != null ? String(z.subcategory) : DASH,
}));
return {
summary,
registry,
zouitCount: zouit != null ? zouit.length : null,
zouitOverlaps,
};
}
// ── buildability — factor breakdown (DERIVED / предв.) ─────────────────────────
export interface PticaFactorBar {
name: string;
pct: number;
value: string;
tone: "good" | "bad" | "neutral";
reason: string;
}
export interface PticaBuildabilityDrawer {
gauge: PticaGauge;
summary: string;
factors: PticaFactorBar[];
geometry: DrawerKvRow[];
penalties: Array<{ label: string; impact: string }>;
isReal: boolean;
}
export function adaptBuildabilityDrawer(
a: ParcelAnalysis,
): PticaBuildabilityDrawer {
const gauge = adaptBuildabilityGauge(a);
const gate = a.gate_verdict;
const geom = a.geometry_suitability;
const utilCount = a.utilities?.summary?.length ?? null;
// Soft, derived factor contributions — labelled «предв.» everywhere.
const factors: PticaFactorBar[] = [
{
name: "Гейт-вердикт",
pct:
gate?.can_build_mkd === true
? 90
: gate?.can_build_mkd === false
? 20
: 55,
value: gate?.verdict_label ?? DASH,
tone:
gate?.can_build_mkd === true
? "good"
: gate?.can_build_mkd === false
? "bad"
: "neutral",
reason: gate ? `источник: ${gate.source}` : "нет gate_verdict",
},
{
name: "Геометрия",
pct: geom?.suitability_score != null ? geom.suitability_score * 100 : 0,
value:
geom?.suitability_score != null
? `${fmtNum(geom.suitability_score * 10, 1)} / 10`
: DASH,
tone: "neutral",
reason: geom?.label ? `форма: ${geom.label}` : "нет геометрии",
},
{
name: "Инженерия",
pct: utilCount != null ? Math.min(100, utilCount * 20) : 0,
value: utilCount != null ? `${utilCount} сетей` : DASH,
tone: utilCount != null && utilCount >= 3 ? "good" : "neutral",
reason: "близость инж. узлов (utilities)",
},
];
// ── Пятно / продаваемая площадь — REAL when ПЗЗ max_far flows (PR #1847) ──
// КСИТ ёмкость (надземная GFA) = площадь × max_far. Footprint (пятно) needs
// the building-coverage % (max_building_pct); sellable still depends on
// МОП/parking, so it stays an estimate placeholder.
const z = a.nspd_zoning;
const areaM2 = parcelAreaM2(a);
const maxFar = z?.max_far;
const farReal = maxFar != null;
const buildingPct = z?.max_building_pct;
const spotRow: DrawerKvRow =
areaM2 != null && buildingPct != null
? {
k: "Пятно застройки",
field: {
value: `${formatInt(areaM2 * (buildingPct / 100))} м²`,
isReal: true,
caption: `${formatInt(buildingPct)} % площади · НСПД`,
},
}
: { k: "Пятно застройки", field: notReal(SRC_NSPD_ZONING) };
const sellableRow: DrawerKvRow =
farReal && areaM2 != null
? {
k: "Продаваемая площадь",
field: {
value: `${formatInt(areaM2 * maxFar)} м²`,
isReal: true,
caption: "оценка по КСИТ-ёмкости · без вычета МОП/паркинга",
},
}
: { k: "Продаваемая площадь", field: notReal("нужен max_far (НСПД)") };
const geometry: DrawerKvRow[] = [
{
k: "Площадь",
field:
geom?.area_ha != null
? real(`${formatHa(geom.area_ha)} · ${areaM2FromHa(geom.area_ha)}`)
: notReal("нет геометрии"),
},
{
k: "Периметр",
field: metersField(geom?.perimeter_m, "нет геометрии"),
},
{
k: "Соотношение сторон",
field:
geom?.aspect_ratio != null
? real(fmtNum(geom.aspect_ratio, 2))
: notReal("нет геометрии"),
},
{
k: "Suitability score",
field:
geom?.suitability_score != null
? real(`${fmtNum(geom.suitability_score * 10, 1)} / 10`)
: notReal("нет геометрии"),
},
spotRow,
sellableRow,
];
const penalties = (geom?.penalties ?? []).map((p) => ({
label: p,
impact: "штраф",
}));
const summary = farReal
? "Индекс застраиваемости — предварительная оценка по гейт-вердикту, геометрии " +
"участка и близости инженерии. Это эвристика, а не итоговый индекс: точное " +
"значение появится после финмодели. КСИТ-ёмкость рассчитана по регламенту НСПД."
: "Индекс застраиваемости — предварительная оценка по гейт-вердикту, геометрии " +
"участка и близости инженерии. Это эвристика, а не итоговый индекс: точное " +
"значение появится после расчёта потенциала (max_far НСПД) и финмодели.";
return {
gauge,
summary,
factors,
geometry,
penalties,
isReal: false,
};
}
// ── urban — Градостроительство (mostly PLACEHOLDER) ───────────────────────────
export interface PticaUrbanDrawer {
summary: string;
regulation: DrawerKvRow[];
zoneCode: PticaField;
zoneName: PticaField;
}
export function adaptUrbanDrawer(a: ParcelAnalysis): PticaUrbanDrawer {
const zoning = a.nspd_zoning;
// Территориальная зона: prefer human index ("Ж-2"), else reg-number code.
const zone = regulationZone(zoning);
const zoneCode = zone ? real(zone) : notReal(SRC_NSPD_ZONING);
const zoneName = zoning?.zone_name
? real(zoning.zone_name)
: notReal(SRC_NSPD_ZONING);
const maxFar = zoning?.max_far;
const maxHeight = zoning?.max_height_m;
const buildingPct = zoning?.max_building_pct;
const areaM2 = parcelAreaM2(a);
const regSource = zoning?.regulation_source;
// КСИТ ёмкость (надземная GFA) = площадь × max_far.
const densityField: PticaField =
maxFar != null && areaM2 != null
? {
value: `${formatInt(areaM2 * maxFar)} м²`,
isReal: true,
caption: regSource
? `КСИТ ${formatFar(maxFar)} · ${regSource}`
: `КСИТ ${formatFar(maxFar)} · НСПД`,
}
: maxFar != null
? notReal("нужна геометрия участка")
: notReal("после расчёта потенциала");
const farReal = maxFar != null;
const regulation: DrawerKvRow[] = [
{ k: "Территориальная зона", field: zoneCode },
{ k: "Наименование зоны", field: zoneName },
{
k: "ВРИ (основной)",
field: a.egrn?.permitted_use_text
? real(a.egrn.permitted_use_text)
: notReal("нет данных ЕГРН"),
},
{
k: "КСИТ (max FAR)",
field: farReal
? real(formatFar(maxFar))
: notReal("нужен max_far (НСПД)"),
},
{
k: "Предельная высота",
field:
maxHeight != null
? real(`${formatInt(maxHeight)} м`)
: notReal(SRC_NSPD_ZONING),
},
{
k: "Этажность (предельная)",
field:
zoning?.max_floors != null
? real(`${formatInt(zoning.max_floors)} эт.`)
: notReal(SRC_NSPD_ZONING),
},
{
k: "Коэфф. застройки (пятно)",
field:
buildingPct != null
? real(`${formatInt(buildingPct)} %`)
: notReal(SRC_NSPD_ZONING),
},
{ k: "Плотность (КСИТ)", field: densityField },
];
const summary = farReal
? "Градостроительный регламент участка из НСПД-зонирования (ПЗЗ). " +
"Территориальная зона, КСИТ (max_far), предельная высота и коэффициент " +
"застройки — реальные регламентные значения; плотность рассчитана от " +
"площади участка."
: "Градостроительный регламент участка. Территориальная зона и ВРИ берутся из " +
"НСПД-зонирования / ЕГРН; КСИТ (max_far), предельная высота и плотность пока " +
"недоступны в выгрузке — отмечены «—» с источником.";
return { summary, regulation, zoneCode, zoneName };
}
// ── restrictions / legal — ЗОУИТ (REAL list) ──────────────────────────────────
export interface PticaRestrictionsDrawer {
summary: string;
zouitCount: number | null;
zouitRows: Array<{ layer: string; sub: string; reg: string }>;
statusRows: Array<{ label: string; state: string; tone: "ok" | "warn" }>;
}
export function adaptRestrictionsDrawer(
a: ParcelAnalysis,
): PticaRestrictionsDrawer {
const zouit = a.nspd_zouit_overlaps ?? null;
const count = zouit != null ? zouit.length : null;
const zouitRows = (zouit ?? []).map((z) => ({
layer: z.name ?? z.type_zone ?? z.layer,
sub:
z.category_name ?? (z.subcategory != null ? String(z.subcategory) : DASH),
reg: z.group_key,
}));
const statusRows: Array<{
label: string;
state: string;
tone: "ok" | "warn";
}> = [
{
label: "ЗОУИТ (зоны с особыми условиями)",
state: count != null ? `${count}` : "нет данных НСПД",
tone: count != null && count > 0 ? "warn" : "ok",
},
{
label: "Красные линии",
state:
a.nspd_red_lines != null
? `${a.nspd_red_lines.length}`
: "источник НСПД",
tone: (a.nspd_red_lines?.length ?? 0) > 0 ? "warn" : "ok",
},
{
label: "ОКН (объекты культ. наследия)",
state: "источник НСПД",
tone: "ok",
},
{ label: "Сервитуты", state: "источник ЕГРН", tone: "ok" },
];
const summary =
count != null
? `Зон с особыми условиями использования территории (ЗОУИТ): ${count}. ` +
"Красные линии, ОКН и сервитуты требуют отдельной выгрузки НСПД / ЕГРН."
: "Данные НСПД по ограничениям недоступны — ЗОУИТ, красные линии, ОКН и " +
"сервитуты отмечены источником.";
return { summary, zouitCount: count, zouitRows, statusRows };
}
export interface PticaLegalDrawer {
summary: string;
gate: DrawerKvRow[];
restrictions: Array<{ label: string; state: string; tone: "ok" | "warn" }>;
registry: DrawerKvRow[];
}
export function adaptLegalDrawer(a: ParcelAnalysis): PticaLegalDrawer {
const gate = a.gate_verdict;
const restr = adaptRestrictionsDrawer(a);
const gateRows: DrawerKvRow[] = [
{
k: "Можно строить МКД",
field: gate ? real(gate.verdict_label) : notReal("нет gate_verdict"),
},
{
k: "Статус участка",
field: a.egrn?.parcel_status
? real(a.egrn.parcel_status)
: notReal("нет данных ЕГРН"),
},
{
k: "Категория земель",
field: a.egrn?.land_category
? real(a.egrn.land_category)
: notReal("нет данных ЕГРН"),
},
{
k: "ВРИ",
field: a.egrn?.permitted_use_text
? real(a.egrn.permitted_use_text)
: notReal("нет данных ЕГРН"),
},
{
k: "Зона ПЗЗ",
field: a.nspd_zoning?.zone_code
? real(a.nspd_zoning.zone_code)
: notReal("источник НСПД"),
},
];
const registry: DrawerKvRow[] = [
{
k: "ЗОУИТ",
field:
restr.zouitCount != null
? real(formatInt(restr.zouitCount))
: notReal("источник НСПД"),
},
{ k: "Обременения", field: { value: DASH, isReal: true } },
{
k: "Блокеры (gate)",
field: gate
? real(formatInt(gate.blockers.length))
: notReal("нет gate_verdict"),
},
{
k: "Предупреждения (gate)",
field: gate
? real(formatInt(gate.warnings.length))
: notReal("нет gate_verdict"),
},
];
const summary =
"Правовой статус: гейт-вердикт о допустимости МКД (НСПД-дамп) + реестр " +
"ограничений. Обременения в текущей выгрузке не раскрываются («—»).";
return { summary, gate: gateRows, restrictions: restr.statusRows, registry };
}
// ── engineering — utilities.summary (REAL) ────────────────────────────────────
export interface PticaEngineeringDrawer {
summary: string;
rows: Array<{ label: string; nearest: string; name: string; count: string }>;
bars: PticaFactorBar[];
hasData: boolean;
}
export function adaptEngineeringDrawer(
a: ParcelAnalysis,
): PticaEngineeringDrawer {
const summaryArr = a.utilities?.summary ?? [];
const hasData = summaryArr.length > 0;
const rows = summaryArr.map((u) => ({
label: utilityLabel(u.subtype),
nearest: `${formatInt(u.nearest_m)} м`,
name: u.name ?? DASH,
count: u.count_within_2km != null ? formatInt(u.count_within_2km) : DASH,
}));
// bar = inverse distance (closer → fuller). Cap reference 2000 m.
const bars: PticaFactorBar[] = summaryArr.map((u) => ({
name: utilityLabel(u.subtype),
pct: Math.max(4, 100 - Math.min(100, (u.nearest_m / 2000) * 100)),
value: `${formatInt(u.nearest_m)} м`,
tone: u.nearest_m <= 700 ? "good" : "neutral",
reason: u.name ?? "",
}));
const summary = hasData
? `Инженерная обеспеченность: ${summaryArr.length} типов сетей в радиусе ` +
"анализа. Расстояния — до ближайшего узла каждого ресурса (НСПД)."
: "Данные по инженерным сетям недоступны для участка.";
return { summary, rows, bars, hasData };
}
// ── market — 4 sub-tabs (comp REAL · plan/speed/trend mixed) ───────────────────
export interface PticaMarketCompetitor {
name: string;
dev: string;
cls: string;
flats: string;
distance: string;
status: string;
}
export interface PticaMarketDrawer {
summary: string;
competitors: PticaMarketCompetitor[];
medianField: PticaField;
trend: {
field: PticaField;
recent: string;
prior: string;
count: string;
available: boolean;
};
speed: {
field: PticaField;
velocityScore: string;
monthly: string;
median: string;
confidence: string;
available: boolean;
};
plan: {
available: boolean;
note: string;
pipelineByClass: Array<{ cls: string; count: number }>;
};
}
function competitorRow(c: ParcelAnalysisCompetitor): PticaMarketCompetitor {
return {
name: c.comm_name ?? `#${c.obj_id}`,
dev: c.dev_name ?? DASH,
cls: c.obj_class ?? DASH,
flats: c.flat_count != null ? formatInt(c.flat_count) : DASH,
distance: `${formatInt(c.distance_m)} м`,
status: c.site_status ?? DASH,
};
}
export function adaptMarketDrawer(a: ParcelAnalysis): PticaMarketDrawer {
const competitors = (a.competitors ?? []).slice(0, 12).map(competitorRow);
const median = a.district?.median_price_per_m2;
const medianField =
median != null
? real(formatRubPerM2(median))
: notReal("нет данных района");
const mt = a.market_trend;
const trend = {
field:
mt != null
? real(
`${mt.delta_6m_pct >= 0 ? "+" : ""}${fmtNum(mt.delta_6m_pct, 1)}%`,
)
: notReal("после прогноза"),
recent: mt != null ? formatRubPerM2(mt.recent_avg_price_per_m2) : DASH,
prior: mt != null ? formatRubPerM2(mt.prior_avg_price_per_m2) : DASH,
count: mt != null ? `${formatInt(mt.recent_deals_count)} сделок` : DASH,
available: mt != null,
};
const v = a.velocity ?? null;
const speed = {
field:
v != null
? real(`${fmtNum(v.velocity_score * 10, 1)} / 10`)
: notReal("после прогноза"),
velocityScore: v != null ? fmtNum(v.velocity_score * 10, 1) : DASH,
monthly: v != null ? `${formatInt(v.monthly_velocity_sqm)} м²/мес` : DASH,
median: v != null ? `${formatInt(v.ekb_median_sqm)} м²/мес` : DASH,
confidence: v != null ? v.confidence : DASH,
available: v != null,
};
const pipeline = a.pipeline_24mo ?? null;
const pipelineByClass = pipeline
? Object.entries(pipeline.by_class).map(([cls, count]) => ({ cls, count }))
: [];
const plan = {
available: pipeline != null,
note:
pipeline != null
? `Пайплайн 24 мес: ${pipeline.objects_count} проектов, ` +
`${formatInt(pipeline.flats_total)} квартир.`
: "Планировки и квартирография — после прогноза (§22).",
pipelineByClass,
};
const summary =
`Конкурентов в радиусе анализа: ${competitors.length}` +
(median != null ? `, медиана ${formatRubPerM2(median)}` : "") +
(pipeline != null
? `, ${pipeline.objects_count} проектов в пайплайне`
: "") +
". Тренд, скорость и планировки уточняются прогнозом §22.";
return { summary, competitors, medianField, trend, speed, plan };
}
// ── environment (Среда) — noise / AQI / wind / weather (REAL) ──────────────────
export interface PticaEnvironmentDrawer {
summary: string;
noise: {
rows: DrawerKvRow[];
sources: Array<{ name: string; db: string; dist: string }>;
available: boolean;
};
air: { rows: DrawerKvRow[]; available: boolean };
wind: { rows: DrawerKvRow[]; available: boolean };
weather: { rows: DrawerKvRow[]; available: boolean };
}
const WIND_DIR_NOTE = "источник: прогноз ветра";
export function adaptEnvironmentDrawer(
a: ParcelAnalysis,
): PticaEnvironmentDrawer {
const n = a.noise;
const aq = a.air_quality;
const w = a.wind;
const wt = a.weather;
const noiseRows: DrawerKvRow[] = n
? [
{ k: "Уровень", field: real(n.level) },
{ k: "Оценка шума", field: real(`${formatInt(n.estimated_db)} дБ`) },
{ k: "Скор", field: real(fmtNum(n.score, 2)) },
{ k: "Источников шума", field: real(formatInt(n.sources.length)) },
]
: [{ k: "Шум", field: notReal("нет данных шума") }];
const airRows: DrawerKvRow[] = aq
? [
{ k: "PM2.5", field: real(`${fmtNum(aq.pm2_5, 1)} мкг/м³`) },
{ k: "PM10", field: real(`${fmtNum(aq.pm10, 1)} мкг/м³`) },
{ k: "NO₂", field: real(`${fmtNum(aq.no2, 1)} мкг/м³`) },
{ k: "Источник", field: real(aq.source) },
]
: [{ k: "Воздух", field: notReal("нет данных AQI") }];
const windRows: DrawerKvRow[] = w
? [
{
k: "Направление",
field: real(
`${w.dominant_direction_label} (${formatInt(w.dominant_direction_deg)}°)`,
),
},
{
k: "Макс. скорость",
field:
w.max_speed_m_s != null
? real(`${fmtNum(w.max_speed_m_s, 1)} м/с`)
: notReal(WIND_DIR_NOTE),
},
{
k: "Горизонт прогноза",
field: real(`${formatInt(w.forecast_days)} дн.`),
},
]
: [{ k: "Ветер", field: notReal("нет данных ветра") }];
const weatherRows: DrawerKvRow[] = wt
? [
{
k: "Ср. макс. °C",
field:
wt.temperature.avg_max_c != null
? real(`${fmtNum(wt.temperature.avg_max_c, 1)} °C`)
: notReal("нет данных погоды"),
},
{
k: "Ср. мин. °C",
field:
wt.temperature.avg_min_c != null
? real(`${fmtNum(wt.temperature.avg_min_c, 1)} °C`)
: notReal("нет данных погоды"),
},
{
k: "Осадки (сумма)",
field: real(`${formatInt(wt.precipitation_total_mm)} мм`),
},
{ k: "Дней с осадками", field: real(formatInt(wt.precipitation_days)) },
]
: [{ k: "Погода", field: notReal("нет данных погоды") }];
const sources = (n?.sources ?? []).map((s) => ({
name: s.name ?? s.source_type,
db: `${formatInt(s.estimated_db)} дБ`,
dist: `${formatInt(s.distance_m)} м`,
}));
const summary =
"Средовые факторы участка: шум, качество воздуха, преобладающий ветер и " +
"погодный фон. Данные из атмосферного слоя /analyze.";
return {
summary,
noise: { rows: noiseRows, sources, available: n != null },
air: { rows: airRows, available: aq != null },
wind: { rows: windRows, available: w != null },
weather: { rows: weatherRows, available: wt != null },
};
}
// ── georisks — geology / hydrology / geotech (REAL where present) ──────────────
export interface PticaGeorisksDrawer {
summary: string;
geology: {
rows: DrawerKvRow[];
links: Array<{ label: string; href: string }>;
available: boolean;
};
hydrology: { rows: DrawerKvRow[]; available: boolean };
geotech: { rows: DrawerKvRow[]; available: boolean };
}
export function adaptGeorisksDrawer(a: ParcelAnalysis): PticaGeorisksDrawer {
const g = a.geology;
const h = a.hydrology;
const gt = a.geotech_risk;
const geologyRows: DrawerKvRow[] = g
? [
{
k: "Данные геологии",
field: g.data_available
? real("доступны")
: notReal("нет ИГИ — нужен полевой расчёт"),
},
{ k: "Примечание", field: g.note ? real(g.note) : notReal("—") },
]
: [{ k: "Геология", field: notReal("нет данных геологии") }];
const geologyLinks = g
? [
{ label: "Карпинский WebGIS", href: g.links.karpinsky_webgis },
{ label: "ЕФГИ (фед. реестр)", href: g.links.efgi_federal_registry },
]
: [];
const hydrologyRows: DrawerKvRow[] = h
? [
{
k: "Риск подтопления",
field: real(h.flood_risk_flag ? "есть" : "нет"),
},
{
k: "Ближайший водоём",
field:
h.nearest.length > 0
? real(`${formatInt(h.nearest[0].distance_m)} м`)
: notReal("нет данных"),
},
{ k: "Примечание", field: h.note ? real(h.note) : notReal("—") },
]
: [{ k: "Гидрология", field: notReal("нет данных гидрологии") }];
const geotechRows: DrawerKvRow[] = gt
? [
{ k: "Сейсмика", field: real(gt.seismic_label) },
{
k: "Баллы (сейсм.)",
field:
gt.seismic_intensity_balls != null
? real(formatInt(gt.seismic_intensity_balls))
: notReal("нет данных"),
},
{
k: "Многолетняя мерзлота",
field: real(gt.permafrost ? "есть" : "нет"),
},
{
k: "Пром. объектов <500 м",
field: real(formatInt(gt.industrial_within_500m)),
},
]
: [{ k: "Геотехника", field: notReal("нет данных геотехники") }];
const summary =
"Инженерно-геологический фон участка: геология, гидрология и геотехнические " +
"риски. Поля без полевых данных отмечены «—» (нужны ИГИ).";
return {
summary,
geology: { rows: geologyRows, links: geologyLinks, available: g != null },
hydrology: { rows: hydrologyRows, available: h != null },
geotech: { rows: geotechRows, available: gt != null },
};
}
// ── risks — composite breakdown (DERIVED / предв.) ─────────────────────────────
export interface PticaRisksDrawer {
gauge: PticaGauge;
summary: string;
breakdown: PticaFactorBar[];
blockers: Array<{ code: string; detail: string }>;
warnings: Array<{ code: string; detail: string }>;
isReal: boolean;
}
export function adaptRisksDrawer(a: ParcelAnalysis): PticaRisksDrawer {
const gauge = adaptRiskGauge(a);
const gate = a.gate_verdict;
const geom = a.geometry_suitability;
const n = a.noise;
const breakdown: PticaFactorBar[] = [
{
name: "Правовые / гейт",
pct: gate
? Math.min(100, gate.blockers.length * 30 + gate.warnings.length * 12)
: 0,
value: gate
? `${gate.blockers.length} блок. · ${gate.warnings.length} пред.`
: DASH,
tone: (gate?.blockers.length ?? 0) > 0 ? "bad" : "good",
reason: gate ? "из gate_verdict" : "нет gate_verdict",
},
{
name: "Геометрия",
pct:
geom?.suitability_score != null
? (1 - geom.suitability_score) * 100
: 0,
value:
geom?.suitability_score != null
? `${fmtNum((1 - geom.suitability_score) * 10, 1)} / 10`
: DASH,
tone: "neutral",
reason: geom?.label ? geom.label : "нет геометрии",
},
{
name: "Шум / среда",
pct: n != null ? Math.min(100, (n.estimated_db / 80) * 100) : 0,
value: n != null ? `${formatInt(n.estimated_db)} дБ · ${n.level}` : DASH,
tone: n != null && n.estimated_db >= 60 ? "bad" : "neutral",
reason: n != null ? "из шумового слоя" : "нет данных шума",
},
];
const summary =
"Сводный риск — предварительная композиция из гейт-вердикта, геометрии и " +
"средовых факторов. Это эвристика («предв.»), а не итоговая риск-модель.";
return {
gauge,
summary,
breakdown,
blockers: gate?.blockers ?? [],
warnings: gate?.warnings ?? [],
isReal: false,
};
}
// ── product — §22 product_tz («после прогноза» while absent) ───────────────────
export interface PticaProductDrawer {
available: boolean;
summary: string;
objClass: PticaField;
mix: Array<{ bucket: string; cls: string; signal: string; pct: number }>;
usp: Array<{ text: string; cls: string }>;
reasons: string[];
}
function mixBarPct(entry: ProductMixEntry, total: number): number {
if (entry.pct != null) return entry.pct;
// No explicit share → even split as a visual hint only.
return total > 0 ? Math.round(100 / total) : 0;
}
export function adaptProductDrawer(
report?: ForecastReport,
): PticaProductDrawer {
const tz = report?.product_tz;
if (!tz) {
return {
available: false,
summary:
"Продуктовая стратегия (класс, квартирография, USP) формируется в " +
"асинхронном прогнозе §22. Откройте вкладку «Сценарии», чтобы запустить расчёт.",
objClass: notReal("после прогноза (Scenarios)"),
mix: [],
usp: [],
reasons: [],
};
}
const mix = tz.mix.map((m) => ({
bucket: m.bucket ?? DASH,
cls: m.obj_class ?? DASH,
signal: m.signal ?? DASH,
pct: mixBarPct(m, tz.mix.length),
}));
const usp = tz.usp.map((u) => ({
text: u.usp_text ?? DASH,
cls: u.obj_class ?? DASH,
}));
const reasons = tz.reasons
.map((r) => r.why)
.filter((w): w is string => Boolean(w));
return {
available: true,
summary: tz.summary ?? "Рекомендация продукта из прогноза §22 (advisory).",
objClass: tz.obj_class
? { value: tz.obj_class, isReal: true, caption: "advisory · §22" }
: notReal("нет рекомендации класса"),
mix,
usp,
reasons,
};
}
// ── potential — ёмкость (PLACEHOLDER — нужен max_far) ──────────────────────────
export interface PticaPotentialDrawer {
summary: string;
rows: DrawerKvRow[];
}
export function adaptPotentialDrawer(a: ParcelAnalysis): PticaPotentialDrawer {
const areaHa = a.geometry_suitability?.area_ha;
const areaField =
areaHa != null
? real(`${areaM2FromHa(areaHa)} (${formatHa(areaHa)})`)
: notReal("нет геометрии");
const z = a.nspd_zoning;
const areaM2 = parcelAreaM2(a);
const maxFar = z?.max_far;
const farReal = maxFar != null;
const buildingPct = z?.max_building_pct;
const maxHeight = z?.max_height_m;
const maxFloors = z?.max_floors;
// КСИТ ёмкость (надземная GFA) = площадь × max_far — real with both inputs.
const capacityField: PticaField =
farReal && areaM2 != null
? {
value: `${formatInt(areaM2 * maxFar)} м²`,
isReal: true,
caption: `КСИТ ${formatFar(maxFar)} · площадь × КСИТ`,
}
: notReal("нужен max_far (НСПД)");
// Пятно застройки = площадь × коэф. застройки (max_building_pct).
const spotField: PticaField =
areaM2 != null && buildingPct != null
? {
value: `${formatInt(areaM2 * (buildingPct / 100))} м²`,
isReal: true,
caption: `${formatInt(buildingPct)} % площади · НСПД`,
}
: notReal("нужен % застройки (НСПД)");
// Расчётная этажность — прямо из регламента, либо высота / 3 м (этаж).
const floorsField: PticaField =
maxFloors != null
? real(`${formatInt(maxFloors)} эт.`)
: maxHeight != null
? {
value: `${formatInt(maxHeight / 3)} эт.`,
isReal: true,
caption: `от предельной высоты ${formatInt(maxHeight)} м`,
}
: notReal("нужна предельная высота (НСПД)");
const rows: DrawerKvRow[] = [
{ k: "Площадь участка", field: areaField },
{
k: "КСИТ (max_far)",
field: farReal
? real(formatFar(maxFar))
: notReal("нужен max_far (НСПД)"),
},
{ k: "Ёмкость по КСИТ", field: capacityField },
{ k: "Пятно застройки", field: spotField },
{
k: "Продаваемая площадь",
field:
farReal && areaM2 != null
? {
value: `${formatInt(areaM2 * maxFar)} м²`,
isReal: true,
caption: "оценка по КСИТ-ёмкости · без вычета МОП/паркинга",
}
: notReal("после расчёта потенциала"),
},
{ k: "Расчётная этажность", field: floorsField },
];
const summary = farReal
? "Расчёт ёмкости по регламенту НСПД-зонирования: ёмкость по КСИТ = площадь × " +
"max_far, пятно — по коэффициенту застройки. Продаваемая площадь — оценка " +
"(без вычета МОП и паркинга), уточняется финмоделью."
: "Расчёт ёмкости (плотность, пятно, продаваемая площадь) требует предельного " +
"КСИТ (max_far) и высоты из НСПД-зонирования. Доступна только площадь участка.";
return { summary, rows };
}
// ── economy / finance — PLACEHOLDER (после финмодели) ──────────────────────────
export interface PticaEconomyDrawer {
summary: string;
rows: DrawerKvRow[];
}
export function adaptEconomyDrawer(): PticaEconomyDrawer {
return {
summary:
"Экономика проекта (выручка, маржа, ROI, IRR) рассчитывается после " +
"финансовой модели (§22). Пока показатели недоступны.",
rows: [
{ k: "Потенц. выручка (GDV)", field: notReal("после финмодели") },
{ k: "Себестоимость", field: notReal("после финмодели") },
{ k: "Маржа", field: notReal("после финмодели") },
{ k: "ROI", field: notReal("после финмодели") },
{ k: "IRR", field: notReal("после финмодели") },
],
};
}
export function adaptFinanceDrawer(): PticaEconomyDrawer {
return {
summary:
"Финансовая модель (P&L, cash-flow по фазам, сценарный анализ) формируется " +
"в асинхронном расчёте §22. Откройте «Сценарии», чтобы запустить прогноз.",
rows: [
{ k: "GDV (выручка)", field: notReal("после финмодели") },
{ k: "Совокупные затраты", field: notReal("после финмодели") },
{ k: "Чистая прибыль", field: notReal("после финмодели") },
{ k: "ROI / IRR", field: notReal("после финмодели") },
{ k: "Срок проекта", field: notReal("после финмодели") },
],
};
}
// ── oks — Нет данных ──────────────────────────────────────────────────────────
export interface PticaOksDrawer {
summary: string;
available: boolean;
}
export function adaptOksDrawer(): PticaOksDrawer {
return {
summary:
"Объекты капитального строительства на участке в текущей выгрузке /analyze " +
"не раскрываются. Раздел появится после интеграции реестра ОКС (ЕГРН).",
available: false,
};
}
// ══════════════════════════════════════════════════════════════════════════════
// 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). */
export const EKB_CENTER: [number, number] = [56.838, 60.6];