fix(site-finder): фронт light-отчёта — encumbrance/obj_class/ЗОУИТ display + forecast axis & flat-state (#1954 #1955 #1957 #1958) #1985

Merged
bot-backend merged 1 commit from fix/report-frontend-display-1953 into main 2026-06-27 12:00:14 +00:00
10 changed files with 437 additions and 45 deletions

View file

@ -72,7 +72,10 @@ export function LandTab({ data }: Props) {
<SectionLabel style={{ marginBottom: 10 }}>
Зоны с особыми условиями использования (ЗОУИТ)
</SectionLabel>
<NspdZouitOverlapsBlock overlaps={data.nspd_zouit_overlaps ?? []} />
<NspdZouitOverlapsBlock
overlaps={data.nspd_zouit_overlaps ?? []}
parcelCad={data.cad_num}
/>
</div>
)}

View file

@ -1,12 +1,20 @@
"use client";
import { AlertTriangle } from "lucide-react";
import type { NspdZouitOverlap } from "@/types/nspd";
interface Props {
overlaps: NspdZouitOverlap[];
/**
* #1957 кадастровый номер анализируемого участка. Нужен, чтобы подсветить
* «СЗЗ вашего участка» (когда описание/рег-номер охранной зоны ссылается на
* сам участок). Optional: вызовы без cad просто не делают подсветку.
*/
parcelCad?: string;
}
// Group key → human-readable label
// Group key → human-readable label (fallback, когда type_zone отсутствует).
const GROUP_LABELS: Record<string, string> = {
engineering: "Инженерные коммуникации",
okn: "Объекты культурного наследия",
@ -15,27 +23,62 @@ const GROUP_LABELS: Record<string, string> = {
other: "Прочие ЗОУИТ",
};
// Determine a color-coded severity badge from group_key
function getSeverityStyle(groupKey: string): {
bg: string;
color: string;
label: string;
} {
// #1957 — СЗЗ (санитарно-защитная зона): размещение жилья внутри ограничено/
// запрещено (СанПиН 2.2.1/2.1.1.1200-03), это материальный инвест-риск.
function isSzz(typeZone: string | null | undefined): boolean {
return (typeZone ?? "").toLowerCase().includes("санитарно-защитн");
}
// Severity → токенные цвета (раньше тут был сырой hex, вне ui-tokens).
function getSeverityStyle(
groupKey: string,
szz: boolean,
): { bg: string; color: string; label: string } {
// СЗЗ — всегда danger вне зависимости от group_key (материальный риск).
if (szz)
return { bg: "var(--danger-soft)", color: "var(--danger)", label: "СЗЗ" };
switch (groupKey) {
case "engineering":
return { bg: "#fef3c7", color: "#92400e", label: "warn" };
return { bg: "var(--warn-soft)", color: "var(--warn)", label: "warn" };
case "okn":
return { bg: "#fee2e2", color: "#991b1b", label: "block" };
case "protected":
return { bg: "#fee2e2", color: "#991b1b", label: "block" };
return {
bg: "var(--danger-soft)",
color: "var(--danger)",
label: "block",
};
case "natural":
return { bg: "#dbeafe", color: "#1e40af", label: "low" };
return { bg: "var(--accent-soft)", color: "var(--accent)", label: "low" };
default:
return { bg: "#f3f4f6", color: "#374151", label: "info" };
return {
bg: "var(--bg-card-alt)",
color: "var(--fg-secondary)",
label: "info",
};
}
}
export function NspdZouitOverlapsBlock({ overlaps }: Props) {
/**
* #1957 ссылается ли охранная зона на сам участок? Сверяем cad участка с
* descr / name_by_doc / reg_numb_border / name. (СЗЗ собственного предприятия
* участка «СЗЗ вашего участка», а не чужая зона по соседству.)
*/
function referencesOwnParcel(
overlap: NspdZouitOverlap,
parcelCad: string | undefined,
): boolean {
if (!parcelCad) return false;
const raw = overlap.raw_props ?? {};
const haystacks = [
overlap.reg_numb_border,
overlap.name,
typeof raw.descr === "string" ? raw.descr : null,
typeof raw.name_by_doc === "string" ? raw.name_by_doc : null,
];
return haystacks.some((h) => typeof h === "string" && h.includes(parcelCad));
}
export function NspdZouitOverlapsBlock({ overlaps, parcelCad }: Props) {
if (overlaps.length === 0) {
return (
<div
@ -43,16 +86,16 @@ export function NspdZouitOverlapsBlock({ overlaps }: Props) {
display: "flex",
alignItems: "center",
gap: 8,
background: "#f0fdf4",
border: "1px solid #bbf7d0",
background: "var(--success-soft)",
border: "1px solid var(--success)",
borderRadius: 8,
padding: "10px 14px",
}}
>
<span
style={{
background: "#dcfce7",
color: "#15803d",
background: "var(--bg-card)",
color: "var(--success)",
borderRadius: 6,
padding: "2px 10px",
fontSize: 12,
@ -61,30 +104,76 @@ export function NspdZouitOverlapsBlock({ overlaps }: Props) {
>
Нет пересечений
</span>
<span style={{ fontSize: 13, color: "#15803d" }}>
<span style={{ fontSize: 13, color: "var(--success)" }}>
Охранных зон ЗОУИТ не обнаружено
</span>
</div>
);
}
// #1957 — СЗЗ-caveat: показываем, если хоть один overlap — санитарно-защитная
// зона. Если эта СЗЗ ссылается на сам участок — формулировка строже.
const hasSzz = overlaps.some((o) => isSzz(o.type_zone) || isSzz(o.name));
const ownSzz = overlaps.some(
(o) =>
(isSzz(o.type_zone) || isSzz(o.name)) &&
referencesOwnParcel(o, parcelCad),
);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{hasSzz && (
<div
role="alert"
style={{
display: "flex",
alignItems: "flex-start",
gap: 10,
padding: "12px 16px",
background: "var(--danger-soft)",
border: "1px solid var(--danger)",
borderRadius: 10,
fontSize: 13,
color: "var(--danger)",
}}
>
<AlertTriangle
size={16}
strokeWidth={1.5}
style={{ flexShrink: 0, marginTop: 1 }}
/>
<span>
{ownSzz
? "Участок попадает в санитарно-защитную зону (СЗЗ вашего участка). "
: "Участок пересекает санитарно-защитную зону (СЗЗ). "}
Размещение жилой застройки в границах СЗЗ ограничено и, как правило,
не допускается (СанПиН 2.2.1/2.1.1.1200-03). Это материальный
инвест-риск: до проектирования нужно подтвердить возможность
строительства жилья на участке.
</span>
</div>
)}
{overlaps.map((overlap, idx) => {
const style = getSeverityStyle(overlap.group_key);
const label = GROUP_LABELS[overlap.group_key] ?? overlap.group_key;
const detail =
const szz = isSzz(overlap.type_zone) || isSzz(overlap.name);
const style = getSeverityStyle(overlap.group_key, szz);
const own = szz && referencesOwnParcel(overlap, parcelCad);
// #1957 — заголовок строки: человекочитаемая категория зоны (type_zone),
// иначе name, иначе fallback на group-label. type_zone приоритетнее
// общей метки группы («Охраняемые зоны»).
const title =
overlap.type_zone ||
overlap.name ||
(typeof overlap.subcategory === "string"
? overlap.subcategory
: null);
GROUP_LABELS[overlap.group_key] ||
overlap.group_key;
const reg = overlap.reg_numb_border;
return (
<div
key={idx}
style={{
background: "#f9fafb",
border: "1px solid #e5e7eb",
background: "var(--bg-card-alt)",
border: "1px solid var(--border-card)",
borderRadius: 8,
padding: "10px 14px",
display: "flex",
@ -102,17 +191,43 @@ export function NspdZouitOverlapsBlock({ overlaps }: Props) {
fontWeight: 700,
whiteSpace: "nowrap",
flexShrink: 0,
textTransform: "uppercase",
}}
>
{style.label.toUpperCase()}
{style.label}
</span>
<div>
<div style={{ fontSize: 13, fontWeight: 500, color: "#1f2937" }}>
{label}
<div
style={{
fontSize: 13,
fontWeight: 500,
color: "var(--fg-primary)",
}}
>
{title}
{own && (
<span
style={{
marginLeft: 8,
fontSize: 11,
fontWeight: 700,
color: "var(--danger)",
}}
>
СЗЗ вашего участка
</span>
)}
</div>
{detail && (
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 2 }}>
{detail}
{reg && (
<div
style={{
fontSize: 12,
color: "var(--fg-tertiary)",
marginTop: 2,
fontVariantNumeric: "tabular-nums",
}}
>
Рег. номер границы: {reg}
</div>
)}
</div>

View file

@ -31,6 +31,20 @@ function formatArea(v: number | null): string {
return v.toFixed(0) + " м²";
}
/**
* #1955 подпись бакета с классом ЖК: «Студии 15-30 · Комфорт». Класс убирает
* визуальные дубли строк с одинаковым area-label (Комфорт vs Типовой vs «не
* указан»). Defensive: backend объявляет obj_class required, но старый кэш
* прогона мог его не нести (null/пусто/«unknown») тогда показываем только
* area-label без фантомного « · » хвоста.
*/
function formatBucketLabel(bucket: SuccessRankingBucket): string {
const cls = (bucket.obj_class ?? "").trim();
const isMeaningful =
cls.length > 0 && cls.toLowerCase() !== "unknown" && cls !== "—";
return isMeaningful ? `${bucket.bucket} · ${cls}` : bucket.bucket;
}
function SuccessBadge({ bucket }: { bucket: SuccessRankingBucket }) {
const color = successColor(bucket.success_score);
const bg = successBg(bucket.success_score);
@ -168,10 +182,16 @@ export function SuccessRecommendationBlock({ recommendation }: Props) {
</tr>
</thead>
<tbody>
{top5.map((row) => {
const isTop = row.bucket === top_bucket.bucket;
{top5.map((row, idx) => {
// #1955 — бакеты больше не уникальны по area-label (одинаковый
// «Студии 15-30» в разных классах), поэтому матчим топ по паре
// (bucket, obj_class), а React key включает индекс — иначе строки
// с дублирующимся area-label делят key и схлопываются в рендере.
const isTop =
row.bucket === top_bucket.bucket &&
(row.obj_class ?? "") === (top_bucket.obj_class ?? "");
return (
<tr key={row.bucket}>
<tr key={`${row.bucket}-${row.obj_class ?? ""}-${idx}`}>
<td
style={{
padding: "6px 12px 6px 0",
@ -181,7 +201,7 @@ export function SuccessRecommendationBlock({ recommendation }: Props) {
}}
>
{isTop ? "* " : ""}
{row.bucket}
{formatBucketLabel(row)}
</td>
<td
style={{

View file

@ -111,6 +111,33 @@ function rateByHorizon(
return horizons.map((h) => byH.get(h) ?? null);
}
// #1958 — близость к ±1 трактуем как clamp-floor: backend подрезает deficit_index
// к [1,1], и вырожденный (недифференцированный) прогноз садится на границу.
const DEGENERATE_CLAMP_EPS = 0.02;
/**
* #1958 вырожден ли deficit-ряд? Линия «плоская и неинформативная», когда:
* (a) backend схлопнул сценарии (scenarios_collapsed) 3 линии идентичны; ИЛИ
* (b) все ненулевые точки дефицита РАВНЫ между собой И сидят у clamp-границы
* (|value| 1) дифференциации сегментов нет, линия почти невидима у края.
*
* Чистая функция (без ECharts) переиспользуется тестами. Реальный фикс
* дифференциации backend #1959; здесь только честный flat-state callout.
*/
export function isDeficitDegenerate(
values: (number | null)[],
scenariosCollapsed: boolean,
): boolean {
if (scenariosCollapsed) return true;
const present = values.filter((v): v is number => v != null);
if (present.length === 0) return false;
const first = present[0];
const allEqual = present.every((v) => Math.abs(v - first) < 1e-9);
if (!allEqual) return false;
// Все точки равны — вырождено только если они у clamp-floor (|value| ≈ 1).
return Math.abs(Math.abs(first) - 1) < DEGENERATE_CLAMP_EPS;
}
export function ForecastChart({ report, selectedHorizon }: Props) {
const fm = report.future_market;
const byScenario = report.scenarios.by_scenario;
@ -241,7 +268,14 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
min: -1,
max: 1,
interval: 0.5,
nameTextStyle: { color: FG_SECONDARY, fontSize: 11, align: "left" },
// #1958 — раньше nameLocation по умолчанию ("end") ставил подпись оси в
// верхний угол, где она перекрывалась с легендой. Кладём подпись ВДОЛЬ оси
// (middle, повёрнута на 90°) с зазором nameGap, чтобы она не лезла в
// легенду и не наезжала на axisLabel'ы.
nameLocation: "middle",
nameGap: 40,
nameRotate: 90,
nameTextStyle: { color: FG_SECONDARY, fontSize: 11 },
axisLabel: {
color: FG_SECONDARY,
formatter: (v: number) => fmtNum(v, 1),
@ -255,7 +289,12 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
name: "Ставка, % годовых",
position: "right",
scale: true,
nameTextStyle: { color: FG_SECONDARY, fontSize: 11, align: "right" },
// #1958 — то же, что для левой оси: подпись вдоль правой оси (повёрнута
// на 90°), nameGap чуть больше под формат «14 %», не в легенду.
nameLocation: "middle",
nameGap: 48,
nameRotate: -90,
nameTextStyle: { color: FG_SECONDARY, fontSize: 11 },
axisLabel: {
color: FG_SECONDARY,
formatter: (v: number) => `${fmtNum(v, 0)} %`,
@ -312,8 +351,50 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
targetHorizon,
]);
// #1958 — детектим вырожденный (плоский) deficit-ряд на тех же данных, что
// кормят график: per-scenario forecasts либо fallback forecasts_by_horizon.
const isDegenerate = useMemo(() => {
const horizons = sortedUniqueHorizons(report);
const values: (number | null)[] = hasScenarios
? effectiveScenarios.flatMap((key) =>
deficitByHorizon(byScenario[key]?.forecasts ?? [], horizons),
)
: deficitByHorizon(fm.forecasts_by_horizon, horizons);
return isDeficitDegenerate(values, collapsed);
}, [report, fm, byScenario, hasScenarios, effectiveScenarios, collapsed]);
// Thin reports (no horizons AND no scenarios) → render nothing.
if (!hasHorizons && !hasScenarios) return null;
return <ChartShell option={option} height={300} notMerge />;
return (
<div style={{ position: "relative" }}>
<ChartShell option={option} height={300} notMerge />
{isDegenerate && (
<div
// #1958 — плоская/вырожденная линия почти невидима у края шкалы;
// честная серая плашка поверх центра графика объясняет, почему
// дифференциации сегментов нет (реальный фикс — backend #1959).
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
maxWidth: 360,
padding: "10px 14px",
background: "var(--bg-card-alt)",
border: "1px solid var(--border-strong)",
borderRadius: 8,
fontSize: 12,
lineHeight: 1.4,
color: "var(--fg-secondary)",
textAlign: "center",
pointerEvents: "none",
}}
>
Прогноз дефицита плоский данных недостаточно для дифференциации
сегментов.
</div>
)}
</div>
);
}

View file

@ -22,6 +22,7 @@ import { PoiList2Gis } from "./PoiList2Gis";
import { ExportButtons } from "./ExportButtons";
import {
adaptEgrn,
formatEncumbrance,
useParcelAnalyzeQuery,
useParcelPoiScoreQuery,
} from "@/lib/site-finder-api";
@ -193,6 +194,13 @@ export function Section1ParcelInfo({ cad }: Props) {
// empty in prod (#1217). Empty `{}` from backend → null → fallback stub.
const egrn: ParcelEgrn = adaptEgrn(data.egrn, cad) ?? buildFallbackEgrn(cad);
// #1954 — обременения живут в отдельном `encumbrance_block` (top-level
// `encumbrance`), а НЕ в `egrn_block`, поэтому adaptEgrn по дизайну ставит
// «—». Подставляем ЗОУИТ-сводку, чтобы строка «Обременения» ЕГРН-таблицы
// показывала реальные данные («ЗОУИТ: N (типы)» / «не выявлено (НСПД)»)
// вместо захардкоженного прочерка.
egrn.encumbrance = formatEncumbrance(data.encumbrance);
// KPI values
const areaHa =
egrn.area_m2 > 0 ? `${(egrn.area_m2 / 10000).toFixed(2)} га` : "—";

View file

@ -233,6 +233,7 @@ export function Section4Estimate({ cad }: Props) {
</div>
<NspdZouitOverlapsBlock
overlaps={analysis.nspd_zouit_overlaps ?? []}
parcelCad={cad}
/>
</div>
)}

View file

@ -0,0 +1,60 @@
/**
* Tests for `isDeficitDegenerate` issue #1958 (flat/degenerate forecast state).
*
* The §22 deficit line is "flat and uninformative" when either:
* (a) the backend collapsed the 3 scenarios (scenarios_collapsed) the
* base/aggressive/conservative deficit lines are identical; OR
* (b) every present deficit point is EQUAL and sits at the clamp floor
* (|value| 1) no segment differentiation, the line hugs the edge and
* is near-invisible.
*
* In either case ForecastChart renders a grey flat-state callout over the chart.
* The real differentiation fix is backend #1959 (separate).
*/
import { describe, expect, it } from "vitest";
import { isDeficitDegenerate } from "../ForecastChart";
describe("isDeficitDegenerate — #1958 flat-state detection", () => {
it("is degenerate when scenarios_collapsed is true (regardless of values)", () => {
expect(isDeficitDegenerate([0.1, 0.2, -0.3], true)).toBe(true);
expect(isDeficitDegenerate([], true)).toBe(true);
});
it("is degenerate when all points equal AND clamped at +1", () => {
expect(isDeficitDegenerate([1, 1, 1], false)).toBe(true);
});
it("is degenerate when all points equal AND clamped at 1", () => {
expect(isDeficitDegenerate([-1, -1, -1], false)).toBe(true);
});
it("treats near-1 (clamp floor within eps) as degenerate", () => {
expect(isDeficitDegenerate([0.99, 0.99, 0.99], false)).toBe(true);
});
it("is NOT degenerate when all equal but mid-range (flat but informative)", () => {
// Flat at e.g. 0.3 is a real balanced signal, not a clamp artefact.
expect(isDeficitDegenerate([0.3, 0.3, 0.3], false)).toBe(false);
});
it("is NOT degenerate when points differ (real trajectory)", () => {
expect(isDeficitDegenerate([1, 0.5, 0.2], false)).toBe(false);
expect(isDeficitDegenerate([-1, -0.8, -0.4], false)).toBe(false);
});
it("ignores nulls and gates on present values only", () => {
expect(isDeficitDegenerate([null, 1, null, 1], false)).toBe(true);
expect(isDeficitDegenerate([null, 1, 0.5], false)).toBe(false);
});
it("is NOT degenerate when there are no present values", () => {
expect(isDeficitDegenerate([null, null], false)).toBe(false);
expect(isDeficitDegenerate([], false)).toBe(false);
});
it("a single clamped point counts as degenerate; single mid-range does not", () => {
expect(isDeficitDegenerate([1], false)).toBe(true);
expect(isDeficitDegenerate([0.4], false)).toBe(false);
});
});

View file

@ -0,0 +1,63 @@
/**
* Tests for `formatEncumbrance` issue #1954.
*
* Bug: the ЕГРН-таблица (EgrnPropertyTable) hardcoded the «Обременения» row to
* «», although the backend already ships an `encumbrance` block (top-level,
* derived from cad_zouit parcel geom). `formatEncumbrance` turns that block
* into the human row value Section1ParcelInfo writes into `egrn.encumbrance`.
*
* Wire shape (verified live POST /analyze for 66:41:0205010:287):
* { has_zouit: true, zouit_count: 4, zouit_types: ["Санитарно-защитная зона"] }
*/
import { describe, expect, it } from "vitest";
import { formatEncumbrance } from "../site-finder-api";
describe("formatEncumbrance — #1954 ЗОУИТ → «Обременения» row", () => {
it("renders «ЗОУИТ: N (types)» when has_zouit and types present", () => {
expect(
formatEncumbrance({
has_zouit: true,
zouit_count: 4,
zouit_types: ["Санитарно-защитная зона"],
}),
).toBe("ЗОУИТ: 4 (Санитарно-защитная зона)");
});
it("joins multiple zouit types with comma", () => {
expect(
formatEncumbrance({
has_zouit: true,
zouit_count: 3,
zouit_types: ["Санитарно-защитная зона", "Охранная зона ЛЭП"],
}),
).toBe("ЗОУИТ: 3 (Санитарно-защитная зона, Охранная зона ЛЭП)");
});
it("renders «ЗОУИТ: N» (no parens) when has_zouit but types empty", () => {
expect(
formatEncumbrance({ has_zouit: true, zouit_count: 2, zouit_types: [] }),
).toBe("ЗОУИТ: 2");
});
it("filters out blank type strings before joining", () => {
expect(
formatEncumbrance({
has_zouit: true,
zouit_count: 1,
zouit_types: ["", "Санитарно-защитная зона"],
}),
).toBe("ЗОУИТ: 1 (Санитарно-защитная зона)");
});
it("renders «не выявлено (НСПД)» when has_zouit is false", () => {
expect(
formatEncumbrance({ has_zouit: false, zouit_count: 0, zouit_types: [] }),
).toBe("не выявлено (НСПД)");
});
it("renders «—» (unknown) when the block is null/undefined (old cache)", () => {
expect(formatEncumbrance(null)).toBe("—");
expect(formatEncumbrance(undefined)).toBe("—");
});
});

View file

@ -42,11 +42,7 @@ import type {
export type ParcelStatus = "free" | "in_progress" | "favorite";
export type ParcelVri =
| "multistory"
| "mixed"
| "individual"
| "office"
| "other";
"multistory" | "mixed" | "individual" | "office" | "other";
export interface ParcelBboxItem {
cad_num: string;
@ -302,6 +298,37 @@ export interface ParcelEgrn {
last_updated: string | null;
}
// ── Encumbrance (#1954) ──────────────────────────────────────────────────────
/**
* Обременения участка ЗОУИТ-сводка из `encumbrance_block` бэкенда
* (`parcels.py`, сериализуется top-level как `encumbrance`). Пересечение геома
* участка с `cad_zouit`.
*/
export interface ParcelEncumbrance {
has_zouit: boolean;
zouit_count: number;
zouit_types: string[];
}
/**
* Человекочитаемая строка обременений для строки «Обременения» ЕГРН-таблицы
* (#1954). Раньше ячейка была захардкожена «», хотя бэкенд уже отдаёт ЗОУИТ-
* сводку. has_zouit=true «ЗОУИТ: N (типы через запятую)»; иначе честный
* «не выявлено (НСПД)». null/отсутствие блока (старый кэш) «» (неизвестно).
*/
export function formatEncumbrance(
enc: ParcelEncumbrance | null | undefined,
): string {
if (enc == null) return "—";
if (!enc.has_zouit) return "не выявлено (НСПД)";
const types = enc.zouit_types.filter((t) => t && t.length > 0);
const count = enc.zouit_count;
return types.length > 0
? `ЗОУИТ: ${count} (${types.join(", ")})`
: `ЗОУИТ: ${count}`;
}
// ── Adapter: backend egrn_block → frontend ParcelEgrn (issue #1217) ──────────
/**
@ -403,6 +430,15 @@ export interface ParcelAnalyzeResponse {
* root cause of #1217. May be `null`/missing if B5-extended hasn't shipped.
*/
egrn?: unknown;
/**
* #1954 обременения участка. Бэкенд (`parcels.py` `encumbrance_block`,
* сериализуется как top-level `encumbrance`) пересекает геом участка с
* `cad_zouit` и отдаёт ЗОУИТ-сводку. `has_zouit=false` обременений по НСПД
* не выявлено; `has_zouit=true` `zouit_count` зон, `zouit_types`
* уникальные категории («Санитарно-защитная зона» и т.п.). Optional +
* nullable: старый кэш / тонкий 202-стаб поля не несут.
*/
encumbrance?: ParcelEncumbrance | null;
/** Engineering / utilities nearby — present when NSPD data available */
utilities?: UtilitiesData | null;
// #999 (958-B4) — рыночные слои для карты Site Finder. Все optional:

View file

@ -28,6 +28,11 @@ export interface NspdZouitOverlap {
// source = "cad_zouit". В nspd-dump path обоих может не быть → optional.
type_zone?: string | null;
category_name?: string | null;
// #1957 — рег. номер границы охранной зоны (cad_zouit fallback path), напр.
// "66:41-6.4380". Optional: nspd-dump path может не отдавать поле.
reg_numb_border?: string | null;
// #1957 — доля площади участка под зоной (0..1). Optional + graceful.
coverage_pct?: number | null;
source?: string;
// #255 — geometry охранной зоны для map-слоя. ST_AsGeoJSON(geom::geometry) →
// строка GeoJSON (Polygon/MultiPolygon). Optional + graceful: backend пока может