From 406b3ebbe1f7101b7f64ed090abc52d3c9aea7c9 Mon Sep 17 00:00:00 2001 From: Blayne Chard Date: Fri, 6 Sep 2024 16:01:49 +1200 Subject: [PATCH] feat(linzjs-geojson): add iterate and truncate utilities for geojson (#3340) ### Motivation We have had a need to reproject and truncate geojson objects and instead of copying the logic into multiple locations add it to the geojson helper we have here. ### Modifications Add truncate to truncate lat lon pairs to by default 8 decimal places Add iterate to iterate over all the points in a geojson feature ### Verification unit tests and is currently in use in other code bases. --- packages/linzjs-geojson/src/index.ts | 2 + .../src/util/__test__/iterate.test.ts | 80 +++++++++++++++++++ .../src/util/__test__/truncate.test.ts | 48 +++++++++++ packages/linzjs-geojson/src/util/iterate.ts | 39 +++++++++ packages/linzjs-geojson/src/util/truncate.ts | 27 +++++++ 5 files changed, 196 insertions(+) create mode 100644 packages/linzjs-geojson/src/util/__test__/iterate.test.ts create mode 100644 packages/linzjs-geojson/src/util/__test__/truncate.test.ts create mode 100644 packages/linzjs-geojson/src/util/iterate.ts create mode 100644 packages/linzjs-geojson/src/util/truncate.ts diff --git a/packages/linzjs-geojson/src/index.ts b/packages/linzjs-geojson/src/index.ts index 41428eb17..e6bd58cb2 100644 --- a/packages/linzjs-geojson/src/index.ts +++ b/packages/linzjs-geojson/src/index.ts @@ -4,4 +4,6 @@ export * from './multipolygon/area.js'; export * from './multipolygon/clipped.js'; export * from './multipolygon/convert.js'; export * from './types.js'; +export * from './util/iterate.js'; +export * from './util/truncate.js'; export * from './wgs84.js'; diff --git a/packages/linzjs-geojson/src/util/__test__/iterate.test.ts b/packages/linzjs-geojson/src/util/__test__/iterate.test.ts new file mode 100644 index 000000000..566392a20 --- /dev/null +++ b/packages/linzjs-geojson/src/util/__test__/iterate.test.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import { iterate } from '../iterate.js'; + +export const TestGeometries = { + Point: { type: 'Point', coordinates: [0, 1] } as GeoJSON.Point, + MultiPoint: { + type: 'MultiPoint', + coordinates: [ + [0, 1], + [1, 2], + ], + } as GeoJSON.MultiPoint, + Polygon: { + type: 'Polygon', + coordinates: [ + [ + [0, 1], + [1, 2], + ], + [ + [3, 4], + [5, 6], + ], + ], + } as GeoJSON.Polygon, + MultiPolygon: { + type: 'MultiPolygon', + coordinates: [ + [ + [ + [0, 1], + [1, 2], + ], + [ + [3, 4], + [5, 6], + ], + ], + ], + } as GeoJSON.MultiPolygon, + LineString: { + type: 'LineString', + coordinates: [ + [0, 1], + [1, 2], + ], + } as GeoJSON.LineString, + MultiLineString: { + type: 'MultiLineString', + coordinates: [ + [ + [0, 1], + [1, 2], + ], + [[1, 2]], + ], + } as GeoJSON.MultiLineString, +}; + +describe('iterate', () => { + const fakeGeojson = { type: 'Feature', properties: {} } as const; + + for (const [name, geometry] of Object.entries(TestGeometries)) { + describe(name, () => { + it('should iterate a ' + name, (t) => { + const cb = t.mock.fn(); + iterate({ ...fakeGeojson, geometry }, cb); + const flatCoords = geometry.coordinates.flat(100); + + assert.equal(cb.mock.callCount(), flatCoords.length / 2); + for (let i = 0; i < flatCoords.length; i += 2) { + const coord = [flatCoords[i], flatCoords[i + 1]]; + assert.deepEqual(cb.mock.calls[i / 2].arguments[0], coord); + } + }); + }); + } +}); diff --git a/packages/linzjs-geojson/src/util/__test__/truncate.test.ts b/packages/linzjs-geojson/src/util/__test__/truncate.test.ts new file mode 100644 index 000000000..31f68bf6f --- /dev/null +++ b/packages/linzjs-geojson/src/util/__test__/truncate.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import { iterate } from '../iterate.js'; +import { truncate } from '../truncate.js'; +import { TestGeometries } from './iterate.test.js'; + +describe('truncate', () => { + const fakeGeojson = { type: 'Feature', properties: {} } as const; + + for (const [name, geom] of Object.entries(TestGeometries)) { + describe(name, () => { + it(`should truncate ${name}`, () => { + const geometry = structuredClone(geom); + const json = { ...fakeGeojson, geometry } as GeoJSON.Feature; + iterate(json, (pt) => { + pt[0] = 1 + 1e-9; + pt[1] = 1 - 1e-9; + }); + + truncate(json); + + // Validate every point has been truncated to 1 or -1 + assert.equal( + geometry.coordinates.flat(100).every((f) => f === 1), + true, + ); + }); + + it(`should not truncate less ${name} to than 8dp`, () => { + const geometry = structuredClone(geom); + const json = { ...fakeGeojson, geometry } as GeoJSON.Feature; + iterate(json, (pt) => { + pt[0] = 1.1; + pt[1] = -1.1; + }); + + truncate(json); + + // Validate every point has been truncated to 1 or -1 + assert.equal( + geometry.coordinates.flat(100).every((f) => f === 1.1 || f === -1.1), + true, + ); + }); + }); + } +}); diff --git a/packages/linzjs-geojson/src/util/iterate.ts b/packages/linzjs-geojson/src/util/iterate.ts new file mode 100644 index 000000000..55a21a13f --- /dev/null +++ b/packages/linzjs-geojson/src/util/iterate.ts @@ -0,0 +1,39 @@ +/** + * Iterate all the positions inside the features positions + * + * @throws if geometry is of unknown type + * + * @param feature Feature to iterate + * @param cb call back to run on the position + * @returns + */ +export function iterate(feature: GeoJSON.Feature, cb: (pt: [number, number]) => void): void { + const geom = feature.geometry; + if (geom.type === 'Point') return cb(geom.coordinates as [number, number]); + if (geom.type === 'MultiPoint') return iteratePosition(geom.coordinates, cb); + if (geom.type === 'Polygon') return iteratePosition2(geom.coordinates, cb); + if (geom.type === 'MultiPolygon') return iteratePosition3(geom.coordinates, cb); + if (geom.type === 'LineString') return iteratePosition(geom.coordinates, cb); + if (geom.type === 'MultiLineString') return iteratePosition2(geom.coordinates, cb); + + throw new Error('Unknown geometry type '); +} + +// Iteration functions for three levels of nested positions commonly used in geojson +function iteratePosition3(coords: GeoJSON.Position[][][], cb: (pt: [number, number]) => void): void { + for (const outer of coords) { + for (const poly of outer) { + for (const pt of poly) cb(pt as [number, number]); + } + } +} + +function iteratePosition2(coords: GeoJSON.Position[][], cb: (pt: [number, number]) => void): void { + for (const poly of coords) { + for (const pt of poly) cb(pt as [number, number]); + } +} + +function iteratePosition(coords: GeoJSON.Position[], cb: (pt: [number, number]) => void): void { + for (const pt of coords) cb(pt as [number, number]); +} diff --git a/packages/linzjs-geojson/src/util/truncate.ts b/packages/linzjs-geojson/src/util/truncate.ts new file mode 100644 index 000000000..ea039d221 --- /dev/null +++ b/packages/linzjs-geojson/src/util/truncate.ts @@ -0,0 +1,27 @@ +import { iterate } from './iterate.js'; + +/** + * Number of decimal places to restrict capture areas to + * Rough numbers of decimal places to precision in meters + * + * 5DP - 1m + * 6DP - 0.1m + * 7DP - 0.01m (1cm) + * 8DP - 0.001m (1mm) + */ +const DefaultTruncationFactor = 8; + +/** + * Truncate a multi polygon in lat,lng to {@link DefaultTruncationFactor} decimal places + * + * @warning This destroys the source geometry + * @param polygons + */ +export function truncate(feature: GeoJSON.Feature, truncateFactor = DefaultTruncationFactor): void { + const factor = Math.pow(10, truncateFactor); + + iterate(feature, (pt) => { + pt[0] = Math.round(pt[0] * factor) / factor; + pt[1] = Math.round(pt[1] * factor) / factor; + }); +}