gendesign/frontend/src/components/site-finder/analysis/nspd-regulation.ts
Light1YT 58e71dab01 fix(report): surface ПЗЗ-градрегламент НСПД в Раздел 1 (#1962)
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.
2026-06-27 17:10:20 +05:00

138 lines
5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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