From 1bbc0323165afffb8bc728cf4f27bff6759a9f92 Mon Sep 17 00:00:00 2001
From: Light1YT
Date: Sat, 27 Jun 2026 17:52:51 +0500
Subject: [PATCH] =?UTF-8?q?fix(site-finder):=20show=20networks=20&=20conne?=
=?UTF-8?q?ction=20points=20on=20=D0=A0=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=203?=
=?UTF-8?q?=20map=20(#1961)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The "Сети" section table summarised engineering networks and distances, but
users could not see those points on the map. Three root causes:
- Connection-points layer was always empty: NSPD engineering_structures
(cat 36328) arrive as Polygon/MultiPolygon in EPSG:3857 (prod: 587 Polygon
+ 298 MultiPolygon, zero Point), but extractLatLon only handled Point in
EPSG:4326. Extend it to compute the outer-ring centroid for
Polygon/MultiPolygon and reproject 3857→4326 (frontend math, no backend
change), so the 10+ connection points render as markers with popups.
- Map vs table coverage mismatch: the table counts "В 2 км" while the OSM
utility-infrastructure layer fetched only 500 m (1 object inside 500 m vs
153 within 2 km). Raise the hook default to a shared
UTILITY_COVERAGE_RADIUS_M = 2000 so the map matches the table column.
- Coherence microcopy: clarify that the map shows the same networks as the
table (2 km), plus NSPD connection points and protection zones.
Adds unit tests for extractLatLon polygon-centroid + 3857→4326 reprojection.
Refs #1961
---
.../site-finder/ConnectionPointsLayer.tsx | 109 +++++++++++--
.../__tests__/ConnectionPointsLayer.test.ts | 143 ++++++++++++++++++
.../analysis/Section2NetworksUtilities.tsx | 9 +-
.../src/hooks/useUtilityInfrastructure.ts | 18 ++-
4 files changed, 258 insertions(+), 21 deletions(-)
create mode 100644 frontend/src/components/site-finder/__tests__/ConnectionPointsLayer.test.ts
diff --git a/frontend/src/components/site-finder/ConnectionPointsLayer.tsx b/frontend/src/components/site-finder/ConnectionPointsLayer.tsx
index 1e25173a..847da34b 100644
--- a/frontend/src/components/site-finder/ConnectionPointsLayer.tsx
+++ b/frontend/src/components/site-finder/ConnectionPointsLayer.tsx
@@ -9,13 +9,7 @@ import type { EngineeringStructure } from "@/types/nspd";
// ---------------------------------------------------------------------------
export type CpCategory =
- | "electricity"
- | "gas"
- | "water"
- | "heat"
- | "sewage"
- | "telecom"
- | "other";
+ "electricity" | "gas" | "water" | "heat" | "sewage" | "telecom" | "other";
export interface CpCategoryStyle {
color: string;
@@ -104,17 +98,106 @@ export function groupStructuresByCategory(
// ---------------------------------------------------------------------------
// Geometry helpers
// ---------------------------------------------------------------------------
+//
+// #1961 — NSPD engineering_structures (cat 36328 — ТП/ЦТП/насосные/опоры ЛЭП)
+// приходят как Polygon/MultiPolygon (контуры сооружений), НЕ как Point — в
+// проде 587 Polygon + 298 MultiPolygon и НОЛЬ Point. Старый extractLatLon
+// обрабатывал только Point → слой точек подключения был всегда пуст.
+//
+// Вдобавок geometry_geojson этих объектов хранится/отдаётся в EPSG:3857
+// (Web-Mercator: x≈6.7e6, y≈7.7e6), а карта (участок + OSM-слой) — в EPSG:4326.
+// Без репроекции центроид улетал бы в Атлантику. Репроецируем 3857→4326 на
+// фронте (чистая математика, без изменений бэка). Эвристика CRS: если |x|>180
+// или |y|>90 — это метры (3857), иначе уже градусы (4326).
-function extractLatLon(
+const EARTH_RADIUS_M = 6378137; // WGS84 большая полуось — как в EPSG:3857
+const ORIGIN_SHIFT = Math.PI * EARTH_RADIUS_M; // 20037508.342789244
+
+/** EPSG:3857 (метры) → EPSG:4326 [lon, lat] (градусы). */
+function mercatorToLonLat(x: number, y: number): [number, number] {
+ const lon = (x / ORIGIN_SHIFT) * 180;
+ const lat =
+ (Math.atan(Math.exp((y / ORIGIN_SHIFT) * Math.PI)) * 360) / Math.PI - 90;
+ return [lon, lat];
+}
+
+/** Эвристика: координаты вне диапазона градусов → это метры (Web-Mercator). */
+function looksLikeMercator(x: number, y: number): boolean {
+ return Math.abs(x) > 180 || Math.abs(y) > 90;
+}
+
+/** [x|lon, y|lat] любого CRS → [lat, lon] (4326) для Leaflet. */
+function toLeafletLatLon(pos: number[]): [number, number] | null {
+ if (pos.length < 2) return null;
+ const [x, y] = pos;
+ if (typeof x !== "number" || typeof y !== "number") return null;
+ if (looksLikeMercator(x, y)) {
+ const [lon, lat] = mercatorToLonLat(x, y);
+ return [lat, lon];
+ }
+ // Уже градусы: GeoJSON-порядок [lon, lat] → Leaflet [lat, lon].
+ return [y, x];
+}
+
+/**
+ * Среднее по координатам внешнего кольца(колец) полигона — достаточно для
+ * маркера-точки внутри/рядом с небольшим сооружением (ТП ≈ 10-30 м).
+ */
+function ringCentroid(rings: number[][]): [number, number] | null {
+ let sumX = 0;
+ let sumY = 0;
+ let n = 0;
+ for (const p of rings) {
+ if (p.length < 2 || typeof p[0] !== "number" || typeof p[1] !== "number") {
+ continue;
+ }
+ sumX += p[0];
+ sumY += p[1];
+ n += 1;
+ }
+ if (n === 0) return null;
+ return toLeafletLatLon([sumX / n, sumY / n]);
+}
+
+/**
+ * Возвращает [lat, lon] (4326) представительной точки геометрии:
+ * Point → сама точка
+ * Polygon → центроид внешнего кольца coordinates[0]
+ * MultiPolygon → центроид внешнего кольца первого полигона
+ * CRS (3857 vs 4326) определяется автоматически (см. toLeafletLatLon).
+ * Возвращает null для пустой/невалидной геометрии — маркер тогда не рисуется.
+ */
+export function extractLatLon(
geojson: Record,
): [number, number] | null {
- if (geojson.type === "Point") {
- const coords = geojson.coordinates as number[] | undefined;
- if (coords && coords.length >= 2) {
- // GeoJSON: [lon, lat]
- return [coords[1], coords[0]];
+ const type = geojson.type;
+ const coords = geojson.coordinates;
+
+ if (type === "Point") {
+ if (Array.isArray(coords) && coords.length >= 2) {
+ return toLeafletLatLon(coords as number[]);
}
+ return null;
}
+
+ if (type === "Polygon") {
+ // coordinates: number[][][] — берём внешнее кольцо [0].
+ const outer = Array.isArray(coords)
+ ? (coords as number[][][])[0]
+ : undefined;
+ if (Array.isArray(outer)) return ringCentroid(outer);
+ return null;
+ }
+
+ if (type === "MultiPolygon") {
+ // coordinates: number[][][][] — внешнее кольцо первого полигона [0][0].
+ const outer = Array.isArray(coords)
+ ? (coords as number[][][][])[0]?.[0]
+ : undefined;
+ if (Array.isArray(outer)) return ringCentroid(outer);
+ return null;
+ }
+
return null;
}
diff --git a/frontend/src/components/site-finder/__tests__/ConnectionPointsLayer.test.ts b/frontend/src/components/site-finder/__tests__/ConnectionPointsLayer.test.ts
new file mode 100644
index 00000000..9f45705b
--- /dev/null
+++ b/frontend/src/components/site-finder/__tests__/ConnectionPointsLayer.test.ts
@@ -0,0 +1,143 @@
+/**
+ * #1961 — unit tests for ConnectionPointsLayer geometry handling.
+ *
+ * NSPD engineering_structures (cat 36328) приходят как Polygon/MultiPolygon в
+ * EPSG:3857 (в проде: 587 Polygon + 298 MultiPolygon, НОЛЬ Point), а карта —
+ * в EPSG:4326. extractLatLon должен:
+ * 1) считать центроид внешнего кольца Polygon/MultiPolygon (не только Point);
+ * 2) репроецировать 3857→4326, иначе маркер улетает в океан;
+ * 3) graceful-skip (null) на пустой/невалидной геометрии.
+ *
+ * Тестируем чистую функцию (jsdom не монтирует Leaflet ), т.к.
+ * именно тут жил баг «точки подключения не на карте».
+ */
+import {
+ classifyStructure,
+ extractLatLon,
+ groupStructuresByCategory,
+} from "../ConnectionPointsLayer";
+import type { EngineeringStructure } from "@/types/nspd";
+
+// Реальный объект из прода (ТП в квартале 66:41:0204028) — Polygon в EPSG:3857.
+// Внешнее кольцо вокруг (≈6739509, ≈7732776) → центр ЕКБ ≈ lon 60.5, lat 56.87.
+const MERCATOR_POLYGON = {
+ type: "Polygon",
+ coordinates: [
+ [
+ [6739509.5, 7732776.0],
+ [6739491.9, 7732789.0],
+ [6739480.0, 7732760.0],
+ [6739509.5, 7732776.0],
+ ],
+ ],
+};
+
+const MERCATOR_MULTIPOLYGON = {
+ type: "MultiPolygon",
+ coordinates: [
+ [
+ [
+ [6739615.5, 7733013.2],
+ [6739615.5, 7733013.2],
+ [6739600.0, 7733000.0],
+ [6739615.5, 7733013.2],
+ ],
+ ],
+ ],
+};
+
+// Уже-градусная Point-геометрия (4326) — на случай будущей смены контракта.
+const DEGREE_POINT = { type: "Point", coordinates: [60.54, 56.87] };
+
+describe("extractLatLon", () => {
+ it("returns centroid of a Mercator (EPSG:3857) Polygon reprojected to 4326", () => {
+ const ll = extractLatLon(MERCATOR_POLYGON);
+ expect(ll).not.toBeNull();
+ const [lat, lon] = ll!;
+ // Должно попасть в ЕКБ: lat ≈ 56.87, lon ≈ 60.5 — НЕ в океан (0,0).
+ expect(lat).toBeGreaterThan(56.8);
+ expect(lat).toBeLessThan(56.95);
+ expect(lon).toBeGreaterThan(60.4);
+ expect(lon).toBeLessThan(60.7);
+ });
+
+ it("returns centroid of a Mercator MultiPolygon (first ring)", () => {
+ const ll = extractLatLon(MERCATOR_MULTIPOLYGON);
+ expect(ll).not.toBeNull();
+ const [lat, lon] = ll!;
+ expect(lat).toBeGreaterThan(56.8);
+ expect(lat).toBeLessThan(56.95);
+ expect(lon).toBeGreaterThan(60.4);
+ expect(lon).toBeLessThan(60.7);
+ });
+
+ it("handles a degree Point (no reprojection, lon/lat → lat/lon swap)", () => {
+ const ll = extractLatLon(DEGREE_POINT);
+ expect(ll).toEqual([56.87, 60.54]);
+ });
+
+ it("handles a Mercator Point (reprojects to 4326)", () => {
+ const ll = extractLatLon({
+ type: "Point",
+ coordinates: [6739509.5, 7732776.0],
+ });
+ expect(ll).not.toBeNull();
+ const [lat, lon] = ll!;
+ expect(lat).toBeGreaterThan(56.8);
+ expect(lat).toBeLessThan(56.95);
+ expect(lon).toBeGreaterThan(60.4);
+ expect(lon).toBeLessThan(60.7);
+ });
+
+ it("returns null for missing / empty / invalid geometry (graceful skip)", () => {
+ expect(extractLatLon({})).toBeNull();
+ expect(extractLatLon({ type: "Point" })).toBeNull();
+ expect(extractLatLon({ type: "Point", coordinates: [1] })).toBeNull();
+ expect(extractLatLon({ type: "Polygon", coordinates: [] })).toBeNull();
+ expect(extractLatLon({ type: "Polygon", coordinates: [[]] })).toBeNull();
+ expect(extractLatLon({ type: "MultiPolygon", coordinates: [] })).toBeNull();
+ expect(extractLatLon({ type: "LineString", coordinates: [] })).toBeNull();
+ expect(extractLatLon({ type: "GeometryCollection" })).toBeNull();
+ });
+});
+
+// ── classification / grouping (sanity — unchanged behavior) ─────────────────
+// NB: source="" чтобы исключить keyword "36328" (входит в electricity-список и
+// иначе перекрывает name/type при source="nspd_36328").
+
+function struct(partial: Partial): EngineeringStructure {
+ return {
+ name: null,
+ type: null,
+ cad_num: null,
+ distance_to_boundary_m: 0,
+ geometry_geojson: MERCATOR_POLYGON,
+ readable_address: null,
+ raw_props: {},
+ source: "",
+ ...partial,
+ };
+}
+
+describe("classifyStructure / groupStructuresByCategory", () => {
+ it("classifies by keyword in name/type", () => {
+ expect(classifyStructure(struct({ name: "ТП-123 подстанция" }))).toBe(
+ "electricity",
+ );
+ expect(classifyStructure(struct({ type: "газопровод" }))).toBe("gas");
+ expect(classifyStructure(struct({ name: "Водопроводный колодец" }))).toBe(
+ "water",
+ );
+ expect(classifyStructure(struct({ name: "что-то иное" }))).toBe("other");
+ });
+
+ it("groups every structure into a category bucket (no drops)", () => {
+ const grouped = groupStructuresByCategory([
+ struct({ name: "подстанция" }),
+ struct({ type: "газопровод" }),
+ ]);
+ expect(grouped.get("electricity")).toHaveLength(1);
+ expect(grouped.get("gas")).toHaveLength(1);
+ expect(grouped.get("water")).toEqual([]);
+ });
+});
diff --git a/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx b/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx
index d0798847..df06c67f 100644
--- a/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx
+++ b/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx
@@ -658,10 +658,11 @@ function ConnectionPointsMap({ data }: ConnectionPointsMapProps) {
color: "var(--fg-tertiary)",
}}
>
- Инженерные объекты и зоны с особыми условиями из снимка кадастрового
- квартала (НСПД) и сети из OpenStreetMap (ЛЭП, подстанции, трубопроводы).
- Показывает, где находятся точки подключения и трассы сетей, а не только
- расстояние до них. Слои переключаются под картой.
+ Те же инженерные сети, что в таблице выше, показаны на карте: трассы и
+ объекты из OpenStreetMap (ЛЭП, подстанции, трубопроводы) в радиусе 2 км
+ — как столбец «В 2 км», плюс точки подключения и охранные зоны из снимка
+ кадастрового квартала (НСПД). Карта показывает, где находятся эти точки
+ и трассы, а не только расстояние до них. Слои переключаются под картой.
{isLoading ? (
diff --git a/frontend/src/hooks/useUtilityInfrastructure.ts b/frontend/src/hooks/useUtilityInfrastructure.ts
index 5f52a983..69cbbdfa 100644
--- a/frontend/src/hooks/useUtilityInfrastructure.ts
+++ b/frontend/src/hooks/useUtilityInfrastructure.ts
@@ -13,18 +13,28 @@ export type UtilityInfrastructureFeature =
export type UtilityInfrastructureSummary =
components["schemas"]["UtilityInfrastructureSummary"];
+/**
+ * #1961 — единый радиус «зоны охвата» для раздела «Сети». Таблица utilities
+ * (osm_noise_sources_ekb) считает «В 2 км», а карта-слой раньше тянула только
+ * 500 м → внутри 500 м был 1 объект, а в 2 км — 153, и пользователь не видел
+ * на карте то, что показывала таблица. Делаем радиус карты = радиус таблицы.
+ * Бэкенд допускает 50..2000 м (ST_DWithin geography по ЕКБ на 2 км ок по перф).
+ */
+export const UTILITY_COVERAGE_RADIUS_M = 2000;
+
/**
* Слой инженерной инфраструктуры из OSM (#1746). Источник — open-data таблица
* osm_utility_infrastructure_ekb (weekly-sync). Большинство объектов — ЛЭП
* (power=line, LineString) и подстанции; точечных узлов (towers) меньше.
*
- * Зеркалит useConnectionPoints, но с параметром radius_m (бэкенд: 50..2000,
- * default 500). Сервер — единственный источник истины по форме ответа
- * (см. UtilityInfrastructureResponse в api-types.ts).
+ * Зеркалит useConnectionPoints, но с параметром radius_m (бэкенд: 50..2000).
+ * #1961 — дефолт повышен до UTILITY_COVERAGE_RADIUS_M (2 км), чтобы охват карты
+ * совпадал с таблицей «В 2 км». Сервер — единственный источник истины по форме
+ * ответа (см. UtilityInfrastructureResponse в api-types.ts).
*/
export function useUtilityInfrastructure(
cadNum: string | null | undefined,
- radiusM = 500,
+ radiusM: number = UTILITY_COVERAGE_RADIUS_M,
enabled = true,
) {
return useQuery({