diff --git a/frontend/src/components/site-finder/analysis/NspdRegulationCard.tsx b/frontend/src/components/site-finder/analysis/NspdRegulationCard.tsx
new file mode 100644
index 00000000..61812cdd
--- /dev/null
+++ b/frontend/src/components/site-finder/analysis/NspdRegulationCard.tsx
@@ -0,0 +1,176 @@
+/**
+ * NspdRegulationCard — «Градрегламент (НСПД)» block for Раздел 1 (#1962).
+ *
+ * Surfaces the REAL ПЗЗ-градрегламент resolved into `nspd_zoning` (synth #1891):
+ * тер.зона, КСИТ (max_far), пред. высота, этажность, коэф. застройки, ёмкость,
+ * источник + the permitted-use (ВРИ) list of the zone. Previously the light
+ * report only rendered the dead `zoning.data_available=false` (closed-PKK6 path)
+ * and never showed the real regulation the user asked about («мы выгружали НСПД —
+ * где они?»).
+ *
+ * Mirrors EgrnPropertyTable's card pattern (uppercase header + striped 2-col
+ * table). Pure display — view-model built by `adaptNspdRegulation`. Graceful
+ * empty-state when the resolver produced nothing (gap-зоны / не-ЕКБ).
+ */
+
+import { Landmark } from "lucide-react";
+import type { NspdZoning } from "@/types/nspd";
+import { adaptNspdRegulation } from "./nspd-regulation";
+
+interface Props {
+ zoning: NspdZoning | null | undefined;
+ /** Parcel area in m² (ЕГРН) — drives the КСИТ-ёмкость / пятно rows. */
+ areaM2: number | null;
+}
+
+const HEADER_STYLE: React.CSSProperties = {
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ padding: "10px 16px",
+ borderBottom: "1px solid var(--border-soft)",
+ fontSize: 12,
+ fontWeight: 500,
+ textTransform: "uppercase",
+ letterSpacing: "0.04em",
+ color: "var(--fg-tertiary)",
+};
+
+export function NspdRegulationCard({ zoning, areaM2 }: Props) {
+ const vm = adaptNspdRegulation(zoning, areaM2);
+
+ return (
+
+
+
+ Градрегламент (НСПД)
+
+
+ {vm.present ? (
+ <>
+
+
+ {vm.rows.map((row, i) => (
+
+ |
+ {row.label}
+ |
+
+ {row.value}
+ |
+
+ ))}
+
+
+
+ {/* КСИТ microcopy — расшифровка жаргона на первой странице (ui-microcopy). */}
+
+ КСИТ — коэффициент строительного использования: предельная надземная
+ площадь застройки на участке (площадь участка × КСИТ).
+
+
+ {vm.permittedUses.length > 0 && (
+
+
+ Разрешённые ВРИ зоны
+
+
+ {vm.permittedUses.map((use) => (
+ - {use}
+ ))}
+
+
+ )}
+ >
+ ) : (
+
+ Градрегламент не определён: территориальная зона ПЗЗ для участка не
+ разрешена (вне ЕКБ или участок в разрыве зон).
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/site-finder/analysis/Section1ParcelInfo.tsx b/frontend/src/components/site-finder/analysis/Section1ParcelInfo.tsx
index e940ebc6..5535fbb7 100644
--- a/frontend/src/components/site-finder/analysis/Section1ParcelInfo.tsx
+++ b/frontend/src/components/site-finder/analysis/Section1ParcelInfo.tsx
@@ -17,6 +17,7 @@ import { HeadlineBar } from "@/components/ui/HeadlineBar";
import { KpiCard } from "@/components/site-finder/KpiCard";
import { MiniMap } from "./MiniMap";
import { EgrnPropertyTable } from "./EgrnPropertyTable";
+import { NspdRegulationCard } from "./NspdRegulationCard";
import { NspdVerifyLink } from "./NspdVerifyLink";
import { PoiList2Gis } from "./PoiList2Gis";
import { ExportButtons } from "./ExportButtons";
@@ -307,10 +308,23 @@ export function Section1ParcelInfo({ cad }: Props) {
{/* Left: mini-map */}
- {/* Right: EGRN table + POI list */}
+ {/* Right: EGRN table + НСПД-градрегламент + POI list */}
+ {/* #1962 — реальный ПЗЗ-градрегламент из nspd_zoning (synth #1891).
+ Раньше отчёт показывал только мёртвый zoning.data_available=false
+ (закрытый PKK6-путь) и НЕ выводил резолвленный регламент. КСИТ-ёмкость
+ считается от площади ЕГРН (egrn.area_m2). */}
+
0
+ ? egrn.area_m2
+ : null
+ }
+ />
+
{poiData ? (
{
+ it("parses wire strings, passes numbers, rejects junk", () => {
+ expect(coerceFloat("2.4")).toBe(2.4);
+ expect(coerceFloat("80")).toBe(80);
+ expect(coerceFloat(2)).toBe(2);
+ expect(coerceFloat(null)).toBeNull();
+ expect(coerceFloat(undefined)).toBeNull();
+ expect(coerceFloat("")).toBeNull();
+ expect(coerceFloat("n/a")).toBeNull();
+ });
+});
+
+describe("adaptNspdRegulation (#1962)", () => {
+ it("maps the live nspd_zoning shape into regulation rows", () => {
+ const vm = adaptNspdRegulation(LIVE_ZONING, AREA_M2);
+
+ expect(vm.present).toBe(true);
+ expect(vm.zone).toBe("ПК-1");
+ expect(vm.source).toBe("ekb-geoportal-urbancard");
+
+ const byLabel = Object.fromEntries(vm.rows.map((r) => [r.label, r.value]));
+ expect(byLabel["Тер. зона"]).toBe("ПК-1");
+ expect(byLabel["КСИТ (max FAR)"]).toBe("2,4");
+ expect(byLabel["Этажность"]).toBe("2 эт.");
+ expect(byLabel["Коэф. застройки"]).toBe("80 %");
+ // max_height_m null → row omitted, not a "—" with NaN.
+ expect(byLabel["Пред. высота"]).toBeUndefined();
+ // КСИТ-ёмкость = area × max_far = 106378 × 2.4 = 255307,2 → 255 307 (digits
+ // grouped by a NBSP from ru-RU toLocaleString, hence the regex over \D).
+ expect(byLabel["КСИТ-ёмкость"].replace(/\D/g, "")).toBe("255307");
+ // Пятно застройки = area × 80% = 85 102.
+ expect(byLabel["Пятно застройки"].replace(/\D/g, "")).toBe("85102");
+ expect(byLabel["Источник"]).toBe("ekb-geoportal-urbancard");
+ });
+
+ it("trims and filters the permitted-use (ВРИ) list", () => {
+ const vm = adaptNspdRegulation(LIVE_ZONING, AREA_M2);
+ expect(vm.permittedUses).toEqual([
+ "3.1 Коммунальное обслуживание",
+ "2.7.1 Хранение автотранспорта",
+ ]);
+ });
+
+ it("omits area-derived rows when geometry is unknown", () => {
+ const vm = adaptNspdRegulation(LIVE_ZONING, null);
+ const labels = vm.rows.map((r) => r.label);
+ expect(labels).not.toContain("КСИТ-ёмкость");
+ expect(labels).not.toContain("Пятно застройки");
+ // зона / КСИТ / этажность / коэф. застройки / источник still present.
+ expect(labels).toContain("КСИТ (max FAR)");
+ });
+
+ it("reports not-present for null nspd_zoning (empty-state)", () => {
+ const vm = adaptNspdRegulation(null, AREA_M2);
+ expect(vm.present).toBe(false);
+ expect(vm.zone).toBeNull();
+ expect(vm.rows).toHaveLength(0);
+ expect(vm.permittedUses).toHaveLength(0);
+ });
+
+ it("falls back to zone_code when regulation_zone_index is absent", () => {
+ const vm = adaptNspdRegulation(
+ { zone_code: "Ж-2", zone_name: null },
+ AREA_M2,
+ );
+ expect(vm.present).toBe(true);
+ expect(vm.zone).toBe("Ж-2");
+ });
+});
diff --git a/frontend/src/components/site-finder/analysis/nspd-regulation.ts b/frontend/src/components/site-finder/analysis/nspd-regulation.ts
new file mode 100644
index 00000000..b582f094
--- /dev/null
+++ b/frontend/src/components/site-finder/analysis/nspd-regulation.ts
@@ -0,0 +1,138 @@
+/**
+ * nspd-regulation — PURE typed mapper: NspdZoning → градрегламент view-model.
+ *
+ * Single source of truth for turning the resolved ПЗЗ-регламент (synth #1891,
+ * `nspd_zoning`) into the rows the light report (Section1ParcelInfo, #1962) and
+ * the /ptica cockpit drawer render. Reused by `ptica-adapt` so the two stay
+ * consistent (same field paths, same КСИТ-capacity maths, same coercion).
+ *
+ * No React, no side effects — narrowing + formatting only.
+ */
+
+import type { NspdZoning } from "@/types/nspd";
+
+/**
+ * Coerce a wire value to a finite float. The ekb-geoportal-urbancard regulation
+ * columns arrive as STRINGS ("2.4", "80") in `nspd_zoning.max_far` /
+ * `max_building_pct`, but `max_floors` / `max_height_m` are real numbers — this
+ * accepts both and rejects null / "" / non-numeric so callers never do `NaN`
+ * arithmetic. The single coercion point for #1962 + the cockpit.
+ */
+export function coerceFloat(
+ v: string | number | null | undefined,
+): number | null {
+ if (v == null) return null;
+ if (typeof v === "number") return Number.isFinite(v) ? v : null;
+ const n = Number.parseFloat(v);
+ return Number.isFinite(n) ? n : null;
+}
+
+/** Human regulation zone — prefer the readable index ("ПК-1") over reg-number. */
+export function regulationZoneLabel(
+ z: NspdZoning | null | undefined,
+): string | null {
+ return z?.regulation_zone_index ?? z?.zone_code ?? null;
+}
+
+/** КСИТ (max_far) as a plain RU number — e.g. "2,4". */
+function formatFar(far: number): string {
+ return far.toLocaleString("ru-RU", {
+ minimumFractionDigits: 1,
+ maximumFractionDigits: 1,
+ });
+}
+
+function formatInt(n: number): string {
+ return Math.round(n).toLocaleString("ru-RU");
+}
+
+/** A single value row of the градрегламент block. */
+export interface NspdRegulationRow {
+ label: string;
+ value: string;
+}
+
+/** View-model consumed by the Раздел 1 «Градрегламент (НСПД)» card. */
+export interface NspdRegulationVM {
+ /** True when the resolver produced at least a zone — otherwise hide / empty. */
+ present: boolean;
+ /** тер.зона ("ПК-1") or null. */
+ zone: string | null;
+ /** источник регламента, e.g. "ekb-geoportal-urbancard". */
+ source: string | null;
+ /** Param rows (зона / КСИТ / высота / этажность / коэф. застройки / ёмкость). */
+ rows: NspdRegulationRow[];
+ /** Разрешённые ВРИ зоны (#1891 main_vri), trimmed; empty when absent. */
+ permittedUses: string[];
+}
+
+/**
+ * Map `nspd_zoning` (+ optional parcel area in m² for the КСИТ-capacity row) into
+ * the градрегламент view-model. `areaM2` lets us derive КСИТ-ёмкость
+ * (площадь × max_far) and пятно застройки (площадь × коэф. застройки) — the same
+ * computation `adaptUrbanDrawer` uses; pass null when geometry is unknown and
+ * those derived rows are simply omitted.
+ */
+export function adaptNspdRegulation(
+ z: NspdZoning | null | undefined,
+ areaM2: number | null,
+): NspdRegulationVM {
+ const zone = regulationZoneLabel(z);
+ const maxFar = coerceFloat(z?.max_far);
+ const maxHeight = coerceFloat(z?.max_height_m);
+ const maxFloors = coerceFloat(z?.max_floors);
+ const buildingPct = coerceFloat(z?.max_building_pct);
+ const source = z?.regulation_source ?? null;
+
+ const permittedUses = (z?.main_vri ?? [])
+ .map((v) => v.replace(/\s+/g, " ").trim())
+ .filter((v) => v.length > 0);
+
+ const rows: NspdRegulationRow[] = [];
+
+ if (zone != null) rows.push({ label: "Тер. зона", value: zone });
+
+ if (maxFar != null) {
+ rows.push({ label: "КСИТ (max FAR)", value: formatFar(maxFar) });
+ }
+
+ if (maxHeight != null) {
+ rows.push({ label: "Пред. высота", value: `${formatInt(maxHeight)} м` });
+ }
+
+ if (maxFloors != null) {
+ rows.push({ label: "Этажность", value: `${formatInt(maxFloors)} эт.` });
+ }
+
+ if (buildingPct != null) {
+ rows.push({
+ label: "Коэф. застройки",
+ value: `${formatInt(buildingPct)} %`,
+ });
+ }
+
+ // КСИТ-ёмкость = площадь × max_far (надземная GFA) — real only with both inputs.
+ if (maxFar != null && areaM2 != null) {
+ rows.push({
+ label: "КСИТ-ёмкость",
+ value: `≈ ${formatInt(areaM2 * maxFar)} м²`,
+ });
+ }
+
+ // Пятно застройки = площадь × коэф. застройки.
+ if (buildingPct != null && areaM2 != null) {
+ rows.push({
+ label: "Пятно застройки",
+ value: `≈ ${formatInt(areaM2 * (buildingPct / 100))} м²`,
+ });
+ }
+
+ if (source != null) rows.push({ label: "Источник", value: source });
+
+ // "present" = the resolver produced a zone (synth #1891 always sets one when it
+ // hits). A null nspd_zoning or an empty resolve → not present → caller hides /
+ // shows «регламент не определён».
+ const present = zone != null || rows.length > 0;
+
+ return { present, zone, source, rows, permittedUses };
+}
diff --git a/frontend/src/components/site-finder/ptica/cockpit/InsolationCard.tsx b/frontend/src/components/site-finder/ptica/cockpit/InsolationCard.tsx
index 9db236a2..06706291 100644
--- a/frontend/src/components/site-finder/ptica/cockpit/InsolationCard.tsx
+++ b/frontend/src/components/site-finder/ptica/cockpit/InsolationCard.tsx
@@ -10,6 +10,7 @@
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
import type { ParcelAnalysis } from "@/types/site-finder";
+import { coerceFloat } from "@/components/site-finder/analysis/nspd-regulation";
import { parcelAreaM2 } from "@/components/site-finder/ptica/ptica-adapt";
import { MassingViewer } from "@/components/site-finder/ptica/massing/MassingViewer";
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
@@ -21,7 +22,8 @@ interface Props {
export function InsolationCard({ analysis, onOpenDrawer }: Props) {
const areaM2 = parcelAreaM2(analysis) ?? undefined;
- const maxFar = analysis.nspd_zoning?.max_far ?? undefined;
+ // max_far arrives as a string on the wire — coerce before passing to the 3D viewer.
+ const maxFar = coerceFloat(analysis.nspd_zoning?.max_far) ?? undefined;
const farIsReal = maxFar != null;
return (
diff --git a/frontend/src/components/site-finder/ptica/drawers/DrawerContent.tsx b/frontend/src/components/site-finder/ptica/drawers/DrawerContent.tsx
index 90fc4971..47fa51bb 100644
--- a/frontend/src/components/site-finder/ptica/drawers/DrawerContent.tsx
+++ b/frontend/src/components/site-finder/ptica/drawers/DrawerContent.tsx
@@ -13,6 +13,7 @@
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
import type { ParcelAnalysis } from "@/types/site-finder";
+import { coerceFloat } from "@/components/site-finder/analysis/nspd-regulation";
import type { ForecastReport } from "@/types/forecast";
import type {
DrawerKvRow,
@@ -67,8 +68,9 @@ function massingAreaM2(analysis: ParcelAnalysis): number {
/** 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) {
+ // max_far arrives as a string on the wire — coerce before comparing.
+ const maxFar = coerceFloat(analysis.nspd_zoning?.max_far);
+ if (maxFar != null && maxFar > 0) {
return maxFar;
}
return MASSING_MAX_FAR;
@@ -76,8 +78,8 @@ function massingMaxFar(analysis: ParcelAnalysis): number {
/** 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 maxFar = coerceFloat(analysis.nspd_zoning?.max_far);
+ return maxFar != null && maxFar > 0;
}
const SRC_UPDATED = "источник: /analyze";
diff --git a/frontend/src/components/site-finder/ptica/ptica-adapt.ts b/frontend/src/components/site-finder/ptica/ptica-adapt.ts
index 5b526344..6b3b0b20 100644
--- a/frontend/src/components/site-finder/ptica/ptica-adapt.ts
+++ b/frontend/src/components/site-finder/ptica/ptica-adapt.ts
@@ -16,6 +16,7 @@ import type {
ParcelFinancialEstimate,
} from "@/types/site-finder";
import type { NspdZoning } from "@/types/nspd";
+import { coerceFloat } from "@/components/site-finder/analysis/nspd-regulation";
import type { ForecastReport, ProductMixEntry } from "@/types/forecast";
// ── View-model ──────────────────────────────────────────────────────────────
@@ -413,9 +414,10 @@ function utilityLabel(subtype: string): string {
export function adaptUrbanCard(a: ParcelAnalysis): PticaScanCard {
const z = a.nspd_zoning;
const zone = regulationZone(z);
- const maxFar = z?.max_far;
+ // max_far / max_building_pct arrive as strings on the wire — coerce to numbers.
+ const maxFar = coerceFloat(z?.max_far);
const maxHeight = z?.max_height_m;
- const buildingPct = z?.max_building_pct;
+ const buildingPct = coerceFloat(z?.max_building_pct);
const areaM2 = parcelAreaM2(a);
// Плотность (КСИТ) ёмкость = площадь × КСИТ (м²), real only with both inputs.
@@ -858,9 +860,9 @@ export function adaptBuildabilityDrawer(
// МОП/parking, so it stays an estimate placeholder.
const z = a.nspd_zoning;
const areaM2 = parcelAreaM2(a);
- const maxFar = z?.max_far;
+ const maxFar = coerceFloat(z?.max_far);
const farReal = maxFar != null;
- const buildingPct = z?.max_building_pct;
+ const buildingPct = coerceFloat(z?.max_building_pct);
const spotRow: DrawerKvRow =
areaM2 != null && buildingPct != null
@@ -957,9 +959,9 @@ export function adaptUrbanDrawer(a: ParcelAnalysis): PticaUrbanDrawer {
? real(zoning.zone_name)
: notReal(SRC_NSPD_ZONING);
- const maxFar = zoning?.max_far;
+ const maxFar = coerceFloat(zoning?.max_far);
const maxHeight = zoning?.max_height_m;
- const buildingPct = zoning?.max_building_pct;
+ const buildingPct = coerceFloat(zoning?.max_building_pct);
const areaM2 = parcelAreaM2(a);
const regSource = zoning?.regulation_source;
@@ -1658,9 +1660,9 @@ export function adaptPotentialDrawer(a: ParcelAnalysis): PticaPotentialDrawer {
const z = a.nspd_zoning;
const areaM2 = parcelAreaM2(a);
- const maxFar = z?.max_far;
+ const maxFar = coerceFloat(z?.max_far);
const farReal = maxFar != null;
- const buildingPct = z?.max_building_pct;
+ const buildingPct = coerceFloat(z?.max_building_pct);
const maxHeight = z?.max_height_m;
const maxFloors = z?.max_floors;
@@ -1998,9 +2000,9 @@ 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 maxFar = coerceFloat(z?.max_far);
const maxHeight = z?.max_height_m;
- const buildingPct = z?.max_building_pct;
+ const buildingPct = coerceFloat(z?.max_building_pct);
const areaMetric: PticaPotentialMetric =
areaM2 != null
diff --git a/frontend/src/lib/site-finder-api.ts b/frontend/src/lib/site-finder-api.ts
index 8f5bb56a..ca64a76d 100644
--- a/frontend/src/lib/site-finder-api.ts
+++ b/frontend/src/lib/site-finder-api.ts
@@ -31,6 +31,7 @@ import type {
Pipeline24mo,
} from "@/types/site-finder";
import type {
+ NspdZoning,
NspdZouitOverlap,
OpportunityParcel,
RedLine,
@@ -465,6 +466,16 @@ export interface ParcelAnalyzeResponse {
* если backend ещё не добавил поле).
*/
nspd_zouit_overlaps?: NspdZouitOverlap[] | null;
+ /**
+ * #1962 — резолвленный ПЗЗ-градрегламент зоны (synth #1891). Бэкенд
+ * (`parcels.py`, top-level `nspd_zoning`) отдаёт тер.зону (`regulation_zone_index`
+ * напр. "ПК-1"), КСИТ (`max_far`), предельную высоту/этажность, коэф. застройки
+ * (`max_building_pct`), источник регламента (`regulation_source`) и список
+ * разрешённых ВРИ зоны (`main_vri`). Light-отчёт (Section1ParcelInfo) выводит его
+ * в «Градрегламент (НСПД)»-блоке через `adaptNspdRegulation`. Optional + nullable:
+ * для не-ЕКБ участков / при miss резолвера поле может быть null (gap-зоны #1891).
+ */
+ nspd_zoning?: NspdZoning | null;
}
// ── Types: B6 poi-score response ─────────────────────────────────────────────
diff --git a/frontend/src/types/nspd.ts b/frontend/src/types/nspd.ts
index e0377be4..e22658e2 100644
--- a/frontend/src/types/nspd.ts
+++ b/frontend/src/types/nspd.ts
@@ -7,15 +7,21 @@ 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; // КСИТ (предельный коэф. плотности застройки)
+ // Real ПЗЗ regulation (PR #1847, synth #1891) — optional + nullable so the
+ // cockpit stays backward-compatible until the field flows, then auto-lights-up.
+ // NB: max_far / max_building_pct arrive on the wire as STRINGS ("2.4", "80")
+ // from the ekb-geoportal-urbancard regulation columns — typed `string | number`
+ // so consumers must coerce (see coerceFloat in nspd-regulation.ts) before maths.
+ max_far?: string | number | null; // КСИТ (предельный коэф. плотности застройки)
max_height_m?: number | null; // предельная высота, м
max_floors?: number | null; // предельная этажность
- max_building_pct?: number | null; // коэф. застройки (процент пятна)
+ max_building_pct?: string | number | null; // коэф. застройки (процент пятна)
min_parcel_area_m2?: number | null; // мин. площадь участка, м²
- regulation_zone_index?: string | null; // human zone index, e.g. "Ж-2"
- regulation_source?: string | null; // источник регламента
+ regulation_zone_index?: string | null; // human zone index, e.g. "ПК-1"
+ regulation_source?: string | null; // источник регламента (ekb-geoportal-urbancard)
+ // #1891 — authoritative permitted-use (ВРИ) list of the zone, used by the gate
+ // and surfaced read-only in the Раздел 1 регламент-блок (#1962).
+ main_vri?: string[] | null;
}
export interface NspdZouitOverlap {