gendesign/frontend/src/components/site-finder/ZouitLayer.tsx
bot-backend 6d6e0ce0ca
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 2m2s
Deploy / build-worker (push) Successful in 3m29s
Deploy / build-frontend (push) Successful in 3m44s
Deploy / deploy (push) Successful in 1m30s
feat(site-finder): §3 «Сети и точки подключения» — попапы показывают характеристики (#2111) (#2112)
2026-06-30 13:16:07 +00:00

301 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/**
* ZouitLayer — #255 (по требованию Макса): ЗОУИТ (зоны с особыми условиями
* использования территории) схематичными полигонами поверх карты Site Finder.
*
* Раньше пересечения были только текстовым списком под картой
* (NspdZouitOverlapsBlock). Этот слой рисует реальную геометрию охранных зон,
* чтобы видно было ГДЕ зона относительно участка.
*
* Геометрия приходит из /analyze payload: каждый NspdZouitOverlap.geom_geojson —
* строка GeoJSON (ST_AsGeoJSON(geom::geometry) на backend, EPSG:4326,
* Polygon/MultiPolygon). Парсим через JSON.parse → <GeoJSON> (react-leaflet).
*
* Цвета — token-hex (документированное исключение карты/чартов, ui-tokens.md):
* blocker → --danger (#b3261e) — охранные зоны трубопровода / электр. / газа
* warning → --accent-2 (#f2994a) — СЗЗ, охранная зона тепловой сети
* info → --viz-2 (#0ea5e9) — зоны публичного сервитута и прочее
*
* Severity выводим из type_zone / category_name / name (keyword-match), т.к.
* cad_zouit fallback кладёт человекочитаемый тип в type_zone, а nspd-dump path —
* в name. Видимость per-severity управляется из SiteMap (как MarketLayers).
*
* Graceful: overlap без geom_geojson / с невалидным JSON / без полигональной
* геометрии просто пропускается (текстовый список остаётся источником истины).
*/
import { GeoJSON, LayerGroup, Popup } from "react-leaflet";
import type { Feature, Geometry } from "geojson";
import type { NspdZouitOverlap } from "@/types/nspd";
// ---------------------------------------------------------------------------
// Severity classification
// ---------------------------------------------------------------------------
export type ZouitSeverity = "blocker" | "warning" | "info";
export interface ZouitSeverityStyle {
color: string;
label: string;
}
// token-hex (map/chart exception per ui-tokens.md)
export const ZOUIT_SEVERITY_STYLES: Record<ZouitSeverity, ZouitSeverityStyle> =
{
blocker: { color: "#b3261e", label: "Блокирующие" }, // --danger
warning: { color: "#f2994a", label: "Ограничивающие" }, // --accent-2
info: { color: "#0ea5e9", label: "Информационные" }, // --viz-2
};
// Порядок отрисовки / легенды: blocker сверху (важнее), info снизу.
export const ZOUIT_ALL_SEVERITIES: ZouitSeverity[] = [
"blocker",
"warning",
"info",
];
// Keyword-правила (проверяются по порядку; первый матч выигрывает). Сначала
// blocker (опасные инженерные зоны), затем warning (СЗЗ/тепло). Без матча → info.
const SEVERITY_KEYWORDS: Array<{ sev: ZouitSeverity; keywords: string[] }> = [
{
sev: "blocker",
keywords: [
"трубопровод",
"электр",
"газопровод",
"газораспредел",
"линий электропередач",
"нефтепровод",
"магистральн",
"взрывоопасн",
"опасн",
],
},
{
sev: "warning",
keywords: [
"санитарно-защитн",
"сзз",
"тепловой сети",
"теплосет",
"охранная зона связи",
"охранная зона",
],
},
];
/**
* Классифицирует ЗОУИТ по severity. Источник текста: type_zone (cad_zouit
* fallback) → category_name → name (nspd-dump path). Без совпадений → "info"
* (зоны сервитута и прочее — наименее ограничивающие).
*/
export function classifyZouit(overlap: NspdZouitOverlap): ZouitSeverity {
const haystack = [overlap.type_zone, overlap.category_name, overlap.name]
.filter((s): s is string => typeof s === "string" && s.length > 0)
.join(" ")
.toLowerCase();
for (const { sev, keywords } of SEVERITY_KEYWORDS) {
if (keywords.some((kw) => haystack.includes(kw))) return sev;
}
return "info";
}
// ---------------------------------------------------------------------------
// Geometry parsing
// ---------------------------------------------------------------------------
// Какие типы геометрии этот слой умеет рисовать как заливку.
const POLYGON_TYPES = new Set<Geometry["type"]>(["Polygon", "MultiPolygon"]);
/**
* Парсит geom_geojson (строка) → Geometry. Возвращает null для отсутствующего /
* невалидного / неполигонального ввода — фича тогда просто не рисуется (graceful).
* Backend отдаёт строку через ST_AsGeoJSON; терпим и уже-распарсенный объект на
* случай будущей смены контракта.
*/
export function parseZouitGeometry(
raw: string | null | undefined,
): Geometry | null {
if (raw === null || raw === undefined) return null;
let parsed: unknown;
if (typeof raw === "string") {
if (raw.length === 0) return null;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
} else {
parsed = raw;
}
if (typeof parsed !== "object" || parsed === null) return null;
const candidate = parsed as { type?: unknown; coordinates?: unknown };
if (
typeof candidate.type !== "string" ||
!POLYGON_TYPES.has(candidate.type as Geometry["type"]) ||
!Array.isArray(candidate.coordinates)
) {
return null;
}
return parsed as Geometry;
}
// ---------------------------------------------------------------------------
// Grouping helper (exported so SiteMap can build per-severity counts)
// ---------------------------------------------------------------------------
export interface ZouitMappable {
overlap: NspdZouitOverlap;
geometry: Geometry;
severity: ZouitSeverity;
}
/**
* Из сырых overlaps оставляет только рисуемые (валидная полигональная геометрия)
* и группирует по severity. Используется и слоем, и панелью-тумблером (счётчики
* должны отражать именно рисуемые объекты, а не все overlaps).
*/
export function groupZouitBySeverity(
overlaps: NspdZouitOverlap[],
): Map<ZouitSeverity, ZouitMappable[]> {
const grouped = new Map<ZouitSeverity, ZouitMappable[]>();
for (const sev of ZOUIT_ALL_SEVERITIES) grouped.set(sev, []);
for (const overlap of overlaps) {
const geometry = parseZouitGeometry(overlap.geom_geojson);
if (!geometry) continue;
const severity = classifyZouit(overlap);
grouped.get(severity)!.push({ overlap, geometry, severity });
}
return grouped;
}
// ---------------------------------------------------------------------------
// Map layer
// ---------------------------------------------------------------------------
interface LayerProps {
grouped: Map<ZouitSeverity, ZouitMappable[]>;
visibleSeverities: Set<ZouitSeverity>;
}
export function ZouitLayer({ grouped, visibleSeverities }: LayerProps) {
return (
<>
{ZOUIT_ALL_SEVERITIES.map((sev) => {
if (!visibleSeverities.has(sev)) return null;
const items = grouped.get(sev) ?? [];
if (items.length === 0) return null;
const style = ZOUIT_SEVERITY_STYLES[sev];
return (
<LayerGroup key={sev}>
{items.map(({ overlap, geometry }, idx) => {
const feature: Feature = {
type: "Feature",
geometry,
properties: {},
};
const typeZone = overlap.type_zone ?? overlap.layer ?? null;
return (
<GeoJSON
// react-leaflet GeoJSON кэширует слой и не реагирует на смену
// data prop — геометрия в key форсирует remount при смене
// участка (тот же приём, что у изохрон/риск-зон в
// SiteMap/MarketLayers). slice(0,24) GeoJSON-строки тут НЕ
// годится: префикс `{"type":"Polygon","coord` константен и не
// зависит от координат, поэтому при A→B с тем же числом
// полигонов в том же порядке ключи совпадали и старая
// геометрия оставалась на карте. Берём строку целиком.
key={`zouit-${sev}-${idx}-${overlap.geom_geojson ?? ""}`}
data={feature}
style={{
color: style.color,
weight: 1.5,
fillColor: style.color,
fillOpacity: 0.3,
}}
>
<Popup>
<div
style={{ fontSize: 12, lineHeight: 1.55, minWidth: 180 }}
>
<div
style={{
fontWeight: 700,
marginBottom: 4,
display: "flex",
alignItems: "center",
gap: 5,
}}
>
<span
style={{
display: "inline-block",
width: 10,
height: 10,
borderRadius: 2,
background: style.color,
flexShrink: 0,
}}
/>
ЗОУИТ
</div>
{typeZone && (
<div style={{ marginBottom: 2 }}>
<strong>{typeZone}</strong>
</div>
)}
{overlap.name && overlap.name !== typeZone && (
<div style={{ color: "#374151", marginBottom: 2 }}>
{overlap.name}
</div>
)}
{overlap.reg_numb_border && (
<div
style={{
color: "#374151",
marginBottom: 2,
fontFamily: "monospace",
}}
>
Рег. : {overlap.reg_numb_border}
</div>
)}
{typeof overlap.coverage_pct === "number" && (
<div style={{ color: "#374151", marginBottom: 2 }}>
Перекрытие участка:{" "}
<strong>
{Math.round(overlap.coverage_pct * 100)}%
</strong>
</div>
)}
{overlap.source && (
<div
style={{
marginTop: 4,
fontSize: 11,
color: "#9ca3af",
borderTop: "1px solid #f3f4f6",
paddingTop: 4,
}}
>
Источник: {overlap.source}
</div>
)}
</div>
</Popup>
</GeoJSON>
);
})}
</LayerGroup>
);
})}
</>
);
}