|
{isTop ? "* " : ""}
- {row.bucket}
+ {formatBucketLabel(row)}
|
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 ;
+ return (
+
+
+ {isDegenerate && (
+
+ Прогноз дефицита плоский — данных недостаточно для дифференциации
+ сегментов.
+
+ )}
+
+ );
}
diff --git a/frontend/src/components/site-finder/analysis/Section1ParcelInfo.tsx b/frontend/src/components/site-finder/analysis/Section1ParcelInfo.tsx
index 3a364e7d..e940ebc6 100644
--- a/frontend/src/components/site-finder/analysis/Section1ParcelInfo.tsx
+++ b/frontend/src/components/site-finder/analysis/Section1ParcelInfo.tsx
@@ -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)} га` : "—";
diff --git a/frontend/src/components/site-finder/analysis/Section4Estimate.tsx b/frontend/src/components/site-finder/analysis/Section4Estimate.tsx
index 8cc5be9d..92b97e3b 100644
--- a/frontend/src/components/site-finder/analysis/Section4Estimate.tsx
+++ b/frontend/src/components/site-finder/analysis/Section4Estimate.tsx
@@ -233,6 +233,7 @@ export function Section4Estimate({ cad }: Props) {
)}
diff --git a/frontend/src/components/site-finder/analysis/__tests__/forecastChart-degenerate.test.ts b/frontend/src/components/site-finder/analysis/__tests__/forecastChart-degenerate.test.ts
new file mode 100644
index 00000000..1fdd6598
--- /dev/null
+++ b/frontend/src/components/site-finder/analysis/__tests__/forecastChart-degenerate.test.ts
@@ -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);
+ });
+});
diff --git a/frontend/src/lib/__tests__/formatEncumbrance.test.ts b/frontend/src/lib/__tests__/formatEncumbrance.test.ts
new file mode 100644
index 00000000..c4d4f448
--- /dev/null
+++ b/frontend/src/lib/__tests__/formatEncumbrance.test.ts
@@ -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("—");
+ });
+});
diff --git a/frontend/src/lib/site-finder-api.ts b/frontend/src/lib/site-finder-api.ts
index 6ba833a5..8f5bb56a 100644
--- a/frontend/src/lib/site-finder-api.ts
+++ b/frontend/src/lib/site-finder-api.ts
@@ -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:
diff --git a/frontend/src/types/nspd.ts b/frontend/src/types/nspd.ts
index bc0d84aa..e0377be4 100644
--- a/frontend/src/types/nspd.ts
+++ b/frontend/src/types/nspd.ts
@@ -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 пока может
|