This commit is contained in:
parent
1337fadb49
commit
d9915c7834
5 changed files with 561 additions and 292 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue