@motionscript/geo 0.0.0-stage → 0.1.0-alpha.3

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 (108) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +201 -0
  3. package/dist/border/format.d.ts +73 -0
  4. package/dist/border/format.d.ts.map +1 -0
  5. package/dist/border/format.js +68 -0
  6. package/dist/border/format.js.map +1 -0
  7. package/dist/border/geo-border.d.ts +44 -0
  8. package/dist/border/geo-border.d.ts.map +1 -0
  9. package/dist/border/geo-border.js +234 -0
  10. package/dist/border/geo-border.js.map +1 -0
  11. package/dist/border/geometry.d.ts +40 -0
  12. package/dist/border/geometry.d.ts.map +1 -0
  13. package/dist/border/geometry.js +382 -0
  14. package/dist/border/geometry.js.map +1 -0
  15. package/dist/border/index.d.ts +15 -0
  16. package/dist/border/index.d.ts.map +1 -0
  17. package/dist/border/index.js +15 -0
  18. package/dist/border/index.js.map +1 -0
  19. package/dist/border/loader.d.ts +31 -0
  20. package/dist/border/loader.d.ts.map +1 -0
  21. package/dist/border/loader.js +87 -0
  22. package/dist/border/loader.js.map +1 -0
  23. package/dist/border/registry.d.ts +64 -0
  24. package/dist/border/registry.d.ts.map +1 -0
  25. package/dist/border/registry.js +152 -0
  26. package/dist/border/registry.js.map +1 -0
  27. package/dist/border/selection.d.ts +21 -0
  28. package/dist/border/selection.d.ts.map +1 -0
  29. package/dist/border/selection.js +86 -0
  30. package/dist/border/selection.js.map +1 -0
  31. package/dist/border/simplify.d.ts +12 -0
  32. package/dist/border/simplify.d.ts.map +1 -0
  33. package/dist/border/simplify.js +116 -0
  34. package/dist/border/simplify.js.map +1 -0
  35. package/dist/browser/index.js +25 -0
  36. package/dist/browser/index.js.map +7 -0
  37. package/dist/browser/manifest.json +11 -0
  38. package/dist/globe/atmosphere.d.ts +38 -0
  39. package/dist/globe/atmosphere.d.ts.map +1 -0
  40. package/dist/globe/atmosphere.js +91 -0
  41. package/dist/globe/atmosphere.js.map +1 -0
  42. package/dist/globe/borders.d.ts +39 -0
  43. package/dist/globe/borders.d.ts.map +1 -0
  44. package/dist/globe/borders.js +116 -0
  45. package/dist/globe/borders.js.map +1 -0
  46. package/dist/globe/data/ne-110m.d.ts +25 -0
  47. package/dist/globe/data/ne-110m.d.ts.map +1 -0
  48. package/dist/globe/data/ne-110m.js +198 -0
  49. package/dist/globe/data/ne-110m.js.map +1 -0
  50. package/dist/globe/globe-places.d.ts +140 -0
  51. package/dist/globe/globe-places.d.ts.map +1 -0
  52. package/dist/globe/globe-places.js +262 -0
  53. package/dist/globe/globe-places.js.map +1 -0
  54. package/dist/globe/globe.d.ts +193 -0
  55. package/dist/globe/globe.d.ts.map +1 -0
  56. package/dist/globe/globe.js +435 -0
  57. package/dist/globe/globe.js.map +1 -0
  58. package/dist/globe/index.d.ts +17 -0
  59. package/dist/globe/index.d.ts.map +1 -0
  60. package/dist/globe/index.js +17 -0
  61. package/dist/globe/index.js.map +1 -0
  62. package/dist/globe/places.d.ts +69 -0
  63. package/dist/globe/places.d.ts.map +1 -0
  64. package/dist/globe/places.js +94 -0
  65. package/dist/globe/places.js.map +1 -0
  66. package/dist/globe/projection.d.ts +182 -0
  67. package/dist/globe/projection.d.ts.map +1 -0
  68. package/dist/globe/projection.js +215 -0
  69. package/dist/globe/projection.js.map +1 -0
  70. package/dist/globe/world-map.d.ts +83 -0
  71. package/dist/globe/world-map.d.ts.map +1 -0
  72. package/dist/globe/world-map.js +169 -0
  73. package/dist/globe/world-map.js.map +1 -0
  74. package/dist/globe/world.d.ts +46 -0
  75. package/dist/globe/world.d.ts.map +1 -0
  76. package/dist/globe/world.js +71 -0
  77. package/dist/globe/world.js.map +1 -0
  78. package/dist/index.d.ts +5 -0
  79. package/dist/index.d.ts.map +1 -0
  80. package/dist/index.js +6 -0
  81. package/dist/index.js.map +1 -0
  82. package/dist/nodes.d.ts +19 -0
  83. package/dist/nodes.d.ts.map +1 -0
  84. package/dist/nodes.js +19 -0
  85. package/dist/nodes.js.map +1 -0
  86. package/package.json +69 -3
  87. package/registry.json +31 -0
  88. package/src/border/format.ts +147 -0
  89. package/src/border/geo-border.ts +260 -0
  90. package/src/border/geometry.ts +463 -0
  91. package/src/border/index.ts +14 -0
  92. package/src/border/loader.ts +97 -0
  93. package/src/border/registry.ts +191 -0
  94. package/src/border/selection.ts +95 -0
  95. package/src/border/simplify.ts +110 -0
  96. package/src/globe/atmosphere.ts +98 -0
  97. package/src/globe/borders.ts +166 -0
  98. package/src/globe/data/ne-110m.ts +214 -0
  99. package/src/globe/globe-places.ts +352 -0
  100. package/src/globe/globe.ts +552 -0
  101. package/src/globe/index.ts +16 -0
  102. package/src/globe/places.ts +132 -0
  103. package/src/globe/projection.ts +261 -0
  104. package/src/globe/world-map.ts +227 -0
  105. package/src/globe/world.ts +111 -0
  106. package/src/index.ts +5 -0
  107. package/src/nodes.ts +19 -0
  108. package/README.md +0 -4
@@ -0,0 +1,463 @@
1
+ import {
2
+ geoArea,
3
+ geoBounds,
4
+ geoConicEqualArea,
5
+ geoEquirectangular,
6
+ geoMercator,
7
+ geoOrthographic,
8
+ geoPath,
9
+ geoStream,
10
+ type GeoPermissibleObjects,
11
+ type GeoProjection,
12
+ } from "d3-geo"
13
+
14
+ import { ringCoordinates, type GeoArc, type GeoFeature, type GeoPolygon, type GeoRingRef } from "./format"
15
+ import type { GeoFrame } from "./selection"
16
+
17
+ export type GeoProjectionKind = "conic" | "mercator" | "equirectangular" | "orthographic"
18
+ export type GeoBorders = "all" | "outline"
19
+
20
+ export interface GeoContour {
21
+ /** Interleaved x/y in unit space: the longer side spans 1, centred on the origin, y up. */
22
+ points: Float32Array
23
+ closed: boolean
24
+ length: number
25
+ }
26
+
27
+ export interface GeoGeometry {
28
+ /** The drawing's extent in unit space; the longer of the two is 1. */
29
+ width: number
30
+ height: number
31
+ fills: GeoContour[]
32
+ lines: GeoContour[]
33
+ fillLength: number
34
+ lineLength: number
35
+ bytes: number
36
+ }
37
+
38
+ interface Part {
39
+ polygon: GeoPolygon
40
+ largest: boolean
41
+ }
42
+
43
+ interface Frame {
44
+ projection: GeoProjection
45
+ centerX: number
46
+ centerY: number
47
+ size: number
48
+ width: number
49
+ height: number
50
+ }
51
+
52
+ const FIT = 1024
53
+
54
+ // `detail` maps onto a weight threshold that is a fraction of the selection's
55
+ // own area, on a log scale — which is what makes a value look equally stylised
56
+ // on Monaco and on Russia. 0 keeps points worth 1% of the area; just below 1,
57
+ // points worth a billionth of it.
58
+ const POINT_BASE = 2
59
+ const POINT_RANGE = 7
60
+ const POLYGON_BASE = 1.5
61
+ const POLYGON_RANGE = 6
62
+
63
+ const clamp01 = (value: number) => (value > 1 ? 1 : value > 0 ? value : 0)
64
+
65
+ /** The highest weight bucket a point may carry and still be drawn. */
66
+ export function pointBucketLimit(detail: number, area: number): number {
67
+ if (detail >= 1 || !(area > 0)) return 255
68
+ const minWeight = area * 10 ** -(POINT_BASE + POINT_RANGE * clamp01(detail))
69
+ return Math.floor(-2 * Math.log2(minWeight))
70
+ }
71
+
72
+ /** Rings smaller than this (steradians) are dropped, bar each region's largest. */
73
+ export function polygonAreaLimit(detail: number, area: number): number {
74
+ if (detail >= 1) return 0
75
+ return area * 10 ** -(POLYGON_BASE + POLYGON_RANGE * clamp01(detail))
76
+ }
77
+
78
+ function partsOf(features: readonly GeoFeature[], frame: GeoFrame): Part[] {
79
+ const parts: Part[] = []
80
+ for (const feature of features) {
81
+ const mainland = frame === "mainland" ? feature.polygons.filter((p) => p.mainland) : []
82
+ const polygons = mainland.length > 0 ? mainland : feature.polygons
83
+ let largest: GeoPolygon | null = null
84
+ for (const polygon of polygons) if (!largest || polygon.area > largest.area) largest = polygon
85
+ for (const polygon of polygons) parts.push({ polygon, largest: polygon === largest })
86
+ }
87
+ return parts
88
+ }
89
+
90
+ function centerLongitude(west: number, east: number): number {
91
+ if (west <= east) return (west + east) / 2
92
+ const mid = (west + east + 360) / 2
93
+ return mid > 180 ? mid - 360 : mid
94
+ }
95
+
96
+ function makeProjection(kind: GeoProjectionKind, west: number, south: number, east: number, north: number): GeoProjection {
97
+ const lon = centerLongitude(west, east)
98
+ const lat = (south + north) / 2
99
+ switch (kind) {
100
+ case "mercator":
101
+ return geoMercator().rotate([-lon, 0])
102
+ case "equirectangular":
103
+ return geoEquirectangular().rotate([-lon, 0])
104
+ case "orthographic":
105
+ return geoOrthographic().rotate([-lon, -lat])
106
+ case "conic": {
107
+ const span = north - south
108
+ return geoConicEqualArea()
109
+ .rotate([-lon, 0])
110
+ .center([0, lat])
111
+ .parallels([south + span / 6, north - span / 6])
112
+ }
113
+ }
114
+ }
115
+
116
+ function fitFrame(kind: GeoProjectionKind, parts: readonly Part[]): Frame | null {
117
+ if (parts.length === 0) return null
118
+ const shape = {
119
+ type: "MultiPolygon",
120
+ coordinates: parts.map(({ polygon }) => [ringCoordinates(polygon.rings[0])]),
121
+ } as GeoPermissibleObjects
122
+ const [[west, south], [east, north]] = geoBounds(shape)
123
+
124
+ for (const candidate of [kind, "equirectangular"] as const) {
125
+ const projection = makeProjection(candidate, west, south, east, north).fitSize([FIT, FIT], shape)
126
+ const [[x0, y0], [x1, y1]] = geoPath(projection).bounds(shape)
127
+ const size = Math.max(x1 - x0, y1 - y0)
128
+ if (!Number.isFinite(size) || size <= 0) continue
129
+ return {
130
+ projection,
131
+ centerX: (x0 + x1) / 2,
132
+ centerY: (y0 + y1) / 2,
133
+ size,
134
+ width: (x1 - x0) / size,
135
+ height: (y1 - y0) / size,
136
+ }
137
+ }
138
+ return null
139
+ }
140
+
141
+ function project(frame: Frame, object: GeoPermissibleObjects, closed: boolean): GeoContour[] {
142
+ const contours: GeoContour[] = []
143
+ let current: number[] | null = null
144
+ const { centerX, centerY, size } = frame
145
+ const sink = {
146
+ point(x: number, y: number) {
147
+ current?.push((x - centerX) / size, -(y - centerY) / size)
148
+ },
149
+ lineStart() {
150
+ current = []
151
+ },
152
+ lineEnd() {
153
+ const flat = current
154
+ current = null
155
+ if (!flat || flat.length < 4) return
156
+ let isClosed = closed
157
+ if (!closed) {
158
+ const n = flat.length
159
+ if (flat[0] === flat[n - 2] && flat[1] === flat[n - 1] && n >= 8) {
160
+ flat.length = n - 2
161
+ isClosed = true
162
+ }
163
+ }
164
+ const points = Float32Array.from(flat)
165
+ contours.push({ points, closed: isClosed, length: contourLength(points, isClosed) })
166
+ },
167
+ polygonStart() {},
168
+ polygonEnd() {},
169
+ sphere() {},
170
+ }
171
+ geoStream(object, frame.projection.stream(sink))
172
+ return contours
173
+ }
174
+
175
+ function contourLength(points: Float32Array, closed: boolean): number {
176
+ let length = 0
177
+ const n = points.length / 2
178
+ for (let i = 1; i < n; i++) {
179
+ length += Math.hypot(points[i * 2] - points[i * 2 - 2], points[i * 2 + 1] - points[i * 2 - 1])
180
+ }
181
+ if (closed && n > 1) length += Math.hypot(points[0] - points[n * 2 - 2], points[1] - points[n * 2 - 1])
182
+ return length
183
+ }
184
+
185
+ // Longest edge, in degrees, that simplification may leave. d3 takes an edge as
186
+ // the short way round the sphere, so an edge across more than half the planet
187
+ // (Kaliningrad to Chukotka, once the coast between is dropped) turns inside out.
188
+ const MAX_EDGE_DEGREES = 20
189
+
190
+ type MaskFor = (arc: GeoArc) => Uint8Array
191
+
192
+ /** Which of an arc's points survive `limit`, the same whichever way the arc is walked. */
193
+ function keepMask(arc: GeoArc, limit: number): Uint8Array {
194
+ const n = arc.buckets.length
195
+ const mask = new Uint8Array(n)
196
+ let lastLon = arc.points[0]
197
+ let lastLat = arc.points[1]
198
+ for (let i = 0; i < n; i++) {
199
+ const lon = arc.points[i * 2]
200
+ const lat = arc.points[i * 2 + 1]
201
+ let far = false
202
+ if (arc.buckets[i] > limit) {
203
+ let dLon = Math.abs(lon - lastLon)
204
+ if (dLon > 180) dLon = 360 - dLon
205
+ far = Math.abs(lat - lastLat) > MAX_EDGE_DEGREES || dLon * Math.cos((lat * Math.PI) / 180) > MAX_EDGE_DEGREES
206
+ }
207
+ if (arc.buckets[i] <= limit || far) {
208
+ mask[i] = 1
209
+ lastLon = lon
210
+ lastLat = lat
211
+ }
212
+ }
213
+ return mask
214
+ }
215
+
216
+ function filteredPoints(refs: readonly GeoRingRef[], maskFor: MaskFor): [number, number][] {
217
+ const out: [number, number][] = []
218
+ for (const { arc, reversed } of refs) {
219
+ const n = arc.buckets.length
220
+ const mask = maskFor(arc)
221
+ for (let j = out.length === 0 ? 0 : 1; j < n; j++) {
222
+ const i = reversed ? n - 1 - j : j
223
+ if (mask[i]) out.push([arc.points[i * 2], arc.points[i * 2 + 1]])
224
+ }
225
+ }
226
+ return out
227
+ }
228
+
229
+ /** A ring keeps at least three vertices, raising its arcs' limits if the global one would collapse it. */
230
+ function ringLimit(ring: readonly GeoRingRef[], limit: number): number {
231
+ let kept = 0
232
+ for (const { arc } of ring) {
233
+ for (let i = 0; i < arc.buckets.length - 1; i++) if (arc.buckets[i] <= limit) kept++
234
+ }
235
+ if (kept >= 3) return limit
236
+ const buckets: number[] = []
237
+ for (const { arc } of ring) for (let i = 0; i < arc.buckets.length - 1; i++) buckets.push(arc.buckets[i])
238
+ buckets.sort((a, b) => a - b)
239
+ return Math.max(limit, buckets[Math.min(2, buckets.length - 1)] ?? limit)
240
+ }
241
+
242
+ /**
243
+ * Whether a simplified ring still winds the way its original did. Dropping
244
+ * points can fold a small ring over itself, and d3 reads an exterior wound the
245
+ * wrong way as the rest of the planet — one such island fills the whole frame.
246
+ */
247
+ function windsAsOriginal(ring: [number, number][], exterior: boolean): boolean {
248
+ if (ring.length < 4) return true
249
+ const inverted = geoArea({ type: "Polygon", coordinates: [ring] }) > 2 * Math.PI
250
+ return inverted !== exterior
251
+ }
252
+
253
+ interface Chain {
254
+ refs: GeoRingRef[]
255
+ closed: boolean
256
+ }
257
+
258
+ const endKey = (arc: GeoArc, last: boolean) => {
259
+ const i = last ? arc.buckets.length - 1 : 0
260
+ return `${arc.points[i * 2]},${arc.points[i * 2 + 1]}`
261
+ }
262
+
263
+ /** Joins arcs that meet end to end with nothing else at the joint, so a border draws as one line with real joins. */
264
+ function chainArcs(arcs: readonly GeoArc[]): Chain[] {
265
+ const atNode = new Map<string, GeoArc[]>()
266
+ for (const arc of arcs) {
267
+ for (const key of [endKey(arc, false), endKey(arc, true)]) {
268
+ const list = atNode.get(key)
269
+ if (list) list.push(arc)
270
+ else atNode.set(key, [arc])
271
+ }
272
+ }
273
+ const visited = new Set<GeoArc>()
274
+ const chains: Chain[] = []
275
+ const refStart = ({ arc, reversed }: GeoRingRef) => endKey(arc, reversed)
276
+ const refEnd = ({ arc, reversed }: GeoRingRef) => endKey(arc, !reversed)
277
+ const nextAt = (node: string): GeoArc | null => {
278
+ const list = atNode.get(node)!
279
+ if (list.length !== 2) return null
280
+ return list.find((arc) => !visited.has(arc)) ?? null
281
+ }
282
+
283
+ for (const arc of arcs) {
284
+ if (visited.has(arc)) continue
285
+ visited.add(arc)
286
+ const refs: GeoRingRef[] = [{ arc, reversed: false }]
287
+ for (let next = nextAt(refEnd(refs[refs.length - 1])); next; next = nextAt(refEnd(refs[refs.length - 1]))) {
288
+ visited.add(next)
289
+ refs.push({ arc: next, reversed: endKey(next, false) !== refEnd(refs[refs.length - 1]) })
290
+ }
291
+ for (let prev = nextAt(refStart(refs[0])); prev; prev = nextAt(refStart(refs[0]))) {
292
+ visited.add(prev)
293
+ refs.unshift({ arc: prev, reversed: endKey(prev, true) !== refStart(refs[0]) })
294
+ }
295
+ chains.push({ refs, closed: refStart(refs[0]) === refEnd(refs[refs.length - 1]) })
296
+ }
297
+ return chains
298
+ }
299
+
300
+ function buildGeometry(
301
+ features: readonly GeoFeature[],
302
+ frame: GeoFrame,
303
+ fitted: Frame,
304
+ borders: GeoBorders,
305
+ detail: number
306
+ ): GeoGeometry {
307
+ const parts = partsOf(features, frame)
308
+ const area = parts.reduce((sum, { polygon }) => sum + polygon.area, 0)
309
+ const pointLimit = pointBucketLimit(detail, area)
310
+ const polygonLimit = polygonAreaLimit(detail, area)
311
+
312
+ const kept: GeoRingRef[][][] = []
313
+ for (const { polygon, largest } of parts) {
314
+ if (!largest && polygon.area < polygonLimit) continue
315
+ kept.push(polygon.rings.filter((_, i) => i === 0 || polygon.ringAreas[i] >= polygonLimit))
316
+ }
317
+
318
+ const limits = new Map<GeoArc["key"], number>()
319
+ const usage = new Map<GeoArc["key"], { arc: GeoArc; count: number }>()
320
+ for (const rings of kept) {
321
+ for (const ring of rings) {
322
+ const limit = ringLimit(ring, pointLimit)
323
+ for (const { arc } of ring) {
324
+ limits.set(arc.key, Math.max(limits.get(arc.key) ?? 0, limit))
325
+ const use = usage.get(arc.key)
326
+ if (use) use.count++
327
+ else usage.set(arc.key, { arc, count: 1 })
328
+ }
329
+ }
330
+ }
331
+
332
+ const masks = new Map<GeoArc["key"], Uint8Array>()
333
+ const maskFor: MaskFor = (arc) => {
334
+ let mask = masks.get(arc.key)
335
+ if (!mask) {
336
+ mask = keepMask(arc, limits.get(arc.key) ?? 255)
337
+ masks.set(arc.key, mask)
338
+ }
339
+ return mask
340
+ }
341
+
342
+ if (pointLimit < 255) {
343
+ for (const rings of kept) {
344
+ rings.forEach((ring, index) => {
345
+ let limit = Math.min(...ring.map(({ arc }) => limits.get(arc.key) ?? pointLimit))
346
+ while (limit < 255 && !windsAsOriginal(filteredPoints(ring, maskFor), index === 0)) {
347
+ limit = Math.min(255, limit + 2)
348
+ for (const { arc } of ring) {
349
+ limits.set(arc.key, Math.max(limits.get(arc.key) ?? 0, limit))
350
+ masks.delete(arc.key)
351
+ }
352
+ }
353
+ })
354
+ }
355
+ }
356
+
357
+ const fills = project(
358
+ fitted,
359
+ {
360
+ type: "MultiPolygon",
361
+ coordinates: kept.flatMap((rings) => {
362
+ const [exterior, ...holes] = rings.map((ring) => filteredPoints(ring, maskFor))
363
+ return exterior.length < 4 ? [] : [[exterior, ...holes.filter((hole) => hole.length >= 4)]]
364
+ }),
365
+ } as GeoPermissibleObjects,
366
+ true
367
+ )
368
+
369
+ const lineArcs = [...usage.values()].filter(({ count }) => borders === "all" || count === 1).map(({ arc }) => arc)
370
+ const lines: GeoContour[] = []
371
+ for (const chain of chainArcs(lineArcs)) {
372
+ lines.push(
373
+ ...project(fitted, { type: "LineString", coordinates: filteredPoints(chain.refs, maskFor) } as GeoPermissibleObjects, false)
374
+ )
375
+ }
376
+
377
+ const sum = (contours: GeoContour[]) => contours.reduce((total, c) => total + c.length, 0)
378
+ const bytes = [...fills, ...lines].reduce((total, c) => total + c.points.byteLength + 32, 0)
379
+ return {
380
+ width: fitted.width,
381
+ height: fitted.height,
382
+ fills,
383
+ lines,
384
+ fillLength: sum(fills),
385
+ lineLength: sum(lines),
386
+ bytes,
387
+ }
388
+ }
389
+
390
+ class ByteCache<V extends { bytes: number }> {
391
+ private _entries = new Map<string, V>()
392
+ private _bytes = 0
393
+
394
+ constructor(private readonly _budget: number) {}
395
+
396
+ get(key: string): V | undefined {
397
+ const value = this._entries.get(key)
398
+ if (value) {
399
+ this._entries.delete(key)
400
+ this._entries.set(key, value)
401
+ }
402
+ return value
403
+ }
404
+
405
+ set(key: string, value: V): void {
406
+ const previous = this._entries.get(key)
407
+ if (previous) this._bytes -= previous.bytes
408
+ this._entries.delete(key)
409
+ this._entries.set(key, value)
410
+ this._bytes += value.bytes
411
+ for (const [oldest, entry] of this._entries) {
412
+ if (this._bytes <= this._budget || this._entries.size <= 1) break
413
+ this._entries.delete(oldest)
414
+ this._bytes -= entry.bytes
415
+ }
416
+ }
417
+
418
+ clear(): void {
419
+ this._entries.clear()
420
+ this._bytes = 0
421
+ }
422
+ }
423
+
424
+ const frames = new ByteCache<Frame & { bytes: number }>(64)
425
+ const geometries = new ByteCache<GeoGeometry>(64 * 1024 * 1024)
426
+
427
+ export interface GeoGeometryRequest {
428
+ /** Identifies `features` and `frame`: equal keys must mean equal selections. */
429
+ selectionKey: string
430
+ features: readonly GeoFeature[]
431
+ frame: GeoFrame
432
+ projection: GeoProjectionKind
433
+ borders: GeoBorders
434
+ detail: number
435
+ }
436
+
437
+ /**
438
+ * The drawing for a selection, cached. The projection is fitted to the
439
+ * selection at full detail, so a `detail` tween never moves or rescales it.
440
+ */
441
+ export function geoGeometry(request: GeoGeometryRequest): GeoGeometry | null {
442
+ const detail = Math.round(clamp01(request.detail) * 100) / 100
443
+ const frameKey = `${request.selectionKey}|${request.projection}`
444
+ const key = `${frameKey}|${request.borders}|${detail}`
445
+ const cached = geometries.get(key)
446
+ if (cached) return cached
447
+
448
+ let fitted: Frame | undefined = frames.get(frameKey)
449
+ if (!fitted) {
450
+ const made = fitFrame(request.projection, partsOf(request.features, request.frame))
451
+ if (!made) return null
452
+ fitted = made
453
+ frames.set(frameKey, { ...made, bytes: 1 })
454
+ }
455
+ const geometry = buildGeometry(request.features, request.frame, fitted, request.borders, detail)
456
+ geometries.set(key, geometry)
457
+ return geometry
458
+ }
459
+
460
+ export function clearGeoGeometry(): void {
461
+ frames.clear()
462
+ geometries.clear()
463
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * GeoBorder: country and subdivision outlines by ISO 3166 code.
3
+ *
4
+ * `ms add geo-border` copies 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"
@@ -0,0 +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
+ }