@motionscript/geography 0.1.0-alpha.7 → 0.1.0-alpha.9

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +104 -1
  2. package/CREDITS.md +8 -8
  3. package/LICENSE +201 -201
  4. package/NOTICE +49 -48
  5. package/README.md +149 -85
  6. package/dist/border/format.d.ts +73 -0
  7. package/dist/border/format.d.ts.map +1 -0
  8. package/dist/border/format.js +68 -0
  9. package/dist/border/format.js.map +1 -0
  10. package/dist/border/geo-border.d.ts +103 -0
  11. package/dist/border/geo-border.d.ts.map +1 -0
  12. package/dist/border/geo-border.js +203 -0
  13. package/dist/border/geo-border.js.map +1 -0
  14. package/dist/border/geometry.d.ts +40 -0
  15. package/dist/border/geometry.d.ts.map +1 -0
  16. package/dist/border/geometry.js +382 -0
  17. package/dist/border/geometry.js.map +1 -0
  18. package/dist/border/index.d.ts +15 -0
  19. package/dist/border/index.d.ts.map +1 -0
  20. package/dist/border/index.js +15 -0
  21. package/dist/border/index.js.map +1 -0
  22. package/dist/border/loader.d.ts +31 -0
  23. package/dist/border/loader.d.ts.map +1 -0
  24. package/dist/border/loader.js +87 -0
  25. package/dist/border/loader.js.map +1 -0
  26. package/dist/border/registry.d.ts +64 -0
  27. package/dist/border/registry.d.ts.map +1 -0
  28. package/dist/border/registry.js +163 -0
  29. package/dist/border/registry.js.map +1 -0
  30. package/dist/border/selection.d.ts +21 -0
  31. package/dist/border/selection.d.ts.map +1 -0
  32. package/dist/border/selection.js +86 -0
  33. package/dist/border/selection.js.map +1 -0
  34. package/dist/border/simplify.d.ts +12 -0
  35. package/dist/border/simplify.d.ts.map +1 -0
  36. package/dist/border/simplify.js +116 -0
  37. package/dist/border/simplify.js.map +1 -0
  38. package/dist/browser/THIRD_PARTY_LICENSES.txt +58 -0
  39. package/dist/browser/index.js +2 -0
  40. package/dist/browser/index.js.map +7 -0
  41. package/dist/browser/manifest.json +23 -0
  42. package/dist/index.d.ts +3 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +3 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/nodes.d.ts +15 -0
  47. package/dist/nodes.d.ts.map +1 -0
  48. package/dist/nodes.js +16 -0
  49. package/dist/nodes.js.map +1 -0
  50. package/package.json +46 -8
  51. package/registry.json +16 -0
  52. package/src/border/format.ts +147 -0
  53. package/src/border/geo-border.ts +268 -0
  54. package/src/border/geometry.ts +463 -0
  55. package/src/border/index.ts +14 -0
  56. package/src/border/loader.ts +97 -0
  57. package/src/border/registry.ts +201 -0
  58. package/src/border/selection.ts +95 -0
  59. package/src/border/simplify.ts +110 -0
  60. package/src/index.ts +2 -0
  61. package/src/nodes.ts +16 -0
@@ -0,0 +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()
@@ -0,0 +1,95 @@
1
+ import type { GeoFeature } from "./format"
2
+ import { countryFileKey, geoFileEntry, statesFileKey } from "./loader"
3
+ import { geoRegistry } from "./registry"
4
+
5
+ export type GeoLevel = "country" | "state"
6
+ export type GeoFrame = "mainland" | "all"
7
+
8
+ const CODE = /^[A-Z]{2}(-[A-Z0-9]{1,3})?$/
9
+
10
+ /** `"fr, de"`, `["FR", "de"]` → `["FR", "DE"]`; unique, in order, empties dropped. */
11
+ export function normalizeIso(value: unknown): string[] {
12
+ const raw = Array.isArray(value) ? value : typeof value === "string" ? value.split(/[\s,;]+/) : []
13
+ const out: string[] = []
14
+ for (const item of raw) {
15
+ if (typeof item !== "string") continue
16
+ const code = item.trim().toUpperCase()
17
+ if (code && !out.includes(code)) out.push(code)
18
+ }
19
+ return out
20
+ }
21
+
22
+ function isSubdivision(code: string): boolean {
23
+ return code.includes("-")
24
+ }
25
+
26
+ /** Every data file a selection reads, for `declareAssets`. Depends on nothing but its arguments. */
27
+ export function geoFilesFor(codes: readonly string[], level: GeoLevel): string[] {
28
+ const files = new Set<string>()
29
+ for (const code of codes) {
30
+ if (!CODE.test(code) || geoRegistry.defined(code)) continue
31
+ if (isSubdivision(code)) files.add(statesFileKey(code.slice(0, 2)))
32
+ else files.add(level === "state" ? statesFileKey(code) : countryFileKey(code))
33
+ }
34
+ return [...files]
35
+ }
36
+
37
+ export interface GeoSelection {
38
+ features: GeoFeature[]
39
+ /** A file this selection needs has not landed yet. */
40
+ pending: boolean
41
+ /** Codes whose file loaded without them. */
42
+ unknown: string[]
43
+ }
44
+
45
+ /**
46
+ * The features a selection draws. A bare country code at state level is its
47
+ * first-level subdivisions — minus the remote ones when framing the mainland —
48
+ * or the country itself when it has none.
49
+ */
50
+ export function selectGeoFeatures(codes: readonly string[], level: GeoLevel, frame: GeoFrame): GeoSelection {
51
+ const features: GeoFeature[] = []
52
+ const seen = new Set<string>()
53
+ const unknown: string[] = []
54
+ let pending = false
55
+
56
+ const add = (feature: GeoFeature) => {
57
+ if (seen.has(feature.code)) return
58
+ seen.add(feature.code)
59
+ features.push(feature)
60
+ }
61
+
62
+ for (const code of codes) {
63
+ const defined = geoRegistry.defined(code)
64
+ if (defined) {
65
+ add(defined)
66
+ continue
67
+ }
68
+ if (!CODE.test(code)) {
69
+ unknown.push(code)
70
+ continue
71
+ }
72
+ const subdivision = isSubdivision(code)
73
+ const key = subdivision || level === "state" ? statesFileKey(code.slice(0, 2)) : countryFileKey(code)
74
+ const entry = geoFileEntry(key)
75
+ if (!entry || entry.status !== "ready") {
76
+ if (!entry) pending = true
77
+ continue
78
+ }
79
+ const file = entry.file.features
80
+ if (subdivision || level === "country") {
81
+ const feature = file.get(code)
82
+ if (feature) add(feature)
83
+ else unknown.push(code)
84
+ continue
85
+ }
86
+ const children = [...file.values()].filter(
87
+ (f) => f.parent === code && f.first && !(frame === "mainland" && f.remote)
88
+ )
89
+ if (children.length > 0) children.forEach(add)
90
+ else if (file.get(code)) add(file.get(code)!)
91
+ else unknown.push(code)
92
+ }
93
+
94
+ return { features, pending, unknown }
95
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Visvalingam–Whyatt weights for a hand-defined ring, in the same buckets the
3
+ * generated data carries (`-2·log2` of the effective area in steradians), so a
4
+ * shape passed to `geoRegistry.define` answers to `detail` like any other.
5
+ *
6
+ * Planar in degrees with longitude scaled by latitude: accurate enough for the
7
+ * handful of shapes an author defines, where the generated data uses the exact
8
+ * spherical area.
9
+ */
10
+ export function ringBuckets(points: Float64Array): Uint8Array {
11
+ const n = points.length / 2
12
+ const buckets = new Uint8Array(n)
13
+ if (n < 3) return buckets
14
+
15
+ const radians = Math.PI / 180
16
+ const weight = new Float64Array(n)
17
+ const prev = new Int32Array(n)
18
+ const next = new Int32Array(n)
19
+ for (let i = 0; i < n; i++) {
20
+ prev[i] = i - 1
21
+ next[i] = i + 1
22
+ }
23
+
24
+ const area = (a: number, b: number, c: number): number => {
25
+ const cos = Math.cos(points[b * 2 + 1] * radians)
26
+ const ax = points[a * 2] * cos, ay = points[a * 2 + 1]
27
+ const bx = points[b * 2] * cos, by = points[b * 2 + 1]
28
+ const cx = points[c * 2] * cos, cy = points[c * 2 + 1]
29
+ return (Math.abs((bx - ax) * (cy - ay) - (cx - ax) * (by - ay)) / 2) * radians * radians
30
+ }
31
+
32
+ const heap: number[] = []
33
+ const position = new Int32Array(n).fill(-1)
34
+ const less = (i: number, j: number) => weight[heap[i]] < weight[heap[j]]
35
+ const swap = (i: number, j: number) => {
36
+ ;[heap[i], heap[j]] = [heap[j], heap[i]]
37
+ position[heap[i]] = i
38
+ position[heap[j]] = j
39
+ }
40
+ const up = (i: number) => {
41
+ while (i > 0) {
42
+ const parent = (i - 1) >> 1
43
+ if (!less(i, parent)) break
44
+ swap(i, parent)
45
+ i = parent
46
+ }
47
+ }
48
+ const down = (i: number) => {
49
+ for (;;) {
50
+ const l = 2 * i + 1
51
+ const r = l + 1
52
+ let smallest = i
53
+ if (l < heap.length && less(l, smallest)) smallest = l
54
+ if (r < heap.length && less(r, smallest)) smallest = r
55
+ if (smallest === i) return
56
+ swap(i, smallest)
57
+ i = smallest
58
+ }
59
+ }
60
+ const push = (i: number) => {
61
+ position[i] = heap.length
62
+ heap.push(i)
63
+ up(heap.length - 1)
64
+ }
65
+ const remove = (i: number) => {
66
+ const at = position[i]
67
+ const last = heap.pop()!
68
+ position[i] = -1
69
+ if (at < heap.length) {
70
+ heap[at] = last
71
+ position[last] = at
72
+ up(at)
73
+ down(position[last])
74
+ }
75
+ }
76
+
77
+ for (let i = 1; i < n - 1; i++) {
78
+ weight[i] = area(i - 1, i, i + 1)
79
+ push(i)
80
+ }
81
+
82
+ let floor = 0
83
+ const final = new Float64Array(n)
84
+ final[0] = final[n - 1] = Infinity
85
+ while (heap.length > 0) {
86
+ const i = heap[0]
87
+ remove(i)
88
+ floor = Math.max(floor, weight[i])
89
+ final[i] = floor
90
+ const p = prev[i]
91
+ const q = next[i]
92
+ if (p >= 0) next[p] = q
93
+ if (q < n) prev[q] = p
94
+ for (const j of [p, q]) {
95
+ if (j <= 0 || j >= n - 1 || position[j] < 0) continue
96
+ remove(j)
97
+ weight[j] = area(prev[j], j, next[j])
98
+ push(j)
99
+ }
100
+ }
101
+
102
+ for (let i = 0; i < n; i++) buckets[i] = weightBucket(final[i])
103
+ return buckets
104
+ }
105
+
106
+ export function weightBucket(weight: number): number {
107
+ if (weight === Infinity) return 0
108
+ if (!(weight > 0)) return 255
109
+ return Math.min(254, Math.max(1, Math.round(-2 * Math.log2(weight))))
110
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./border";
2
+ export { NODES } from "./nodes";
package/src/nodes.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { GeoBorder } from "./border";
2
+
3
+ /**
4
+ * Every node type this package publishes.
5
+ *
6
+ * A host registers these by handing the array to an engine —
7
+ * `new Engine(platform, { nodes: NODES })` — which reads each component's key.
8
+ * Defining a component only *declares* that key: nothing is registered by
9
+ * importing a module, so a registry holds exactly what its owner asked for.
10
+ *
11
+ * Listing the component **values** is also what survives bundling: this package
12
+ * declares `sideEffects: false`, and a document names a node type by string, so
13
+ * an import kept only for its side effect is exactly what gets shaken out.
14
+ */
15
+ // Typed by name, so the declaration names each component rather than spelling out its type through core.
16
+ export const NODES: (typeof GeoBorder)[] = [GeoBorder];