From 58e71dab01ef050524ecaa497cee543014cb3f52 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 27 Jun 2026 17:10:20 +0500 Subject: [PATCH 1/2] =?UTF-8?q?fix(report):=20surface=20=D0=9F=D0=97=D0=97?= =?UTF-8?q?-=D0=B3=D1=80=D0=B0=D0=B4=D1=80=D0=B5=D0=B3=D0=BB=D0=B0=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=20=D0=9D=D0=A1=D0=9F=D0=94=20=D0=B2=20=D0=A0?= =?UTF-8?q?=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=201=20(#1962)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Light report (Section1ParcelInfo) only rendered the dead top-level zoning.data_available=false (closed-PKK6 path) and never showed the resolved nspd_zoning, so the resolved regulation (synth #1891) was invisible to the user («мы выгружали НСПД — где они?»). - Add «Градрегламент (НСПД)»-card to Раздел 1 (right column, под ЕГРН): тер.зона, КСИТ/max_far, пред. высота, этажность, коэф. застройки, КСИТ-ёмкость/пятно (от площади ЕГРН), источник + список разрешённых ВРИ зоны. Empty-state когда регламент не определён (gap-зоны / не-ЕКБ). КСИТ-microcopy расшифровывает жаргон на первой странице. - New pure mapper adaptNspdRegulation (nspd-regulation.ts) reused by ptica-adapt so light report и /ptica остаются consistent. - coerceFloat: max_far/max_building_pct приходят с провода СТРОКАМИ ("2.4"/"80") — фикс латентного бага cockpit'а (typeof===number глушил реальный строковый КСИТ в massing/insolation). - Widen NspdZoning.max_far/max_building_pct to string|number, add main_vri; expose nspd_zoning on ParcelAnalyzeResponse. - Unit test для field-mapping + coercion + empty-state. --- .../analysis/NspdRegulationCard.tsx | 176 ++++++++++++++++++ .../analysis/Section1ParcelInfo.tsx | 16 +- .../__tests__/nspd-regulation.test.ts | 106 +++++++++++ .../site-finder/analysis/nspd-regulation.ts | 138 ++++++++++++++ .../ptica/cockpit/InsolationCard.tsx | 4 +- .../ptica/drawers/DrawerContent.tsx | 10 +- .../site-finder/ptica/ptica-adapt.ts | 22 ++- frontend/src/lib/site-finder-api.ts | 11 ++ frontend/src/types/nspd.ts | 18 +- 9 files changed, 479 insertions(+), 22 deletions(-) create mode 100644 frontend/src/components/site-finder/analysis/NspdRegulationCard.tsx create mode 100644 frontend/src/components/site-finder/analysis/__tests__/nspd-regulation.test.ts create mode 100644 frontend/src/components/site-finder/analysis/nspd-regulation.ts 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 { -- 2.45.3 From 63d685a105054a6b4ffa8dd1072a4e9b10ad0eec Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 27 Jun 2026 17:18:57 +0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(report):=20=D1=83=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20React=20key=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=92=D0=A0=D0=98-=D1=81=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=20(=D0=B4=D1=83=D0=B1=D0=BB=D1=8C-VRI=20warning)?= =?UTF-8?q?=20(#1962)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/site-finder/analysis/NspdRegulationCard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/site-finder/analysis/NspdRegulationCard.tsx b/frontend/src/components/site-finder/analysis/NspdRegulationCard.tsx index 61812cdd..82da05bb 100644 --- a/frontend/src/components/site-finder/analysis/NspdRegulationCard.tsx +++ b/frontend/src/components/site-finder/analysis/NspdRegulationCard.tsx @@ -150,8 +150,8 @@ export function NspdRegulationCard({ zoning, areaM2 }: Props) { color: "var(--fg-secondary)", }} > - {vm.permittedUses.map((use) => ( -
  • {use}
  • + {vm.permittedUses.map((use, i) => ( +
  • {use}
  • ))}
    -- 2.45.3