feat(site-finder): ЗОУИТ visual layer на Leaflet карте (#255) #1320
7 changed files with 630 additions and 3 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
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-ЗУ + красные линии застройки.
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue