Render the §12.1-13 geometry already exposed on /analyze but previously dropped by the MiniMap adapter: - Перспективные ЗУ (nspd_opportunity_parcels.geom_wkt) — viz-3 polygons - Красные линии застройки (nspd_red_lines.geom_wkt) — warn dashed lines Wired as toggles in the existing CpLayerControlPanel (Рынок group), reusing wkt.ts (extended with LINESTRING/MULTILINESTRING for red lines). Empty/invalid geometry renders nothing gracefully; popups RU plain-text. §22 forecast (future_market/special_indices), ППТ-ПМТ planning polygons and future-ЖК points carry no map-able geometry on the frontend yet — left as backend follow-ups, not faked. Refs #958
151 lines
5.4 KiB
TypeScript
151 lines
5.4 KiB
TypeScript
/**
|
||
* Minimal WKT → GeoJSON parser (no new deps — see #999 conventions).
|
||
*
|
||
* Extracted from MarketLayers.tsx so the hand-rolled parser is unit-testable in
|
||
* isolation (the #999 risk-zone parser had zero coverage and never ran on the QA
|
||
* parcel). Behavior is otherwise identical to the inlined version.
|
||
*
|
||
* Supports the types the NSPD layers actually emit (ST_AsText, EPSG:4326,
|
||
* `lon lat` order): POINT, LINESTRING, MULTILINESTRING, POLYGON, MULTIPOLYGON.
|
||
* Anything else / garbage → null (the feature then simply is not drawn). NOT
|
||
* general-purpose. LINESTRING / MULTILINESTRING были добавлены для слоя «красные
|
||
* линии застройки» (§13, nspd_red_lines — ST_AsText на LINESTRING-геометрии);
|
||
* POLYGON/MULTIPOLYGON покрывают risk-зоны (§13) и opportunity-ЗУ (§12.1).
|
||
*
|
||
* Coordinate order: WKT is `lon lat`; GeoJSON Position is `[lon, lat]` — we keep
|
||
* that order verbatim (mismatching it is the bug class that drops markers in the
|
||
* ocean).
|
||
*
|
||
* EWKT tolerance: a leading `SRID=<digits>;` prefix (case-insensitive) is
|
||
* stripped before type detection, so `SRID=4326;POLYGON(...)` parses to the same
|
||
* geometry as the bare `POLYGON(...)`. Backend currently emits plain WKT via
|
||
* ST_AsText, but this is cheap defense-in-depth (per the #999 review + chip).
|
||
*/
|
||
|
||
import type { Geometry, Position } from "geojson";
|
||
|
||
// Leading `SRID=4326;` EWKT prefix (case-insensitive), if present.
|
||
const EWKT_SRID_PREFIX = /^SRID=\d+;/i;
|
||
|
||
export function parseRing(body: string): Position[] {
|
||
return body
|
||
.split(",")
|
||
.map((pair) => pair.trim().split(/\s+/).map(Number))
|
||
.filter(
|
||
(nums): nums is [number, number] =>
|
||
nums.length >= 2 &&
|
||
Number.isFinite(nums[0]) &&
|
||
Number.isFinite(nums[1]),
|
||
)
|
||
.map(([lon, lat]) => [lon, lat] as Position);
|
||
}
|
||
|
||
// Делит верхнеуровневые группы внутри MULTIPOLYGON по скобочной вложенности,
|
||
// чтобы запятые ВНУТРИ координат не рвали полигоны.
|
||
export function splitTopLevel(body: string): string[] {
|
||
const parts: string[] = [];
|
||
let depth = 0;
|
||
let start = 0;
|
||
for (let i = 0; i < body.length; i++) {
|
||
const ch = body[i];
|
||
if (ch === "(") depth++;
|
||
else if (ch === ")") depth--;
|
||
else if (ch === "," && depth === 0) {
|
||
parts.push(body.slice(start, i));
|
||
start = i + 1;
|
||
}
|
||
}
|
||
parts.push(body.slice(start));
|
||
return parts;
|
||
}
|
||
|
||
export function wktToGeometry(wkt: string): Geometry | null {
|
||
// Strip a leading EWKT `SRID=<n>;` prefix before type detection so EWKT input
|
||
// parses identically to plain WKT.
|
||
const trimmed = wkt.trim().replace(EWKT_SRID_PREFIX, "");
|
||
const upper = trimmed.toUpperCase();
|
||
|
||
try {
|
||
if (upper.startsWith("POINT")) {
|
||
const body = trimmed.slice(
|
||
trimmed.indexOf("(") + 1,
|
||
trimmed.lastIndexOf(")"),
|
||
);
|
||
const nums = body.trim().split(/\s+/).map(Number);
|
||
if (
|
||
nums.length < 2 ||
|
||
!Number.isFinite(nums[0]) ||
|
||
!Number.isFinite(nums[1])
|
||
) {
|
||
return null;
|
||
}
|
||
return { type: "Point", coordinates: [nums[0], nums[1]] };
|
||
}
|
||
|
||
// NB: MULTILINESTRING проверяется ДО LINESTRING (startsWith), как
|
||
// MULTIPOLYGON до POLYGON ниже.
|
||
if (upper.startsWith("MULTILINESTRING")) {
|
||
// MULTILINESTRING((lon lat, …),(lon lat, …))
|
||
const inner = trimmed.slice(
|
||
trimmed.indexOf("(") + 1,
|
||
trimmed.lastIndexOf(")"),
|
||
);
|
||
const lines = splitTopLevel(inner)
|
||
.map((lineChunk) => parseRing(lineChunk.replace(/[()]/g, "")))
|
||
.filter((line) => line.length >= 2);
|
||
if (lines.length === 0) return null;
|
||
return { type: "MultiLineString", coordinates: lines };
|
||
}
|
||
|
||
if (upper.startsWith("LINESTRING")) {
|
||
// LINESTRING(lon lat, lon lat, …)
|
||
const inner = trimmed.slice(
|
||
trimmed.indexOf("(") + 1,
|
||
trimmed.lastIndexOf(")"),
|
||
);
|
||
const coords = parseRing(inner);
|
||
if (coords.length < 2) return null;
|
||
return { type: "LineString", coordinates: coords };
|
||
}
|
||
|
||
if (upper.startsWith("MULTIPOLYGON")) {
|
||
// MULTIPOLYGON(((ring),(hole)),((ring)))
|
||
const inner = trimmed.slice(
|
||
trimmed.indexOf("(") + 1,
|
||
trimmed.lastIndexOf(")"),
|
||
);
|
||
const polygons = splitTopLevel(inner)
|
||
.map((polyChunk) => {
|
||
const polyBody = polyChunk
|
||
.trim()
|
||
.replace(/^\(/, "")
|
||
.replace(/\)$/, "");
|
||
const rings = splitTopLevel(polyBody)
|
||
.map((ringChunk) => parseRing(ringChunk.replace(/[()]/g, "")))
|
||
.filter((ring) => ring.length >= 3);
|
||
return rings;
|
||
})
|
||
.filter((rings) => rings.length > 0);
|
||
if (polygons.length === 0) return null;
|
||
return { type: "MultiPolygon", coordinates: polygons };
|
||
}
|
||
|
||
if (upper.startsWith("POLYGON")) {
|
||
// POLYGON((ring),(hole))
|
||
const inner = trimmed.slice(
|
||
trimmed.indexOf("(") + 1,
|
||
trimmed.lastIndexOf(")"),
|
||
);
|
||
const rings = splitTopLevel(inner)
|
||
.map((ringChunk) => parseRing(ringChunk.replace(/[()]/g, "")))
|
||
.filter((ring) => ring.length >= 3);
|
||
if (rings.length === 0) return null;
|
||
return { type: "Polygon", coordinates: rings };
|
||
}
|
||
} catch {
|
||
// Любой неожиданный WKT — graceful skip (зона не рисуется).
|
||
return null;
|
||
}
|
||
|
||
return null;
|
||
}
|