diff --git a/frontend/src/components/site-finder/ptica/drawers/DrawerContent.tsx b/frontend/src/components/site-finder/ptica/drawers/DrawerContent.tsx index a630d064..5c8be87b 100644 --- a/frontend/src/components/site-finder/ptica/drawers/DrawerContent.tsx +++ b/frontend/src/components/site-finder/ptica/drawers/DrawerContent.tsx @@ -51,7 +51,7 @@ import { DrawerActions, } from "@/components/site-finder/ptica/drawers/DrawerPrimitives"; -/** КСИТ-цель (FAR) — regulation default, see caption in the 3D sandbox. */ +/** КСИТ-цель (FAR) — regulation default when НСПД max_far is absent. */ const MASSING_MAX_FAR = 3.5; /** Fallback parcel area (m²) when geometry_suitability.area_ha is absent. */ const MASSING_FALLBACK_AREA = 6800; @@ -65,6 +65,21 @@ function massingAreaM2(analysis: ParcelAnalysis): number { return MASSING_FALLBACK_AREA; } +/** Real КСИТ from НСПД-зонирование (PR #1847), else the regulation default. */ +function massingMaxFar(analysis: ParcelAnalysis): number { + const maxFar = analysis.nspd_zoning?.max_far; + if (typeof maxFar === "number" && Number.isFinite(maxFar) && maxFar > 0) { + return maxFar; + } + return MASSING_MAX_FAR; +} + +/** True when the КСИТ used by the massing sandbox is the real НСПД value. */ +function massingFarIsReal(analysis: ParcelAnalysis): boolean { + const maxFar = analysis.nspd_zoning?.max_far; + return typeof maxFar === "number" && Number.isFinite(maxFar) && maxFar > 0; +} + const SRC_UPDATED = "источник: /analyze"; // ── shared helpers ──────────────────────────────────────────────────────────── @@ -807,22 +822,31 @@ export function OksContent() { // ══════════════════════════════════════════════════════════════════════════════ export function Future3dContent({ analysis }: { analysis: ParcelAnalysis }) { + const maxFar = massingMaxFar(analysis); + const farReal = massingFarIsReal(analysis); return ( <> - Генеративная масса по регламенту: КСИТ-цель {MASSING_MAX_FAR} · пятно - и высота подбираются под целевую ёмкость, GFA и КСИТ-факт считаются от - вводных. Орбита, зум и солнце — интерактивные. + Генеративная масса по регламенту: КСИТ-цель {maxFar.toFixed(1)} · + пятно и высота подбираются под целевую ёмкость, GFA и КСИТ-факт + считаются от вводных. Орбита, зум и солнце — интерактивные. - + ); } @@ -840,7 +864,8 @@ export function InsolationContent({ analysis }: { analysis: ParcelAnalysis }) { diff --git a/frontend/src/components/site-finder/ptica/massing/MassingScene.tsx b/frontend/src/components/site-finder/ptica/massing/MassingScene.tsx index 1ee75614..1596b96d 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; @@ -705,7 +708,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 {