diff --git a/frontend/src/components/site-finder/LandTab.tsx b/frontend/src/components/site-finder/LandTab.tsx index 8a9bd016..27cae5de 100644 --- a/frontend/src/components/site-finder/LandTab.tsx +++ b/frontend/src/components/site-finder/LandTab.tsx @@ -72,7 +72,10 @@ export function LandTab({ data }: Props) { Зоны с особыми условиями использования (ЗОУИТ) - + )} diff --git a/frontend/src/components/site-finder/NspdZouitOverlapsBlock.tsx b/frontend/src/components/site-finder/NspdZouitOverlapsBlock.tsx index 4a08f6ea..9f6bbfff 100644 --- a/frontend/src/components/site-finder/NspdZouitOverlapsBlock.tsx +++ b/frontend/src/components/site-finder/NspdZouitOverlapsBlock.tsx @@ -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 = { engineering: "Инженерные коммуникации", okn: "Объекты культурного наследия", @@ -15,27 +23,62 @@ const GROUP_LABELS: Record = { 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 (
Нет пересечений - + Охранных зон ЗОУИТ не обнаружено
); } + // #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 (
+ {hasSzz && ( +
+ + + {ownSzz + ? "Участок попадает в санитарно-защитную зону (СЗЗ вашего участка). " + : "Участок пересекает санитарно-защитную зону (СЗЗ). "} + Размещение жилой застройки в границах СЗЗ ограничено и, как правило, + не допускается (СанПиН 2.2.1/2.1.1.1200-03). Это материальный + инвест-риск: до проектирования нужно подтвердить возможность + строительства жилья на участке. + +
+ )} + {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 (
- {style.label.toUpperCase()} + {style.label}
-
- {label} +
+ {title} + {own && ( + + СЗЗ вашего участка + + )}
- {detail && ( -
- {detail} + {reg && ( +
+ Рег. номер границы: {reg}
)}
diff --git a/frontend/src/components/site-finder/SuccessRecommendationBlock.tsx b/frontend/src/components/site-finder/SuccessRecommendationBlock.tsx index df952f47..071003f3 100644 --- a/frontend/src/components/site-finder/SuccessRecommendationBlock.tsx +++ b/frontend/src/components/site-finder/SuccessRecommendationBlock.tsx @@ -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) { - {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 ( - + {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 пока может