"use client"; /** * ZouitLayer — #255 (по требованию Макса): ЗОУИТ (зоны с особыми условиями * использования территории) схематичными полигонами поверх карты Site Finder. * * Раньше пересечения были только текстовым списком под картой * (NspdZouitOverlapsBlock). Этот слой рисует реальную геометрию охранных зон, * чтобы видно было ГДЕ зона относительно участка. * * Геометрия приходит из /analyze payload: каждый NspdZouitOverlap.geom_geojson — * строка GeoJSON (ST_AsGeoJSON(geom::geometry) на backend, EPSG:4326, * Polygon/MultiPolygon). Парсим через JSON.parse → (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 = { 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(["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 { const grouped = new Map(); 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; visibleSeverities: Set; } 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 ( {items.map(({ overlap, geometry }, idx) => { const feature: Feature = { type: "Feature", geometry, properties: {}, }; const typeZone = overlap.type_zone ?? overlap.layer ?? null; return (
ЗОУИТ
{typeZone && (
{typeZone}
)} {overlap.name && overlap.name !== typeZone && (
{overlap.name}
)} {overlap.reg_numb_border && (
Рег. №: {overlap.reg_numb_border}
)} {typeof overlap.coverage_pct === "number" && (
Перекрытие участка:{" "} {Math.round(overlap.coverage_pct * 100)}%
)} {overlap.source && (
Источник: {overlap.source}
)}
); })}
); })} ); }