fix(site-finder): сети и точки подключения на карте Раздела 3 (#1961) #1998

Merged
bot-backend merged 1 commit from fix/report-map-points-1961 into main 2026-06-27 13:00:15 +00:00
4 changed files with 258 additions and 21 deletions

View file

@ -9,13 +9,7 @@ import type { EngineeringStructure } from "@/types/nspd";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export type CpCategory = export type CpCategory =
| "electricity" "electricity" | "gas" | "water" | "heat" | "sewage" | "telecom" | "other";
| "gas"
| "water"
| "heat"
| "sewage"
| "telecom"
| "other";
export interface CpCategoryStyle { export interface CpCategoryStyle {
color: string; color: string;
@ -104,17 +98,106 @@ export function groupStructuresByCategory(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Geometry helpers // 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<string, unknown>, geojson: Record<string, unknown>,
): [number, number] | null { ): [number, number] | null {
if (geojson.type === "Point") { const type = geojson.type;
const coords = geojson.coordinates as number[] | undefined; const coords = geojson.coordinates;
if (coords && coords.length >= 2) {
// GeoJSON: [lon, lat] if (type === "Point") {
return [coords[1], coords[0]]; 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; return null;
} }

View file

@ -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) репроецировать 38574326, иначе маркер улетает в океан;
* 3) graceful-skip (null) на пустой/невалидной геометрии.
*
* Тестируем чистую функцию (jsdom не монтирует Leaflet <CircleMarker>), т.к.
* именно тут жил баг «точки подключения не на карте».
*/
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>): 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([]);
});
});

View file

@ -658,10 +658,11 @@ function ConnectionPointsMap({ data }: ConnectionPointsMapProps) {
color: "var(--fg-tertiary)", color: "var(--fg-tertiary)",
}} }}
> >
Инженерные объекты и зоны с особыми условиями из снимка кадастрового Те же инженерные сети, что в таблице выше, показаны на карте: трассы и
квартала (НСПД) и сети из OpenStreetMap (ЛЭП, подстанции, трубопроводы). объекты из OpenStreetMap (ЛЭП, подстанции, трубопроводы) в радиусе 2 км
Показывает, где находятся точки подключения и трассы сетей, а не только как столбец «В 2 км», плюс точки подключения и охранные зоны из снимка
расстояние до них. Слои переключаются под картой. кадастрового квартала (НСПД). Карта показывает, где находятся эти точки
и трассы, а не только расстояние до них. Слои переключаются под картой.
</p> </p>
{isLoading ? ( {isLoading ? (

View file

@ -13,18 +13,28 @@ export type UtilityInfrastructureFeature =
export type UtilityInfrastructureSummary = export type UtilityInfrastructureSummary =
components["schemas"]["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 (#1746). Источник open-data таблица
* osm_utility_infrastructure_ekb (weekly-sync). Большинство объектов ЛЭП * osm_utility_infrastructure_ekb (weekly-sync). Большинство объектов ЛЭП
* (power=line, LineString) и подстанции; точечных узлов (towers) меньше. * (power=line, LineString) и подстанции; точечных узлов (towers) меньше.
* *
* Зеркалит useConnectionPoints, но с параметром radius_m (бэкенд: 50..2000, * Зеркалит useConnectionPoints, но с параметром radius_m (бэкенд: 50..2000).
* default 500). Сервер единственный источник истины по форме ответа * #1961 дефолт повышен до UTILITY_COVERAGE_RADIUS_M (2 км), чтобы охват карты
* (см. UtilityInfrastructureResponse в api-types.ts). * совпадал с таблицей «В 2 км». Сервер единственный источник истины по форме
* ответа (см. UtilityInfrastructureResponse в api-types.ts).
*/ */
export function useUtilityInfrastructure( export function useUtilityInfrastructure(
cadNum: string | null | undefined, cadNum: string | null | undefined,
radiusM = 500, radiusM: number = UTILITY_COVERAGE_RADIUS_M,
enabled = true, enabled = true,
) { ) {
return useQuery<UtilityInfrastructureResponse>({ return useQuery<UtilityInfrastructureResponse>({