From d9915c7834e6ed20b8aaf0a83f5f128cc0bfa5cc Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Mon, 29 Jun 2026 11:56:11 +0000
Subject: [PATCH] =?UTF-8?q?fix(site-finder):=20unify=20=C2=A73=20=C2=AB?=
=?UTF-8?q?=D0=A1=D0=B5=D1=82=D0=B8=C2=BB=20table=E2=86=94map=20source=20+?=
=?UTF-8?q?=20enrich=20popups=20(#1953,=20batch=20C)=20(#2097)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../site-finder/ConnectionPointsLayer.tsx | 17 +-
.../UtilityInfrastructureLayer.tsx | 46 +-
.../analysis/Section2NetworksUtilities.tsx | 570 +++++++++---------
.../analysis/__tests__/utility-table.test.ts | 146 +++++
.../site-finder/analysis/utility-table.ts | 74 +++
5 files changed, 561 insertions(+), 292 deletions(-)
create mode 100644 frontend/src/components/site-finder/analysis/__tests__/utility-table.test.ts
create mode 100644 frontend/src/components/site-finder/analysis/utility-table.ts
diff --git a/frontend/src/components/site-finder/ConnectionPointsLayer.tsx b/frontend/src/components/site-finder/ConnectionPointsLayer.tsx
index 847da34b..250b9fa0 100644
--- a/frontend/src/components/site-finder/ConnectionPointsLayer.tsx
+++ b/frontend/src/components/site-finder/ConnectionPointsLayer.tsx
@@ -239,6 +239,10 @@ export function ConnectionPointsLayer({
weight: 2,
}}
>
+ {/* Попап точки подключения (НСПД). Рендерим только имеющиеся
+ атрибуты — категория, название/тип, адрес, расстояние до
+ границы, источник. Тех-характеристик (напряжение/диаметр)
+ в НСПД-снимке нет → таких полей тут нет (не выдумываем). */}
{style.label}
+
+ Точка подключения (НСПД)
+
{s.name ?? s.type ?? "Объект"}
{s.type && s.name && (
- {s.type}
+ Тип: {s.type}
)}
{s.readable_address && (
@@ -278,7 +291,7 @@ export function ConnectionPointsLayer({
)}
- До границы:{" "}
+ До границы участка:{" "}
{Math.round(s.distance_to_boundary_m)} м
diff --git a/frontend/src/components/site-finder/UtilityInfrastructureLayer.tsx b/frontend/src/components/site-finder/UtilityInfrastructureLayer.tsx
index 6284d99f..95b31461 100644
--- a/frontend/src/components/site-finder/UtilityInfrastructureLayer.tsx
+++ b/frontend/src/components/site-finder/UtilityInfrastructureLayer.tsx
@@ -30,13 +30,7 @@ import type { UtilityInfrastructureFeature } from "@/hooks/useUtilityInfrastruct
// ---------------------------------------------------------------------------
export type UtilityKind =
- | "power"
- | "water"
- | "gas"
- | "heat"
- | "communication"
- | "sewage"
- | "other";
+ "power" | "water" | "gas" | "heat" | "communication" | "sewage" | "other";
export interface UtilityKindStyle {
color: string;
@@ -72,7 +66,9 @@ export const UTILITY_ALL_KINDS: UtilityKind[] = [
* Бэк отдаёт power/water/gas/heat/communication/sewage; неизвестное → "other"
* (graceful — слой не падает на новом виде).
*/
-export function classifyUtilityKind(raw: string | null | undefined): UtilityKind {
+export function classifyUtilityKind(
+ raw: string | null | undefined,
+): UtilityKind {
switch (raw) {
case "power":
case "water":
@@ -103,9 +99,7 @@ const LINE_POLY_TYPES = new Set
([
* будущей смены контракта). Возвращает Geometry либо null для пустого/невалидного
* ввода — фича тогда просто не рисуется (graceful).
*/
-export function parseUtilityGeometry(
- raw: unknown,
-): Geometry | null {
+export function parseUtilityGeometry(raw: unknown): Geometry | null {
if (raw === null || raw === undefined) return null;
let parsed: unknown = raw;
@@ -175,6 +169,13 @@ export function groupUtilityByKind(
// Popup (shared by point + line/polygon render paths)
// ---------------------------------------------------------------------------
+/**
+ * Попап объекта инж. инфраструктуры OSM. Показываем ТОЛЬКО имеющиеся атрибуты —
+ * вид (UTILITY_KIND_STYLES.label) + сырой infrastructure_kind / source_tag (то,
+ * что реально есть в osm_utility_infrastructure_ekb), название, расстояние до
+ * границы участка, источник (OSM osm_type/osm_id). Напряжения/диаметра в OSM нет
+ * — соответствующих полей нет в контракте и тут не выдумываются.
+ */
function UtilityPopup({
feature,
style,
@@ -182,6 +183,12 @@ function UtilityPopup({
feature: UtilityInfrastructureFeature;
style: UtilityKindStyle;
}) {
+ // Сырой вид/тег OSM показываем как уточнение, если он добавляет информацию
+ // сверх человеческой подписи (не дублирует kind).
+ const rawKind = feature.infrastructure_kind?.trim() || null;
+ const tag = feature.source_tag?.trim() || null;
+ const detail = tag ?? rawKind;
+
return (
@@ -206,18 +213,20 @@ function UtilityPopup({
/>
{style.label}
+
+ Инж. сеть (OSM)
+
{feature.name && (
{feature.name}
)}
- {feature.source_tag && (
-
- {feature.source_tag}
-
+ {detail && (
+ Тип: {detail}
)}
- До границы: {Math.round(feature.distance_m)} м
+ До границы участка:{" "}
+ {Math.round(feature.distance_m)} м
- Источник: OSM ({feature.osm_type} {feature.osm_id})
+ Источник: OpenStreetMap ({feature.osm_type} {feature.osm_id})
@@ -284,8 +293,7 @@ export function UtilityInfrastructureLayer({
// Заливка применяется только к полигонам; для линий fillOpacity
// игнорируется Leaflet, но weight даёт видимую ЛЭП-линию.
const isPolygon =
- geometry.type === "Polygon" ||
- geometry.type === "MultiPolygon";
+ geometry.type === "Polygon" || geometry.type === "MultiPolygon";
const geoFeature: Feature = {
type: "Feature",
geometry,
diff --git a/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx b/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx
index df06c67f..09bd4b9d 100644
--- a/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx
+++ b/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx
@@ -5,36 +5,45 @@
*
* Layout:
* HeadlineBar (title «Сети» + subtitle с расстояниями)
- * Utilities table: 4 rows × [Иконка / Тип / Расстояние / Количество / Статус]
+ * Utilities table: строка на каждый вид инж. сети × [Иконка / Тип /
+ * Ближайший / В радиусе / Статус]
* Warning banner if power_line_охранная_зона_flag === true
+ * Карта (точки подключения НСПД + инж.сети OSM + охранные зоны)
* Note (caveat text)
*
- * Data: useParcelAnalyzeQuery (B5) — field `utilities`.
- * If utilities absent from response — shows "нет данных" state gracefully.
+ * #1953 — УНИФИКАЦИЯ ИСТОЧНИКОВ: таблица «Сети» выводится из ТОГО ЖЕ источника,
+ * что карта — useUtilityInfrastructure → osm_utility_infrastructure_ekb (а НЕ
+ * из data.utilities/osm_noise_sources_ekb). Поэтому число в строке таблицы по
+ * каждому виду == число объектов этого вида на карте (один и тот же
+ * groupUtilityByKind, один и тот же радиус UTILITY_COVERAGE_RADIUS_M).
+ *
+ * Точки подключения (НСПД) — ОТДЕЛЬНАЯ сущность (useConnectionPoints), показаны
+ * отдельным подписанным слоем/счётчиком, чтобы «5 точек подключения» не путали
+ * с «инж.сети OSM».
+ *
+ * data.utilities используется ТОЛЬКО для флага охранной зоны ЛЭП
+ * (power_line_охранная_зона_flag) — отдельный safety-сигнал ЗОУИТ, которого нет
+ * в OSM-источнике.
*/
-import type { ReactNode } from "react";
import dynamic from "next/dynamic";
-import {
- Zap,
- Flame,
- Droplet,
- Pipette,
- AlertTriangle,
- Info,
- MapPin,
-} from "lucide-react";
+import { AlertTriangle, Info, MapPin, Network } from "lucide-react";
import type { Geometry } from "geojson";
import { HeadlineBar } from "@/components/ui/HeadlineBar";
import { NspdVerifyLink } from "./NspdVerifyLink";
import { useParcelAnalyzeQuery } from "@/lib/site-finder-api";
-import type {
- ParcelAnalyzeResponse,
- UtilitySummaryItem,
-} from "@/lib/site-finder-api";
+import type { ParcelAnalyzeResponse } from "@/lib/site-finder-api";
import type { ParcelAnalysis } from "@/types/site-finder";
-import { useConnectionPoints } from "@/hooks/useConnectionPoints";
-import { useUtilityInfrastructure } from "@/hooks/useUtilityInfrastructure";
+import {
+ useConnectionPoints,
+ type ConnectionPointsResponse,
+} from "@/hooks/useConnectionPoints";
+import {
+ useUtilityInfrastructure,
+ UTILITY_COVERAGE_RADIUS_M,
+ type UtilityInfrastructureResponse,
+} from "@/hooks/useUtilityInfrastructure";
+import { buildUtilityTableRows, type UtilityTableRow } from "./utility-table";
// ── Connection-points map (#1746) ─────────────────────────────────────────────
// Высота тайла карты для drill-in уровня раздела. Легенда + CpLayerControlPanel
@@ -71,213 +80,68 @@ const SiteMap = dynamic(
},
);
-// ── Subtype display config ────────────────────────────────────────────────────
+// ── Радиус «зоны охвата» (км) для подписей — тот же, что у карты/хука ──────────
-interface UtilityDisplayConfig {
- label: string;
- icon: ReactNode;
-}
-
-function getUtilityConfig(subtype: string): UtilityDisplayConfig {
- switch (subtype) {
- // ── Электричество ──
- case "substation":
- return {
- label: "Электричество (подстанция)",
- icon: (
-
- ),
- };
- case "transformer":
- return {
- label: "Электричество (трансформатор)",
- icon: (
-
- ),
- };
- case "power_line":
- return {
- label: "Электричество (ЛЭП)",
- icon: (
-
- ),
- };
- // ── Газ ──
- case "gas_pipeline":
- return {
- label: "Газ (газопровод)",
- icon: (
-
- ),
- };
- case "pipeline":
- return {
- label: "Трубопровод (без вещества)",
- icon: (
-
- ),
- };
- // ── Водопровод ──
- case "water_main":
- return {
- label: "Водопровод (магистраль)",
- icon: (
-
- ),
- };
- case "water_works":
- return {
- label: "Водопровод (водозабор)",
- icon: (
-
- ),
- };
- case "water_tower":
- return {
- label: "Водопровод (водонапорная башня)",
- icon: (
-
- ),
- };
- case "storage_tank":
- return {
- label: "Резервуар",
- icon: (
-
- ),
- };
- // ── Канализация ──
- case "sewerage":
- return {
- label: "Канализация (коллектор)",
- icon: (
-
- ),
- };
- case "wastewater_plant":
- return {
- label: "Канализация (очистные)",
- icon: (
-
- ),
- };
- // ── Теплоснабжение ──
- case "heat_substation":
- return {
- label: "Теплоснабжение (ЦТП)",
- icon: (
-
- ),
- };
- default:
- return {
- label: subtype,
- icon: (
-
- ),
- };
- }
-}
+const COVERAGE_RADIUS_KM = UTILITY_COVERAGE_RADIUS_M / 1000;
// ── Distance → presence status ────────────────────────────────────────────────
+// Все строки таблицы — объекты В радиусе охвата (UTILITY_COVERAGE_RADIUS_M),
+// поэтому отдельного «absent» статуса нет: статус отражает только близость
+// ближайшего объекта вида.
-type PresenceStatus = "close" | "reachable" | "far" | "absent";
+type PresenceStatus = "close" | "reachable" | "far";
function getPresenceStatus(nearestM: number): PresenceStatus {
if (nearestM <= 200) return "close";
if (nearestM <= 500) return "reachable";
- if (nearestM <= 1000) return "far";
- return "absent";
+ return "far";
}
const PRESENCE_LABEL: Record = {
- close: "В радиусе 200 м",
- reachable: "В радиусе 500 м",
- far: "В радиусе 1 км",
- absent: "Далее 1 км",
+ close: "≤ 200 м",
+ reachable: "≤ 500 м",
+ far: `до ${COVERAGE_RADIUS_KM} км`,
};
const PRESENCE_COLOR: Record = {
close: "var(--success)",
reachable: "var(--accent)",
far: "var(--warn)",
- absent: "var(--danger)",
};
const PRESENCE_BG: Record = {
close: "var(--success-soft)",
reachable: "var(--accent-soft)",
far: "var(--warn-soft)",
- absent: "var(--danger-soft)",
};
// ── HeadlineBar subtitle helper ───────────────────────────────────────────────
-function buildSubtitle(items: UtilitySummaryItem[]): string {
+/**
+ * Подзаголовок плашки «Сети»: ближайшие расстояния по ключевым видам (электро/
+ * газ/вода) из ТОГО ЖЕ источника, что таблица и карта (utility-table rows).
+ */
+function buildSubtitle(rows: UtilityTableRow[]): string {
const parts: string[] = [];
- const electric = items.find(
- (i) =>
- i.subtype === "substation" ||
- i.subtype === "transformer" ||
- i.subtype === "power_line",
- );
- const gas = items.find((i) => i.subtype === "gas_pipeline");
- const water = items.find(
- (i) =>
- i.subtype === "water_main" ||
- i.subtype === "water_works" ||
- i.subtype === "water_tower",
- );
+ const power = rows.find((r) => r.kind === "power");
+ const gas = rows.find((r) => r.kind === "gas");
+ const water = rows.find((r) => r.kind === "water");
- if (electric)
- parts.push(`электричество ${electric.nearest_m.toLocaleString("ru")} м`);
- if (gas) parts.push(`газ ${gas.nearest_m.toLocaleString("ru")} м`);
- if (water) parts.push(`водопровод ${water.nearest_m.toLocaleString("ru")} м`);
+ if (power?.nearestM != null)
+ parts.push(
+ `электричество ${Math.round(power.nearestM).toLocaleString("ru")} м`,
+ );
+ if (gas?.nearestM != null)
+ parts.push(`газ ${Math.round(gas.nearestM).toLocaleString("ru")} м`);
+ if (water?.nearestM != null)
+ parts.push(
+ `водопровод ${Math.round(water.nearestM).toLocaleString("ru")} м`,
+ );
return parts.length > 0
? parts.join(" · ")
- : "данные из НСПД об инженерной инфраструктуре";
+ : `инженерные сети OSM в радиусе ${COVERAGE_RADIUS_KM} км`;
}
// ── Loading skeleton ──────────────────────────────────────────────────────────
@@ -351,8 +215,9 @@ function Section2NoData() {
}}
>
- Данные НСПД об инженерной инфраструктуре не получены для этого участка.
- Проверьте наличие NSPD-снимка в базе.
+ Инженерные сети (OSM) и точки подключения (НСПД) не найдены в радиусе
+ охвата для этого участка. Проверьте наличие данных по кадастровому
+ кварталу в базе.
);
@@ -393,12 +258,18 @@ function Section2Error({ message }: { message: string }) {
}
// ── Utilities table ───────────────────────────────────────────────────────────
+//
+// #1953 — строки выводятся из buildUtilityTableRows(utilityInfrastructure.features),
+// т.е. из ТОГО ЖЕ источника и того же groupUtilityByKind, что рисует карта. Поэтому
+// столбец «В радиусе» по каждому виду == число объектов этого вида на карте.
+
+const GRID_COLS = "32px 1fr 120px 130px 130px";
interface UtilitiesTableProps {
- items: UtilitySummaryItem[];
+ rows: UtilityTableRow[];
}
-function UtilitiesTable({ items }: UtilitiesTableProps) {
+function UtilitiesTable({ rows }: UtilitiesTableProps) {
return (
- Тип объекта
+ Вид сети
- В 2 км
+ В {COVERAGE_RADIUS_KM} км
{/* Table rows */}
- {items.map((item, idx) => {
- const config = getUtilityConfig(item.subtype);
- const status = getPresenceStatus(item.nearest_m);
- const isLast = idx === items.length - 1;
+ {rows.map((row, idx) => {
+ const status =
+ row.nearestM != null ? getPresenceStatus(row.nearestM) : null;
+ const isLast = idx === rows.length - 1;
return (
- {/* Icon */}
+ {/* Color dot — совпадает с цветом вида на карте/в легенде */}
- {config.icon}
+
{/* Label */}
@@ -507,19 +388,7 @@ function UtilitiesTable({ items }: UtilitiesTableProps) {
fontWeight: 400,
}}
>
- {config.label}
- {item.name && (
-
- {item.name}
-
- )}
+ {row.label}
{/* Nearest distance */}
@@ -531,10 +400,12 @@ function UtilitiesTable({ items }: UtilitiesTableProps) {
textAlign: "right",
}}
>
- {item.nearest_m.toLocaleString("ru")} м
+ {row.nearestM != null
+ ? `${Math.round(row.nearestM).toLocaleString("ru")} м`
+ : "—"}
- {/* Count within 2km */}
+ {/* Count within coverage radius — == счётчик карты по этому виду */}
- {item.count_within_2km} шт.
+ {row.count.toLocaleString("ru")} шт.
{/* Status badge */}
-
- {PRESENCE_LABEL[status]}
-
+ {status && (
+
+ {PRESENCE_LABEL[status]}
+
+ )}
);
@@ -569,6 +442,88 @@ function UtilitiesTable({ items }: UtilitiesTableProps) {
);
}
+// ── Counters legend (явное разделение источников счётчиков) ────────────────────
+//
+// #1953 — чтобы «5 точек подключения» не путали с «инж.сети OSM», над картой
+// показываем два чётко подписанных счётчика с указанием источника и радиуса.
+
+interface SourceCountersProps {
+ utilityCount: number;
+ connectionPointCount: number;
+}
+
+function SourceCounters({
+ utilityCount,
+ connectionPointCount,
+}: SourceCountersProps) {
+ return (
+
+ {/* Инж. сети (OSM) — тот же счётчик, что в таблице и на карте */}
+
+
+
+ Инж. сети (OSM):{" "}
+
+ {utilityCount.toLocaleString("ru")}
+ {" "}
+ объ. в радиусе {COVERAGE_RADIUS_KM} км
+
+
+
+ {/* Точки подключения (НСПД) — отдельная сущность, отдельный счётчик */}
+
+
+
+ Точки подключения (НСПД):{" "}
+
+ {connectionPointCount.toLocaleString("ru")}
+ {" "}
+ по кадастровому кварталу
+
+
+
+ );
+}
+
// ── Connection-points map block (#1746) ───────────────────────────────────────
/**
@@ -599,23 +554,17 @@ function toCpMapData(data: ParcelAnalyzeResponse): ParcelAnalysis {
interface ConnectionPointsMapProps {
data: ParcelAnalyzeResponse;
+ connectionPoints: ConnectionPointsResponse | undefined;
+ utilityInfrastructure: UtilityInfrastructureResponse | undefined;
+ isLoading: boolean;
}
-function ConnectionPointsMap({ data }: ConnectionPointsMapProps) {
- // Точки подключения ресурсов (электро/газ/вода/тепло) по кварталу — тянем по
- // cad_num и пробрасываем в SiteMap, который рисует ConnectionPointsLayer +
- // CpLayerControlPanel при truthy prop `connectionPoints` (паттерн MiniMap.tsx).
- 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;
-
+function ConnectionPointsMap({
+ data,
+ connectionPoints,
+ utilityInfrastructure,
+ isLoading,
+}: ConnectionPointsMapProps) {
const hasPoints =
!!connectionPoints &&
connectionPoints.dump_available &&
@@ -659,10 +608,12 @@ function ConnectionPointsMap({ data }: ConnectionPointsMapProps) {
}}
>
Те же инженерные сети, что в таблице выше, показаны на карте: трассы и
- объекты из OpenStreetMap (ЛЭП, подстанции, трубопроводы) в радиусе 2 км
- — как столбец «В 2 км», плюс точки подключения и охранные зоны из снимка
- кадастрового квартала (НСПД). Карта показывает, где находятся эти точки
- и трассы, а не только расстояние до них. Слои переключаются под картой.
+ объекты из OpenStreetMap (ЛЭП, подстанции, трубопроводы) в радиусе{" "}
+ {COVERAGE_RADIUS_KM} км — как столбец «В {COVERAGE_RADIUS_KM} км» в
+ таблице. Отдельным слоем — точки подключения и охранные зоны из снимка
+ кадастрового квартала (НСПД): это разные источники, поэтому их счётчики
+ не совпадают. Карта показывает, где находятся эти точки и трассы, а не
+ только расстояние до них. Слои переключаются под картой.
{isLoading ? (
@@ -720,6 +671,15 @@ interface Props {
export function Section2NetworksUtilities({ cad }: Props) {
const { isLoading, isError, error, data } = useParcelAnalyzeQuery(cad);
+ // #1953 — ЕДИНЫЙ ИСТОЧНИК для таблицы И карты. Оба хука keyed по cad → TanStack
+ // дедуплицирует запрос, а таблица/карта/счётчики строятся из ОДНОГО результата,
+ // поэтому числа сходятся by construction.
+ const { data: utilityInfrastructure, isLoading: utilLoading } =
+ useUtilityInfrastructure(data?.cad_num);
+ const { data: connectionPoints, isLoading: cpLoading } = useConnectionPoints(
+ data?.cad_num,
+ );
+
if (isLoading) return ;
if (isError || !data) {
@@ -734,21 +694,37 @@ export function Section2NetworksUtilities({ cad }: Props) {
);
}
- const utilities = data.utilities;
+ const networksLoading = utilLoading || cpLoading;
- // No utilities data in response (NSPD snapshot absent)
- if (!utilities || utilities.summary.length === 0) {
+ // Таблица «Сети» — из ТОГО ЖЕ источника, что карта (osm_utility_infrastructure).
+ const tableRows = utilityInfrastructure
+ ? buildUtilityTableRows(utilityInfrastructure.features)
+ : [];
+ const utilityMappableCount = tableRows.reduce((sum, r) => sum + r.count, 0);
+
+ // Точки подключения (НСПД) — ОТДЕЛЬНАЯ сущность, отдельный счётчик. Считаем
+ // только при наличии снимка (dump_available), иначе 0.
+ const connectionPointCount = connectionPoints?.dump_available
+ ? connectionPoints.engineering_structures.length
+ : 0;
+
+ // Охранная зона ЛЭП — отдельный safety-флаг из analyze (data.utilities), его нет
+ // в OSM-источнике; держим как есть (#1953 не трогает этот сигнал).
+ const powerLineZone = data.utilities?.power_line_охранная_зона_flag ?? false;
+ const note = data.utilities?.note ?? null;
+
+ // Нет ни одной инж. сети (OSM) и ни одной точки подключения (НСПД), и загрузка
+ // завершена → graceful empty-state.
+ if (
+ !networksLoading &&
+ utilityMappableCount === 0 &&
+ connectionPointCount === 0
+ ) {
return ;
}
- const {
- summary,
- power_line_охранная_зона_flag: powerLineZone,
- note,
- } = utilities;
-
- // HeadlineBar subtitle: list distances for key utility types
- const headlineSubtitle = buildSubtitle(summary);
+ // HeadlineBar subtitle: ближайшие расстояния по ключевым видам из таблицы.
+ const headlineSubtitle = buildSubtitle(tableRows);
return (
)}
- {/* Utilities table */}
-
+ {/* Счётчики источников — явно разделяем «инж.сети OSM» и «точки
+ подключения НСПД», чтобы числа не путались (#1953). */}
+ {!networksLoading && (
+
+ )}
- {/* Карта точек подключения + охранных зон сетей (#1746) */}
-
+ {/* Utilities table — строки из osm_utility_infrastructure (= карта) */}
+ {networksLoading ? (
+
+ Загрузка инженерных сетей...
+
+ ) : tableRows.length > 0 ? (
+
+ ) : (
+
+
+ Инженерные сети (OSM) не найдены в радиусе {COVERAGE_RADIUS_KM} км.
+ {connectionPointCount > 0
+ ? " Точки подключения (НСПД) показаны на карте ниже."
+ : ""}
+
+ )}
+
+ {/* Карта точек подключения + охранных зон сетей + инж.сети OSM (#1746) */}
+
{/* Caveat note */}
{note && (
diff --git a/frontend/src/components/site-finder/analysis/__tests__/utility-table.test.ts b/frontend/src/components/site-finder/analysis/__tests__/utility-table.test.ts
new file mode 100644
index 00000000..5cf5e11c
--- /dev/null
+++ b/frontend/src/components/site-finder/analysis/__tests__/utility-table.test.ts
@@ -0,0 +1,146 @@
+/**
+ * #1953 — unit tests for buildUtilityTableRows (таблица §3 «Сети»).
+ *
+ * Ключевое свойство, которое проверяем: count в строке таблицы по каждому виду
+ * == число РИСУЕМЫХ объектов этого вида (тот же groupUtilityByKind, что у карты).
+ * Поэтому достаточно сравнить счётчики строк с прямой группировкой features —
+ * если они расходятся, счётчики таблица↔карта снова разъедутся (исходный баг).
+ *
+ * Также: nearest = минимум distance_m по рисуемым features вида; виды без
+ * рисуемых объектов опускаются; невалидная геометрия не считается.
+ */
+import type { UtilityInfrastructureFeature } from "@/hooks/useUtilityInfrastructure";
+import { groupUtilityByKind } from "../../UtilityInfrastructureLayer";
+import { buildUtilityTableRows } from "../utility-table";
+
+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: { type: "Point", coordinates: [60.6, 56.83] },
+ ...partial,
+ };
+}
+
+const POINT = { type: "Point", coordinates: [60.6, 56.83] };
+const LINE = {
+ type: "LineString",
+ coordinates: [
+ [60.6, 56.83],
+ [60.61, 56.84],
+ ],
+};
+
+describe("buildUtilityTableRows", () => {
+ it("row count per kind == groupUtilityByKind count (счётчики == карта)", () => {
+ const features = [
+ feat({
+ infrastructure_kind: "power",
+ geometry_geojson: LINE,
+ distance_m: 120,
+ }),
+ feat({
+ infrastructure_kind: "power",
+ geometry_geojson: POINT,
+ distance_m: 40,
+ }),
+ feat({
+ infrastructure_kind: "gas",
+ geometry_geojson: LINE,
+ distance_m: 300,
+ }),
+ feat({
+ infrastructure_kind: "water",
+ geometry_geojson: POINT,
+ distance_m: 800,
+ }),
+ ];
+
+ const rows = buildUtilityTableRows(features);
+ const grouped = groupUtilityByKind(features);
+
+ for (const row of rows) {
+ expect(row.count).toBe(grouped.get(row.kind)!.length);
+ }
+ // Сумма строк = число рисуемых объектов на карте.
+ const total = rows.reduce((s, r) => s + r.count, 0);
+ const mapTotal = [...grouped.values()].reduce((s, v) => s + v.length, 0);
+ expect(total).toBe(mapTotal);
+ });
+
+ it("nearestM = минимум distance_m по рисуемым features вида", () => {
+ const rows = buildUtilityTableRows([
+ feat({
+ infrastructure_kind: "power",
+ geometry_geojson: LINE,
+ distance_m: 120,
+ }),
+ feat({
+ infrastructure_kind: "power",
+ geometry_geojson: POINT,
+ distance_m: 40,
+ }),
+ ]);
+ const power = rows.find((r) => r.kind === "power");
+ expect(power?.count).toBe(2);
+ expect(power?.nearestM).toBe(40);
+ });
+
+ it("опускает виды без рисуемых объектов; порядок — power первым", () => {
+ const rows = buildUtilityTableRows([
+ feat({
+ infrastructure_kind: "gas",
+ geometry_geojson: LINE,
+ distance_m: 10,
+ }),
+ feat({
+ infrastructure_kind: "power",
+ geometry_geojson: LINE,
+ distance_m: 10,
+ }),
+ ]);
+ expect(rows.map((r) => r.kind)).toEqual(["power", "gas"]);
+ });
+
+ it("не считает features с невалидной геометрией (не рисуются → не в счётчике)", () => {
+ const rows = buildUtilityTableRows([
+ feat({
+ infrastructure_kind: "power",
+ geometry_geojson: LINE,
+ distance_m: 50,
+ }),
+ feat({
+ infrastructure_kind: "power",
+ geometry_geojson: {},
+ distance_m: 5,
+ }),
+ ]);
+ const power = rows.find((r) => r.kind === "power");
+ expect(power?.count).toBe(1);
+ // nearest НЕ 5 (тот объект не рисуется), а 50.
+ expect(power?.nearestM).toBe(50);
+ });
+
+ it("неизвестный вид → строка 'other' с человеческой подписью", () => {
+ const rows = buildUtilityTableRows([
+ feat({
+ infrastructure_kind: "telecom",
+ geometry_geojson: POINT,
+ distance_m: 200,
+ }),
+ ]);
+ expect(rows).toHaveLength(1);
+ expect(rows[0].kind).toBe("other");
+ expect(rows[0].label).toBe("Прочее");
+ });
+
+ it("пустой ввод → пустой массив (graceful empty-state)", () => {
+ expect(buildUtilityTableRows([])).toEqual([]);
+ });
+});
diff --git a/frontend/src/components/site-finder/analysis/utility-table.ts b/frontend/src/components/site-finder/analysis/utility-table.ts
new file mode 100644
index 00000000..292817ea
--- /dev/null
+++ b/frontend/src/components/site-finder/analysis/utility-table.ts
@@ -0,0 +1,74 @@
+/**
+ * utility-table — построение строк таблицы «Сети» §3 из ТОГО ЖЕ источника, что
+ * рисует карта (#1953 unify): osm_utility_infrastructure_ekb через
+ * useUtilityInfrastructure → groupUtilityByKind.
+ *
+ * Почему так: раньше таблица читала data.utilities (osm_noise_sources_ekb,
+ * агрегат by_subtype), а карта — osm_utility_infrastructure_ekb. Два разных
+ * источника → два разных счётчика («электричество 5 точек», а на карте 10+).
+ * Теперь строки таблицы выводятся из ровно тех же сгруппированных features, что
+ * рисует UtilityInfrastructureLayer, поэтому count в строке == число объектов
+ * этого вида на карте BY CONSTRUCTION (одна и та же Map).
+ *
+ * Расстояние per-kind считаем как минимум feature.distance_m среди РИСУЕМЫХ
+ * (валидная геометрия) features этого вида — не из summary.nearest_by_kind,
+ * который агрегирует по всем features (включая без геометрии), иначе строка
+ * могла бы показывать «ближайший 40 м», которого на карте нет.
+ *
+ * Поля напряжения / диаметра в OSM ОТСУТСТВУЮТ — здесь их нет и не выдумываем.
+ */
+
+import type { UtilityInfrastructureFeature } from "@/hooks/useUtilityInfrastructure";
+import {
+ UTILITY_ALL_KINDS,
+ UTILITY_KIND_STYLES,
+ groupUtilityByKind,
+ type UtilityKind,
+} from "@/components/site-finder/UtilityInfrastructureLayer";
+
+export interface UtilityTableRow {
+ kind: UtilityKind;
+ /** Человеческая подпись вида (электричество/газ/…) — из UTILITY_KIND_STYLES. */
+ label: string;
+ /** token-hex цвета вида (тот же, что dot на карте/в легенде). */
+ color: string;
+ /** Кол-во РИСУЕМЫХ объектов этого вида в радиусе — == счётчик карты. */
+ count: number;
+ /** Ближайшее расстояние, м (минимум по рисуемым features). null — нет объектов. */
+ nearestM: number | null;
+}
+
+/**
+ * Из ответа useUtilityInfrastructure строит строки таблицы «Сети» — по одной на
+ * каждый непустой UtilityKind, в порядке UTILITY_ALL_KINDS (power первым).
+ * Виды без рисуемых объектов опускаются (graceful — пустая таблица, если совсем
+ * ничего; вызывающий показывает empty-state).
+ */
+export function buildUtilityTableRows(
+ features: UtilityInfrastructureFeature[],
+): UtilityTableRow[] {
+ const grouped = groupUtilityByKind(features);
+ const rows: UtilityTableRow[] = [];
+
+ for (const kind of UTILITY_ALL_KINDS) {
+ const items = grouped.get(kind) ?? [];
+ if (items.length === 0) continue;
+
+ let nearestM: number | null = null;
+ for (const { feature } of items) {
+ const d = feature.distance_m;
+ if (typeof d !== "number" || Number.isNaN(d)) continue;
+ if (nearestM === null || d < nearestM) nearestM = d;
+ }
+
+ rows.push({
+ kind,
+ label: UTILITY_KIND_STYLES[kind].label,
+ color: UTILITY_KIND_STYLES[kind].color,
+ count: items.length,
+ nearestM,
+ });
+ }
+
+ return rows;
+}