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