diff --git a/frontend/src/components/site-finder/SiteMap.tsx b/frontend/src/components/site-finder/SiteMap.tsx index bd7b2148..309fc7d4 100644 --- a/frontend/src/components/site-finder/SiteMap.tsx +++ b/frontend/src/components/site-finder/SiteMap.tsx @@ -24,6 +24,7 @@ import type { RedLine, RiskZone, } from "@/types/nspd"; +import type { UtilityInfrastructureResponse } from "@/hooks/useUtilityInfrastructure"; import type { CustomPoi } from "@/types/customPoi"; import { MarketLayers, @@ -43,6 +44,14 @@ import { type ZouitSeverity, } from "@/components/site-finder/ZouitLayer"; import { ZouitLayerControlPanel } from "@/components/site-finder/ZouitLayerControlPanel"; +import { + UtilityInfrastructureLayer, + UTILITY_ALL_KINDS, + groupUtilityByKind, + type UtilityKind, + type UtilityMappable, +} from "@/components/site-finder/UtilityInfrastructureLayer"; +import { UtilityLayerControlPanel } from "@/components/site-finder/UtilityLayerControlPanel"; import { CustomPoiLayer } from "@/components/site-finder/CustomPoiLayer"; import { CustomPoiToggleButton } from "@/components/site-finder/CustomPoiToggleButton"; import { CustomPoiAddModal } from "@/components/site-finder/CustomPoiAddModal"; @@ -124,6 +133,8 @@ interface Props { data: ParcelAnalysis; isochrones?: FeatureCollection; connectionPoints?: ConnectionPointsResponse; + // #1746 — инженерная инфраструктура из OSM (доп. слои на той же карте). + utilityInfrastructure?: UtilityInfrastructureResponse; customPois?: CustomPoi[]; parcelCad?: string; // #999 (958-B4) — рыночные слои (опциональны: SiteMap используется и без них). @@ -172,6 +183,7 @@ export function SiteMap({ data, isochrones, connectionPoints, + utilityInfrastructure, customPois, parcelCad, competitors, @@ -239,6 +251,32 @@ export function SiteMap({ }); } + // #1746 — слой инж. инфраструктуры OSM. Все виды видимы по умолчанию (как CP); + // пользователь отключает шумные слои тумблером в UtilityLayerControlPanel. + const [visibleUtilityKinds, setVisibleUtilityKinds] = useState< + Set + >(() => new Set(UTILITY_ALL_KINDS)); + + function toggleUtilityKind(kind: UtilityKind) { + setVisibleUtilityKinds((prev) => { + const next = new Set(prev); + if (next.has(kind)) { + next.delete(kind); + } else { + next.add(kind); + } + return next; + }); + } + + function toggleAllUtilityKinds() { + setVisibleUtilityKinds((prev) => + prev.size === UTILITY_ALL_KINDS.length + ? new Set() + : new Set(UTILITY_ALL_KINDS), + ); + } + // Custom POI add mode state const [addMode, setAddMode] = useState(false); const [pendingCoords, setPendingCoords] = useState<{ @@ -344,6 +382,17 @@ export function SiteMap({ ); const hasZouitGeom = zouitMappableCount > 0; + // #1746 — инж. инфраструктура OSM. Группируем по виду; в карту/панель идут + // только features с валидной геометрией (Point/Line/Polygon). + const utilityGrouped = utilityInfrastructure + ? groupUtilityByKind(utilityInfrastructure.features) + : new Map(); + const utilityMappableCount = UTILITY_ALL_KINDS.reduce( + (sum, kind) => sum + (utilityGrouped.get(kind)?.length ?? 0), + 0, + ); + const hasUtilityGeom = utilityMappableCount > 0; + return (
{/* Map */} @@ -496,6 +545,15 @@ export function SiteMap({ /> )} + {/* #1746 — инж. инфраструктура OSM (ЛЭП-линии, подстанции, узлы). + Линии/полигоны как , точки как CircleMarker. */} + {hasUtilityGeom && ( + + )} + {/* Custom POI markers — topmost layer */} {customPois && customPois.length > 0 && ( )} + + {/* #1746 — control panel слоя инж. инфраструктуры OSM. Рендерится только + при наличии рисуемой геометрии. */} + {hasUtilityGeom && ( + + )}
); } diff --git a/frontend/src/components/site-finder/UtilityInfrastructureLayer.tsx b/frontend/src/components/site-finder/UtilityInfrastructureLayer.tsx new file mode 100644 index 00000000..6284d99f --- /dev/null +++ b/frontend/src/components/site-finder/UtilityInfrastructureLayer.tsx @@ -0,0 +1,317 @@ +"use client"; + +/** + * UtilityInfrastructureLayer — #1746: инженерная инфраструктура из OSM + * (osm_utility_infrastructure_ekb) поверх карты Site Finder. + * + * Данные — преимущественно ЛЭП (power=line → LineString ways, ~95% объектов) + * и подстанции (substation ways), плюс относительно немного точечных узлов + * (towers → Point nodes). Поэтому слой ОБЯЗАН рисовать все три типа геометрии: + * Point → (как ConnectionPointsLayer) + * LineString/MultiLineString → polyline (основная масса) + * Polygon/MultiPolygon → filled polygon (подстанции) + * + * geometry_geojson приходит уже распарсенным GeoJSON-объектом (ST_AsGeoJSON на + * бэке), координаты в порядке [lon, lat] — для точечных CircleMarker меняем на + * [lat, lon]. Зеркалит ConnectionPointsLayer (CircleMarker+Popup) и ZouitLayer + * ( + key-remount по геометрии). + * + * Цвета — token-hex (документированное исключение карты/чартов, ui-tokens.md): + * цвет per infrastructure_kind, группировка/видимость управляются из SiteMap. + */ + +import { CircleMarker, GeoJSON, LayerGroup, Popup } from "react-leaflet"; +import type { Feature, Geometry } from "geojson"; + +import type { UtilityInfrastructureFeature } from "@/hooks/useUtilityInfrastructure"; + +// --------------------------------------------------------------------------- +// Kind classification + styles +// --------------------------------------------------------------------------- + +export type UtilityKind = + | "power" + | "water" + | "gas" + | "heat" + | "communication" + | "sewage" + | "other"; + +export interface UtilityKindStyle { + color: string; + label: string; + radius: number; +} + +// token-hex (map/chart exception per ui-tokens.md). Совпадает с палитрой +// CP_CATEGORY_STYLES, чтобы один и тот же ресурс читался одним цветом на карте. +export const UTILITY_KIND_STYLES: Record = { + power: { color: "#f59e0b", label: "Электросети", radius: 6 }, // amber + water: { color: "#06b6d4", label: "Водоснабжение", radius: 6 }, // cyan + gas: { color: "#3b82f6", label: "Газоснабжение", radius: 6 }, // blue + heat: { color: "#ef4444", label: "Теплоснабжение", radius: 6 }, // red + communication: { color: "#10b981", label: "Связь", radius: 5 }, // emerald + sewage: { color: "#8b5cf6", label: "Канализация", radius: 5 }, // violet + other: { color: "#6b7280", label: "Прочее", radius: 5 }, // gray +}; + +// Порядок отрисовки / легенды: power первым (доминирующий слой), other последним. +export const UTILITY_ALL_KINDS: UtilityKind[] = [ + "power", + "water", + "gas", + "heat", + "communication", + "sewage", + "other", +]; + +/** + * Нормализует infrastructure_kind (строка из бэка) в известный UtilityKind. + * Бэк отдаёт power/water/gas/heat/communication/sewage; неизвестное → "other" + * (graceful — слой не падает на новом виде). + */ +export function classifyUtilityKind(raw: string | null | undefined): UtilityKind { + switch (raw) { + case "power": + case "water": + case "gas": + case "heat": + case "communication": + case "sewage": + return raw; + default: + return "other"; + } +} + +// --------------------------------------------------------------------------- +// Geometry parsing +// --------------------------------------------------------------------------- + +const POINT_TYPES = new Set(["Point"]); +const LINE_POLY_TYPES = new Set([ + "LineString", + "MultiLineString", + "Polygon", + "MultiPolygon", +]); + +/** + * geometry_geojson уже распарсен (объект). Принимаем object | строку (на случай + * будущей смены контракта). Возвращает Geometry либо null для пустого/невалидного + * ввода — фича тогда просто не рисуется (graceful). + */ +export function parseUtilityGeometry( + raw: unknown, +): Geometry | null { + if (raw === null || raw === undefined) return null; + + let parsed: unknown = raw; + if (typeof raw === "string") { + if (raw.length === 0) return null; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + } + + if (typeof parsed !== "object" || parsed === null) return null; + const candidate = parsed as { type?: unknown; coordinates?: unknown }; + if ( + typeof candidate.type !== "string" || + !Array.isArray(candidate.coordinates) + ) { + return null; + } + const t = candidate.type as Geometry["type"]; + if (!POINT_TYPES.has(t) && !LINE_POLY_TYPES.has(t)) return null; + return parsed as Geometry; +} + +// Точечная геометрия → [lat, lon] для Leaflet CircleMarker (GeoJSON = [lon,lat]). +function pointLatLon(geom: Geometry): [number, number] | null { + if (geom.type !== "Point") return null; + const coords = geom.coordinates; + if (!Array.isArray(coords) || coords.length < 2) return null; + const [lon, lat] = coords; + if (typeof lat !== "number" || typeof lon !== "number") return null; + return [lat, lon]; +} + +// --------------------------------------------------------------------------- +// Grouping helper (exported so SiteMap / panel build per-kind counts) +// --------------------------------------------------------------------------- + +export interface UtilityMappable { + feature: UtilityInfrastructureFeature; + geometry: Geometry; + kind: UtilityKind; +} + +/** + * Из сырых features оставляет только рисуемые (валидная геометрия) и группирует + * по infrastructure_kind. Счётчики панели берутся отсюда — отражают именно + * рисуемые объекты, а не все features. + */ +export function groupUtilityByKind( + features: UtilityInfrastructureFeature[], +): Map { + const grouped = new Map(); + for (const kind of UTILITY_ALL_KINDS) grouped.set(kind, []); + + for (const feature of features) { + const geometry = parseUtilityGeometry(feature.geometry_geojson); + if (!geometry) continue; + const kind = classifyUtilityKind(feature.infrastructure_kind); + grouped.get(kind)!.push({ feature, geometry, kind }); + } + return grouped; +} + +// --------------------------------------------------------------------------- +// Popup (shared by point + line/polygon render paths) +// --------------------------------------------------------------------------- + +function UtilityPopup({ + feature, + style, +}: { + feature: UtilityInfrastructureFeature; + style: UtilityKindStyle; +}) { + return ( + +
+
+ + {style.label} +
+ {feature.name && ( +
+ {feature.name} +
+ )} + {feature.source_tag && ( +
+ {feature.source_tag} +
+ )} +
+ До границы: {Math.round(feature.distance_m)} м +
+
+ Источник: OSM ({feature.osm_type} {feature.osm_id}) +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Map layer +// --------------------------------------------------------------------------- + +interface LayerProps { + grouped: Map; + visibleKinds: Set; +} + +export function UtilityInfrastructureLayer({ + grouped, + visibleKinds, +}: LayerProps) { + return ( + <> + {UTILITY_ALL_KINDS.map((kind) => { + if (!visibleKinds.has(kind)) return null; + const items = grouped.get(kind) ?? []; + if (items.length === 0) return null; + const style = UTILITY_KIND_STYLES[kind]; + + return ( + + {items.map(({ feature, geometry }, idx) => { + // Point → CircleMarker (узлы: towers и т.п.) + if (geometry.type === "Point") { + const latLon = pointLatLon(geometry); + if (!latLon) return null; + return ( + + + + ); + } + + // LineString / MultiLineString / Polygon / MultiPolygon → . + // Заливка применяется только к полигонам; для линий fillOpacity + // игнорируется Leaflet, но weight даёт видимую ЛЭП-линию. + const isPolygon = + geometry.type === "Polygon" || + geometry.type === "MultiPolygon"; + const geoFeature: Feature = { + type: "Feature", + geometry, + properties: {}, + }; + return ( + + + + ); + })} + + ); + })} + + ); +} diff --git a/frontend/src/components/site-finder/UtilityLayerControlPanel.tsx b/frontend/src/components/site-finder/UtilityLayerControlPanel.tsx new file mode 100644 index 00000000..1ab54f3b --- /dev/null +++ b/frontend/src/components/site-finder/UtilityLayerControlPanel.tsx @@ -0,0 +1,164 @@ +"use client"; + +/** + * UtilityLayerControlPanel — #1746: тумблеры слоя инженерной инфраструктуры OSM + * (по образцу CpLayerControlPanel / ZouitLayerControlPanel). Collapsible, + * checkbox per-kind с цветным dot + счётчиком рисуемых объектов, «показать все». + * + * Счётчики берём из grouped (groupUtilityByKind) — это РИСУЕМЫЕ объекты (валидная + * геометрия), а не все features: фича без геометрии на карте не появится, поэтому + * панель не должна обещать её пользователю. + */ + +import { useState } from "react"; + +import { + UTILITY_ALL_KINDS, + UTILITY_KIND_STYLES, + type UtilityKind, + type UtilityMappable, +} from "@/components/site-finder/UtilityInfrastructureLayer"; + +interface Props { + grouped: Map; + visibleKinds: Set; + onToggleKind: (kind: UtilityKind) => void; + onToggleAll: () => void; +} + +export function UtilityLayerControlPanel({ + grouped, + visibleKinds, + onToggleKind, + onToggleAll, +}: Props) { + const [collapsed, setCollapsed] = useState(false); + + const totalCount = UTILITY_ALL_KINDS.reduce( + (sum, kind) => sum + (grouped.get(kind)?.length ?? 0), + 0, + ); + + // «Показать все» отражает состояние только непустых (рисуемых) видов. + const togglableKinds = UTILITY_ALL_KINDS.filter( + (kind) => (grouped.get(kind)?.length ?? 0) > 0, + ); + const allVisible = + togglableKinds.length > 0 && + togglableKinds.every((kind) => visibleKinds.has(kind)); + + return ( +
+ {/* Header */} +
setCollapsed((v) => !v)} + > + + Инженерные сети (OSM) + + + {totalCount} объ. {collapsed ? "▲" : "▼"} + +
+ + {!collapsed && ( +
+ {totalCount === 0 ? ( +
+ Данные OSM по инж. сетям не получены +
+ ) : ( + <> + {/* Toggle-all */} + + + {/* Per-kind */} +
+ {UTILITY_ALL_KINDS.map((kind) => { + const items = grouped.get(kind) ?? []; + if (items.length === 0) return null; + const style = UTILITY_KIND_STYLES[kind]; + const active = visibleKinds.has(kind); + return ( + + ); + })} +
+ + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/site-finder/__tests__/UtilityInfrastructureLayer.test.ts b/frontend/src/components/site-finder/__tests__/UtilityInfrastructureLayer.test.ts new file mode 100644 index 00000000..769b21ca --- /dev/null +++ b/frontend/src/components/site-finder/__tests__/UtilityInfrastructureLayer.test.ts @@ -0,0 +1,152 @@ +/** + * Unit tests for the #1746 OSM utility-infrastructure layer pure logic: kind + * classification, geometry_geojson parsing (the three-geometry-type contract + + * graceful-skip), and grouping. + * + * The layer renders Leaflet / (jsdom can't mount those + * meaningfully), so we test the data-shaping that decides WHAT gets drawn and + * HOW it's colored — that's where the bugs live (power-lines must NOT be + * skipped; [lon,lat]→[lat,lat] swap; bad parse → crash or ocean geometry). + */ +import type { UtilityInfrastructureFeature } from "@/hooks/useUtilityInfrastructure"; +import { + classifyUtilityKind, + groupUtilityByKind, + parseUtilityGeometry, +} from "../UtilityInfrastructureLayer"; + +function feat( + partial: Partial, +): UtilityInfrastructureFeature { + return { + osm_id: 1, + osm_type: "way", + infrastructure_kind: "power", + name: null, + source_tag: null, + distance_m: 0, + geometry_geojson: {}, + ...partial, + }; +} + +// geometry_geojson приходит уже распарсенным объектом (ST_AsGeoJSON на бэке). +const POINT_GEOM = { type: "Point", coordinates: [60.6, 56.83] }; +const LINESTRING_GEOM = { + type: "LineString", + coordinates: [ + [60.6, 56.83], + [60.61, 56.84], + ], +}; +const POLYGON_GEOM = { + type: "Polygon", + coordinates: [ + [ + [60.6, 56.83], + [60.61, 56.83], + [60.61, 56.84], + [60.6, 56.83], + ], + ], +}; + +describe("classifyUtilityKind", () => { + it("passes through known kinds", () => { + expect(classifyUtilityKind("power")).toBe("power"); + expect(classifyUtilityKind("water")).toBe("water"); + expect(classifyUtilityKind("gas")).toBe("gas"); + expect(classifyUtilityKind("heat")).toBe("heat"); + expect(classifyUtilityKind("communication")).toBe("communication"); + expect(classifyUtilityKind("sewage")).toBe("sewage"); + }); + + it("maps unknown / null kind to 'other' (graceful)", () => { + expect(classifyUtilityKind("telecom")).toBe("other"); + expect(classifyUtilityKind(null)).toBe("other"); + expect(classifyUtilityKind(undefined)).toBe("other"); + }); +}); + +describe("parseUtilityGeometry", () => { + it("parses Point geometry (towers / nodes)", () => { + expect(parseUtilityGeometry(POINT_GEOM)?.type).toBe("Point"); + }); + + it("parses LineString geometry (power lines — the majority, must NOT skip)", () => { + expect(parseUtilityGeometry(LINESTRING_GEOM)?.type).toBe("LineString"); + }); + + it("parses Polygon geometry (substations)", () => { + expect(parseUtilityGeometry(POLYGON_GEOM)?.type).toBe("Polygon"); + }); + + it("parses MultiLineString / MultiPolygon geometry", () => { + expect( + parseUtilityGeometry({ + type: "MultiLineString", + coordinates: [LINESTRING_GEOM.coordinates], + })?.type, + ).toBe("MultiLineString"); + expect( + parseUtilityGeometry({ + type: "MultiPolygon", + coordinates: [POLYGON_GEOM.coordinates], + })?.type, + ).toBe("MultiPolygon"); + }); + + it("tolerates a GeoJSON string (future contract change)", () => { + expect(parseUtilityGeometry(JSON.stringify(POINT_GEOM))?.type).toBe( + "Point", + ); + }); + + it("returns null for missing / empty / invalid input (graceful skip)", () => { + expect(parseUtilityGeometry(null)).toBeNull(); + expect(parseUtilityGeometry(undefined)).toBeNull(); + expect(parseUtilityGeometry("")).toBeNull(); + expect(parseUtilityGeometry("{not json")).toBeNull(); + expect(parseUtilityGeometry({})).toBeNull(); + expect(parseUtilityGeometry({ type: "Point" })).toBeNull(); + expect( + parseUtilityGeometry({ type: "GeometryCollection", geometries: [] }), + ).toBeNull(); + }); +}); + +describe("groupUtilityByKind", () => { + it("groups all three geometry types and keeps power lines", () => { + const grouped = groupUtilityByKind([ + feat({ infrastructure_kind: "power", geometry_geojson: LINESTRING_GEOM }), + feat({ infrastructure_kind: "power", geometry_geojson: POINT_GEOM }), + feat({ infrastructure_kind: "power", geometry_geojson: POLYGON_GEOM }), + feat({ infrastructure_kind: "water", geometry_geojson: POINT_GEOM }), + feat({ infrastructure_kind: "gas", geometry_geojson: LINESTRING_GEOM }), + ]); + expect(grouped.get("power")).toHaveLength(3); + expect(grouped.get("water")).toHaveLength(1); + expect(grouped.get("gas")).toHaveLength(1); + }); + + it("drops features without valid geometry", () => { + const grouped = groupUtilityByKind([ + feat({ infrastructure_kind: "power", geometry_geojson: LINESTRING_GEOM }), + feat({ infrastructure_kind: "power", geometry_geojson: {} }), + ]); + expect(grouped.get("power")).toHaveLength(1); + }); + + it("buckets unknown kinds under 'other'", () => { + const grouped = groupUtilityByKind([ + feat({ infrastructure_kind: "telecom", geometry_geojson: POINT_GEOM }), + ]); + expect(grouped.get("other")).toHaveLength(1); + }); + + it("returns empty buckets (not undefined) when features is empty", () => { + const grouped = groupUtilityByKind([]); + expect(grouped.get("power")).toEqual([]); + expect(grouped.get("other")).toEqual([]); + }); +}); diff --git a/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx b/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx index 460a47de..d0798847 100644 --- a/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx +++ b/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx @@ -34,6 +34,7 @@ import type { } from "@/lib/site-finder-api"; import type { ParcelAnalysis } from "@/types/site-finder"; import { useConnectionPoints } from "@/hooks/useConnectionPoints"; +import { useUtilityInfrastructure } from "@/hooks/useUtilityInfrastructure"; // ── Connection-points map (#1746) ───────────────────────────────────────────── // Высота тайла карты для drill-in уровня раздела. Легенда + CpLayerControlPanel @@ -604,14 +605,24 @@ function ConnectionPointsMap({ data }: ConnectionPointsMapProps) { // Точки подключения ресурсов (электро/газ/вода/тепло) по кварталу — тянем по // cad_num и пробрасываем в SiteMap, который рисует ConnectionPointsLayer + // CpLayerControlPanel при truthy prop `connectionPoints` (паттерн MiniMap.tsx). - const { data: connectionPoints, isLoading } = useConnectionPoints( + const { data: connectionPoints, isLoading: cpLoading } = useConnectionPoints( data.cad_num, ); + // #1746 — инж. инфраструктура OSM (ЛЭП-линии, подстанции, узлы). Те же + // доп. слои на ТОЙ ЖЕ карте (одна карта, больше слоёв — лучше UX). + const { data: utilityInfrastructure, isLoading: utilLoading } = + useUtilityInfrastructure(data.cad_num); + + const isLoading = cpLoading || utilLoading; + const hasPoints = !!connectionPoints && connectionPoints.dump_available && connectionPoints.engineering_structures.length > 0; + const hasUtility = + !!utilityInfrastructure && utilityInfrastructure.features.length > 0; + const hasAnyLayer = hasPoints || hasUtility; return (
@@ -637,7 +648,7 @@ function ConnectionPointsMap({ data }: ConnectionPointsMapProps) { color: "var(--fg-primary)", }} > - Точки подключения и охранные зоны сетей (НСПД) + Точки подключения, охранные зоны и инженерные сети

Инженерные объекты и зоны с особыми условиями из снимка кадастрового - квартала (НСПД). Показывает, где находятся точки подключения, а не только - расстояние до них. + квартала (НСПД) и сети из OpenStreetMap (ЛЭП, подстанции, трубопроводы). + Показывает, где находятся точки подключения и трассы сетей, а не только + расстояние до них. Слои переключаются под картой.

{isLoading ? ( @@ -666,12 +678,13 @@ function ConnectionPointsMap({ data }: ConnectionPointsMapProps) { fontSize: 13, }} > - Загрузка точек подключения... + Загрузка инженерных сетей... - ) : hasPoints ? ( + ) : hasAnyLayer ? ( ) : ( @@ -689,8 +702,8 @@ function ConnectionPointsMap({ data }: ConnectionPointsMapProps) { }} > - Точки подключения по кварталу не загружены. Снимок инженерной - инфраструктуры НСПД для этого кадастрового квартала отсутствует. + Точки подключения НСПД и данные OSM по инж. сетям не получены для + этого кадастрового квартала. )} diff --git a/frontend/src/hooks/useUtilityInfrastructure.ts b/frontend/src/hooks/useUtilityInfrastructure.ts new file mode 100644 index 00000000..5f52a983 --- /dev/null +++ b/frontend/src/hooks/useUtilityInfrastructure.ts @@ -0,0 +1,39 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { apiFetch } from "@/lib/api"; +import type { components } from "@/lib/api-types"; + +// Reuse generated OpenAPI types (api-types.ts) — НЕ дублировать руками. +export type UtilityInfrastructureResponse = + components["schemas"]["UtilityInfrastructureResponse"]; +export type UtilityInfrastructureFeature = + components["schemas"]["UtilityInfrastructureFeature"]; +export type UtilityInfrastructureSummary = + components["schemas"]["UtilityInfrastructureSummary"]; + +/** + * Слой инженерной инфраструктуры из OSM (#1746). Источник — open-data таблица + * osm_utility_infrastructure_ekb (weekly-sync). Большинство объектов — ЛЭП + * (power=line, LineString) и подстанции; точечных узлов (towers) меньше. + * + * Зеркалит useConnectionPoints, но с параметром radius_m (бэкенд: 50..2000, + * default 500). Сервер — единственный источник истины по форме ответа + * (см. UtilityInfrastructureResponse в api-types.ts). + */ +export function useUtilityInfrastructure( + cadNum: string | null | undefined, + radiusM = 500, + enabled = true, +) { + return useQuery({ + queryKey: ["utility-infrastructure", cadNum, radiusM], + queryFn: () => + apiFetch( + `/api/v1/parcels/${encodeURIComponent(cadNum!)}/utility-infrastructure?radius_m=${radiusM}`, + ), + enabled: !!cadNum && enabled, + staleTime: 5 * 60 * 1000, + }); +}