fix(site-finder): unify §3 «Сети» table↔map source + enrich popups (#1953, batch C) (#2097)
All checks were successful
Deploy / build-backend (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m4s
Deploy / changes (push) Successful in 7s
Deploy / build-worker (push) Has been skipped
Deploy / deploy (push) Successful in 1m21s

This commit is contained in:
bot-backend 2026-06-29 11:56:11 +00:00
parent 1337fadb49
commit d9915c7834
5 changed files with 561 additions and 292 deletions

View file

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

View file

@ -30,13 +30,7 @@ import type { UtilityInfrastructureFeature } from "@/hooks/useUtilityInfrastruct
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export type UtilityKind = export type UtilityKind =
| "power" "power" | "water" | "gas" | "heat" | "communication" | "sewage" | "other";
| "water"
| "gas"
| "heat"
| "communication"
| "sewage"
| "other";
export interface UtilityKindStyle { export interface UtilityKindStyle {
color: string; color: string;
@ -72,7 +66,9 @@ export const UTILITY_ALL_KINDS: UtilityKind[] = [
* Бэк отдаёт power/water/gas/heat/communication/sewage; неизвестное "other" * Бэк отдаёт power/water/gas/heat/communication/sewage; неизвестное "other"
* (graceful слой не падает на новом виде). * (graceful слой не падает на новом виде).
*/ */
export function classifyUtilityKind(raw: string | null | undefined): UtilityKind { export function classifyUtilityKind(
raw: string | null | undefined,
): UtilityKind {
switch (raw) { switch (raw) {
case "power": case "power":
case "water": case "water":
@ -103,9 +99,7 @@ const LINE_POLY_TYPES = new Set<Geometry["type"]>([
* будущей смены контракта). Возвращает Geometry либо null для пустого/невалидного * будущей смены контракта). Возвращает Geometry либо null для пустого/невалидного
* ввода фича тогда просто не рисуется (graceful). * ввода фича тогда просто не рисуется (graceful).
*/ */
export function parseUtilityGeometry( export function parseUtilityGeometry(raw: unknown): Geometry | null {
raw: unknown,
): Geometry | null {
if (raw === null || raw === undefined) return null; if (raw === null || raw === undefined) return null;
let parsed: unknown = raw; let parsed: unknown = raw;
@ -175,6 +169,13 @@ export function groupUtilityByKind(
// Popup (shared by point + line/polygon render paths) // 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({ function UtilityPopup({
feature, feature,
style, style,
@ -182,6 +183,12 @@ function UtilityPopup({
feature: UtilityInfrastructureFeature; feature: UtilityInfrastructureFeature;
style: UtilityKindStyle; style: UtilityKindStyle;
}) { }) {
// Сырой вид/тег OSM показываем как уточнение, если он добавляет информацию
// сверх человеческой подписи (не дублирует kind).
const rawKind = feature.infrastructure_kind?.trim() || null;
const tag = feature.source_tag?.trim() || null;
const detail = tag ?? rawKind;
return ( return (
<Popup> <Popup>
<div style={{ fontSize: 12, lineHeight: 1.55, minWidth: 180 }}> <div style={{ fontSize: 12, lineHeight: 1.55, minWidth: 180 }}>
@ -206,18 +213,20 @@ function UtilityPopup({
/> />
{style.label} {style.label}
</div> </div>
<div style={{ color: "#6b7280", marginBottom: 4, fontSize: 11 }}>
Инж. сеть (OSM)
</div>
{feature.name && ( {feature.name && (
<div style={{ marginBottom: 2 }}> <div style={{ marginBottom: 2 }}>
<strong>{feature.name}</strong> <strong>{feature.name}</strong>
</div> </div>
)} )}
{feature.source_tag && ( {detail && (
<div style={{ color: "#6b7280", marginBottom: 2 }}> <div style={{ color: "#6b7280", marginBottom: 2 }}>Тип: {detail}</div>
{feature.source_tag}
</div>
)} )}
<div style={{ marginTop: 4, color: "#374151" }}> <div style={{ marginTop: 4, color: "#374151" }}>
До границы: <strong>{Math.round(feature.distance_m)} м</strong> До границы участка:{" "}
<strong>{Math.round(feature.distance_m)} м</strong>
</div> </div>
<div <div
style={{ style={{
@ -228,7 +237,7 @@ function UtilityPopup({
paddingTop: 4, paddingTop: 4,
}} }}
> >
Источник: OSM ({feature.osm_type} {feature.osm_id}) Источник: OpenStreetMap ({feature.osm_type} {feature.osm_id})
</div> </div>
</div> </div>
</Popup> </Popup>
@ -284,8 +293,7 @@ export function UtilityInfrastructureLayer({
// Заливка применяется только к полигонам; для линий fillOpacity // Заливка применяется только к полигонам; для линий fillOpacity
// игнорируется Leaflet, но weight даёт видимую ЛЭП-линию. // игнорируется Leaflet, но weight даёт видимую ЛЭП-линию.
const isPolygon = const isPolygon =
geometry.type === "Polygon" || geometry.type === "Polygon" || geometry.type === "MultiPolygon";
geometry.type === "MultiPolygon";
const geoFeature: Feature = { const geoFeature: Feature = {
type: "Feature", type: "Feature",
geometry, geometry,

View file

@ -5,36 +5,45 @@
* *
* Layout: * Layout:
* HeadlineBar (title «Сети» + subtitle с расстояниями) * HeadlineBar (title «Сети» + subtitle с расстояниями)
* Utilities table: 4 rows × [Иконка / Тип / Расстояние / Количество / Статус] * Utilities table: строка на каждый вид инж. сети × [Иконка / Тип /
* Ближайший / В радиусе / Статус]
* Warning banner if power_line_охранная_зона_flag === true * Warning banner if power_line_охранная_зона_flag === true
* Карта (точки подключения НСПД + инж.сети OSM + охранные зоны)
* Note (caveat text) * Note (caveat text)
* *
* Data: useParcelAnalyzeQuery (B5) field `utilities`. * #1953 УНИФИКАЦИЯ ИСТОЧНИКОВ: таблица «Сети» выводится из ТОГО ЖЕ источника,
* If utilities absent from response shows "нет данных" state gracefully. * что карта 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 dynamic from "next/dynamic";
import { import { AlertTriangle, Info, MapPin, Network } from "lucide-react";
Zap,
Flame,
Droplet,
Pipette,
AlertTriangle,
Info,
MapPin,
} from "lucide-react";
import type { Geometry } from "geojson"; import type { Geometry } from "geojson";
import { HeadlineBar } from "@/components/ui/HeadlineBar"; import { HeadlineBar } from "@/components/ui/HeadlineBar";
import { NspdVerifyLink } from "./NspdVerifyLink"; import { NspdVerifyLink } from "./NspdVerifyLink";
import { useParcelAnalyzeQuery } from "@/lib/site-finder-api"; import { useParcelAnalyzeQuery } from "@/lib/site-finder-api";
import type { import type { ParcelAnalyzeResponse } from "@/lib/site-finder-api";
ParcelAnalyzeResponse,
UtilitySummaryItem,
} from "@/lib/site-finder-api";
import type { ParcelAnalysis } from "@/types/site-finder"; import type { ParcelAnalysis } from "@/types/site-finder";
import { useConnectionPoints } from "@/hooks/useConnectionPoints"; import {
import { useUtilityInfrastructure } from "@/hooks/useUtilityInfrastructure"; 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) ───────────────────────────────────────────── // ── Connection-points map (#1746) ─────────────────────────────────────────────
// Высота тайла карты для drill-in уровня раздела. Легенда + CpLayerControlPanel // Высота тайла карты для drill-in уровня раздела. Легенда + CpLayerControlPanel
@ -71,213 +80,68 @@ const SiteMap = dynamic(
}, },
); );
// ── Subtype display config ──────────────────────────────────────────────────── // ── Радиус «зоны охвата» (км) для подписей — тот же, что у карты/хука ──────────
interface UtilityDisplayConfig { const COVERAGE_RADIUS_KM = UTILITY_COVERAGE_RADIUS_M / 1000;
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)" }}
/>
),
};
}
}
// ── Distance → presence status ──────────────────────────────────────────────── // ── Distance → presence status ────────────────────────────────────────────────
// Все строки таблицы — объекты В радиусе охвата (UTILITY_COVERAGE_RADIUS_M),
// поэтому отдельного «absent» статуса нет: статус отражает только близость
// ближайшего объекта вида.
type PresenceStatus = "close" | "reachable" | "far" | "absent"; type PresenceStatus = "close" | "reachable" | "far";
function getPresenceStatus(nearestM: number): PresenceStatus { function getPresenceStatus(nearestM: number): PresenceStatus {
if (nearestM <= 200) return "close"; if (nearestM <= 200) return "close";
if (nearestM <= 500) return "reachable"; if (nearestM <= 500) return "reachable";
if (nearestM <= 1000) return "far"; return "far";
return "absent";
} }
const PRESENCE_LABEL: Record<PresenceStatus, string> = { const PRESENCE_LABEL: Record<PresenceStatus, string> = {
close: "В радиусе 200 м", close: "≤ 200 м",
reachable: "В радиусе 500 м", reachable: "≤ 500 м",
far: "В радиусе 1 км", far: `до ${COVERAGE_RADIUS_KM} км`,
absent: "Далее 1 км",
}; };
const PRESENCE_COLOR: Record<PresenceStatus, string> = { const PRESENCE_COLOR: Record<PresenceStatus, string> = {
close: "var(--success)", close: "var(--success)",
reachable: "var(--accent)", reachable: "var(--accent)",
far: "var(--warn)", far: "var(--warn)",
absent: "var(--danger)",
}; };
const PRESENCE_BG: Record<PresenceStatus, string> = { const PRESENCE_BG: Record<PresenceStatus, string> = {
close: "var(--success-soft)", close: "var(--success-soft)",
reachable: "var(--accent-soft)", reachable: "var(--accent-soft)",
far: "var(--warn-soft)", far: "var(--warn-soft)",
absent: "var(--danger-soft)",
}; };
// ── HeadlineBar subtitle helper ─────────────────────────────────────────────── // ── HeadlineBar subtitle helper ───────────────────────────────────────────────
function buildSubtitle(items: UtilitySummaryItem[]): string { /**
* Подзаголовок плашки «Сети»: ближайшие расстояния по ключевым видам (электро/
* газ/вода) из ТОГО ЖЕ источника, что таблица и карта (utility-table rows).
*/
function buildSubtitle(rows: UtilityTableRow[]): string {
const parts: string[] = []; const parts: string[] = [];
const electric = items.find( const power = rows.find((r) => r.kind === "power");
(i) => const gas = rows.find((r) => r.kind === "gas");
i.subtype === "substation" || const water = rows.find((r) => r.kind === "water");
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",
);
if (electric) if (power?.nearestM != null)
parts.push(`электричество ${electric.nearest_m.toLocaleString("ru")} м`); parts.push(
if (gas) parts.push(`газ ${gas.nearest_m.toLocaleString("ru")} м`); `электричество ${Math.round(power.nearestM).toLocaleString("ru")} м`,
if (water) parts.push(`водопровод ${water.nearest_m.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 return parts.length > 0
? parts.join(" · ") ? parts.join(" · ")
: "данные из НСПД об инженерной инфраструктуре"; : `инженерные сети OSM в радиусе ${COVERAGE_RADIUS_KM} км`;
} }
// ── Loading skeleton ────────────────────────────────────────────────────────── // ── Loading skeleton ──────────────────────────────────────────────────────────
@ -351,8 +215,9 @@ function Section2NoData() {
}} }}
> >
<Info size={16} strokeWidth={1.5} style={{ flexShrink: 0 }} /> <Info size={16} strokeWidth={1.5} style={{ flexShrink: 0 }} />
Данные НСПД об инженерной инфраструктуре не получены для этого участка. Инженерные сети (OSM) и точки подключения (НСПД) не найдены в радиусе
Проверьте наличие NSPD-снимка в базе. охвата для этого участка. Проверьте наличие данных по кадастровому
кварталу в базе.
</div> </div>
</section> </section>
); );
@ -393,12 +258,18 @@ function Section2Error({ message }: { message: string }) {
} }
// ── Utilities table ─────────────────────────────────────────────────────────── // ── Utilities table ───────────────────────────────────────────────────────────
//
// #1953 — строки выводятся из buildUtilityTableRows(utilityInfrastructure.features),
// т.е. из ТОГО ЖЕ источника и того же groupUtilityByKind, что рисует карта. Поэтому
// столбец «В радиусе» по каждому виду == число объектов этого вида на карте.
const GRID_COLS = "32px 1fr 120px 130px 130px";
interface UtilitiesTableProps { interface UtilitiesTableProps {
items: UtilitySummaryItem[]; rows: UtilityTableRow[];
} }
function UtilitiesTable({ items }: UtilitiesTableProps) { function UtilitiesTable({ rows }: UtilitiesTableProps) {
return ( return (
<div <div
style={{ style={{
@ -411,7 +282,7 @@ function UtilitiesTable({ items }: UtilitiesTableProps) {
<div <div
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: "32px 1fr 120px 110px 140px", gridTemplateColumns: GRID_COLS,
gap: 0, gap: 0,
background: "var(--bg-card-alt)", background: "var(--bg-card-alt)",
borderBottom: "1px solid var(--border-soft)", borderBottom: "1px solid var(--border-soft)",
@ -429,7 +300,7 @@ function UtilitiesTable({ items }: UtilitiesTableProps) {
letterSpacing: "0.04em", letterSpacing: "0.04em",
}} }}
> >
Тип объекта Вид сети
</div> </div>
<div <div
style={{ style={{
@ -453,7 +324,7 @@ function UtilitiesTable({ items }: UtilitiesTableProps) {
textAlign: "right", textAlign: "right",
}} }}
> >
В 2 км В {COVERAGE_RADIUS_KM} км
</div> </div>
<div <div
style={{ style={{
@ -470,17 +341,17 @@ function UtilitiesTable({ items }: UtilitiesTableProps) {
</div> </div>
{/* Table rows */} {/* Table rows */}
{items.map((item, idx) => { {rows.map((row, idx) => {
const config = getUtilityConfig(item.subtype); const status =
const status = getPresenceStatus(item.nearest_m); row.nearestM != null ? getPresenceStatus(row.nearestM) : null;
const isLast = idx === items.length - 1; const isLast = idx === rows.length - 1;
return ( return (
<div <div
key={`${item.subtype}-${idx}`} key={row.kind}
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: "32px 1fr 120px 110px 140px", gridTemplateColumns: GRID_COLS,
gap: 0, gap: 0,
padding: "10px 16px", padding: "10px 16px",
alignItems: "center", alignItems: "center",
@ -488,7 +359,7 @@ function UtilitiesTable({ items }: UtilitiesTableProps) {
background: "var(--bg-card)", background: "var(--bg-card)",
}} }}
> >
{/* Icon */} {/* Color dot — совпадает с цветом вида на карте/в легенде */}
<div <div
style={{ style={{
display: "flex", display: "flex",
@ -496,7 +367,17 @@ function UtilitiesTable({ items }: UtilitiesTableProps) {
justifyContent: "flex-start", justifyContent: "flex-start",
}} }}
> >
{config.icon} <span
aria-hidden
style={{
display: "inline-block",
width: 10,
height: 10,
borderRadius: "50%",
background: row.color,
flexShrink: 0,
}}
/>
</div> </div>
{/* Label */} {/* Label */}
@ -507,19 +388,7 @@ function UtilitiesTable({ items }: UtilitiesTableProps) {
fontWeight: 400, fontWeight: 400,
}} }}
> >
{config.label} {row.label}
{item.name && (
<span
style={{
display: "block",
fontSize: 12,
color: "var(--fg-tertiary)",
marginTop: 2,
}}
>
{item.name}
</span>
)}
</div> </div>
{/* Nearest distance */} {/* Nearest distance */}
@ -531,10 +400,12 @@ function UtilitiesTable({ items }: UtilitiesTableProps) {
textAlign: "right", textAlign: "right",
}} }}
> >
{item.nearest_m.toLocaleString("ru")} м {row.nearestM != null
? `${Math.round(row.nearestM).toLocaleString("ru")} м`
: "—"}
</div> </div>
{/* Count within 2km */} {/* Count within coverage radius — == счётчик карты по этому виду */}
<div <div
style={{ style={{
fontSize: 14, fontSize: 14,
@ -543,24 +414,26 @@ function UtilitiesTable({ items }: UtilitiesTableProps) {
textAlign: "right", textAlign: "right",
}} }}
> >
{item.count_within_2km} шт. {row.count.toLocaleString("ru")} шт.
</div> </div>
{/* Status badge */} {/* Status badge */}
<div style={{ textAlign: "right" }}> <div style={{ textAlign: "right" }}>
<span {status && (
style={{ <span
display: "inline-block", style={{
padding: "2px 8px", display: "inline-block",
borderRadius: 6, padding: "2px 8px",
fontSize: 12, borderRadius: 6,
fontWeight: 500, fontSize: 12,
color: PRESENCE_COLOR[status], fontWeight: 500,
background: PRESENCE_BG[status], color: PRESENCE_COLOR[status],
}} background: PRESENCE_BG[status],
> }}
{PRESENCE_LABEL[status]} >
</span> {PRESENCE_LABEL[status]}
</span>
)}
</div> </div>
</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) ─────────────────────────────────────── // ── Connection-points map block (#1746) ───────────────────────────────────────
/** /**
@ -599,23 +554,17 @@ function toCpMapData(data: ParcelAnalyzeResponse): ParcelAnalysis {
interface ConnectionPointsMapProps { interface ConnectionPointsMapProps {
data: ParcelAnalyzeResponse; data: ParcelAnalyzeResponse;
connectionPoints: ConnectionPointsResponse | undefined;
utilityInfrastructure: UtilityInfrastructureResponse | undefined;
isLoading: boolean;
} }
function ConnectionPointsMap({ data }: ConnectionPointsMapProps) { function ConnectionPointsMap({
// Точки подключения ресурсов (электро/газ/вода/тепло) по кварталу — тянем по data,
// cad_num и пробрасываем в SiteMap, который рисует ConnectionPointsLayer + connectionPoints,
// CpLayerControlPanel при truthy prop `connectionPoints` (паттерн MiniMap.tsx). utilityInfrastructure,
const { data: connectionPoints, isLoading: cpLoading } = useConnectionPoints( isLoading,
data.cad_num, }: ConnectionPointsMapProps) {
);
// #1746 — инж. инфраструктура OSM (ЛЭП-линии, подстанции, узлы). Те же
// доп. слои на ТОЙ ЖЕ карте (одна карта, больше слоёв — лучше UX).
const { data: utilityInfrastructure, isLoading: utilLoading } =
useUtilityInfrastructure(data.cad_num);
const isLoading = cpLoading || utilLoading;
const hasPoints = const hasPoints =
!!connectionPoints && !!connectionPoints &&
connectionPoints.dump_available && connectionPoints.dump_available &&
@ -659,10 +608,12 @@ function ConnectionPointsMap({ data }: ConnectionPointsMapProps) {
}} }}
> >
Те же инженерные сети, что в таблице выше, показаны на карте: трассы и Те же инженерные сети, что в таблице выше, показаны на карте: трассы и
объекты из OpenStreetMap (ЛЭП, подстанции, трубопроводы) в радиусе 2 км объекты из OpenStreetMap (ЛЭП, подстанции, трубопроводы) в радиусе{" "}
как столбец «В 2 км», плюс точки подключения и охранные зоны из снимка {COVERAGE_RADIUS_KM} км как столбец «В {COVERAGE_RADIUS_KM} км» в
кадастрового квартала (НСПД). Карта показывает, где находятся эти точки таблице. Отдельным слоем точки подключения и охранные зоны из снимка
и трассы, а не только расстояние до них. Слои переключаются под картой. кадастрового квартала (НСПД): это разные источники, поэтому их счётчики
не совпадают. Карта показывает, где находятся эти точки и трассы, а не
только расстояние до них. Слои переключаются под картой.
</p> </p>
{isLoading ? ( {isLoading ? (
@ -720,6 +671,15 @@ interface Props {
export function Section2NetworksUtilities({ cad }: Props) { export function Section2NetworksUtilities({ cad }: Props) {
const { isLoading, isError, error, data } = useParcelAnalyzeQuery(cad); 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 (isLoading) return <Section2Skeleton />;
if (isError || !data) { 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) // Таблица «Сети» — из ТОГО ЖЕ источника, что карта (osm_utility_infrastructure).
if (!utilities || utilities.summary.length === 0) { 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 />; return <Section2NoData />;
} }
const { // HeadlineBar subtitle: ближайшие расстояния по ключевым видам из таблицы.
summary, const headlineSubtitle = buildSubtitle(tableRows);
power_line_охранная_зона_flag: powerLineZone,
note,
} = utilities;
// HeadlineBar subtitle: list distances for key utility types
const headlineSubtitle = buildSubtitle(summary);
return ( return (
<section <section
@ -824,11 +800,63 @@ export function Section2NetworksUtilities({ cad }: Props) {
</div> </div>
)} )}
{/* Utilities table */} {/* Счётчики источников явно разделяем «инж.сети OSM» и «точки
<UtilitiesTable items={summary} /> подключения НСПД», чтобы числа не путались (#1953). */}
{!networksLoading && (
<SourceCounters
utilityCount={utilityMappableCount}
connectionPointCount={connectionPointCount}
/>
)}
{/* Карта точек подключения + охранных зон сетей (#1746) */} {/* Utilities table — строки из osm_utility_infrastructure (= карта) */}
<ConnectionPointsMap data={data} /> {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 */} {/* Caveat note */}
{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;
}