feat(site-finder): ЗОУИТ visual layer на Leaflet карте (#255)
All checks were successful
CI / changes (push) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (push) Successful in 54s
CI / frontend-tests (pull_request) Successful in 54s
All checks were successful
CI / changes (push) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (push) Successful in 54s
CI / frontend-tests (pull_request) Successful in 54s
ZouitLayer (GeoJSON-полигоны из geom_geojson, severity blocker/warning/info по keyword, popup тип+имя) + ZouitLayerControlPanel (collapsible toggle per-severity, счётчик mappable) + SiteMap integration (default blocker+warning ON, info OFF, фон под POI). Graceful: нет geom_geojson → слой/панель скрыты, текстовый NspdZouitOverlapsBlock остаётся. 10 vitest. Closes #255
This commit is contained in:
parent
adc6dde015
commit
7d81b1b65f
7 changed files with 630 additions and 3 deletions
|
|
@ -36,6 +36,13 @@ import {
|
||||||
type CpCategory,
|
type CpCategory,
|
||||||
} from "@/components/site-finder/ConnectionPointsLayer";
|
} from "@/components/site-finder/ConnectionPointsLayer";
|
||||||
import { CpLayerControlPanel } from "@/components/site-finder/CpLayerControlPanel";
|
import { CpLayerControlPanel } from "@/components/site-finder/CpLayerControlPanel";
|
||||||
|
import {
|
||||||
|
ZouitLayer,
|
||||||
|
ZOUIT_ALL_SEVERITIES,
|
||||||
|
groupZouitBySeverity,
|
||||||
|
type ZouitSeverity,
|
||||||
|
} from "@/components/site-finder/ZouitLayer";
|
||||||
|
import { ZouitLayerControlPanel } from "@/components/site-finder/ZouitLayerControlPanel";
|
||||||
import { CustomPoiLayer } from "@/components/site-finder/CustomPoiLayer";
|
import { CustomPoiLayer } from "@/components/site-finder/CustomPoiLayer";
|
||||||
import { CustomPoiToggleButton } from "@/components/site-finder/CustomPoiToggleButton";
|
import { CustomPoiToggleButton } from "@/components/site-finder/CustomPoiToggleButton";
|
||||||
import { CustomPoiAddModal } from "@/components/site-finder/CustomPoiAddModal";
|
import { CustomPoiAddModal } from "@/components/site-finder/CustomPoiAddModal";
|
||||||
|
|
@ -213,6 +220,25 @@ export function SiteMap({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #255 — ЗОУИТ-слой. По умолчанию показываем blocker + warning (важные
|
||||||
|
// охранные зоны), info (зоны сервитута) OFF, чтобы не зашумлять карту —
|
||||||
|
// пользователь включает тумблером в ZouitLayerControlPanel.
|
||||||
|
const [visibleZouit, setVisibleZouit] = useState<Set<ZouitSeverity>>(
|
||||||
|
() => new Set<ZouitSeverity>(["blocker", "warning"]),
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggleZouit(sev: ZouitSeverity) {
|
||||||
|
setVisibleZouit((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(sev)) {
|
||||||
|
next.delete(sev);
|
||||||
|
} else {
|
||||||
|
next.add(sev);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Custom POI add mode state
|
// Custom POI add mode state
|
||||||
const [addMode, setAddMode] = useState(false);
|
const [addMode, setAddMode] = useState(false);
|
||||||
const [pendingCoords, setPendingCoords] = useState<{
|
const [pendingCoords, setPendingCoords] = useState<{
|
||||||
|
|
@ -308,6 +334,16 @@ export function SiteMap({
|
||||||
(p) => typeof p.lat === "number" && typeof p.lon === "number",
|
(p) => typeof p.lat === "number" && typeof p.lon === "number",
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
|
// #255 — ЗОУИТ-полигоны из analyze payload (geom_geojson). Группируем по
|
||||||
|
// severity; в карту/панель идут только overlaps с валидной геометрией.
|
||||||
|
const zouitOverlaps = data.nspd_zouit_overlaps ?? [];
|
||||||
|
const zouitGrouped = groupZouitBySeverity(zouitOverlaps);
|
||||||
|
const zouitMappableCount = ZOUIT_ALL_SEVERITIES.reduce(
|
||||||
|
(sum, sev) => sum + (zouitGrouped.get(sev)?.length ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const hasZouitGeom = zouitMappableCount > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Map */}
|
{/* Map */}
|
||||||
|
|
@ -346,6 +382,15 @@ export function SiteMap({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* #255 — ЗОУИТ-полигоны (охранные зоны). Рисуем рано (под точками POI
|
||||||
|
и рыночными маркерами) как заливку-фон, поверх полигона участка. */}
|
||||||
|
{hasZouitGeom && (
|
||||||
|
<ZouitLayer
|
||||||
|
grouped={zouitGrouped}
|
||||||
|
visibleSeverities={visibleZouit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Isochrones — render in reverse order (largest range at bottom).
|
{/* Isochrones — render in reverse order (largest range at bottom).
|
||||||
NB: react-leaflet GeoJSON кэширует слой, не реагирует на смену
|
NB: react-leaflet GeoJSON кэширует слой, не реагирует на смену
|
||||||
`data` prop. Включаем в key первую координату feature (она
|
`data` prop. Включаем в key первую координату feature (она
|
||||||
|
|
@ -624,6 +669,16 @@ export function SiteMap({
|
||||||
onToggleMarketLayer={toggleMarketLayer}
|
onToggleMarketLayer={toggleMarketLayer}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* #255 — ЗОУИТ-слой control panel. Рендерится только при наличии
|
||||||
|
полигональной геометрии охранных зон в payload. */}
|
||||||
|
{hasZouitGeom && (
|
||||||
|
<ZouitLayerControlPanel
|
||||||
|
grouped={zouitGrouped}
|
||||||
|
visibleSeverities={visibleZouit}
|
||||||
|
onToggleSeverity={toggleZouit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
279
frontend/src/components/site-finder/ZouitLayer.tsx
Normal file
279
frontend/src/components/site-finder/ZouitLayer.tsx
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
"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).
|
||||||
|
key={`zouit-${sev}-${idx}-${
|
||||||
|
overlap.geom_geojson?.slice(0, 24) ?? ""
|
||||||
|
}`}
|
||||||
|
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.source && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 4,
|
||||||
|
fontSize: 11,
|
||||||
|
color: "#9ca3af",
|
||||||
|
borderTop: "1px solid #f3f4f6",
|
||||||
|
paddingTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Источник: {overlap.source}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</GeoJSON>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</LayerGroup>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
frontend/src/components/site-finder/ZouitLayerControlPanel.tsx
Normal file
130
frontend/src/components/site-finder/ZouitLayerControlPanel.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZouitLayerControlPanel — #255: тумблеры ЗОУИТ-слоя (по образцу
|
||||||
|
* CpLayerControlPanel). Collapsible, checkbox per-severity с цветным dot +
|
||||||
|
* счётчиком рисуемых полигонов.
|
||||||
|
*
|
||||||
|
* Счётчики берём из grouped (groupZouitBySeverity) — это РИСУЕМЫЕ объекты
|
||||||
|
* (валидная полигональная геометрия), а не все overlaps: ЗОУИТ без geom_geojson
|
||||||
|
* на карте не появится, поэтому панель не должна обещать его пользователю.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ZOUIT_ALL_SEVERITIES,
|
||||||
|
ZOUIT_SEVERITY_STYLES,
|
||||||
|
type ZouitMappable,
|
||||||
|
type ZouitSeverity,
|
||||||
|
} from "@/components/site-finder/ZouitLayer";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
grouped: Map<ZouitSeverity, ZouitMappable[]>;
|
||||||
|
visibleSeverities: Set<ZouitSeverity>;
|
||||||
|
onToggleSeverity: (sev: ZouitSeverity) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ZouitLayerControlPanel({
|
||||||
|
grouped,
|
||||||
|
visibleSeverities,
|
||||||
|
onToggleSeverity,
|
||||||
|
}: Props) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
|
const totalCount = ZOUIT_ALL_SEVERITIES.reduce(
|
||||||
|
(sum, sev) => sum + (grouped.get(sev)?.length ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: 10,
|
||||||
|
marginTop: 10,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "8px 12px",
|
||||||
|
cursor: "pointer",
|
||||||
|
userSelect: "none",
|
||||||
|
borderBottom: collapsed ? "none" : "1px solid #f3f4f6",
|
||||||
|
}}
|
||||||
|
onClick={() => setCollapsed((v) => !v)}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 700, color: "#1f2937" }}>
|
||||||
|
Охранные зоны ЗОУИТ
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "#6b7280", fontSize: 11 }}>
|
||||||
|
{totalCount} зон {collapsed ? "▲" : "▼"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<div style={{ padding: "8px 12px 10px" }}>
|
||||||
|
{totalCount === 0 ? (
|
||||||
|
<div style={{ color: "#6b7280", fontSize: 11 }}>
|
||||||
|
Нет геометрии охранных зон для отображения
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "4px 14px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ZOUIT_ALL_SEVERITIES.map((sev) => {
|
||||||
|
const items = grouped.get(sev) ?? [];
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
const style = ZOUIT_SEVERITY_STYLES[sev];
|
||||||
|
const active = visibleSeverities.has(sev);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={sev}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 5,
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "#374151",
|
||||||
|
opacity: active ? 1 : 0.45,
|
||||||
|
transition: "opacity 0.15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={active}
|
||||||
|
onChange={() => onToggleSeverity(sev)}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: style.color,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{style.label}
|
||||||
|
<span style={{ color: "#9ca3af" }}>{items.length}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
frontend/src/components/site-finder/__tests__/ZouitLayer.test.ts
Normal file
138
frontend/src/components/site-finder/__tests__/ZouitLayer.test.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
/**
|
||||||
|
* Unit tests for the #255 ЗОУИТ-layer pure logic: severity classification,
|
||||||
|
* geom_geojson parsing (the graceful-skip contract), and grouping.
|
||||||
|
*
|
||||||
|
* The layer renders Leaflet <GeoJSON> (jsdom can't mount that meaningfully), so
|
||||||
|
* we test the data-shaping that decides WHAT gets drawn and HOW it's colored —
|
||||||
|
* that's where the bugs live (wrong severity → wrong color; bad parse → ocean
|
||||||
|
* polygon or crash).
|
||||||
|
*/
|
||||||
|
import type { NspdZouitOverlap } from "@/types/nspd";
|
||||||
|
import {
|
||||||
|
classifyZouit,
|
||||||
|
groupZouitBySeverity,
|
||||||
|
parseZouitGeometry,
|
||||||
|
} from "../ZouitLayer";
|
||||||
|
|
||||||
|
function overlap(partial: Partial<NspdZouitOverlap>): NspdZouitOverlap {
|
||||||
|
return {
|
||||||
|
group_key: "cad_zouit",
|
||||||
|
layer: "",
|
||||||
|
subcategory: null,
|
||||||
|
name: null,
|
||||||
|
...partial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A minimal valid GeoJSON Polygon string (как ST_AsGeoJSON отдаёт).
|
||||||
|
const POLYGON_STR = JSON.stringify({
|
||||||
|
type: "Polygon",
|
||||||
|
coordinates: [
|
||||||
|
[
|
||||||
|
[60.6, 56.83],
|
||||||
|
[60.61, 56.83],
|
||||||
|
[60.61, 56.84],
|
||||||
|
[60.6, 56.83],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("classifyZouit", () => {
|
||||||
|
it("classifies pipeline / electricity / gas zones as blocker", () => {
|
||||||
|
expect(
|
||||||
|
classifyZouit(overlap({ type_zone: "Охранная зона трубопровода" })),
|
||||||
|
).toBe("blocker");
|
||||||
|
expect(
|
||||||
|
classifyZouit(
|
||||||
|
overlap({ type_zone: "Охранная зона электросетевого хозяйства" }),
|
||||||
|
),
|
||||||
|
).toBe("blocker");
|
||||||
|
expect(
|
||||||
|
classifyZouit(overlap({ type_zone: "Охранная зона газопровода" })),
|
||||||
|
).toBe("blocker");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies СЗЗ and heat-network zones as warning", () => {
|
||||||
|
expect(
|
||||||
|
classifyZouit(
|
||||||
|
overlap({ type_zone: "Санитарно-защитная зона предприятия" }),
|
||||||
|
),
|
||||||
|
).toBe("warning");
|
||||||
|
expect(
|
||||||
|
classifyZouit(overlap({ type_zone: "Охранная зона тепловой сети" })),
|
||||||
|
).toBe("warning");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies servitude / unknown zones as info", () => {
|
||||||
|
expect(
|
||||||
|
classifyZouit(
|
||||||
|
overlap({ type_zone: "Публичный сервитут в целях размещения сети" }),
|
||||||
|
),
|
||||||
|
).toBe("info");
|
||||||
|
expect(classifyZouit(overlap({ type_zone: null, name: null }))).toBe(
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to name when type_zone is absent (nspd-dump path)", () => {
|
||||||
|
expect(
|
||||||
|
classifyZouit(overlap({ name: "Охранная зона трубопровода с кад. №…" })),
|
||||||
|
).toBe("blocker");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseZouitGeometry", () => {
|
||||||
|
it("parses a valid GeoJSON Polygon string", () => {
|
||||||
|
const geom = parseZouitGeometry(POLYGON_STR);
|
||||||
|
expect(geom?.type).toBe("Polygon");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for missing / empty / invalid input (graceful skip)", () => {
|
||||||
|
expect(parseZouitGeometry(null)).toBeNull();
|
||||||
|
expect(parseZouitGeometry(undefined)).toBeNull();
|
||||||
|
expect(parseZouitGeometry("")).toBeNull();
|
||||||
|
expect(parseZouitGeometry("{not json")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for non-polygon geometry (Point/LineString not drawn)", () => {
|
||||||
|
expect(
|
||||||
|
parseZouitGeometry(
|
||||||
|
JSON.stringify({ type: "Point", coordinates: [60.6, 56.83] }),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts an already-parsed object (contract tolerance)", () => {
|
||||||
|
// Контракт — строка (ST_AsGeoJSON), но parseZouitGeometry терпит и объект на
|
||||||
|
// случай смены контракта. Прогоняем object-путь через unknown-cast.
|
||||||
|
const obj = JSON.parse(POLYGON_STR) as unknown;
|
||||||
|
expect(parseZouitGeometry(obj as string)?.type).toBe("Polygon");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("groupZouitBySeverity", () => {
|
||||||
|
it("groups only mappable overlaps and drops geometry-less ones", () => {
|
||||||
|
const grouped = groupZouitBySeverity([
|
||||||
|
overlap({
|
||||||
|
type_zone: "Охранная зона газопровода",
|
||||||
|
geom_geojson: POLYGON_STR,
|
||||||
|
}),
|
||||||
|
overlap({
|
||||||
|
type_zone: "Охранная зона тепловой сети",
|
||||||
|
geom_geojson: POLYGON_STR,
|
||||||
|
}),
|
||||||
|
// Без geom_geojson — в карту не идёт.
|
||||||
|
overlap({ type_zone: "Публичный сервитут", geom_geojson: null }),
|
||||||
|
]);
|
||||||
|
expect(grouped.get("blocker")).toHaveLength(1);
|
||||||
|
expect(grouped.get("warning")).toHaveLength(1);
|
||||||
|
expect(grouped.get("info")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty buckets (not undefined) when overlaps is empty", () => {
|
||||||
|
const grouped = groupZouitBySeverity([]);
|
||||||
|
expect(grouped.get("blocker")).toEqual([]);
|
||||||
|
expect(grouped.get("warning")).toEqual([]);
|
||||||
|
expect(grouped.get("info")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -75,6 +75,8 @@ function toSiteMapData(data: ParcelAnalyzeResponse): ParcelAnalysis {
|
||||||
// §12.1-13 (#958) — opportunity-ЗУ + красные линии застройки.
|
// §12.1-13 (#958) — opportunity-ЗУ + красные линии застройки.
|
||||||
nspd_opportunity_parcels: data.nspd_opportunity_parcels ?? undefined,
|
nspd_opportunity_parcels: data.nspd_opportunity_parcels ?? undefined,
|
||||||
nspd_red_lines: data.nspd_red_lines ?? undefined,
|
nspd_red_lines: data.nspd_red_lines ?? undefined,
|
||||||
|
// #255 — ЗОУИТ overlaps (geom_geojson) для map-слоя охранных зон.
|
||||||
|
nspd_zouit_overlaps: data.nspd_zouit_overlaps ?? undefined,
|
||||||
noise: null,
|
noise: null,
|
||||||
air_quality: null,
|
air_quality: null,
|
||||||
wind: null,
|
wind: null,
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,12 @@ import type {
|
||||||
ParcelAnalysisCompetitor,
|
ParcelAnalysisCompetitor,
|
||||||
Pipeline24mo,
|
Pipeline24mo,
|
||||||
} from "@/types/site-finder";
|
} from "@/types/site-finder";
|
||||||
import type { OpportunityParcel, RedLine, RiskZone } from "@/types/nspd";
|
import type {
|
||||||
|
NspdZouitOverlap,
|
||||||
|
OpportunityParcel,
|
||||||
|
RedLine,
|
||||||
|
RiskZone,
|
||||||
|
} from "@/types/nspd";
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -385,6 +390,14 @@ export interface ParcelAnalyzeResponse {
|
||||||
nspd_opportunity_parcels?: OpportunityParcel[] | null;
|
nspd_opportunity_parcels?: OpportunityParcel[] | null;
|
||||||
/** TIER 4 красные линии застройки (#94, граддок) — geom_wkt (LINESTRING). */
|
/** TIER 4 красные линии застройки (#94, граддок) — geom_wkt (LINESTRING). */
|
||||||
nspd_red_lines?: RedLine[] | null;
|
nspd_red_lines?: RedLine[] | null;
|
||||||
|
/**
|
||||||
|
* #255 — ЗОУИТ overlaps (зоны с особыми условиями использования). Бэкенд уже
|
||||||
|
* отдаёт их в /analyze (Section4Estimate читает через cast в ParcelAnalysis);
|
||||||
|
* объявляем здесь, чтобы MiniMap-адаптер пробрасывал их в ЗОУИТ-слой карты без
|
||||||
|
* cast. geom_geojson на каждом overlap — для отрисовки полигонов (graceful,
|
||||||
|
* если backend ещё не добавил поле).
|
||||||
|
*/
|
||||||
|
nspd_zouit_overlaps?: NspdZouitOverlap[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Types: B6 poi-score response ─────────────────────────────────────────────
|
// ── Types: B6 poi-score response ─────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,20 @@ export interface NspdZoning {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NspdZouitOverlap {
|
export interface NspdZouitOverlap {
|
||||||
group_key: string; // e.g. "engineering", "okn", "natural", "protected", "other"
|
group_key: string; // e.g. "engineering", "okn", "natural", "protected", "other", "cad_zouit"
|
||||||
layer: string; // e.g. "zouit_engineering"
|
layer: string; // e.g. "zouit_engineering"; для cad_zouit fallback = type_zone
|
||||||
subcategory: string | number | null;
|
subcategory: string | number | null;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
// cad_zouit fallback path (quarter_dump_lookup._get_cad_zouit_overlaps) — плоские
|
||||||
|
// поля поверх raw_props. type_zone = категория зоны (охранная зона тепла / СЗЗ / …),
|
||||||
|
// source = "cad_zouit". В nspd-dump path обоих может не быть → optional.
|
||||||
|
type_zone?: string | null;
|
||||||
|
category_name?: string | null;
|
||||||
|
source?: string;
|
||||||
|
// #255 — geometry охранной зоны для map-слоя. ST_AsGeoJSON(geom::geometry) →
|
||||||
|
// строка GeoJSON (Polygon/MultiPolygon). Optional + graceful: backend пока может
|
||||||
|
// не отдавать поле (см. ZouitLayer — overlap без geom_geojson просто не рисуется).
|
||||||
|
geom_geojson?: string | null;
|
||||||
raw_props?: Record<string, unknown>;
|
raw_props?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue