From 7d81b1b65f46ea436d6197f8b34a2c843f7bee0c Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 13 Jun 2026 22:31:59 +0500 Subject: [PATCH] =?UTF-8?q?feat(site-finder):=20=D0=97=D0=9E=D0=A3=D0=98?= =?UTF-8?q?=D0=A2=20visual=20layer=20=D0=BD=D0=B0=20Leaflet=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=B5=20(#255)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/components/site-finder/SiteMap.tsx | 55 ++++ .../src/components/site-finder/ZouitLayer.tsx | 279 ++++++++++++++++++ .../site-finder/ZouitLayerControlPanel.tsx | 130 ++++++++ .../site-finder/__tests__/ZouitLayer.test.ts | 138 +++++++++ .../site-finder/analysis/MiniMap.tsx | 2 + frontend/src/lib/site-finder-api.ts | 15 +- frontend/src/types/nspd.ts | 14 +- 7 files changed, 630 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/site-finder/ZouitLayer.tsx create mode 100644 frontend/src/components/site-finder/ZouitLayerControlPanel.tsx create mode 100644 frontend/src/components/site-finder/__tests__/ZouitLayer.test.ts diff --git a/frontend/src/components/site-finder/SiteMap.tsx b/frontend/src/components/site-finder/SiteMap.tsx index a69cd427..bd7b2148 100644 --- a/frontend/src/components/site-finder/SiteMap.tsx +++ b/frontend/src/components/site-finder/SiteMap.tsx @@ -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>( + () => new Set(["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 (
{/* Map */} @@ -346,6 +382,15 @@ export function SiteMap({ /> )} + {/* #255 — ЗОУИТ-полигоны (охранные зоны). Рисуем рано (под точками POI + и рыночными маркерами) как заливку-фон, поверх полигона участка. */} + {hasZouitGeom && ( + + )} + {/* 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 && ( + + )}
); } diff --git a/frontend/src/components/site-finder/ZouitLayer.tsx b/frontend/src/components/site-finder/ZouitLayer.tsx new file mode 100644 index 00000000..cef47b43 --- /dev/null +++ b/frontend/src/components/site-finder/ZouitLayer.tsx @@ -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 → (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.source && ( +
+ Источник: {overlap.source} +
+ )} +
+
+
+ ); + })} +
+ ); + })} + + ); +} diff --git a/frontend/src/components/site-finder/ZouitLayerControlPanel.tsx b/frontend/src/components/site-finder/ZouitLayerControlPanel.tsx new file mode 100644 index 00000000..6186148c --- /dev/null +++ b/frontend/src/components/site-finder/ZouitLayerControlPanel.tsx @@ -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; + visibleSeverities: Set; + 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 ( +
+ {/* Header */} +
setCollapsed((v) => !v)} + > + + Охранные зоны ЗОУИТ + + + {totalCount} зон {collapsed ? "▲" : "▼"} + +
+ + {!collapsed && ( +
+ {totalCount === 0 ? ( +
+ Нет геометрии охранных зон для отображения +
+ ) : ( +
+ {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 ( + + ); + })} +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/site-finder/__tests__/ZouitLayer.test.ts b/frontend/src/components/site-finder/__tests__/ZouitLayer.test.ts new file mode 100644 index 00000000..7a88288a --- /dev/null +++ b/frontend/src/components/site-finder/__tests__/ZouitLayer.test.ts @@ -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 (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 { + 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([]); + }); +}); diff --git a/frontend/src/components/site-finder/analysis/MiniMap.tsx b/frontend/src/components/site-finder/analysis/MiniMap.tsx index 5c4ff079..aac4a61c 100644 --- a/frontend/src/components/site-finder/analysis/MiniMap.tsx +++ b/frontend/src/components/site-finder/analysis/MiniMap.tsx @@ -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, diff --git a/frontend/src/lib/site-finder-api.ts b/frontend/src/lib/site-finder-api.ts index 6de20144..0690ae3d 100644 --- a/frontend/src/lib/site-finder-api.ts +++ b/frontend/src/lib/site-finder-api.ts @@ -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 ───────────────────────────────────────────── diff --git a/frontend/src/types/nspd.ts b/frontend/src/types/nspd.ts index 3785d67c..43c4340f 100644 --- a/frontend/src/types/nspd.ts +++ b/frontend/src/types/nspd.ts @@ -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; }