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
187 lines
6.4 KiB
TypeScript
187 lines
6.4 KiB
TypeScript
/**
|
|
* Unit tests for the hand-rolled WKT → GeoJSON parser (#999 risk-zone parser).
|
|
*
|
|
* Highest-risk untested code: this parser drives the risk-zone polygons drawn on
|
|
* the Site Finder map. The bug class it must guard against is coordinate-order
|
|
* inversion (WKT `lon lat` → GeoJSON `[lon, lat]`) — getting that wrong puts
|
|
* zones/markers in the ocean.
|
|
*/
|
|
import type {
|
|
LineString,
|
|
MultiLineString,
|
|
MultiPolygon,
|
|
Point,
|
|
Polygon,
|
|
Position,
|
|
} from "geojson";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { parseRing, splitTopLevel, wktToGeometry } from "../wkt";
|
|
|
|
// A simple square ring (closed) in `lon lat` order, EKB-ish coordinates.
|
|
const SQUARE =
|
|
"POLYGON((60.5 56.8, 60.6 56.8, 60.6 56.9, 60.5 56.9, 60.5 56.8))";
|
|
|
|
describe("wktToGeometry — POLYGON", () => {
|
|
it("parses a valid single-ring POLYGON", () => {
|
|
const geom = wktToGeometry(SQUARE) as Polygon | null;
|
|
expect(geom).not.toBeNull();
|
|
expect(geom?.type).toBe("Polygon");
|
|
expect(geom?.coordinates).toHaveLength(1); // one outer ring
|
|
expect(geom?.coordinates[0]).toHaveLength(5); // 5 closed points
|
|
});
|
|
|
|
it("preserves WKT lon/lat order as GeoJSON [lon, lat]", () => {
|
|
// The bug class: this must NOT swap to [lat, lon].
|
|
const geom = wktToGeometry(SQUARE) as Polygon;
|
|
const first = geom.coordinates[0][0];
|
|
expect(first).toEqual([60.5, 56.8]);
|
|
// lon (60) is the X / first element; lat (56) is the Y / second.
|
|
expect(first[0]).toBe(60.5);
|
|
expect(first[1]).toBe(56.8);
|
|
});
|
|
|
|
it("parses a POLYGON with a hole (two rings)", () => {
|
|
const wkt =
|
|
"POLYGON((0 0, 10 0, 10 10, 0 10, 0 0)," + "(2 2, 4 2, 4 4, 2 4, 2 2))";
|
|
const geom = wktToGeometry(wkt) as Polygon | null;
|
|
expect(geom?.type).toBe("Polygon");
|
|
expect(geom?.coordinates).toHaveLength(2); // outer + hole
|
|
expect(geom?.coordinates[1][0]).toEqual([2, 2]);
|
|
});
|
|
|
|
it("skips a ring with fewer than 3 points → null when none survive", () => {
|
|
// Two points cannot form a ring; the single ring is filtered out → null.
|
|
const geom = wktToGeometry("POLYGON((0 0, 1 1))");
|
|
expect(geom).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("wktToGeometry — MULTIPOLYGON", () => {
|
|
it("parses a MULTIPOLYGON with two polygons", () => {
|
|
const wkt =
|
|
"MULTIPOLYGON(" +
|
|
"((0 0, 1 0, 1 1, 0 1, 0 0))," +
|
|
"((5 5, 6 5, 6 6, 5 6, 5 5))" +
|
|
")";
|
|
const geom = wktToGeometry(wkt) as MultiPolygon | null;
|
|
expect(geom?.type).toBe("MultiPolygon");
|
|
expect(geom?.coordinates).toHaveLength(2); // two polygons
|
|
expect(geom?.coordinates[0][0]).toHaveLength(5);
|
|
expect(geom?.coordinates[1][0][0]).toEqual([5, 5]);
|
|
});
|
|
|
|
it("does not let intra-coordinate commas split polygons", () => {
|
|
// The top-level splitter must respect bracket depth — 8 coords, 1 polygon.
|
|
const wkt = "MULTIPOLYGON(((0 0, 1 0, 1 1, 0 1, 0 0)))";
|
|
const geom = wktToGeometry(wkt) as MultiPolygon;
|
|
expect(geom.coordinates).toHaveLength(1);
|
|
expect(geom.coordinates[0]).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("wktToGeometry — POINT", () => {
|
|
it("parses a POINT preserving lon/lat order", () => {
|
|
const geom = wktToGeometry("POINT(60.6 56.8)") as Point | null;
|
|
expect(geom?.type).toBe("Point");
|
|
expect(geom?.coordinates).toEqual([60.6, 56.8]);
|
|
});
|
|
|
|
it("returns null for a POINT with a single coordinate", () => {
|
|
expect(wktToGeometry("POINT(60.6)")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("wktToGeometry — EWKT tolerance", () => {
|
|
it("strips a leading SRID=4326; prefix and parses the same geometry", () => {
|
|
const plain = wktToGeometry(SQUARE) as Polygon;
|
|
const ewkt = wktToGeometry("SRID=4326;" + SQUARE) as Polygon | null;
|
|
expect(ewkt?.type).toBe("Polygon");
|
|
expect(ewkt?.coordinates).toEqual(plain.coordinates);
|
|
});
|
|
|
|
it("strips the SRID prefix case-insensitively", () => {
|
|
const geom = wktToGeometry("srid=3857;POINT(1 2)") as Point | null;
|
|
expect(geom?.type).toBe("Point");
|
|
expect(geom?.coordinates).toEqual([1, 2]);
|
|
});
|
|
});
|
|
|
|
describe("wktToGeometry — invalid / unsupported input", () => {
|
|
it("returns null for a garbage string", () => {
|
|
expect(wktToGeometry("not wkt at all")).toBeNull();
|
|
});
|
|
|
|
it("returns null for an empty string", () => {
|
|
expect(wktToGeometry("")).toBeNull();
|
|
});
|
|
|
|
it("returns null for an unsupported geometry type (GEOMETRYCOLLECTION)", () => {
|
|
expect(
|
|
wktToGeometry("GEOMETRYCOLLECTION(POINT(0 0),LINESTRING(0 0,1 1))"),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("wktToGeometry — LINESTRING (red lines §13)", () => {
|
|
it("parses a LINESTRING preserving lon/lat order", () => {
|
|
const geom = wktToGeometry(
|
|
"LINESTRING(60.5 56.8, 60.6 56.9, 60.7 56.8)",
|
|
) as LineString | null;
|
|
expect(geom?.type).toBe("LineString");
|
|
expect(geom?.coordinates).toHaveLength(3);
|
|
// The bug class: WKT lon/lat must stay [lon, lat], not swap to [lat, lon].
|
|
expect(geom?.coordinates[0]).toEqual([60.5, 56.8]);
|
|
});
|
|
|
|
it("returns null for a LINESTRING with a single point", () => {
|
|
expect(wktToGeometry("LINESTRING(60.6 56.8)")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("wktToGeometry — MULTILINESTRING (red lines §13)", () => {
|
|
it("parses a MULTILINESTRING with two lines", () => {
|
|
const geom = wktToGeometry(
|
|
"MULTILINESTRING((0 0, 1 1, 2 2),(5 5, 6 6))",
|
|
) as MultiLineString | null;
|
|
expect(geom?.type).toBe("MultiLineString");
|
|
expect(geom?.coordinates).toHaveLength(2);
|
|
expect(geom?.coordinates[0]).toHaveLength(3);
|
|
expect(geom?.coordinates[1][0]).toEqual([5, 5]);
|
|
});
|
|
|
|
it("does not let intra-coordinate commas split lines", () => {
|
|
const geom = wktToGeometry(
|
|
"MULTILINESTRING((0 0, 1 1, 2 2))",
|
|
) as MultiLineString;
|
|
expect(geom.coordinates).toHaveLength(1);
|
|
expect(geom.coordinates[0]).toHaveLength(3);
|
|
});
|
|
});
|
|
|
|
describe("parseRing", () => {
|
|
it("parses a coordinate list into [lon, lat] positions", () => {
|
|
const ring: Position[] = parseRing("60.5 56.8, 60.6 56.9");
|
|
expect(ring).toEqual([
|
|
[60.5, 56.8],
|
|
[60.6, 56.9],
|
|
]);
|
|
});
|
|
|
|
it("drops pairs that are not finite numbers", () => {
|
|
const ring = parseRing("60.5 56.8, foo bar, 1 2");
|
|
expect(ring).toEqual([
|
|
[60.5, 56.8],
|
|
[1, 2],
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("splitTopLevel", () => {
|
|
it("splits only on top-level commas, respecting bracket depth", () => {
|
|
const parts = splitTopLevel("(0 0, 1 1),(2 2, 3 3)");
|
|
expect(parts).toHaveLength(2);
|
|
expect(parts[0]).toBe("(0 0, 1 1)");
|
|
expect(parts[1].trim()).toBe("(2 2, 3 3)");
|
|
});
|
|
});
|