Skip to content

Commit 38bb3a4

Browse files
committed
feat(geojson): encoding/decoding, RegionCoverer
1 parent 18c00fb commit 38bb3a4

25 files changed

Lines changed: 794 additions & 1 deletion

geojson/RegionCoverer.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import type * as geojson from 'geojson'
2+
import { MAX_LEVEL } from '../s2/cellid_constants'
3+
import { CellUnion } from '../s2/CellUnion'
4+
import * as geometry from './geometry'
5+
import { Polyline } from '../s2/Polyline'
6+
import { Polygon } from '../s2/Polygon'
7+
import type { Region } from '../s2/Region'
8+
import type { RegionCovererOptions as S2RegionCovererOptions } from '../s2/RegionCoverer'
9+
import { RegionCoverer as S2RegionCoverer } from '../s2/RegionCoverer'
10+
11+
/**
12+
* RegionCovererOptions allows the RegionCoverer to be configured.
13+
*/
14+
export interface RegionCovererOptions extends S2RegionCovererOptions {
15+
/**
16+
* the maximum desired number of cells for each member of a multi-member geometry in the approximation.
17+
* @default Math.max(Math.floor(maxCells / 10), 8)
18+
*/
19+
memberMaxCells?: number
20+
21+
/**
22+
* the maximum size the approximation may reach before a compaction is triggered.
23+
* used to avoid OOM errors.
24+
* @default 65536
25+
*/
26+
compactAt?: number
27+
28+
/**
29+
* the maximum area of a shape to be considered for fast covering.
30+
* used to speed up covering small shapes.
31+
* area values are between 0 and 4*Pi.
32+
* @default 1e-6
33+
*/
34+
smallAreaEpsilon?: number
35+
}
36+
37+
/**
38+
* RegionCoverer allows arbitrary GeoJSON geometries to be approximated as unions of cells (CellUnion).
39+
*
40+
* Typical usage:
41+
*
42+
* feature = loadGeoJSON()
43+
* rc = new RegionCoverer({ maxCells: 256, memberMaxCells: 64 })
44+
* covering = rc.covering(feature.geometry)
45+
*
46+
* @beta unstable API
47+
*/
48+
export class RegionCoverer {
49+
coverer: S2RegionCoverer
50+
memberCoverer: S2RegionCoverer
51+
compactAt: number
52+
smallAreaEpsilon: number
53+
54+
/**
55+
* Returns a new RegionCoverer with the appropriate defaults.
56+
*
57+
* @param options - RegionCoverer options
58+
*
59+
* @category Constructors
60+
*/
61+
constructor({
62+
minLevel = 0,
63+
maxLevel = MAX_LEVEL,
64+
levelMod = 1,
65+
maxCells = 8,
66+
memberMaxCells = Math.max(Math.floor(maxCells / 10), 8),
67+
compactAt = 65536,
68+
smallAreaEpsilon = 1e-6
69+
}: RegionCovererOptions = {}) {
70+
this.coverer = new S2RegionCoverer({ minLevel, maxLevel, levelMod, maxCells })
71+
this.memberCoverer = new S2RegionCoverer({ minLevel, maxLevel, levelMod, maxCells: memberMaxCells })
72+
this.compactAt = compactAt
73+
this.smallAreaEpsilon = smallAreaEpsilon
74+
}
75+
76+
/** Computes the covering of a multi-member geometry (ie. MultiPoint, MultiLineString, MultiPolygon). */
77+
private mutliMemberCovering(shapes: Region[]): CellUnion {
78+
// sort shapes from largest to smallest
79+
shapes.sort((a: Region, b: Region): number => RegionCoverer.area(b) - RegionCoverer.area(a))
80+
81+
let union = new CellUnion()
82+
shapes.forEach((shape: Region) => {
83+
// optionally elect to use a fast covering method for small areas
84+
const fast = union.length >= this.memberCoverer.maxCells && RegionCoverer.area(shape) < this.smallAreaEpsilon
85+
86+
// append covering to union
87+
union = CellUnion.fromUnion(
88+
union,
89+
fast ? this.memberCoverer.fastCovering(shape) : this.memberCoverer.covering(shape)
90+
)
91+
92+
// force compact large coverings to avoid OOM errors
93+
if (union.length >= this.compactAt) union = this.coverer.covering(union)
94+
})
95+
96+
// reduce the global covering to maxCells
97+
if (union.length < this.coverer.maxCells) return union
98+
return this.coverer.covering(union)
99+
}
100+
101+
/** Returns a CellUnion that covers the given GeoJSON geometry and satisfies the various restrictions. */
102+
covering(geom: geojson.Geometry): CellUnion {
103+
const shape = geometry.unmarshal(geom)
104+
if (Array.isArray(shape)) return this.mutliMemberCovering(shape as Region[])
105+
return this.coverer.covering(shape)
106+
}
107+
108+
/** Computes the area of a shape */
109+
static area(shape: Region): number {
110+
if (shape instanceof Polygon) return shape.area()
111+
if (shape instanceof Polyline) shape.capBound().area()
112+
return 0
113+
}
114+
}

geojson/_index.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Module geojson implements types and functions for working with GeoJSON.
3+
* @module geojson
4+
*/
5+
// export * as Position from './position'
6+
// export * as Loop from './loop'
7+
8+
export * as Point from './point'
9+
export * as Linestring from './linestring'
10+
export * as Polygon from './polygon'
11+
12+
export * as MultiPoint from './point_multi'
13+
export * as MultiLineString from './linestring_multi'
14+
export * as MultiPolygon from './polygon_multi'
15+
16+
export * as Geometry from './geometry'
17+
18+
export * as Rect from './rect'
19+
export * as Cell from './cell'
20+
export * as CellID from './cellid'
21+
22+
export { RegionCoverer } from './RegionCoverer'
23+
export type { RegionCovererOptions } from './RegionCoverer'

geojson/cell.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type * as geojson from 'geojson'
2+
import { Cell } from '../s2/Cell'
3+
import { Loop } from '../s2/Loop'
4+
import { Polygon } from '../s2/Polygon'
5+
import * as polygon from './polygon'
6+
import * as loop from './loop'
7+
8+
/**
9+
* Returns a geojson Polygon given an s2 Cell.
10+
* @category Constructors
11+
*/
12+
export const marshal = (cell: Cell): geojson.Polygon => {
13+
const loop = new Loop([cell.vertex(0), cell.vertex(1), cell.vertex(2), cell.vertex(3)])
14+
return polygon.marshal(new Polygon([loop]))
15+
}
16+
17+
/**
18+
* Constructs a Cell from the centroid of a geojson Polygon.
19+
* @category Constructors
20+
*/
21+
export const unmarshal = (geometry: geojson.Polygon): Cell => {
22+
const ring = loop.unmarshal(geometry.coordinates[0], 0)
23+
return Cell.fromPoint(ring.capBound().centroid())
24+
}

geojson/cell_test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { test, describe } from 'node:test'
2+
import { deepEqual } from 'node:assert/strict'
3+
import * as cell from './cell'
4+
import { randomCellIDForLevel } from '../s2/testing'
5+
import { Cell } from '../s2/Cell'
6+
7+
describe('geojson', () => {
8+
test('cell', (t) => {
9+
// @todo: test other levels
10+
const faceCells: Cell[] = [
11+
Cell.fromCellID(randomCellIDForLevel(30)),
12+
Cell.fromCellID(randomCellIDForLevel(30)),
13+
Cell.fromCellID(randomCellIDForLevel(30)),
14+
Cell.fromCellID(randomCellIDForLevel(30)),
15+
Cell.fromCellID(randomCellIDForLevel(30)),
16+
Cell.fromCellID(randomCellIDForLevel(30)),
17+
Cell.fromCellID(randomCellIDForLevel(30))
18+
]
19+
20+
faceCells.forEach((c) => {
21+
const encoded = cell.marshal(c)
22+
const decoded = cell.unmarshal(encoded)
23+
deepEqual(decoded, c)
24+
})
25+
})
26+
})

geojson/cellid.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type * as geojson from 'geojson'
2+
import type { CellID } from '../s2/cellid'
3+
import { Cell } from '../s2/Cell'
4+
import * as cellid from '../s2/cellid'
5+
import * as cell from './cell'
6+
import * as loop from './loop'
7+
8+
/**
9+
* Returns a geojson Polygon given an s2 CellID.
10+
* @category Constructors
11+
*/
12+
export const marshal = (cid: CellID): geojson.Polygon => {
13+
return cell.marshal(Cell.fromCellID(cid))
14+
}
15+
16+
/**
17+
* Constructs the centroid CellID given a geojson Polygon.
18+
* @category Constructors
19+
*/
20+
export const unmarshal = (geometry: geojson.Polygon): CellID => {
21+
const ring = loop.unmarshal(geometry.coordinates[0], 0)
22+
return cellid.fromPoint(ring.capBound().centroid())
23+
}

geojson/cellid_test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { test, describe } from 'node:test'
2+
import { equal } from 'node:assert/strict'
3+
import * as cellid from './cellid'
4+
import type { CellID } from '../s2/cellid'
5+
import { randomCellIDForLevel } from '../s2/testing'
6+
7+
describe('geojson', () => {
8+
test('cellid', (t) => {
9+
// @todo: test other levels
10+
const faces: CellID[] = [
11+
randomCellIDForLevel(30),
12+
randomCellIDForLevel(30),
13+
randomCellIDForLevel(30),
14+
randomCellIDForLevel(30),
15+
randomCellIDForLevel(30),
16+
randomCellIDForLevel(30),
17+
randomCellIDForLevel(30)
18+
]
19+
20+
faces.forEach((cid) => {
21+
const encoded = cellid.marshal(cid)
22+
const decoded = cellid.unmarshal(encoded)
23+
equal(decoded, cid)
24+
})
25+
})
26+
})

geojson/geometry.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type * as geojson from 'geojson'
2+
import { Point } from '../s2/Point'
3+
import { Polyline } from '../s2/Polyline'
4+
import { Polygon } from '../s2/Polygon'
5+
import * as point from './point'
6+
import * as linestring from './linestring'
7+
import * as polygon from './polygon'
8+
import * as point_multi from './point_multi'
9+
import * as linestring_multi from './linestring_multi'
10+
import * as polygon_multi from './polygon_multi'
11+
12+
type Geometry = Point | Polyline | Polygon | Geometry[]
13+
14+
/**
15+
* Returns a geojson Geometry given a s2 shape(s).
16+
* @category Constructors
17+
*/
18+
export const marshal = (shape: Geometry): geojson.Geometry => {
19+
if (shape instanceof Point) return point.marshal(shape)
20+
if (shape instanceof Polyline) return linestring.marshal(shape)
21+
if (shape instanceof Polygon) return polygon.marshal(shape)
22+
23+
if (Array.isArray(shape) && shape.length) {
24+
if (shape.every((g) => g instanceof Point)) return point_multi.marshal(shape as Point[])
25+
if (shape.every((g) => g instanceof Polyline)) return linestring_multi.marshal(shape as Polyline[])
26+
if (shape.every((g) => g instanceof Polygon)) return polygon_multi.marshal(shape as Polygon[])
27+
}
28+
29+
throw new Error(`unsupported: ${shape?.constructor?.name || 'UnknownShape'}`)
30+
}
31+
32+
/**
33+
* Constructs s2 shape(s) given a geojson geometry.
34+
* @category Constructors
35+
*/
36+
export const unmarshal = (geometry: geojson.Geometry): Geometry => {
37+
const t = geometry?.type
38+
39+
if (t === 'Point') return point.unmarshal(geometry as geojson.Point)
40+
if (t === 'LineString') return linestring.unmarshal(geometry as geojson.LineString)
41+
if (t === 'Polygon') return polygon.unmarshal(geometry as geojson.Polygon)
42+
43+
if (t === 'MultiPoint') return point_multi.unmarshal(geometry as geojson.MultiPoint)
44+
if (t === 'MultiLineString') return linestring_multi.unmarshal(geometry as geojson.MultiLineString)
45+
if (t === 'MultiPolygon') return polygon_multi.unmarshal(geometry as geojson.MultiPolygon)
46+
47+
throw new Error(`unsupported: ${t || 'UnknownGeometryType'}`)
48+
}

geojson/linestring.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type * as geojson from 'geojson'
2+
import * as position from './position'
3+
import { Polyline } from '../s2/Polyline'
4+
5+
/**
6+
* Returns a geojson LineString geometry given an s2 Polyline.
7+
* @category Constructors
8+
*/
9+
export const marshal = (polyline: Polyline): geojson.LineString => {
10+
return {
11+
type: 'LineString',
12+
coordinates: polyline.points.map(position.marshal)
13+
}
14+
}
15+
16+
/**
17+
* Constructs an s2 Polyline given a geojson LineString geometry.
18+
* @category Constructors
19+
*/
20+
export const unmarshal = (geometry: geojson.LineString): Polyline => {
21+
return new Polyline(geometry.coordinates.map(position.unmarshal))
22+
}

geojson/linestring_multi.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type * as geojson from 'geojson'
2+
import * as position from './position'
3+
import { Polyline } from '../s2/Polyline'
4+
5+
/**
6+
* Returns a geojson MultiLineString geometry given s2 Polylines.
7+
* @category Constructors
8+
*/
9+
export const marshal = (polylines: Polyline[]): geojson.MultiLineString => {
10+
return {
11+
type: 'MultiLineString',
12+
coordinates: polylines.map((polyline) => polyline.points.map(position.marshal))
13+
}
14+
}
15+
16+
/**
17+
* Constructs s2 Polylines given a geojson MultiLineString geometry.
18+
* @category Constructors
19+
*/
20+
export const unmarshal = (geometry: geojson.MultiLineString): Polyline[] => {
21+
return geometry.coordinates.map((polyline) => new Polyline(polyline.map(position.unmarshal)))
22+
}

geojson/linestring_test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { test, describe } from 'node:test'
2+
import { ok } from 'node:assert/strict'
3+
import type * as geojson from 'geojson'
4+
import { approxEqual } from './testing'
5+
import * as linestring from './linestring'
6+
7+
describe('geojson', () => {
8+
test('linestring', (t) => {
9+
const geometries: geojson.LineString[] = [
10+
{
11+
type: 'LineString',
12+
coordinates: [
13+
[0, 0],
14+
[2, 2],
15+
[-180, -90],
16+
[180, 90]
17+
]
18+
},
19+
{
20+
type: 'LineString',
21+
coordinates: [
22+
[1, 1],
23+
[2, 2],
24+
[3, 3],
25+
[4, 4],
26+
[5, 5]
27+
]
28+
},
29+
{
30+
type: 'LineString',
31+
coordinates: [
32+
[102.0, 0.0],
33+
[103.0, 1.0],
34+
[104.0, 0.0],
35+
[105.0, 1.0]
36+
]
37+
}
38+
]
39+
40+
geometries.forEach((geometry) => {
41+
const decoded = linestring.unmarshal(geometry)
42+
const encoded = linestring.marshal(decoded)
43+
ok(approxEqual(encoded, geometry), JSON.stringify(geometry) + ' -> ' + JSON.stringify(encoded))
44+
})
45+
})
46+
})

0 commit comments

Comments
 (0)