10 changed files with 437 additions and 45 deletions
|
|
@ -72,7 +72,10 @@ export function LandTab({ data }: Props) {
|
||||||
<SectionLabel style={{ marginBottom: 10 }}>
|
<SectionLabel style={{ marginBottom: 10 }}>
|
||||||
Зоны с особыми условиями использования (ЗОУИТ)
|
Зоны с особыми условиями использования (ЗОУИТ)
|
||||||
</SectionLabel>
|
</SectionLabel>
|
||||||
<NspdZouitOverlapsBlock overlaps={data.nspd_zouit_overlaps ?? []} />
|
<NspdZouitOverlapsBlock
|
||||||
|
overlaps={data.nspd_zouit_overlaps ?? []}
|
||||||
|
parcelCad={data.cad_num}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,20 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
|
||||||
import type { NspdZouitOverlap } from "@/types/nspd";
|
import type { NspdZouitOverlap } from "@/types/nspd";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
overlaps: NspdZouitOverlap[];
|
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> = {
|
const GROUP_LABELS: Record<string, string> = {
|
||||||
engineering: "Инженерные коммуникации",
|
engineering: "Инженерные коммуникации",
|
||||||
okn: "Объекты культурного наследия",
|
okn: "Объекты культурного наследия",
|
||||||
|
|
@ -15,27 +23,62 @@ const GROUP_LABELS: Record<string, string> = {
|
||||||
other: "Прочие ЗОУИТ",
|
other: "Прочие ЗОУИТ",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine a color-coded severity badge from group_key
|
// #1957 — СЗЗ (санитарно-защитная зона): размещение жилья внутри ограничено/
|
||||||
function getSeverityStyle(groupKey: string): {
|
// запрещено (СанПиН 2.2.1/2.1.1.1200-03), это материальный инвест-риск.
|
||||||
bg: string;
|
function isSzz(typeZone: string | null | undefined): boolean {
|
||||||
color: string;
|
return (typeZone ?? "").toLowerCase().includes("санитарно-защитн");
|
||||||
label: string;
|
}
|
||||||
} {
|
|
||||||
|
// 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) {
|
switch (groupKey) {
|
||||||
case "engineering":
|
case "engineering":
|
||||||
return { bg: "#fef3c7", color: "#92400e", label: "warn" };
|
return { bg: "var(--warn-soft)", color: "var(--warn)", label: "warn" };
|
||||||
case "okn":
|
case "okn":
|
||||||
return { bg: "#fee2e2", color: "#991b1b", label: "block" };
|
|
||||||
case "protected":
|
case "protected":
|
||||||
return { bg: "#fee2e2", color: "#991b1b", label: "block" };
|
return {
|
||||||
|
bg: "var(--danger-soft)",
|
||||||
|
color: "var(--danger)",
|
||||||
|
label: "block",
|
||||||
|
};
|
||||||
case "natural":
|
case "natural":
|
||||||
return { bg: "#dbeafe", color: "#1e40af", label: "low" };
|
return { bg: "var(--accent-soft)", color: "var(--accent)", label: "low" };
|
||||||
default:
|
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) {
|
if (overlaps.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -43,16 +86,16 @@ export function NspdZouitOverlapsBlock({ overlaps }: Props) {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 8,
|
gap: 8,
|
||||||
background: "#f0fdf4",
|
background: "var(--success-soft)",
|
||||||
border: "1px solid #bbf7d0",
|
border: "1px solid var(--success)",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: "10px 14px",
|
padding: "10px 14px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
background: "#dcfce7",
|
background: "var(--bg-card)",
|
||||||
color: "#15803d",
|
color: "var(--success)",
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
padding: "2px 10px",
|
padding: "2px 10px",
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
|
|
@ -61,30 +104,76 @@ export function NspdZouitOverlapsBlock({ overlaps }: Props) {
|
||||||
>
|
>
|
||||||
Нет пересечений
|
Нет пересечений
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 13, color: "#15803d" }}>
|
<span style={{ fontSize: 13, color: "var(--success)" }}>
|
||||||
Охранных зон ЗОУИТ не обнаружено
|
Охранных зон ЗОУИТ не обнаружено
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
<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) => {
|
{overlaps.map((overlap, idx) => {
|
||||||
const style = getSeverityStyle(overlap.group_key);
|
const szz = isSzz(overlap.type_zone) || isSzz(overlap.name);
|
||||||
const label = GROUP_LABELS[overlap.group_key] ?? overlap.group_key;
|
const style = getSeverityStyle(overlap.group_key, szz);
|
||||||
const detail =
|
const own = szz && referencesOwnParcel(overlap, parcelCad);
|
||||||
|
// #1957 — заголовок строки: человекочитаемая категория зоны (type_zone),
|
||||||
|
// иначе name, иначе fallback на group-label. type_zone приоритетнее
|
||||||
|
// общей метки группы («Охраняемые зоны»).
|
||||||
|
const title =
|
||||||
|
overlap.type_zone ||
|
||||||
overlap.name ||
|
overlap.name ||
|
||||||
(typeof overlap.subcategory === "string"
|
GROUP_LABELS[overlap.group_key] ||
|
||||||
? overlap.subcategory
|
overlap.group_key;
|
||||||
: null);
|
const reg = overlap.reg_numb_border;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
style={{
|
style={{
|
||||||
background: "#f9fafb",
|
background: "var(--bg-card-alt)",
|
||||||
border: "1px solid #e5e7eb",
|
border: "1px solid var(--border-card)",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: "10px 14px",
|
padding: "10px 14px",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|
@ -102,17 +191,43 @@ export function NspdZouitOverlapsBlock({ overlaps }: Props) {
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
textTransform: "uppercase",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{style.label.toUpperCase()}
|
{style.label}
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 13, fontWeight: 500, color: "#1f2937" }}>
|
<div
|
||||||
{label}
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--fg-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
{own && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: 8,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--danger)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
СЗЗ вашего участка
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{detail && (
|
{reg && (
|
||||||
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 2 }}>
|
<div
|
||||||
{detail}
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--fg-tertiary)",
|
||||||
|
marginTop: 2,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Рег. номер границы: {reg}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,20 @@ function formatArea(v: number | null): string {
|
||||||
return v.toFixed(0) + " м²";
|
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 }) {
|
function SuccessBadge({ bucket }: { bucket: SuccessRankingBucket }) {
|
||||||
const color = successColor(bucket.success_score);
|
const color = successColor(bucket.success_score);
|
||||||
const bg = successBg(bucket.success_score);
|
const bg = successBg(bucket.success_score);
|
||||||
|
|
@ -168,10 +182,16 @@ export function SuccessRecommendationBlock({ recommendation }: Props) {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{top5.map((row) => {
|
{top5.map((row, idx) => {
|
||||||
const isTop = row.bucket === top_bucket.bucket;
|
// #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 (
|
return (
|
||||||
<tr key={row.bucket}>
|
<tr key={`${row.bucket}-${row.obj_class ?? ""}-${idx}`}>
|
||||||
<td
|
<td
|
||||||
style={{
|
style={{
|
||||||
padding: "6px 12px 6px 0",
|
padding: "6px 12px 6px 0",
|
||||||
|
|
@ -181,7 +201,7 @@ export function SuccessRecommendationBlock({ recommendation }: Props) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isTop ? "* " : ""}
|
{isTop ? "* " : ""}
|
||||||
{row.bucket}
|
{formatBucketLabel(row)}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,33 @@ function rateByHorizon(
|
||||||
return horizons.map((h) => byH.get(h) ?? null);
|
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) {
|
export function ForecastChart({ report, selectedHorizon }: Props) {
|
||||||
const fm = report.future_market;
|
const fm = report.future_market;
|
||||||
const byScenario = report.scenarios.by_scenario;
|
const byScenario = report.scenarios.by_scenario;
|
||||||
|
|
@ -241,7 +268,14 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
|
||||||
min: -1,
|
min: -1,
|
||||||
max: 1,
|
max: 1,
|
||||||
interval: 0.5,
|
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: {
|
axisLabel: {
|
||||||
color: FG_SECONDARY,
|
color: FG_SECONDARY,
|
||||||
formatter: (v: number) => fmtNum(v, 1),
|
formatter: (v: number) => fmtNum(v, 1),
|
||||||
|
|
@ -255,7 +289,12 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
|
||||||
name: "Ставка, % годовых",
|
name: "Ставка, % годовых",
|
||||||
position: "right",
|
position: "right",
|
||||||
scale: true,
|
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: {
|
axisLabel: {
|
||||||
color: FG_SECONDARY,
|
color: FG_SECONDARY,
|
||||||
formatter: (v: number) => `${fmtNum(v, 0)} %`,
|
formatter: (v: number) => `${fmtNum(v, 0)} %`,
|
||||||
|
|
@ -312,8 +351,50 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
|
||||||
targetHorizon,
|
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.
|
// Thin reports (no horizons AND no scenarios) → render nothing.
|
||||||
if (!hasHorizons && !hasScenarios) return null;
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import { PoiList2Gis } from "./PoiList2Gis";
|
||||||
import { ExportButtons } from "./ExportButtons";
|
import { ExportButtons } from "./ExportButtons";
|
||||||
import {
|
import {
|
||||||
adaptEgrn,
|
adaptEgrn,
|
||||||
|
formatEncumbrance,
|
||||||
useParcelAnalyzeQuery,
|
useParcelAnalyzeQuery,
|
||||||
useParcelPoiScoreQuery,
|
useParcelPoiScoreQuery,
|
||||||
} from "@/lib/site-finder-api";
|
} from "@/lib/site-finder-api";
|
||||||
|
|
@ -193,6 +194,13 @@ export function Section1ParcelInfo({ cad }: Props) {
|
||||||
// empty in prod (#1217). Empty `{}` from backend → null → fallback stub.
|
// empty in prod (#1217). Empty `{}` from backend → null → fallback stub.
|
||||||
const egrn: ParcelEgrn = adaptEgrn(data.egrn, cad) ?? buildFallbackEgrn(cad);
|
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
|
// KPI values
|
||||||
const areaHa =
|
const areaHa =
|
||||||
egrn.area_m2 > 0 ? `${(egrn.area_m2 / 10000).toFixed(2)} га` : "—";
|
egrn.area_m2 > 0 ? `${(egrn.area_m2 / 10000).toFixed(2)} га` : "—";
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,7 @@ export function Section4Estimate({ cad }: Props) {
|
||||||
</div>
|
</div>
|
||||||
<NspdZouitOverlapsBlock
|
<NspdZouitOverlapsBlock
|
||||||
overlaps={analysis.nspd_zouit_overlaps ?? []}
|
overlaps={analysis.nspd_zouit_overlaps ?? []}
|
||||||
|
parcelCad={cad}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
63
frontend/src/lib/__tests__/formatEncumbrance.test.ts
Normal file
63
frontend/src/lib/__tests__/formatEncumbrance.test.ts
Normal 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("—");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -42,11 +42,7 @@ import type {
|
||||||
export type ParcelStatus = "free" | "in_progress" | "favorite";
|
export type ParcelStatus = "free" | "in_progress" | "favorite";
|
||||||
|
|
||||||
export type ParcelVri =
|
export type ParcelVri =
|
||||||
| "multistory"
|
"multistory" | "mixed" | "individual" | "office" | "other";
|
||||||
| "mixed"
|
|
||||||
| "individual"
|
|
||||||
| "office"
|
|
||||||
| "other";
|
|
||||||
|
|
||||||
export interface ParcelBboxItem {
|
export interface ParcelBboxItem {
|
||||||
cad_num: string;
|
cad_num: string;
|
||||||
|
|
@ -302,6 +298,37 @@ export interface ParcelEgrn {
|
||||||
last_updated: string | null;
|
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) ──────────
|
// ── 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.
|
* root cause of #1217. May be `null`/missing if B5-extended hasn't shipped.
|
||||||
*/
|
*/
|
||||||
egrn?: unknown;
|
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 */
|
/** Engineering / utilities nearby — present when NSPD data available */
|
||||||
utilities?: UtilitiesData | null;
|
utilities?: UtilitiesData | null;
|
||||||
// #999 (958-B4) — рыночные слои для карты Site Finder. Все optional:
|
// #999 (958-B4) — рыночные слои для карты Site Finder. Все optional:
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,11 @@ export interface NspdZouitOverlap {
|
||||||
// source = "cad_zouit". В nspd-dump path обоих может не быть → optional.
|
// source = "cad_zouit". В nspd-dump path обоих может не быть → optional.
|
||||||
type_zone?: string | null;
|
type_zone?: string | null;
|
||||||
category_name?: 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;
|
source?: string;
|
||||||
// #255 — geometry охранной зоны для map-слоя. ST_AsGeoJSON(geom::geometry) →
|
// #255 — geometry охранной зоны для map-слоя. ST_AsGeoJSON(geom::geometry) →
|
||||||
// строка GeoJSON (Polygon/MultiPolygon). Optional + graceful: backend пока может
|
// строка GeoJSON (Polygon/MultiPolygon). Optional + graceful: backend пока может
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue