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.
138 lines
5 KiB
TypeScript
138 lines
5 KiB
TypeScript
/**
|
||
* 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 };
|
||
}
|