From d60b028c25d9c6576146d3fbc2619d9bb252e2ee Mon Sep 17 00:00:00 2001 From: Brandon Liu Date: Tue, 7 Jul 2026 15:00:11 -0400 Subject: [PATCH] [FEATURE] modify downloaded GeoJSON to match shape of overturemaps-py CLI * lift id field to top-level of GeoJSON feature * remove bbox property * emit Polygon/LineString instead of Multi- geometries Fixes #310 Signed-off-by: Brandon Liu --- __tests__/normalizeGeojson.test.js | 144 +++++++++++++++++++++++++++++ components/nav/DownloadButton.jsx | 5 +- lib/normalizeGeojson.js | 55 +++++++++++ 3 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 __tests__/normalizeGeojson.test.js create mode 100644 lib/normalizeGeojson.js diff --git a/__tests__/normalizeGeojson.test.js b/__tests__/normalizeGeojson.test.js new file mode 100644 index 00000000..b80df86f --- /dev/null +++ b/__tests__/normalizeGeojson.test.js @@ -0,0 +1,144 @@ +/** + * Tests for lib/normalizeGeojson.js + * + * Input is the Uint8Array writeGeoJSON produces; output is a GeoJSON string. + */ + +import { strToU8 } from "fflate"; +import { normalizeGeojson } from "@/lib/normalizeGeojson"; + +const encode = (features) => + strToU8(JSON.stringify({ type: "FeatureCollection", features })); + +// A single-ring square, reused as the inner Polygon coordinates. +const ring = [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]]; + +describe("normalizeGeojson", () => { + it("moves properties.id to the top-level Feature.id and removes it from properties", () => { + const input = encode([ + { + type: "Feature", + geometry: { type: "Point", coordinates: [1, 2] }, + properties: { id: "abc123", name: "Somewhere" }, + }, + ]); + + const [feature] = JSON.parse(normalizeGeojson(input)).features; + + expect(feature.id).toBe("abc123"); + expect(feature.properties).toEqual({ name: "Somewhere" }); + }); + + it("drops the Overture bbox spatial-index property", () => { + const input = encode([ + { + type: "Feature", + geometry: { type: "Point", coordinates: [1, 2] }, + properties: { + id: "abc123", + name: "Somewhere", + bbox: { xmin: 0, ymin: 0, xmax: 1, ymax: 1 }, + }, + }, + ]); + + const [feature] = JSON.parse(normalizeGeojson(input)).features; + + expect(feature.properties).toEqual({ name: "Somewhere" }); + }); + + it("collapses a single-part MultiPolygon to a Polygon", () => { + const input = encode([ + { + type: "Feature", + geometry: { type: "MultiPolygon", coordinates: [ring] }, + properties: { id: "one-part" }, + }, + ]); + + const [feature] = JSON.parse(normalizeGeojson(input)).features; + + expect(feature.geometry.type).toBe("Polygon"); + expect(feature.geometry.coordinates).toEqual(ring); + }); + + it("collapses a single-part MultiLineString to a LineString", () => { + const line = [[0, 0], [1, 1], [2, 2]]; + const input = encode([ + { + type: "Feature", + geometry: { type: "MultiLineString", coordinates: [line] }, + properties: { id: "one-line" }, + }, + ]); + + const [feature] = JSON.parse(normalizeGeojson(input)).features; + + expect(feature.geometry.type).toBe("LineString"); + expect(feature.geometry.coordinates).toEqual(line); + }); + + it("collapses a single-part MultiPoint to a Point", () => { + const input = encode([ + { + type: "Feature", + geometry: { type: "MultiPoint", coordinates: [[3, 4]] }, + properties: { id: "one-point" }, + }, + ]); + + const [feature] = JSON.parse(normalizeGeojson(input)).features; + + expect(feature.geometry.type).toBe("Point"); + expect(feature.geometry.coordinates).toEqual([3, 4]); + }); + + it("leaves multi-part geometries as multi-geometries", () => { + const twoPolygons = [ring, [[[2, 2], [3, 2], [3, 3], [2, 2]]]]; + const twoLines = [[[0, 0], [1, 1]], [[2, 2], [3, 3]]]; + const input = encode([ + { + type: "Feature", + geometry: { type: "MultiPolygon", coordinates: twoPolygons }, + properties: { id: "two-polygons" }, + }, + { + type: "Feature", + geometry: { type: "MultiLineString", coordinates: twoLines }, + properties: { id: "two-lines" }, + }, + ]); + + const [poly, line] = JSON.parse(normalizeGeojson(input)).features; + + expect(poly.geometry.type).toBe("MultiPolygon"); + expect(poly.geometry.coordinates).toEqual(twoPolygons); + expect(line.geometry.type).toBe("MultiLineString"); + expect(line.geometry.coordinates).toEqual(twoLines); + }); + + it("emits one feature per line", () => { + const feature = (id) => ({ + type: "Feature", + geometry: { type: "Point", coordinates: [0, 0] }, + properties: { id }, + }); + const input = encode([feature("a"), feature("b"), feature("c")]); + + const output = normalizeGeojson(input); + + // Three features -> the feature list spans three lines. + const lines = output.split("\n"); + expect(lines).toHaveLength(5); // header, 3 features, footer + expect(lines[1]).toContain('"a"'); + expect(lines[2]).toContain('"b"'); + expect(lines[3]).toContain('"c"'); + // Still valid GeoJSON. + expect(JSON.parse(output).features).toHaveLength(3); + }); + + it("handles an empty FeatureCollection", () => { + const result = normalizeGeojson(encode([])); + expect(JSON.parse(result)).toEqual({ type: "FeatureCollection", features: [] }); + }); +}); diff --git a/components/nav/DownloadButton.jsx b/components/nav/DownloadButton.jsx index 54217840..c3059dbd 100644 --- a/components/nav/DownloadButton.jsx +++ b/components/nav/DownloadButton.jsx @@ -14,6 +14,7 @@ import initWasm from "@geoarrow/geoarrow-wasm/esm/index.js"; import { getVisibleTypes } from "@/lib/LayerManager"; import { downloadAsZip } from "@/lib/zipDownload"; import { buildDownloadMetadata } from "@/lib/downloadMetadata"; +import { normalizeGeojson } from "@/lib/normalizeGeojson"; import DownloadDialog from "@/components/nav/DownloadDialog"; const ZOOM_BOUND = 15; @@ -152,7 +153,9 @@ function DownloadButton({ mode, zoom, setZoom, visibleTypes}) { const files = nonEmptyTables.map((wasmTable) => ({ name: `overture-${releaseVersion}-${wasmTable.type}-${bboxStr}.geojson`, - data: writeGeoJSON(wasmTable.reader), + // writeGeoJSON puts `id` inside properties and emits every polygon + // as a MultiPolygon; normalize to match the source data / CLI. + data: normalizeGeojson(writeGeoJSON(wasmTable.reader)), })); if (files.length === 0) { diff --git a/lib/normalizeGeojson.js b/lib/normalizeGeojson.js new file mode 100644 index 00000000..69a85117 --- /dev/null +++ b/lib/normalizeGeojson.js @@ -0,0 +1,55 @@ +/** + * Post-processing for the GeoJSON produced by `@geoarrow/geoarrow-wasm`'s + * `writeGeoJSON`, to match the conventions of the source GeoParquet and the + * overturemaps-py CLI: + * + * - `id` is moved from `properties` up to the canonical top-level GeoJSON + * Feature.id member (RFC 7946 §3.2). writeGeoJSON emits every column, + * including `id`, as a property. + * - The `bbox` struct column is dropped. It's Overture's spatial index + * ({xmin, ymin, xmax, ymax}) used for row-group pruning during the query, + * not feature data; overturemaps-py drops it too. + * - Single-part multi-geometries are collapsed to their single form + * (MultiPolygon->Polygon, MultiLineString->LineString, MultiPoint->Point). + * geoarrow-wasm types each geometry column as one GeoArrow array, so a + * column mixing single- and multi-part geometries comes out entirely as the + * multi variant. Collapsing restores the plain geometry the source data + * uses; it's lossless — a one-part multi is the same shape. + * + * Input is the Uint8Array from writeGeoJSON; output is a GeoJSON string, which + * buildZip accepts directly (so there's no re-encode). strFromU8 decodes the + * bytes — the sibling zipDownload.js uses fflate the same way, and jsdom (our + * test env) doesn't provide TextDecoder. + * + * @param {Uint8Array} geojsonBytes + * @returns {string} the normalized GeoJSON + */ +import { strFromU8 } from "fflate"; + +// Each single-part multi-geometry collapses to its single form by peeling one +// level of coordinate nesting (coordinates[0]). +const SINGLE_FORM = { + MultiPoint: "Point", + MultiLineString: "LineString", + MultiPolygon: "Polygon", +}; + +export function normalizeGeojson(geojsonBytes) { + const collection = JSON.parse(strFromU8(geojsonBytes)); + for (const feature of collection.features) { + feature.id = feature.properties.id; + delete feature.properties.id; + delete feature.properties.bbox; + + const geom = feature.geometry; + if (geom && SINGLE_FORM[geom.type] && geom.coordinates.length === 1) { + geom.type = SINGLE_FORM[geom.type]; + geom.coordinates = geom.coordinates[0]; + } + } + // Emit one feature per line, matching writeGeoJSON's original layout, rather + // than the single line JSON.stringify(collection) would produce. Keeps the + // file diff-friendly and streamable line-by-line. + const features = collection.features.map((f) => JSON.stringify(f)).join(",\n"); + return `{ "type": "FeatureCollection", "features": [\n${features}\n] }`; +}