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({