301 lines
12 KiB
TypeScript
301 lines
12 KiB
TypeScript
"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>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
}
|