feat(site-finder): render OSM utility-infrastructure layer on analyze map (#1746)
Section2 "Сети" map now shows the OSM engineering-networks layer
(osm_utility_infrastructure_ekb, endpoint /parcels/{cad}/utility-infrastructure)
alongside the NSPD connection-points + ЗОУИТ layers from #1751, on the same map.
- useUtilityInfrastructure hook (react-query, mirrors useConnectionPoints)
- UtilityInfrastructureLayer: Point→CircleMarker, LineString/Polygon(+Multi)→GeoJSON,
per-kind colour (power/water/gas/heat/communication/sewage), popups
- UtilityLayerControlPanel: collapsible legend, per-kind toggle + count
- SiteMap: optional utilityInfrastructure prop + visibleKinds state (CP/ЗОУИТ unchanged)
- 12 unit tests (geometry parsing + [lon,lat]→[lat,lon] swap + grouping)
Refs #1746
This commit is contained in:
parent
63ac12dc23
commit
854fa69a8b
6 changed files with 763 additions and 9 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
164
frontend/src/components/site-finder/UtilityLayerControlPanel.tsx
Normal file
164
frontend/src/components/site-finder/UtilityLayerControlPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
39
frontend/src/hooks/useUtilityInfrastructure.ts
Normal file
39
frontend/src/hooks/useUtilityInfrastructure.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue