fix(site-finder): unify §3 «Сети» table source with map + enrich popups (#1953)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 57s
CI / openapi-codegen-check (pull_request) Successful in 1m48s

Из live-walkthrough Anton'а (66:41:0702017:131, #2 «счётчики не сходятся»).
Таблица «Сети» теперь выводится из osm_utility_infrastructure (useUtilityInfrastructure
→ groupUtilityByKind), как карта, а НЕ из data.utilities/osm_noise_sources →
счётчики таблицы == счётчики карты by construction (+тест-инвариант). Точки
подключения (НСПД) разведены в отдельный подписанный счётчик/слой. Попапы OSM-сетей
и НСПД-точек обогащены имеющимися атрибутами (тип/название/расстояние/источник;
напряжение/диаметр в OSM/НСПД отсутствуют — не выдумываем). 191-ФЗ engineering_networks
пуста — не используется. Бэкенд не тронут (data.utilities остался для ЛЭП-caveat).

Edit-only. +6 unit-тестов (utility-table инвариант), site-finder suite 97/97.
This commit is contained in:
Light1YT 2026-06-29 16:53:06 +05:00
parent 1337fadb49
commit 48148c9627
5 changed files with 561 additions and 292 deletions

View file

@ -239,6 +239,10 @@ export function ConnectionPointsLayer({
weight: 2,
}}
>
{/* Попап точки подключения (НСПД). Рендерим только имеющиеся
атрибуты категория, название/тип, адрес, расстояние до
границы, источник. Тех-характеристик (напряжение/диаметр)
в НСПД-снимке нет таких полей тут нет (не выдумываем). */}
<Popup>
<div
style={{ fontSize: 12, lineHeight: 1.55, minWidth: 180 }}
@ -264,12 +268,21 @@ export function ConnectionPointsLayer({
/>
{style.label}
</div>
<div
style={{
color: "#6b7280",
marginBottom: 4,
fontSize: 11,
}}
>
Точка подключения (НСПД)
</div>
<div style={{ marginBottom: 2 }}>
<strong>{s.name ?? s.type ?? "Объект"}</strong>
</div>
{s.type && s.name && (
<div style={{ color: "#6b7280", marginBottom: 2 }}>
{s.type}
Тип: {s.type}
</div>
)}
{s.readable_address && (
@ -278,7 +291,7 @@ export function ConnectionPointsLayer({
</div>
)}
<div style={{ marginTop: 4, color: "#374151" }}>
До границы:{" "}
До границы участка:{" "}
<strong>
{Math.round(s.distance_to_boundary_m)} м
</strong>

View file

@ -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["type"]>([
* будущей смены контракта). Возвращает 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 (
<Popup>
<div style={{ fontSize: 12, lineHeight: 1.55, minWidth: 180 }}>
@ -206,18 +213,20 @@ function UtilityPopup({
/>
{style.label}
</div>
<div style={{ color: "#6b7280", marginBottom: 4, fontSize: 11 }}>
Инж. сеть (OSM)
</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>
{detail && (
<div style={{ color: "#6b7280", marginBottom: 2 }}>Тип: {detail}</div>
)}
<div style={{ marginTop: 4, color: "#374151" }}>
До границы: <strong>{Math.round(feature.distance_m)} м</strong>
До границы участка:{" "}
<strong>{Math.round(feature.distance_m)} м</strong>
</div>
<div
style={{
@ -228,7 +237,7 @@ function UtilityPopup({
paddingTop: 4,
}}
>
Источник: OSM ({feature.osm_type} {feature.osm_id})
Источник: OpenStreetMap ({feature.osm_type} {feature.osm_id})
</div>
</div>
</Popup>
@ -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,

View file

@ -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: (
<Zap size={16} strokeWidth={1.5} style={{ color: "var(--viz-4)" }} />
),
};
case "transformer":
return {
label: "Электричество (трансформатор)",
icon: (
<Zap size={16} strokeWidth={1.5} style={{ color: "var(--viz-4)" }} />
),
};
case "power_line":
return {
label: "Электричество (ЛЭП)",
icon: (
<Zap size={16} strokeWidth={1.5} style={{ color: "var(--viz-4)" }} />
),
};
// ── Газ ──
case "gas_pipeline":
return {
label: "Газ (газопровод)",
icon: (
<Flame
size={16}
strokeWidth={1.5}
style={{ color: "var(--viz-4)" }}
/>
),
};
case "pipeline":
return {
label: "Трубопровод (без вещества)",
icon: (
<Flame
size={16}
strokeWidth={1.5}
style={{ color: "var(--viz-4)" }}
/>
),
};
// ── Водопровод ──
case "water_main":
return {
label: "Водопровод (магистраль)",
icon: (
<Droplet
size={16}
strokeWidth={1.5}
style={{ color: "var(--viz-2)" }}
/>
),
};
case "water_works":
return {
label: "Водопровод (водозабор)",
icon: (
<Droplet
size={16}
strokeWidth={1.5}
style={{ color: "var(--viz-2)" }}
/>
),
};
case "water_tower":
return {
label: "Водопровод (водонапорная башня)",
icon: (
<Droplet
size={16}
strokeWidth={1.5}
style={{ color: "var(--viz-2)" }}
/>
),
};
case "storage_tank":
return {
label: "Резервуар",
icon: (
<Droplet
size={16}
strokeWidth={1.5}
style={{ color: "var(--viz-2)" }}
/>
),
};
// ── Канализация ──
case "sewerage":
return {
label: "Канализация (коллектор)",
icon: (
<Pipette
size={16}
strokeWidth={1.5}
style={{ color: "var(--fg-secondary)" }}
/>
),
};
case "wastewater_plant":
return {
label: "Канализация (очистные)",
icon: (
<Pipette
size={16}
strokeWidth={1.5}
style={{ color: "var(--fg-secondary)" }}
/>
),
};
// ── Теплоснабжение ──
case "heat_substation":
return {
label: "Теплоснабжение (ЦТП)",
icon: (
<Flame
size={16}
strokeWidth={1.5}
style={{ color: "var(--viz-4)" }}
/>
),
};
default:
return {
label: subtype,
icon: (
<Info
size={16}
strokeWidth={1.5}
style={{ color: "var(--fg-tertiary)" }}
/>
),
};
}
}
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<PresenceStatus, string> = {
close: "В радиусе 200 м",
reachable: "В радиусе 500 м",
far: "В радиусе 1 км",
absent: "Далее 1 км",
close: "≤ 200 м",
reachable: "≤ 500 м",
far: `до ${COVERAGE_RADIUS_KM} км`,
};
const PRESENCE_COLOR: Record<PresenceStatus, string> = {
close: "var(--success)",
reachable: "var(--accent)",
far: "var(--warn)",
absent: "var(--danger)",
};
const PRESENCE_BG: Record<PresenceStatus, string> = {
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() {
}}
>
<Info size={16} strokeWidth={1.5} style={{ flexShrink: 0 }} />
Данные НСПД об инженерной инфраструктуре не получены для этого участка.
Проверьте наличие NSPD-снимка в базе.
Инженерные сети (OSM) и точки подключения (НСПД) не найдены в радиусе
охвата для этого участка. Проверьте наличие данных по кадастровому
кварталу в базе.
</div>
</section>
);
@ -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 (
<div
style={{
@ -411,7 +282,7 @@ function UtilitiesTable({ items }: UtilitiesTableProps) {
<div
style={{
display: "grid",
gridTemplateColumns: "32px 1fr 120px 110px 140px",
gridTemplateColumns: GRID_COLS,
gap: 0,
background: "var(--bg-card-alt)",
borderBottom: "1px solid var(--border-soft)",
@ -429,7 +300,7 @@ function UtilitiesTable({ items }: UtilitiesTableProps) {
letterSpacing: "0.04em",
}}
>
Тип объекта
Вид сети
</div>
<div
style={{
@ -453,7 +324,7 @@ function UtilitiesTable({ items }: UtilitiesTableProps) {
textAlign: "right",
}}
>
В 2 км
В {COVERAGE_RADIUS_KM} км
</div>
<div
style={{
@ -470,17 +341,17 @@ function UtilitiesTable({ items }: UtilitiesTableProps) {
</div>
{/* 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 (
<div
key={`${item.subtype}-${idx}`}
key={row.kind}
style={{
display: "grid",
gridTemplateColumns: "32px 1fr 120px 110px 140px",
gridTemplateColumns: GRID_COLS,
gap: 0,
padding: "10px 16px",
alignItems: "center",
@ -488,7 +359,7 @@ function UtilitiesTable({ items }: UtilitiesTableProps) {
background: "var(--bg-card)",
}}
>
{/* Icon */}
{/* Color dot — совпадает с цветом вида на карте/в легенде */}
<div
style={{
display: "flex",
@ -496,7 +367,17 @@ function UtilitiesTable({ items }: UtilitiesTableProps) {
justifyContent: "flex-start",
}}
>
{config.icon}
<span
aria-hidden
style={{
display: "inline-block",
width: 10,
height: 10,
borderRadius: "50%",
background: row.color,
flexShrink: 0,
}}
/>
</div>
{/* Label */}
@ -507,19 +388,7 @@ function UtilitiesTable({ items }: UtilitiesTableProps) {
fontWeight: 400,
}}
>
{config.label}
{item.name && (
<span
style={{
display: "block",
fontSize: 12,
color: "var(--fg-tertiary)",
marginTop: 2,
}}
>
{item.name}
</span>
)}
{row.label}
</div>
{/* 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")} м`
: "—"}
</div>
{/* Count within 2km */}
{/* Count within coverage radius — == счётчик карты по этому виду */}
<div
style={{
fontSize: 14,
@ -543,24 +414,26 @@ function UtilitiesTable({ items }: UtilitiesTableProps) {
textAlign: "right",
}}
>
{item.count_within_2km} шт.
{row.count.toLocaleString("ru")} шт.
</div>
{/* Status badge */}
<div style={{ textAlign: "right" }}>
<span
style={{
display: "inline-block",
padding: "2px 8px",
borderRadius: 6,
fontSize: 12,
fontWeight: 500,
color: PRESENCE_COLOR[status],
background: PRESENCE_BG[status],
}}
>
{PRESENCE_LABEL[status]}
</span>
{status && (
<span
style={{
display: "inline-block",
padding: "2px 8px",
borderRadius: 6,
fontSize: 12,
fontWeight: 500,
color: PRESENCE_COLOR[status],
background: PRESENCE_BG[status],
}}
>
{PRESENCE_LABEL[status]}
</span>
)}
</div>
</div>
);
@ -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 (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 8,
marginBottom: 12,
}}
>
{/* Инж. сети (OSM) — тот же счётчик, что в таблице и на карте */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 12px",
background: "var(--bg-card-alt)",
border: "1px solid var(--border-card)",
borderRadius: 8,
fontSize: 12,
color: "var(--fg-secondary)",
}}
>
<Network
size={16}
strokeWidth={1.5}
style={{ color: "var(--viz-4)", flexShrink: 0 }}
/>
<span>
Инж. сети (OSM):{" "}
<strong style={{ color: "var(--fg-primary)" }}>
{utilityCount.toLocaleString("ru")}
</strong>{" "}
объ. в радиусе {COVERAGE_RADIUS_KM} км
</span>
</div>
{/* Точки подключения (НСПД) — отдельная сущность, отдельный счётчик */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 12px",
background: "var(--bg-card-alt)",
border: "1px solid var(--border-card)",
borderRadius: 8,
fontSize: 12,
color: "var(--fg-secondary)",
}}
>
<MapPin
size={16}
strokeWidth={1.5}
style={{ color: "var(--accent)", flexShrink: 0 }}
/>
<span>
Точки подключения (НСПД):{" "}
<strong style={{ color: "var(--fg-primary)" }}>
{connectionPointCount.toLocaleString("ru")}
</strong>{" "}
по кадастровому кварталу
</span>
</div>
</div>
);
}
// ── 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} км» в
таблице. Отдельным слоем точки подключения и охранные зоны из снимка
кадастрового квартала (НСПД): это разные источники, поэтому их счётчики
не совпадают. Карта показывает, где находятся эти точки и трассы, а не
только расстояние до них. Слои переключаются под картой.
</p>
{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 <Section2Skeleton />;
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 <Section2NoData />;
}
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 (
<section
@ -824,11 +800,63 @@ export function Section2NetworksUtilities({ cad }: Props) {
</div>
)}
{/* Utilities table */}
<UtilitiesTable items={summary} />
{/* Счётчики источников явно разделяем «инж.сети OSM» и «точки
подключения НСПД», чтобы числа не путались (#1953). */}
{!networksLoading && (
<SourceCounters
utilityCount={utilityMappableCount}
connectionPointCount={connectionPointCount}
/>
)}
{/* Карта точек подключения + охранных зон сетей (#1746) */}
<ConnectionPointsMap data={data} />
{/* Utilities table — строки из osm_utility_infrastructure (= карта) */}
{networksLoading ? (
<div
style={{
height: 160,
background: "var(--bg-card-alt)",
borderRadius: 10,
border: "1px solid var(--border-card)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--fg-tertiary)",
fontSize: 13,
}}
>
Загрузка инженерных сетей...
</div>
) : tableRows.length > 0 ? (
<UtilitiesTable rows={tableRows} />
) : (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "16px",
background: "var(--bg-card-alt)",
border: "1px dashed var(--border-strong)",
borderRadius: 10,
color: "var(--fg-tertiary)",
fontSize: 13,
}}
>
<Info size={16} strokeWidth={1.5} style={{ flexShrink: 0 }} />
Инженерные сети (OSM) не найдены в радиусе {COVERAGE_RADIUS_KM} км.
{connectionPointCount > 0
? " Точки подключения (НСПД) показаны на карте ниже."
: ""}
</div>
)}
{/* Карта точек подключения + охранных зон сетей + инж.сети OSM (#1746) */}
<ConnectionPointsMap
data={data}
connectionPoints={connectionPoints}
utilityInfrastructure={utilityInfrastructure}
isLoading={networksLoading}
/>
{/* Caveat note */}
{note && (

View file

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

View file

@ -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<UtilityKind, >).
*
* Расстояние 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;
}