/** * 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 }; }