feat(site-finder): OSM инж.сети на карту analyze (#1746) #1934

Merged
bot-backend merged 1 commit from feat/1746-utility-infra-map into main 2026-06-26 22:44:00 +00:00
6 changed files with 763 additions and 9 deletions

View file

@ -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<UtilityKind>
>(() => 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<UtilityKind, UtilityMappable[]>();
const utilityMappableCount = UTILITY_ALL_KINDS.reduce(
(sum, kind) => sum + (utilityGrouped.get(kind)?.length ?? 0),
0,
);
const hasUtilityGeom = utilityMappableCount > 0;
return (
<div>
{/* Map */}
@ -496,6 +545,15 @@ export function SiteMap({
/>
)}
{/* #1746 инж. инфраструктура OSM (ЛЭП-линии, подстанции, узлы).
Линии/полигоны как <GeoJSON>, точки как CircleMarker. */}
{hasUtilityGeom && (
<UtilityInfrastructureLayer
grouped={utilityGrouped}
visibleKinds={visibleUtilityKinds}
/>
)}
{/* Custom POI markers — topmost layer */}
{customPois && customPois.length > 0 && (
<CustomPoiLayer
@ -679,6 +737,17 @@ export function SiteMap({
onToggleSeverity={toggleZouit}
/>
)}
{/* #1746 control panel слоя инж. инфраструктуры OSM. Рендерится только
при наличии рисуемой геометрии. */}
{hasUtilityGeom && (
<UtilityLayerControlPanel
grouped={utilityGrouped}
visibleKinds={visibleUtilityKinds}
onToggleKind={toggleUtilityKind}
onToggleAll={toggleAllUtilityKinds}
/>
)}
</div>
);
}

View file

@ -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 <CircleMarker> (как ConnectionPointsLayer)
* LineString/MultiLineString <GeoJSON> polyline (основная масса)
* Polygon/MultiPolygon <GeoJSON> filled polygon (подстанции)
*
* geometry_geojson приходит уже распарсенным GeoJSON-объектом (ST_AsGeoJSON на
* бэке), координаты в порядке [lon, lat] для точечных CircleMarker меняем на
* [lat, lon]. Зеркалит ConnectionPointsLayer (CircleMarker+Popup) и ZouitLayer
* (<GeoJSON> + 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<UtilityKind, UtilityKindStyle> = {
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<Geometry["type"]>(["Point"]);
const LINE_POLY_TYPES = new Set<Geometry["type"]>([
"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<UtilityKind, UtilityMappable[]> {
const grouped = new Map<UtilityKind, UtilityMappable[]>();
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 (
<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: "50%",
background: style.color,
flexShrink: 0,
}}
/>
{style.label}
</div>
{feature.name && (
<div style={{ marginBottom: 2 }}>
<strong>{feature.name}</strong>
</div>
)}
{feature.source_tag && (
<div style={{ color: "#6b7280", marginBottom: 2 }}>
{feature.source_tag}
</div>
)}
<div style={{ marginTop: 4, color: "#374151" }}>
До границы: <strong>{Math.round(feature.distance_m)} м</strong>
</div>
<div
style={{
marginTop: 4,
fontSize: 11,
color: "#9ca3af",
borderTop: "1px solid #f3f4f6",
paddingTop: 4,
}}
>
Источник: OSM ({feature.osm_type} {feature.osm_id})
</div>
</div>
</Popup>
);
}
// ---------------------------------------------------------------------------
// Map layer
// ---------------------------------------------------------------------------
interface LayerProps {
grouped: Map<UtilityKind, UtilityMappable[]>;
visibleKinds: Set<UtilityKind>;
}
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 (
<LayerGroup key={kind}>
{items.map(({ feature, geometry }, idx) => {
// Point → CircleMarker (узлы: towers и т.п.)
if (geometry.type === "Point") {
const latLon = pointLatLon(geometry);
if (!latLon) return null;
return (
<CircleMarker
key={`util-${kind}-pt-${feature.osm_type}-${feature.osm_id}-${idx}`}
center={latLon}
radius={style.radius}
pathOptions={{
color: style.color,
fillColor: style.color,
fillOpacity: 0.9,
weight: 2,
}}
>
<UtilityPopup feature={feature} style={style} />
</CircleMarker>
);
}
// LineString / MultiLineString / Polygon / MultiPolygon → <GeoJSON>.
// Заливка применяется только к полигонам; для линий fillOpacity
// игнорируется Leaflet, но weight даёт видимую ЛЭП-линию.
const isPolygon =
geometry.type === "Polygon" ||
geometry.type === "MultiPolygon";
const geoFeature: Feature = {
type: "Feature",
geometry,
properties: {},
};
return (
<GeoJSON
// react-leaflet GeoJSON кэширует слой и не реагирует на смену
// data prop — geom в key форсирует remount при смене участка
// (тот же приём, что у ZouitLayer/изохрон).
key={`util-${kind}-geo-${feature.osm_type}-${feature.osm_id}-${idx}`}
data={geoFeature}
style={{
color: style.color,
weight: isPolygon ? 1.5 : 2.5,
fillColor: style.color,
fillOpacity: isPolygon ? 0.3 : 0,
}}
>
<UtilityPopup feature={feature} style={style} />
</GeoJSON>
);
})}
</LayerGroup>
);
})}
</>
);
}

View file

@ -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<UtilityKind, UtilityMappable[]>;
visibleKinds: Set<UtilityKind>;
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 (
<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" }}>
Инженерные сети (OSM)
</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 }}>
Данные OSM по инж. сетям не получены
</div>
) : (
<>
{/* Toggle-all */}
<label
style={{
display: "flex",
alignItems: "center",
gap: 6,
marginBottom: 6,
cursor: "pointer",
fontWeight: 600,
color: "#374151",
}}
>
<input
type="checkbox"
checked={allVisible}
onChange={onToggleAll}
style={{ cursor: "pointer" }}
/>
Показать все ({totalCount})
</label>
{/* Per-kind */}
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "4px 14px",
}}
>
{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 (
<label
key={kind}
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={() => onToggleKind(kind)}
style={{ cursor: "pointer" }}
/>
<span
style={{
display: "inline-block",
width: 10,
height: 10,
borderRadius: "50%",
background: style.color,
flexShrink: 0,
}}
/>
{style.label}
<span style={{ color: "#9ca3af" }}>{items.length}</span>
</label>
);
})}
</div>
</>
)}
</div>
)}
</div>
);
}

View file

@ -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 <CircleMarker>/<GeoJSON> (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>,
): 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([]);
});
});

View file

@ -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 (
<div style={{ marginTop: 16 }}>
@ -637,7 +648,7 @@ function ConnectionPointsMap({ data }: ConnectionPointsMapProps) {
color: "var(--fg-primary)",
}}
>
Точки подключения и охранные зоны сетей (НСПД)
Точки подключения, охранные зоны и инженерные сети
</h3>
</div>
<p
@ -648,8 +659,9 @@ function ConnectionPointsMap({ data }: ConnectionPointsMapProps) {
}}
>
Инженерные объекты и зоны с особыми условиями из снимка кадастрового
квартала (НСПД). Показывает, где находятся точки подключения, а не только
расстояние до них.
квартала (НСПД) и сети из OpenStreetMap (ЛЭП, подстанции, трубопроводы).
Показывает, где находятся точки подключения и трассы сетей, а не только
расстояние до них. Слои переключаются под картой.
</p>
{isLoading ? (
@ -666,12 +678,13 @@ function ConnectionPointsMap({ data }: ConnectionPointsMapProps) {
fontSize: 13,
}}
>
Загрузка точек подключения...
Загрузка инженерных сетей...
</div>
) : hasPoints ? (
) : hasAnyLayer ? (
<SiteMap
data={toCpMapData(data)}
connectionPoints={connectionPoints}
connectionPoints={hasPoints ? connectionPoints : undefined}
utilityInfrastructure={hasUtility ? utilityInfrastructure : undefined}
mapHeight={CP_MAP_HEIGHT}
/>
) : (
@ -689,8 +702,8 @@ function ConnectionPointsMap({ data }: ConnectionPointsMapProps) {
}}
>
<Info size={16} strokeWidth={1.5} style={{ flexShrink: 0 }} />
Точки подключения по кварталу не загружены. Снимок инженерной
инфраструктуры НСПД для этого кадастрового квартала отсутствует.
Точки подключения НСПД и данные OSM по инж. сетям не получены для
этого кадастрового квартала.
</div>
)}
</div>

View file

@ -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<UtilityInfrastructureResponse>({
queryKey: ["utility-infrastructure", cadNum, radiusM],
queryFn: () =>
apiFetch<UtilityInfrastructureResponse>(
`/api/v1/parcels/${encodeURIComponent(cadNum!)}/utility-infrastructure?radius_m=${radiusM}`,
),
enabled: !!cadNum && enabled,
staleTime: 5 * 60 * 1000,
});
}