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; +}