@motionscript/geography 0.1.0-alpha.8 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/CREDITS.md +8 -8
- package/LICENSE +201 -201
- package/NOTICE +49 -49
- package/README.md +149 -149
- package/dist/border/format.js.map +1 -1
- package/dist/border/geo-border.js.map +1 -1
- package/dist/border/geometry.js.map +1 -1
- package/dist/border/index.d.ts +1 -1
- package/dist/border/index.js +1 -1
- package/dist/border/index.js.map +1 -1
- package/dist/border/loader.js.map +1 -1
- package/dist/border/registry.js.map +1 -1
- package/dist/border/selection.js.map +1 -1
- package/dist/border/simplify.js.map +1 -1
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/manifest.json +1 -1
- package/dist/index.js.map +1 -1
- package/dist/nodes.js.map +1 -1
- package/package.json +4 -4
- package/registry.json +16 -16
- package/src/border/format.ts +147 -147
- package/src/border/geo-border.ts +268 -268
- package/src/border/geometry.ts +463 -463
- package/src/border/index.ts +14 -14
- package/src/border/loader.ts +97 -97
- package/src/border/registry.ts +201 -201
- package/src/border/selection.ts +95 -95
- package/src/border/simplify.ts +110 -110
- package/src/index.ts +2 -2
- package/src/nodes.ts +16 -16
package/src/border/index.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* GeoBorder: country and subdivision outlines by ISO 3166 code.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* here — the source registry, the file cache, the geometry — stays behind the
|
|
6
|
-
* package, so a forked node shares the host's registered sources instead of
|
|
7
|
-
* owning an empty registry of its own.
|
|
8
|
-
*/
|
|
9
|
-
export * from "./format"
|
|
10
|
-
export * from "./geo-border"
|
|
11
|
-
export * from "./geometry"
|
|
12
|
-
export * from "./loader"
|
|
13
|
-
export * from "./registry"
|
|
14
|
-
export * from "./selection"
|
|
1
|
+
/**
|
|
2
|
+
* GeoBorder: country and subdivision outlines by ISO 3166 code.
|
|
3
|
+
*
|
|
4
|
+
* A copy of `geo-border` takes only `geo-border.ts`. Everything it imports from
|
|
5
|
+
* here — the source registry, the file cache, the geometry — stays behind the
|
|
6
|
+
* package, so a forked node shares the host's registered sources instead of
|
|
7
|
+
* owning an empty registry of its own.
|
|
8
|
+
*/
|
|
9
|
+
export * from "./format"
|
|
10
|
+
export * from "./geo-border"
|
|
11
|
+
export * from "./geometry"
|
|
12
|
+
export * from "./loader"
|
|
13
|
+
export * from "./registry"
|
|
14
|
+
export * from "./selection"
|
package/src/border/loader.ts
CHANGED
|
@@ -1,97 +1,97 @@
|
|
|
1
|
-
import type { LoaderFn } from "@motionscript/sdk"
|
|
2
|
-
|
|
3
|
-
import { decodeGeoFile, type DecodedGeoFile, type GeoFileData } from "./format"
|
|
4
|
-
import { GEO_DATA_VERSION, geoRegistry } from "./registry"
|
|
5
|
-
|
|
6
|
-
export type GeoFileEntry =
|
|
7
|
-
| { status: "ready"; file: DecodedGeoFile }
|
|
8
|
-
| { status: "missing" }
|
|
9
|
-
| { status: "failed"; error: unknown }
|
|
10
|
-
|
|
11
|
-
const entries = new Map<string, GeoFileEntry>()
|
|
12
|
-
const inflight = new Map<string, Promise<GeoFileEntry>>()
|
|
13
|
-
const loaders = new Map<string, LoaderFn>()
|
|
14
|
-
const warned = new Set<string>()
|
|
15
|
-
let version = 0
|
|
16
|
-
|
|
17
|
-
export function countryFileKey(code: string): string {
|
|
18
|
-
return `${GEO_DATA_VERSION}/countries/${code}`
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function statesFileKey(country: string): string {
|
|
22
|
-
return `${GEO_DATA_VERSION}/states/${country}`
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Bumps whenever a file lands, so derived geometry keyed on it rebuilds. */
|
|
26
|
-
export function geoDataVersion(): number {
|
|
27
|
-
return version
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function geoFileEntry(key: string): GeoFileEntry | undefined {
|
|
31
|
-
return entries.get(key)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
async function fetchEntry(key: string): Promise<GeoFileEntry> {
|
|
35
|
-
try {
|
|
36
|
-
let data = (await geoRegistry.resolve(key)) as GeoFileData | null
|
|
37
|
-
// A country with no subdivisions has no states file; its outline stands in.
|
|
38
|
-
if (data === null && key.includes("/states/")) {
|
|
39
|
-
data = (await geoRegistry.resolve(key.replace("/states/", "/countries/"))) as GeoFileData | null
|
|
40
|
-
}
|
|
41
|
-
return data === null ? { status: "missing" } : { status: "ready", file: decodeGeoFile(data) }
|
|
42
|
-
} catch (error) {
|
|
43
|
-
return { status: "failed", error }
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Loads one data file into the shared cache. Never rejects: a rejected loader
|
|
49
|
-
* is retried on every tick and fails an export outright, where a recorded
|
|
50
|
-
* failure draws nothing and is tried again by the next call.
|
|
51
|
-
*/
|
|
52
|
-
export function loadGeoFile(key: string): Promise<GeoFileEntry> {
|
|
53
|
-
const current = entries.get(key)
|
|
54
|
-
if (current?.status === "ready") return Promise.resolve(current)
|
|
55
|
-
let job = inflight.get(key)
|
|
56
|
-
if (!job) {
|
|
57
|
-
job = fetchEntry(key).then((entry) => {
|
|
58
|
-
inflight.delete(key)
|
|
59
|
-
entries.set(key, entry)
|
|
60
|
-
version++
|
|
61
|
-
if (entry.status === "ready") {
|
|
62
|
-
warned.delete(key)
|
|
63
|
-
} else if (!warned.has(key)) {
|
|
64
|
-
warned.add(key)
|
|
65
|
-
if (entry.status === "failed") console.warn(`[GeoBorder] could not load ${key}:`, entry.error)
|
|
66
|
-
else console.warn(`[GeoBorder] no geo source has ${key}`)
|
|
67
|
-
}
|
|
68
|
-
return entry
|
|
69
|
-
})
|
|
70
|
-
inflight.set(key, job)
|
|
71
|
-
}
|
|
72
|
-
return job
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* The loader an `AssetScope` declares for a file: one function per key, so
|
|
77
|
-
* every node that needs the file declares the same one. Nothing is disposed —
|
|
78
|
-
* decoded data is shared by every engine in the process.
|
|
79
|
-
*/
|
|
80
|
-
export function geoLoader(key: string): LoaderFn {
|
|
81
|
-
let load = loaders.get(key)
|
|
82
|
-
if (!load) {
|
|
83
|
-
load = async () => {
|
|
84
|
-
await loadGeoFile(key)
|
|
85
|
-
}
|
|
86
|
-
loaders.set(key, load)
|
|
87
|
-
}
|
|
88
|
-
return load
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Forgets every loaded file — for tests. */
|
|
92
|
-
export function clearGeoFiles(): void {
|
|
93
|
-
entries.clear()
|
|
94
|
-
inflight.clear()
|
|
95
|
-
warned.clear()
|
|
96
|
-
version++
|
|
97
|
-
}
|
|
1
|
+
import type { LoaderFn } from "@motionscript/sdk"
|
|
2
|
+
|
|
3
|
+
import { decodeGeoFile, type DecodedGeoFile, type GeoFileData } from "./format"
|
|
4
|
+
import { GEO_DATA_VERSION, geoRegistry } from "./registry"
|
|
5
|
+
|
|
6
|
+
export type GeoFileEntry =
|
|
7
|
+
| { status: "ready"; file: DecodedGeoFile }
|
|
8
|
+
| { status: "missing" }
|
|
9
|
+
| { status: "failed"; error: unknown }
|
|
10
|
+
|
|
11
|
+
const entries = new Map<string, GeoFileEntry>()
|
|
12
|
+
const inflight = new Map<string, Promise<GeoFileEntry>>()
|
|
13
|
+
const loaders = new Map<string, LoaderFn>()
|
|
14
|
+
const warned = new Set<string>()
|
|
15
|
+
let version = 0
|
|
16
|
+
|
|
17
|
+
export function countryFileKey(code: string): string {
|
|
18
|
+
return `${GEO_DATA_VERSION}/countries/${code}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function statesFileKey(country: string): string {
|
|
22
|
+
return `${GEO_DATA_VERSION}/states/${country}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Bumps whenever a file lands, so derived geometry keyed on it rebuilds. */
|
|
26
|
+
export function geoDataVersion(): number {
|
|
27
|
+
return version
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function geoFileEntry(key: string): GeoFileEntry | undefined {
|
|
31
|
+
return entries.get(key)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function fetchEntry(key: string): Promise<GeoFileEntry> {
|
|
35
|
+
try {
|
|
36
|
+
let data = (await geoRegistry.resolve(key)) as GeoFileData | null
|
|
37
|
+
// A country with no subdivisions has no states file; its outline stands in.
|
|
38
|
+
if (data === null && key.includes("/states/")) {
|
|
39
|
+
data = (await geoRegistry.resolve(key.replace("/states/", "/countries/"))) as GeoFileData | null
|
|
40
|
+
}
|
|
41
|
+
return data === null ? { status: "missing" } : { status: "ready", file: decodeGeoFile(data) }
|
|
42
|
+
} catch (error) {
|
|
43
|
+
return { status: "failed", error }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Loads one data file into the shared cache. Never rejects: a rejected loader
|
|
49
|
+
* is retried on every tick and fails an export outright, where a recorded
|
|
50
|
+
* failure draws nothing and is tried again by the next call.
|
|
51
|
+
*/
|
|
52
|
+
export function loadGeoFile(key: string): Promise<GeoFileEntry> {
|
|
53
|
+
const current = entries.get(key)
|
|
54
|
+
if (current?.status === "ready") return Promise.resolve(current)
|
|
55
|
+
let job = inflight.get(key)
|
|
56
|
+
if (!job) {
|
|
57
|
+
job = fetchEntry(key).then((entry) => {
|
|
58
|
+
inflight.delete(key)
|
|
59
|
+
entries.set(key, entry)
|
|
60
|
+
version++
|
|
61
|
+
if (entry.status === "ready") {
|
|
62
|
+
warned.delete(key)
|
|
63
|
+
} else if (!warned.has(key)) {
|
|
64
|
+
warned.add(key)
|
|
65
|
+
if (entry.status === "failed") console.warn(`[GeoBorder] could not load ${key}:`, entry.error)
|
|
66
|
+
else console.warn(`[GeoBorder] no geo source has ${key}`)
|
|
67
|
+
}
|
|
68
|
+
return entry
|
|
69
|
+
})
|
|
70
|
+
inflight.set(key, job)
|
|
71
|
+
}
|
|
72
|
+
return job
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The loader an `AssetScope` declares for a file: one function per key, so
|
|
77
|
+
* every node that needs the file declares the same one. Nothing is disposed —
|
|
78
|
+
* decoded data is shared by every engine in the process.
|
|
79
|
+
*/
|
|
80
|
+
export function geoLoader(key: string): LoaderFn {
|
|
81
|
+
let load = loaders.get(key)
|
|
82
|
+
if (!load) {
|
|
83
|
+
load = async () => {
|
|
84
|
+
await loadGeoFile(key)
|
|
85
|
+
}
|
|
86
|
+
loaders.set(key, load)
|
|
87
|
+
}
|
|
88
|
+
return load
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Forgets every loaded file — for tests. */
|
|
92
|
+
export function clearGeoFiles(): void {
|
|
93
|
+
entries.clear()
|
|
94
|
+
inflight.clear()
|
|
95
|
+
warned.clear()
|
|
96
|
+
version++
|
|
97
|
+
}
|
package/src/border/registry.ts
CHANGED
|
@@ -1,201 +1,201 @@
|
|
|
1
|
-
import { geoArea } from "d3-geo"
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
GEO_DATA_FORMAT,
|
|
5
|
-
type GeoFeature,
|
|
6
|
-
type GeoFileData,
|
|
7
|
-
type GeoIndex,
|
|
8
|
-
type GeoPolygon,
|
|
9
|
-
type GeoRingRef,
|
|
10
|
-
} from "./format"
|
|
11
|
-
import { ringBuckets } from "./simplify"
|
|
12
|
-
|
|
13
|
-
/** Prefix every data key carries; bumped with the file format. */
|
|
14
|
-
export const GEO_DATA_VERSION = `v${GEO_DATA_FORMAT}`
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Answers a data key — `v1/countries/FR`, `v1/states/US`, `v1/index` — with the
|
|
18
|
-
* parsed file, or `null` when this source does not have it. A throw means "could
|
|
19
|
-
* not tell" (offline, a 500) and is retried on the next load.
|
|
20
|
-
*/
|
|
21
|
-
export type GeoSource = (key: string) => Promise<unknown>
|
|
22
|
-
|
|
23
|
-
type Position = readonly [number, number] | readonly number[]
|
|
24
|
-
type GeoPolygonShape =
|
|
25
|
-
| { type: "Polygon"; coordinates: readonly (readonly Position[])[] }
|
|
26
|
-
| { type: "MultiPolygon"; coordinates: readonly (readonly (readonly Position[])[])[] }
|
|
27
|
-
/** GeoJSON in lon/lat: a Polygon, a MultiPolygon, or a Feature holding one. */
|
|
28
|
-
export type GeoShape = GeoPolygonShape | { type: "Feature"; geometry: GeoPolygonShape; properties?: { name?: string } | null }
|
|
29
|
-
|
|
30
|
-
export interface GeoDefinition {
|
|
31
|
-
shape: GeoShape
|
|
32
|
-
name?: string
|
|
33
|
-
/** The country a subdivision code expands under, when it has one. */
|
|
34
|
-
parent?: string
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Fetches `<base>/<key>.json`; `base` may instead map a key to its URL. */
|
|
38
|
-
export function geoUrlSource(base: string | ((key: string) => string), init?: RequestInit): GeoSource {
|
|
39
|
-
return async (key) => {
|
|
40
|
-
const url = typeof base === "function" ? base(key) : `${base.replace(/\/+$/, "")}/${key}.json`
|
|
41
|
-
const response = await fetch(url, init)
|
|
42
|
-
// Some object stores answer a missing key with 400, not 404.
|
|
43
|
-
if (response.status === 404 || response.status === 400) return null
|
|
44
|
-
if (!response.ok) throw new Error(`${url} → HTTP ${response.status}`)
|
|
45
|
-
return response.json()
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Wraps a function returning the file as text, bytes or parsed JSON (e.g. `fs.readFile`). */
|
|
50
|
-
export function geoLoaderSource(
|
|
51
|
-
load: (key: string) => Promise<string | ArrayBuffer | Uint8Array | object | null | undefined>
|
|
52
|
-
): GeoSource {
|
|
53
|
-
return async (key) => {
|
|
54
|
-
const value = await load(key)
|
|
55
|
-
if (value == null) return null
|
|
56
|
-
if (typeof value === "string") return JSON.parse(value)
|
|
57
|
-
if (value instanceof ArrayBuffer || value instanceof Uint8Array) {
|
|
58
|
-
return JSON.parse(new TextDecoder().decode(value))
|
|
59
|
-
}
|
|
60
|
-
return value
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function definedFeature(code: string, definition: GeoDefinition, serial: number): GeoFeature {
|
|
65
|
-
const shape = definition.shape.type === "Feature" ? definition.shape.geometry : definition.shape
|
|
66
|
-
const feature = definition.shape.type === "Feature" ? definition.shape : null
|
|
67
|
-
const polygons: readonly (readonly (readonly Position[])[])[] =
|
|
68
|
-
shape.type === "Polygon" ? [shape.coordinates] : shape.coordinates
|
|
69
|
-
let arcIndex = 0
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* One ring, wound the way d3 reads it. d3 takes the *inside* of a ring to be
|
|
73
|
-
* the side its points go clockwise around, so an exterior must be small
|
|
74
|
-
* (`geoArea` under a hemisphere) and a hole its complement. GeoJSON (RFC 7946)
|
|
75
|
-
* writes the opposite way round, counter-clockwise exteriors, and a ring
|
|
76
|
-
* taken as written would fill the rest of the sphere; so a ring that reads
|
|
77
|
-
* the wrong way for its place is reversed.
|
|
78
|
-
*/
|
|
79
|
-
const toRing = (ring: readonly Position[], exterior: boolean): GeoRingRef => {
|
|
80
|
-
const closed = ring.length > 0 && ring[0][0] === ring[ring.length - 1][0] && ring[0][1] === ring[ring.length - 1][1]
|
|
81
|
-
let coordinates = closed ? ring : [...ring, ring[0]]
|
|
82
|
-
const encloses = geoArea({ type: "Polygon", coordinates: [coordinates as [number, number][]] }) <= 2 * Math.PI
|
|
83
|
-
if (encloses !== exterior) coordinates = [...coordinates].reverse()
|
|
84
|
-
const points = new Float64Array(coordinates.length * 2)
|
|
85
|
-
coordinates.forEach(([lon, lat], i) => {
|
|
86
|
-
points[i * 2] = lon
|
|
87
|
-
points[i * 2 + 1] = lat
|
|
88
|
-
})
|
|
89
|
-
return { arc: { key: `define:${serial}:${code}:${arcIndex++}`, points, buckets: ringBuckets(points) }, reversed: false }
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const measured: GeoPolygon[] = polygons.map((rings) => {
|
|
93
|
-
const resolved = rings.map((ring, i) => [toRing(ring, i === 0)])
|
|
94
|
-
const ringAreas = rings.map((ring) => {
|
|
95
|
-
const area = geoArea({ type: "Polygon", coordinates: [ring as [number, number][]] })
|
|
96
|
-
return area > 2 * Math.PI ? 4 * Math.PI - area : area
|
|
97
|
-
})
|
|
98
|
-
const area = Math.max(0, ringAreas[0] - ringAreas.slice(1).reduce((a, b) => a + b, 0))
|
|
99
|
-
return { rings: resolved, ringAreas, area, mainland: true }
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
const name = definition.name ?? feature?.properties?.name ?? code
|
|
103
|
-
return {
|
|
104
|
-
code,
|
|
105
|
-
name,
|
|
106
|
-
nameNl: name,
|
|
107
|
-
parent: definition.parent ?? (code.includes("-") ? code.slice(0, 2) : null),
|
|
108
|
-
first: true,
|
|
109
|
-
listed: false,
|
|
110
|
-
remote: false,
|
|
111
|
-
polygons: measured,
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
class GeoRegistry {
|
|
116
|
-
private _sources: GeoSource[] = []
|
|
117
|
-
private _defined = new Map<string, { serial: number; feature: GeoFeature }>()
|
|
118
|
-
private _generation = 0
|
|
119
|
-
private _serial = 0
|
|
120
|
-
|
|
121
|
-
/** Bumps whenever a source or a definition is added or removed. */
|
|
122
|
-
get generation(): number {
|
|
123
|
-
return this._generation
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
get hasSources(): boolean {
|
|
127
|
-
return this._sources.length > 0
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** Adds a source after every source already registered. Returns its removal. */
|
|
131
|
-
use(source: GeoSource): () => void {
|
|
132
|
-
this._sources.push(source)
|
|
133
|
-
this._generation++
|
|
134
|
-
return () => {
|
|
135
|
-
const i = this._sources.indexOf(source)
|
|
136
|
-
if (i < 0) return
|
|
137
|
-
this._sources.splice(i, 1)
|
|
138
|
-
this._generation++
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Shapes (GeoJSON, lon/lat) under codes of your choosing, checked before any
|
|
144
|
-
* source. A definition replaces generated data for the same code.
|
|
145
|
-
*/
|
|
146
|
-
define(shapes: Record<string, GeoShape | GeoDefinition>): () => void {
|
|
147
|
-
const serial = ++this._serial
|
|
148
|
-
const codes = Object.entries(shapes).map(([rawCode, value]) => {
|
|
149
|
-
const code = rawCode.trim().toUpperCase()
|
|
150
|
-
const definition = "shape" in value ? value : { shape: value }
|
|
151
|
-
this._defined.set(code, { serial, feature: definedFeature(code, definition, serial) })
|
|
152
|
-
return code
|
|
153
|
-
})
|
|
154
|
-
this._generation++
|
|
155
|
-
return () => {
|
|
156
|
-
for (const code of codes) {
|
|
157
|
-
if (this._defined.get(code)?.serial === serial) this._defined.delete(code)
|
|
158
|
-
}
|
|
159
|
-
this._generation++
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
defined(code: string): GeoFeature | undefined {
|
|
164
|
-
return this._defined.get(code)?.feature
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/** Removes every source and definition — for tests. */
|
|
168
|
-
reset(): void {
|
|
169
|
-
this._sources = []
|
|
170
|
-
this._defined.clear()
|
|
171
|
-
this._generation++
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Asks each source in registration order; the first non-null answer wins.
|
|
176
|
-
* Resolves `null` only when every source answered that it has no such file.
|
|
177
|
-
*/
|
|
178
|
-
async resolve(key: string): Promise<GeoFileData | GeoIndex | null> {
|
|
179
|
-
if (this._sources.length === 0) {
|
|
180
|
-
throw new Error("No geo source registered — call geoRegistry.use(geoUrlSource(…)) before rendering GeoBorder")
|
|
181
|
-
}
|
|
182
|
-
let failure: unknown = null
|
|
183
|
-
for (const source of [...this._sources]) {
|
|
184
|
-
try {
|
|
185
|
-
const value = await source(key)
|
|
186
|
-
if (value != null) return value as GeoFileData | GeoIndex
|
|
187
|
-
} catch (error) {
|
|
188
|
-
failure ??= error
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
if (failure) throw failure
|
|
192
|
-
return null
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/** Every listed region's code, names and parent, for a picker. */
|
|
196
|
-
async index(): Promise<GeoIndex | null> {
|
|
197
|
-
return (await this.resolve(`${GEO_DATA_VERSION}/index`)) as GeoIndex | null
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
export const geoRegistry = new GeoRegistry()
|
|
1
|
+
import { geoArea } from "d3-geo"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
GEO_DATA_FORMAT,
|
|
5
|
+
type GeoFeature,
|
|
6
|
+
type GeoFileData,
|
|
7
|
+
type GeoIndex,
|
|
8
|
+
type GeoPolygon,
|
|
9
|
+
type GeoRingRef,
|
|
10
|
+
} from "./format"
|
|
11
|
+
import { ringBuckets } from "./simplify"
|
|
12
|
+
|
|
13
|
+
/** Prefix every data key carries; bumped with the file format. */
|
|
14
|
+
export const GEO_DATA_VERSION = `v${GEO_DATA_FORMAT}`
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Answers a data key — `v1/countries/FR`, `v1/states/US`, `v1/index` — with the
|
|
18
|
+
* parsed file, or `null` when this source does not have it. A throw means "could
|
|
19
|
+
* not tell" (offline, a 500) and is retried on the next load.
|
|
20
|
+
*/
|
|
21
|
+
export type GeoSource = (key: string) => Promise<unknown>
|
|
22
|
+
|
|
23
|
+
type Position = readonly [number, number] | readonly number[]
|
|
24
|
+
type GeoPolygonShape =
|
|
25
|
+
| { type: "Polygon"; coordinates: readonly (readonly Position[])[] }
|
|
26
|
+
| { type: "MultiPolygon"; coordinates: readonly (readonly (readonly Position[])[])[] }
|
|
27
|
+
/** GeoJSON in lon/lat: a Polygon, a MultiPolygon, or a Feature holding one. */
|
|
28
|
+
export type GeoShape = GeoPolygonShape | { type: "Feature"; geometry: GeoPolygonShape; properties?: { name?: string } | null }
|
|
29
|
+
|
|
30
|
+
export interface GeoDefinition {
|
|
31
|
+
shape: GeoShape
|
|
32
|
+
name?: string
|
|
33
|
+
/** The country a subdivision code expands under, when it has one. */
|
|
34
|
+
parent?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Fetches `<base>/<key>.json`; `base` may instead map a key to its URL. */
|
|
38
|
+
export function geoUrlSource(base: string | ((key: string) => string), init?: RequestInit): GeoSource {
|
|
39
|
+
return async (key) => {
|
|
40
|
+
const url = typeof base === "function" ? base(key) : `${base.replace(/\/+$/, "")}/${key}.json`
|
|
41
|
+
const response = await fetch(url, init)
|
|
42
|
+
// Some object stores answer a missing key with 400, not 404.
|
|
43
|
+
if (response.status === 404 || response.status === 400) return null
|
|
44
|
+
if (!response.ok) throw new Error(`${url} → HTTP ${response.status}`)
|
|
45
|
+
return response.json()
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Wraps a function returning the file as text, bytes or parsed JSON (e.g. `fs.readFile`). */
|
|
50
|
+
export function geoLoaderSource(
|
|
51
|
+
load: (key: string) => Promise<string | ArrayBuffer | Uint8Array | object | null | undefined>
|
|
52
|
+
): GeoSource {
|
|
53
|
+
return async (key) => {
|
|
54
|
+
const value = await load(key)
|
|
55
|
+
if (value == null) return null
|
|
56
|
+
if (typeof value === "string") return JSON.parse(value)
|
|
57
|
+
if (value instanceof ArrayBuffer || value instanceof Uint8Array) {
|
|
58
|
+
return JSON.parse(new TextDecoder().decode(value))
|
|
59
|
+
}
|
|
60
|
+
return value
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function definedFeature(code: string, definition: GeoDefinition, serial: number): GeoFeature {
|
|
65
|
+
const shape = definition.shape.type === "Feature" ? definition.shape.geometry : definition.shape
|
|
66
|
+
const feature = definition.shape.type === "Feature" ? definition.shape : null
|
|
67
|
+
const polygons: readonly (readonly (readonly Position[])[])[] =
|
|
68
|
+
shape.type === "Polygon" ? [shape.coordinates] : shape.coordinates
|
|
69
|
+
let arcIndex = 0
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One ring, wound the way d3 reads it. d3 takes the *inside* of a ring to be
|
|
73
|
+
* the side its points go clockwise around, so an exterior must be small
|
|
74
|
+
* (`geoArea` under a hemisphere) and a hole its complement. GeoJSON (RFC 7946)
|
|
75
|
+
* writes the opposite way round, counter-clockwise exteriors, and a ring
|
|
76
|
+
* taken as written would fill the rest of the sphere; so a ring that reads
|
|
77
|
+
* the wrong way for its place is reversed.
|
|
78
|
+
*/
|
|
79
|
+
const toRing = (ring: readonly Position[], exterior: boolean): GeoRingRef => {
|
|
80
|
+
const closed = ring.length > 0 && ring[0][0] === ring[ring.length - 1][0] && ring[0][1] === ring[ring.length - 1][1]
|
|
81
|
+
let coordinates = closed ? ring : [...ring, ring[0]]
|
|
82
|
+
const encloses = geoArea({ type: "Polygon", coordinates: [coordinates as [number, number][]] }) <= 2 * Math.PI
|
|
83
|
+
if (encloses !== exterior) coordinates = [...coordinates].reverse()
|
|
84
|
+
const points = new Float64Array(coordinates.length * 2)
|
|
85
|
+
coordinates.forEach(([lon, lat], i) => {
|
|
86
|
+
points[i * 2] = lon
|
|
87
|
+
points[i * 2 + 1] = lat
|
|
88
|
+
})
|
|
89
|
+
return { arc: { key: `define:${serial}:${code}:${arcIndex++}`, points, buckets: ringBuckets(points) }, reversed: false }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const measured: GeoPolygon[] = polygons.map((rings) => {
|
|
93
|
+
const resolved = rings.map((ring, i) => [toRing(ring, i === 0)])
|
|
94
|
+
const ringAreas = rings.map((ring) => {
|
|
95
|
+
const area = geoArea({ type: "Polygon", coordinates: [ring as [number, number][]] })
|
|
96
|
+
return area > 2 * Math.PI ? 4 * Math.PI - area : area
|
|
97
|
+
})
|
|
98
|
+
const area = Math.max(0, ringAreas[0] - ringAreas.slice(1).reduce((a, b) => a + b, 0))
|
|
99
|
+
return { rings: resolved, ringAreas, area, mainland: true }
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const name = definition.name ?? feature?.properties?.name ?? code
|
|
103
|
+
return {
|
|
104
|
+
code,
|
|
105
|
+
name,
|
|
106
|
+
nameNl: name,
|
|
107
|
+
parent: definition.parent ?? (code.includes("-") ? code.slice(0, 2) : null),
|
|
108
|
+
first: true,
|
|
109
|
+
listed: false,
|
|
110
|
+
remote: false,
|
|
111
|
+
polygons: measured,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
class GeoRegistry {
|
|
116
|
+
private _sources: GeoSource[] = []
|
|
117
|
+
private _defined = new Map<string, { serial: number; feature: GeoFeature }>()
|
|
118
|
+
private _generation = 0
|
|
119
|
+
private _serial = 0
|
|
120
|
+
|
|
121
|
+
/** Bumps whenever a source or a definition is added or removed. */
|
|
122
|
+
get generation(): number {
|
|
123
|
+
return this._generation
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
get hasSources(): boolean {
|
|
127
|
+
return this._sources.length > 0
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Adds a source after every source already registered. Returns its removal. */
|
|
131
|
+
use(source: GeoSource): () => void {
|
|
132
|
+
this._sources.push(source)
|
|
133
|
+
this._generation++
|
|
134
|
+
return () => {
|
|
135
|
+
const i = this._sources.indexOf(source)
|
|
136
|
+
if (i < 0) return
|
|
137
|
+
this._sources.splice(i, 1)
|
|
138
|
+
this._generation++
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Shapes (GeoJSON, lon/lat) under codes of your choosing, checked before any
|
|
144
|
+
* source. A definition replaces generated data for the same code.
|
|
145
|
+
*/
|
|
146
|
+
define(shapes: Record<string, GeoShape | GeoDefinition>): () => void {
|
|
147
|
+
const serial = ++this._serial
|
|
148
|
+
const codes = Object.entries(shapes).map(([rawCode, value]) => {
|
|
149
|
+
const code = rawCode.trim().toUpperCase()
|
|
150
|
+
const definition = "shape" in value ? value : { shape: value }
|
|
151
|
+
this._defined.set(code, { serial, feature: definedFeature(code, definition, serial) })
|
|
152
|
+
return code
|
|
153
|
+
})
|
|
154
|
+
this._generation++
|
|
155
|
+
return () => {
|
|
156
|
+
for (const code of codes) {
|
|
157
|
+
if (this._defined.get(code)?.serial === serial) this._defined.delete(code)
|
|
158
|
+
}
|
|
159
|
+
this._generation++
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
defined(code: string): GeoFeature | undefined {
|
|
164
|
+
return this._defined.get(code)?.feature
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Removes every source and definition — for tests. */
|
|
168
|
+
reset(): void {
|
|
169
|
+
this._sources = []
|
|
170
|
+
this._defined.clear()
|
|
171
|
+
this._generation++
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Asks each source in registration order; the first non-null answer wins.
|
|
176
|
+
* Resolves `null` only when every source answered that it has no such file.
|
|
177
|
+
*/
|
|
178
|
+
async resolve(key: string): Promise<GeoFileData | GeoIndex | null> {
|
|
179
|
+
if (this._sources.length === 0) {
|
|
180
|
+
throw new Error("No geo source registered — call geoRegistry.use(geoUrlSource(…)) before rendering GeoBorder")
|
|
181
|
+
}
|
|
182
|
+
let failure: unknown = null
|
|
183
|
+
for (const source of [...this._sources]) {
|
|
184
|
+
try {
|
|
185
|
+
const value = await source(key)
|
|
186
|
+
if (value != null) return value as GeoFileData | GeoIndex
|
|
187
|
+
} catch (error) {
|
|
188
|
+
failure ??= error
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (failure) throw failure
|
|
192
|
+
return null
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Every listed region's code, names and parent, for a picker. */
|
|
196
|
+
async index(): Promise<GeoIndex | null> {
|
|
197
|
+
return (await this.resolve(`${GEO_DATA_VERSION}/index`)) as GeoIndex | null
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export const geoRegistry = new GeoRegistry()
|