Four frontend display fixes for the Site Finder analysis report (audit #1953); all consume fields the (already-deployed) backend now returns. - #1954: ЕГРН «Обременения» row was hardcoded «—». Wire top-level `encumbrance` block {has_zouit, zouit_count, zouit_types} via formatEncumbrance → «ЗОУИТ: N (types)» / «не выявлено (НСПД)». - #1955: show obj_class per success bucket («Студии 15-30 · Комфорт») so rows with identical area-labels are distinct; fix top-row match + React key to (bucket, obj_class). - #1957: NspdZouitOverlapsBlock renders human type_zone + reg_numb_border, adds a СЗЗ caveat banner (СанПиН 2.2.1/2.1.1.1200-03) and «СЗЗ вашего участка» highlight; switched raw hex to UI tokens. - #1958: ForecastChart axis names → nameLocation:middle + nameGap (no legend overlap); grey flat-state callout when the deficit line is degenerate (scenarios_collapsed or all points clamped at ±1). Tests: formatEncumbrance + isDeficitDegenerate (15 new). Real segment differentiation is backend #1959 (separate).
239 lines
8.4 KiB
TypeScript
239 lines
8.4 KiB
TypeScript
"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 (fallback, когда type_zone отсутствует).
|
||
const GROUP_LABELS: Record<string, string> = {
|
||
engineering: "Инженерные коммуникации",
|
||
okn: "Объекты культурного наследия",
|
||
natural: "Природные территории",
|
||
protected: "Охраняемые зоны",
|
||
other: "Прочие ЗОУИТ",
|
||
};
|
||
|
||
// #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: "var(--warn-soft)", color: "var(--warn)", label: "warn" };
|
||
case "okn":
|
||
case "protected":
|
||
return {
|
||
bg: "var(--danger-soft)",
|
||
color: "var(--danger)",
|
||
label: "block",
|
||
};
|
||
case "natural":
|
||
return { bg: "var(--accent-soft)", color: "var(--accent)", label: "low" };
|
||
default:
|
||
return {
|
||
bg: "var(--bg-card-alt)",
|
||
color: "var(--fg-secondary)",
|
||
label: "info",
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* #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
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
background: "var(--success-soft)",
|
||
border: "1px solid var(--success)",
|
||
borderRadius: 8,
|
||
padding: "10px 14px",
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
background: "var(--bg-card)",
|
||
color: "var(--success)",
|
||
borderRadius: 6,
|
||
padding: "2px 10px",
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
Нет пересечений
|
||
</span>
|
||
<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 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 ||
|
||
GROUP_LABELS[overlap.group_key] ||
|
||
overlap.group_key;
|
||
const reg = overlap.reg_numb_border;
|
||
|
||
return (
|
||
<div
|
||
key={idx}
|
||
style={{
|
||
background: "var(--bg-card-alt)",
|
||
border: "1px solid var(--border-card)",
|
||
borderRadius: 8,
|
||
padding: "10px 14px",
|
||
display: "flex",
|
||
alignItems: "flex-start",
|
||
gap: 10,
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
background: style.bg,
|
||
color: style.color,
|
||
borderRadius: 5,
|
||
padding: "2px 8px",
|
||
fontSize: 11,
|
||
fontWeight: 700,
|
||
whiteSpace: "nowrap",
|
||
flexShrink: 0,
|
||
textTransform: "uppercase",
|
||
}}
|
||
>
|
||
{style.label}
|
||
</span>
|
||
<div>
|
||
<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>
|
||
{reg && (
|
||
<div
|
||
style={{
|
||
fontSize: 12,
|
||
color: "var(--fg-tertiary)",
|
||
marginTop: 2,
|
||
fontVariantNumeric: "tabular-nums",
|
||
}}
|
||
>
|
||
Рег. номер границы: {reg}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|