Merge pull request 'fix(report): вывести ПЗЗ-градрегламент НСПД в Раздел 1 + coerceFloat КСИТ (#1962)' (#1987) from fix/report-nspd-zoning-1962 into main
This commit is contained in:
commit
16f375782b
9 changed files with 479 additions and 22 deletions
|
|
@ -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 (
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid var(--border-card)",
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
background: "var(--bg-card)",
|
||||
}}
|
||||
>
|
||||
<div style={HEADER_STYLE}>
|
||||
<Landmark size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
Градрегламент (НСПД)
|
||||
</div>
|
||||
|
||||
{vm.present ? (
|
||||
<>
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<tbody>
|
||||
{vm.rows.map((row, i) => (
|
||||
<tr
|
||||
key={row.label}
|
||||
style={{
|
||||
background:
|
||||
i % 2 === 0 ? "var(--bg-card)" : "var(--bg-card-alt)",
|
||||
}}
|
||||
>
|
||||
<td
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
color: "var(--fg-secondary)",
|
||||
fontWeight: 400,
|
||||
width: "45%",
|
||||
verticalAlign: "top",
|
||||
borderBottom:
|
||||
i < vm.rows.length - 1
|
||||
? "1px solid var(--border-soft)"
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
{row.label}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
color: "var(--fg-primary)",
|
||||
fontWeight: 500,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
verticalAlign: "top",
|
||||
wordBreak: "break-word",
|
||||
borderBottom:
|
||||
i < vm.rows.length - 1
|
||||
? "1px solid var(--border-soft)"
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
{row.value}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* КСИТ microcopy — расшифровка жаргона на первой странице (ui-microcopy). */}
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: "8px 16px",
|
||||
fontSize: 12,
|
||||
lineHeight: "16px",
|
||||
color: "var(--fg-tertiary)",
|
||||
borderTop: "1px solid var(--border-soft)",
|
||||
}}
|
||||
>
|
||||
КСИТ — коэффициент строительного использования: предельная надземная
|
||||
площадь застройки на участке (площадь участка × КСИТ).
|
||||
</p>
|
||||
|
||||
{vm.permittedUses.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 16px",
|
||||
borderTop: "1px solid var(--border-soft)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.04em",
|
||||
color: "var(--fg-tertiary)",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Разрешённые ВРИ зоны
|
||||
</div>
|
||||
<ul
|
||||
style={{
|
||||
margin: 0,
|
||||
paddingLeft: 16,
|
||||
fontSize: 13,
|
||||
lineHeight: "20px",
|
||||
color: "var(--fg-secondary)",
|
||||
}}
|
||||
>
|
||||
{vm.permittedUses.map((use, i) => (
|
||||
<li key={`${i}-${use}`}>{use}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: "12px 16px",
|
||||
fontSize: 13,
|
||||
lineHeight: "20px",
|
||||
color: "var(--fg-secondary)",
|
||||
}}
|
||||
>
|
||||
Градрегламент не определён: территориальная зона ПЗЗ для участка не
|
||||
разрешена (вне ЕКБ или участок в разрыве зон).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 */}
|
||||
<MiniMap data={data} />
|
||||
|
||||
{/* Right: EGRN table + POI list */}
|
||||
{/* Right: EGRN table + НСПД-градрегламент + POI list */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<EgrnPropertyTable data={egrn} />
|
||||
|
||||
{/* #1962 — реальный ПЗЗ-градрегламент из nspd_zoning (synth #1891).
|
||||
Раньше отчёт показывал только мёртвый zoning.data_available=false
|
||||
(закрытый PKK6-путь) и НЕ выводил резолвленный регламент. КСИТ-ёмкость
|
||||
считается от площади ЕГРН (egrn.area_m2). */}
|
||||
<NspdRegulationCard
|
||||
zoning={data.nspd_zoning}
|
||||
areaM2={
|
||||
Number.isFinite(egrn.area_m2) && egrn.area_m2 > 0
|
||||
? egrn.area_m2
|
||||
: null
|
||||
}
|
||||
/>
|
||||
|
||||
{poiData ? (
|
||||
<PoiList2Gis
|
||||
items={poiData.items}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* nspd-regulation mapper tests — audit-issue #1962 «Градрегламент НСПД не выведен
|
||||
* в Раздел 1».
|
||||
*
|
||||
* Pin the field mapping the light report depends on:
|
||||
* 1. Real nspd_zoning (live shape: max_far/max_building_pct as STRINGS) →
|
||||
* coerced rows + ВРИ list + КСИТ-ёмкость from area.
|
||||
* 2. null nspd_zoning → present:false → каллер показывает empty-state.
|
||||
* 3. coerceFloat narrows "2.4"/"80"/numbers, rejects null/""/non-numeric.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { adaptNspdRegulation, coerceFloat } from "../nspd-regulation";
|
||||
import type { NspdZoning } from "@/types/nspd";
|
||||
|
||||
// Live shape for 66:41:0205010:287 (POST /analyze) — max_far / max_building_pct
|
||||
// arrive as strings; regulation_zone_index is the human index "ПК-1".
|
||||
const LIVE_ZONING: NspdZoning = {
|
||||
zone_code: "66:00-7.155",
|
||||
zone_name: "66:00-7.155",
|
||||
source: "nspd-quarter-dump",
|
||||
max_far: "2.4",
|
||||
max_height_m: null,
|
||||
max_floors: 2,
|
||||
max_building_pct: "80",
|
||||
min_parcel_area_m2: null,
|
||||
regulation_zone_index: "ПК-1",
|
||||
regulation_source: "ekb-geoportal-urbancard",
|
||||
main_vri: [
|
||||
"3.1 Коммунальное обслуживание",
|
||||
"2.7.1\n Хранение автотранспорта",
|
||||
" ", // whitespace-only → trimmed away
|
||||
],
|
||||
};
|
||||
|
||||
const AREA_M2 = 106378;
|
||||
|
||||
describe("coerceFloat (#1962)", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
138
frontend/src/components/site-finder/analysis/nspd-regulation.ts
Normal file
138
frontend/src/components/site-finder/analysis/nspd-regulation.ts
Normal file
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -7,15 +7,21 @@ export interface NspdZoning {
|
|||
zone_name: string | null;
|
||||
source?: string;
|
||||
raw_props?: Record<string, unknown>;
|
||||
// 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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue