gendesign/frontend/src/lib/wkt.ts
Light1YT df9d54b532
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m40s
Deploy / deploy (push) Successful in 1m4s
feat(site-finder): add opportunity-ЗУ + red-line map layers (§12.1-13, #958)
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
2026-06-09 07:19:53 +00:00

151 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;
}