feat(ptica): wire real ПЗЗ regulation (КСИТ/height/zone/density) into cockpit
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 51s
CI / openapi-codegen-check (pull_request) Successful in 1m46s

Read nspd_zoning.{max_far,max_height_m,max_floors,max_building_pct,
regulation_zone_index,regulation_source} (backend PR #1847) into the urban
scan-card, urban/buildability/potential drawers and the 3D massing КСИТ-target.
Defensive: every read is optional-chained + != null guarded → identical "—"
placeholders when fields absent, so the cockpit is unchanged until #1847 deploys,
then auto-lights-up. КСИТ density = area × max_far; пятно = area × max_building_pct
(percent 0..100, verified vs backend parser); sellable labeled estimate. 3D maxFar
= nspd_zoning.max_far ?? 3.5 (finite/>0 guarded). NspdZoning type +7 optional fields.
TS strict, no any.
This commit is contained in:
Light1YT 2026-06-20 23:33:41 +05:00
parent 06fa5b4910
commit 167fcbace2
4 changed files with 267 additions and 43 deletions

View file

@ -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 (
<>
<DrawerSection>
<SummaryBox>
Генеративная масса по регламенту: КСИТ-цель {MASSING_MAX_FAR} · пятно
и высота подбираются под целевую ёмкость, GFA и КСИТ-факт считаются от
вводных. Орбита, зум и солнце интерактивные.
Генеративная масса по регламенту: КСИТ-цель {maxFar.toFixed(1)} ·
пятно и высота подбираются под целевую ёмкость, GFA и КСИТ-факт
считаются от вводных. Орбита, зум и солнце интерактивные.
</SummaryBox>
</DrawerSection>
<DrawerSection>
<MassingViewer
areaM2={massingAreaM2(analysis)}
maxFar={MASSING_MAX_FAR}
maxFar={maxFar}
farIsReal={farReal}
/>
</DrawerSection>
<SourceLine source="Песочница массинга · КСИТ-цель регламент-дефолт (НСПД)" />
<SourceLine
source={
farReal
? "Песочница массинга · КСИТ-цель регламент НСПД (max_far)"
: "Песочница массинга · КСИТ-цель регламент-дефолт (НСПД)"
}
/>
</>
);
}
@ -840,7 +864,8 @@ export function InsolationContent({ analysis }: { analysis: ParcelAnalysis }) {
<DrawerSection>
<MassingViewer
areaM2={massingAreaM2(analysis)}
maxFar={MASSING_MAX_FAR}
maxFar={massingMaxFar(analysis)}
farIsReal={massingFarIsReal(analysis)}
/>
</DrawerSection>
<DrawerSection heading="ЧТО ДОБАВИТСЯ В МОДУЛЕ">

View file

@ -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({
</div>
<div className={styles.massingCaption}>
КСИТ-цель {farTarget.toFixed(1)} · регламент-дефолт, источник НСПД ·
КСИТ-цель {farTarget.toFixed(1)} ·{" "}
{farIsReal ? "регламент НСПД" : "регламент-дефолт, источник НСПД"} ·
масса генеративная (этажи/секции/время исследовательские вводные)
</div>
</div>

View file

@ -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 };
}

View file

@ -7,6 +7,15 @@ export interface NspdZoning {
zone_name: string | null;
source?: string;
raw_props?: Record<string, unknown>;
// 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 {