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

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:
Light1YT 2026-06-13 22:31:59 +05:00
parent adc6dde015
commit 7d81b1b65f
7 changed files with 630 additions and 3 deletions

View file

@ -36,6 +36,13 @@ import {
type CpCategory,
} from "@/components/site-finder/ConnectionPointsLayer";
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 { CustomPoiToggleButton } from "@/components/site-finder/CustomPoiToggleButton";
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
const [addMode, setAddMode] = useState(false);
const [pendingCoords, setPendingCoords] = useState<{
@ -308,6 +334,16 @@ export function SiteMap({
(p) => typeof p.lat === "number" && typeof p.lon === "number",
).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 (
<div>
{/* 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).
NB: react-leaflet GeoJSON кэширует слой, не реагирует на смену
`data` prop. Включаем в key первую координату feature (она
@ -624,6 +669,16 @@ export function SiteMap({
onToggleMarketLayer={toggleMarketLayer}
/>
)}
{/* #255 ЗОУИТ-слой control panel. Рендерится только при наличии
полигональной геометрии охранных зон в payload. */}
{hasZouitGeom && (
<ZouitLayerControlPanel
grouped={zouitGrouped}
visibleSeverities={visibleZouit}
onToggleSeverity={toggleZouit}
/>
)}
</div>
);
}

View 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>
);
})}
</>
);
}

View 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>
);
}

View 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([]);
});
});

View file

@ -75,6 +75,8 @@ function toSiteMapData(data: ParcelAnalyzeResponse): ParcelAnalysis {
// §12.1-13 (#958) — opportunity-ЗУ + красные линии застройки.
nspd_opportunity_parcels: data.nspd_opportunity_parcels ?? undefined,
nspd_red_lines: data.nspd_red_lines ?? undefined,
// #255 — ЗОУИТ overlaps (geom_geojson) для map-слоя охранных зон.
nspd_zouit_overlaps: data.nspd_zouit_overlaps ?? undefined,
noise: null,
air_quality: null,
wind: null,

View file

@ -30,7 +30,12 @@ import type {
ParcelAnalysisCompetitor,
Pipeline24mo,
} from "@/types/site-finder";
import type { OpportunityParcel, RedLine, RiskZone } from "@/types/nspd";
import type {
NspdZouitOverlap,
OpportunityParcel,
RedLine,
RiskZone,
} from "@/types/nspd";
// ── Types ─────────────────────────────────────────────────────────────────────
@ -385,6 +390,14 @@ export interface ParcelAnalyzeResponse {
nspd_opportunity_parcels?: OpportunityParcel[] | null;
/** TIER 4 красные линии застройки (#94, граддок) — geom_wkt (LINESTRING). */
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 ─────────────────────────────────────────────

View file

@ -10,10 +10,20 @@ export interface NspdZoning {
}
export interface NspdZouitOverlap {
group_key: string; // e.g. "engineering", "okn", "natural", "protected", "other"
layer: string; // e.g. "zouit_engineering"
group_key: string; // e.g. "engineering", "okn", "natural", "protected", "other", "cad_zouit"
layer: string; // e.g. "zouit_engineering"; для cad_zouit fallback = type_zone
subcategory: string | number | 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>;
}