Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions __tests__/normalizeGeojson.test.js
Original file line number Diff line number Diff line change
@@ -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: [] });
});
});
5 changes: 4 additions & 1 deletion components/nav/DownloadButton.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
55 changes: 55 additions & 0 deletions lib/normalizeGeojson.js
Original file line number Diff line number Diff line change
@@ -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] }`;
}
Loading