diff --git a/frontend/src/components/site-finder/ptica/massing/MassingScene.tsx b/frontend/src/components/site-finder/ptica/massing/MassingScene.tsx
index 015a9024..41706ecf 100644
--- a/frontend/src/components/site-finder/ptica/massing/MassingScene.tsx
+++ b/frontend/src/components/site-finder/ptica/massing/MassingScene.tsx
@@ -26,8 +26,10 @@ import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
export interface MassingSceneProps {
/** Real parcel area in m² when available (fallback ≈ 6800). */
areaM2?: number;
- /** КСИТ-цель (FAR target) — regulation default, not parcel-specific. */
+ /** КСИТ-цель (FAR target) — real НСПД-регламент when present, else default. */
maxFar?: number;
+ /** True when `maxFar` is the real НСПД ПЗЗ value (drives the caption). */
+ farIsReal?: boolean;
}
// ── constants (scene units = metres) ──────────────────────────────────────────
@@ -298,6 +300,7 @@ function modelMetrics(model: MassingModel): Metrics {
export default function MassingScene({
areaM2,
maxFar,
+ farIsReal = false,
}: MassingSceneProps): React.JSX.Element {
const parcelArea =
areaM2 && Number.isFinite(areaM2) && areaM2 > 0 ? areaM2 : DEFAULT_AREA;
@@ -703,7 +706,8 @@ export default function MassingScene({
- КСИТ-цель {farTarget.toFixed(1)} · регламент-дефолт, источник НСПД ·
+ КСИТ-цель {farTarget.toFixed(1)} ·{" "}
+ {farIsReal ? "регламент НСПД" : "регламент-дефолт, источник НСПД"} ·
масса генеративная (этажи/секции/время — исследовательские вводные)
diff --git a/frontend/src/components/site-finder/ptica/ptica-adapt.ts b/frontend/src/components/site-finder/ptica/ptica-adapt.ts
index d36512ad..c0d3ed66 100644
--- a/frontend/src/components/site-finder/ptica/ptica-adapt.ts
+++ b/frontend/src/components/site-finder/ptica/ptica-adapt.ts
@@ -14,6 +14,7 @@ import type {
ParcelAnalysis,
ParcelAnalysisCompetitor,
} from "@/types/site-finder";
+import type { NspdZoning } from "@/types/nspd";
import type { ForecastReport, ProductMixEntry } from "@/types/forecast";
// ── View-model ──────────────────────────────────────────────────────────────
@@ -90,6 +91,29 @@ 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 {
@@ -296,25 +320,49 @@ function utilityLabel(subtype: string): string {
return UTILITY_LABELS[subtype] ?? subtype;
}
-/** Градостроительство — mostly placeholder (НСПД-зонирование / источники). */
+/**
+ * Градостроительство — 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 zoneCode = a.nspd_zoning?.zone_code;
+ 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: zoneCode
- ? { value: zoneCode, isReal: true, caption: "НСПД-зонирование" }
- : placeholder("источник НСПД-зонирование"),
+ field: zone
+ ? { value: zone, isReal: true, caption: "НСПД-зонирование" }
+ : placeholder(SRC_NSPD_ZONING),
},
{
key: "Пред. высота",
- field: placeholder("источник НСПД-зонирование"),
+ field:
+ maxHeight != null
+ ? real(`${formatInt(maxHeight)} м`)
+ : placeholder(SRC_NSPD_ZONING),
},
{
key: "Плотность (КСИТ)",
- field: placeholder("источник НСПД-зонирование"),
+ field: densityField,
},
{
key: "Пятно застройки",
@@ -592,6 +640,40 @@ export function adaptBuildabilityDrawer(
},
];
+ // ── Пятно / продаваемая площадь — 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: "Площадь",
@@ -618,8 +700,8 @@ export function adaptBuildabilityDrawer(
? real(`${fmtNum(geom.suitability_score * 10, 1)} / 10`)
: notReal("нет геометрии"),
},
- { k: "Пятно застройки", field: notReal("нужен max_far (НСПД)") },
- { k: "Продаваемая площадь", field: notReal("нужен max_far (НСПД)") },
+ spotRow,
+ sellableRow,
];
const penalties = (geom?.penalties ?? []).map((p) => ({
@@ -627,10 +709,13 @@ export function adaptBuildabilityDrawer(
impact: "штраф",
}));
- const summary =
- "Индекс застраиваемости — предварительная оценка по гейт-вердикту, геометрии " +
- "участка и близости инженерии. Это эвристика, а не итоговый индекс: точное " +
- "значение появится после расчёта потенциала (max_far НСПД) и финмодели.";
+ const summary = farReal
+ ? "Индекс застраиваемости — предварительная оценка по гейт-вердикту, геометрии " +
+ "участка и близости инженерии. Это эвристика, а не итоговый индекс: точное " +
+ "значение появится после финмодели. КСИТ-ёмкость рассчитана по регламенту НСПД."
+ : "Индекс застраиваемости — предварительная оценка по гейт-вердикту, геометрии " +
+ "участка и близости инженерии. Это эвристика, а не итоговый индекс: точное " +
+ "значение появится после расчёта потенциала (max_far НСПД) и финмодели.";
return {
gauge,
@@ -653,13 +738,34 @@ export interface PticaUrbanDrawer {
export function adaptUrbanDrawer(a: ParcelAnalysis): PticaUrbanDrawer {
const zoning = a.nspd_zoning;
- const zoneCode = zoning?.zone_code
- ? real(zoning.zone_code)
- : notReal("источник НСПД-зонирование");
+ // Территориальная зона: 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("источник НСПД-зонирование");
+ : 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 },
@@ -669,19 +775,44 @@ export function adaptUrbanDrawer(a: ParcelAnalysis): PticaUrbanDrawer {
? real(a.egrn.permitted_use_text)
: notReal("нет данных ЕГРН"),
},
- { k: "КСИТ (max FAR)", field: notReal("нужен max_far (НСПД)") },
- { k: "Предельная высота", field: 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: notReal("источник НСПД-зонирование"),
+ field:
+ buildingPct != null
+ ? real(`${formatInt(buildingPct)} %`)
+ : notReal(SRC_NSPD_ZONING),
},
- { k: "Плотность (КСИТ)", field: notReal("после расчёта потенциала") },
+ { k: "Плотность (КСИТ)", field: densityField },
];
- const summary =
- "Градостроительный регламент участка. Территориальная зона и ВРИ берутся из " +
- "НСПД-зонирования / ЕГРН; КСИТ (max_far), предельная высота и плотность пока " +
- "недоступны в выгрузке — отмечены «—» с источником.";
+ const summary = farReal
+ ? "Градостроительный регламент участка из НСПД-зонирования (ПЗЗ). " +
+ "Территориальная зона, КСИТ (max_far), предельная высота и коэффициент " +
+ "застройки — реальные регламентные значения; плотность рассчитана от " +
+ "площади участка."
+ : "Градостроительный регламент участка. Территориальная зона и ВРИ берутся из " +
+ "НСПД-зонирования / ЕГРН; КСИТ (max_far), предельная высота и плотность пока " +
+ "недоступны в выгрузке — отмечены «—» с источником.";
return { summary, regulation, zoneCode, zoneName };
}
@@ -1298,21 +1429,76 @@ export function adaptPotentialDrawer(a: ParcelAnalysis): PticaPotentialDrawer {
? 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: notReal("нужен max_far (НСПД)") },
- { k: "Ёмкость по КСИТ", field: notReal("нужен max_far (НСПД)") },
- { k: "Пятно застройки", field: notReal("нужен max_far (НСПД)") },
- { k: "Продаваемая площадь", field: notReal("после расчёта потенциала") },
{
- k: "Расчётная этажность",
- field: notReal("нужна предельная высота (НСПД)"),
+ 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 =
- "Расчёт ёмкости (плотность, пятно, продаваемая площадь) требует предельного " +
- "КСИТ (max_far) и высоты из НСПД-зонирования. Доступна только площадь участка.";
+ const summary = farReal
+ ? "Расчёт ёмкости по регламенту НСПД-зонирования: ёмкость по КСИТ = площадь × " +
+ "max_far, пятно — по коэффициенту застройки. Продаваемая площадь — оценка " +
+ "(без вычета МОП и паркинга), уточняется финмоделью."
+ : "Расчёт ёмкости (плотность, пятно, продаваемая площадь) требует предельного " +
+ "КСИТ (max_far) и высоты из НСПД-зонирования. Доступна только площадь участка.";
return { summary, rows };
}
diff --git a/frontend/src/types/nspd.ts b/frontend/src/types/nspd.ts
index 43c4340f..0f7001ff 100644
--- a/frontend/src/types/nspd.ts
+++ b/frontend/src/types/nspd.ts
@@ -7,6 +7,15 @@ export interface NspdZoning {
zone_name: string | null;
source?: string;
raw_props?: Record;
+ // Real ПЗЗ regulation (PR #1847) — optional + nullable so the cockpit stays
+ // backward-compatible until #1847 deploys, then auto-lights-up.
+ max_far?: number | null; // КСИТ (предельный коэф. плотности застройки)
+ max_height_m?: number | null; // предельная высота, м
+ max_floors?: number | null; // предельная этажность
+ max_building_pct?: number | null; // коэф. застройки (процент пятна)
+ min_parcel_area_m2?: number | null; // мин. площадь участка, м²
+ regulation_zone_index?: string | null; // human zone index, e.g. "Ж-2"
+ regulation_source?: string | null; // источник регламента
}
export interface NspdZouitOverlap {