fix(site-finder): show networks & connection points on Раздел 3 map (#1961)
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
This commit is contained in:
parent
16f375782b
commit
1bbc032316
4 changed files with 258 additions and 21 deletions
|
|
@ -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<string, unknown>,
|
||||
): [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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -658,10 +658,11 @@ function ConnectionPointsMap({ data }: ConnectionPointsMapProps) {
|
|||
color: "var(--fg-tertiary)",
|
||||
}}
|
||||
>
|
||||
Инженерные объекты и зоны с особыми условиями из снимка кадастрового
|
||||
квартала (НСПД) и сети из OpenStreetMap (ЛЭП, подстанции, трубопроводы).
|
||||
Показывает, где находятся точки подключения и трассы сетей, а не только
|
||||
расстояние до них. Слои переключаются под картой.
|
||||
Те же инженерные сети, что в таблице выше, показаны на карте: трассы и
|
||||
объекты из OpenStreetMap (ЛЭП, подстанции, трубопроводы) в радиусе 2 км
|
||||
— как столбец «В 2 км», плюс точки подключения и охранные зоны из снимка
|
||||
кадастрового квартала (НСПД). Карта показывает, где находятся эти точки
|
||||
и трассы, а не только расстояние до них. Слои переключаются под картой.
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
|
|
|
|||
|
|
@ -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<UtilityInfrastructureResponse>({
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue