@motionscript/geo 0.0.0-stage → 0.1.0-alpha.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.
Files changed (108) hide show
  1. package/CHANGELOG.md +5 -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 +68 -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,147 @@
1
+ import { geoArea } from "d3-geo"
2
+
3
+ /** The on-disk shape `@motionscript/geo-data` writes; see its `generate.mjs`. */
4
+ export const GEO_DATA_FORMAT = 1
5
+
6
+ export interface GeoFileData {
7
+ format: number
8
+ source?: string
9
+ transform: { scale: [number, number]; translate: [number, number] }
10
+ /** Global arc id per local arc — identical across files for a shared border. */
11
+ arcIds: number[]
12
+ /** Per arc, flat `[x0, y0, w0, dx, dy, w, …]`: delta-encoded quantized lon/lat and a weight bucket. */
13
+ arcs: number[][]
14
+ features: GeoFeatureData[]
15
+ }
16
+
17
+ export interface GeoFeatureData {
18
+ code: string
19
+ name: string
20
+ nameNl?: string
21
+ parent: string | null
22
+ first: boolean
23
+ listed: boolean
24
+ remote?: boolean
25
+ area: number
26
+ mainland: number[] | null
27
+ areas: number[]
28
+ /** Polygons → rings → arc refs (`~i` is arc `i` reversed). */
29
+ polygons: number[][][]
30
+ }
31
+
32
+ /** `[code, name, nameNl, parent, first]` per listed region. */
33
+ export type GeoIndexEntry = [string, string, string, string | null, 0 | 1]
34
+
35
+ export interface GeoIndex {
36
+ format: number
37
+ source?: string
38
+ regions: GeoIndexEntry[]
39
+ }
40
+
41
+ export interface GeoArc {
42
+ /** Identity for de-duplicating a border drawn by two regions, across files. */
43
+ key: number | string
44
+ /** Interleaved lon/lat, in degrees. */
45
+ points: Float64Array
46
+ /** Per point: 0 always survives, higher buckets are less significant; see `thresholdBucket`. */
47
+ buckets: Uint8Array
48
+ }
49
+
50
+ export interface GeoRingRef {
51
+ arc: GeoArc
52
+ reversed: boolean
53
+ }
54
+
55
+ export interface GeoPolygon {
56
+ rings: GeoRingRef[][]
57
+ /** Per ring, in steradians — exterior first, then holes. */
58
+ ringAreas: number[]
59
+ area: number
60
+ mainland: boolean
61
+ }
62
+
63
+ export interface GeoFeature {
64
+ code: string
65
+ name: string
66
+ nameNl: string
67
+ parent: string | null
68
+ first: boolean
69
+ listed: boolean
70
+ remote: boolean
71
+ polygons: GeoPolygon[]
72
+ }
73
+
74
+ export interface DecodedGeoFile {
75
+ features: Map<string, GeoFeature>
76
+ }
77
+
78
+ function decodeArc(encoded: number[], key: number, [kx, ky]: [number, number], [tx, ty]: [number, number]): GeoArc {
79
+ const count = encoded.length / 3
80
+ const points = new Float64Array(count * 2)
81
+ const buckets = new Uint8Array(count)
82
+ let x = 0
83
+ let y = 0
84
+ for (let i = 0; i < count; i++) {
85
+ x += encoded[i * 3]
86
+ y += encoded[i * 3 + 1]
87
+ points[i * 2] = x * kx + tx
88
+ points[i * 2 + 1] = y * ky + ty
89
+ buckets[i] = encoded[i * 3 + 2]
90
+ }
91
+ return { key, points, buckets }
92
+ }
93
+
94
+ export function ringCoordinates(ring: readonly GeoRingRef[]): [number, number][] {
95
+ const out: [number, number][] = []
96
+ for (const { arc, reversed } of ring) {
97
+ const n = arc.buckets.length
98
+ for (let j = out.length === 0 ? 0 : 1; j < n; j++) {
99
+ const i = reversed ? n - 1 - j : j
100
+ out.push([arc.points[i * 2], arc.points[i * 2 + 1]])
101
+ }
102
+ }
103
+ return out
104
+ }
105
+
106
+ /** A ring's area in steradians, whichever way it winds. */
107
+ export function ringArea(ring: readonly GeoRingRef[]): number {
108
+ const coordinates = ringCoordinates(ring)
109
+ if (coordinates.length < 4) return 0
110
+ const area = geoArea({ type: "Polygon", coordinates: [coordinates] })
111
+ return area > 2 * Math.PI ? 4 * Math.PI - area : area
112
+ }
113
+
114
+ export function decodeGeoFile(data: GeoFileData): DecodedGeoFile {
115
+ if (data.format !== GEO_DATA_FORMAT) {
116
+ throw new Error(`geo data format ${data.format} is not supported (expected ${GEO_DATA_FORMAT})`)
117
+ }
118
+ const arcs = data.arcs.map((encoded, i) =>
119
+ decodeArc(encoded, data.arcIds[i], data.transform.scale, data.transform.translate)
120
+ )
121
+ const ref = (index: number): GeoRingRef =>
122
+ index < 0 ? { arc: arcs[~index], reversed: true } : { arc: arcs[index], reversed: false }
123
+
124
+ const features = new Map<string, GeoFeature>()
125
+ for (const f of data.features) {
126
+ const mainland = f.mainland ? new Set(f.mainland) : null
127
+ features.set(f.code, {
128
+ code: f.code,
129
+ name: f.name,
130
+ nameNl: f.nameNl ?? f.name,
131
+ parent: f.parent,
132
+ first: f.first,
133
+ listed: f.listed,
134
+ remote: f.remote === true,
135
+ polygons: f.polygons.map((rings, i) => {
136
+ const resolved = rings.map((ring) => ring.map(ref))
137
+ return {
138
+ rings: resolved,
139
+ ringAreas: resolved.map((ring, r) => (r === 0 ? f.areas[i] : ringArea(ring))),
140
+ area: f.areas[i],
141
+ mainland: mainland === null || mainland.has(i),
142
+ }
143
+ }),
144
+ })
145
+ }
146
+ return { features }
147
+ }
@@ -0,0 +1,260 @@
1
+ import {
2
+ Clip,
3
+ Graphics2D,
4
+ ShapeNode,
5
+ node,
6
+ property,
7
+ type AssetScope,
8
+ type FillResolved,
9
+ type NodeConfig,
10
+ type PathBounds,
11
+ type RenderContext2D,
12
+ type ShadowResolved,
13
+ type ShapeProps,
14
+ type StrokeResolved,
15
+ } from "@motionscript/core"
16
+ import { snapValue } from "@motionscript/core/component"
17
+
18
+ import { geoGeometry, type GeoBorders, type GeoContour, type GeoGeometry, type GeoProjectionKind } from "./geometry"
19
+ import { geoDataVersion, geoLoader } from "./loader"
20
+ import { geoRegistry } from "./registry"
21
+ import { geoFilesFor, normalizeIso, selectGeoFeatures, type GeoFrame, type GeoLevel } from "./selection"
22
+
23
+ export interface GeoBorderProps extends ShapeProps {
24
+ /** ISO 3166-1 alpha-2 (`"FR"`) or ISO 3166-2 (`"US-CA"`) codes. */
25
+ iso: string | readonly string[]
26
+ level: GeoLevel
27
+ /** 0–1: 1 is full resolution, lower is a more stylised outline. */
28
+ detail: number
29
+ projection: GeoProjectionKind
30
+ frame: GeoFrame
31
+ borders: GeoBorders
32
+ }
33
+
34
+ interface Chunk {
35
+ data: string
36
+ /** Where this chunk's contours sit along the whole drawing, as fractions of its length. */
37
+ from: number
38
+ to: number
39
+ }
40
+
41
+ interface Drawn {
42
+ geometry: GeoGeometry
43
+ width: number
44
+ height: number
45
+ fills: Chunk[]
46
+ lines: Chunk[]
47
+ }
48
+
49
+ // Skia's cost for one path grows faster than its contour count, and a shape
50
+ // drawn as several paths under one paint is unioned every frame — so a region
51
+ // is cut into paths of bounded size, each painted on its own.
52
+ const CONTOURS_PER_PATH = 256
53
+ const POINTS_PER_PATH = 40_000
54
+
55
+ const coordinate = (value: number) => String(Math.round(value * 100) / 100)
56
+
57
+ function chunk(contours: readonly GeoContour[], total: number, scale: number): Chunk[] {
58
+ const chunks: Chunk[] = []
59
+ let parts: string[] = []
60
+ let count = 0
61
+ let points = 0
62
+ let from = 0
63
+ let length = 0
64
+ const flush = () => {
65
+ if (count === 0) return
66
+ const to = total > 0 ? Math.min(1, length / total) : 1
67
+ chunks.push({ data: parts.join(""), from, to })
68
+ from = to
69
+ parts = []
70
+ count = 0
71
+ points = 0
72
+ }
73
+ for (const contour of contours) {
74
+ const p = contour.points
75
+ // Path data is y-down; the unit geometry is y-up.
76
+ for (let i = 0; i < p.length; i += 2) {
77
+ parts.push(i === 0 ? "M" : "L", coordinate(p[i] * scale), " ", coordinate(-p[i + 1] * scale))
78
+ }
79
+ if (contour.closed) parts.push("Z")
80
+ count++
81
+ points += p.length / 2
82
+ length += contour.length
83
+ if (count >= CONTOURS_PER_PATH || points >= POINTS_PER_PATH) flush()
84
+ }
85
+ flush()
86
+ return chunks
87
+ }
88
+
89
+ /**
90
+ * Country and subdivision outlines by ISO 3166 code, fitted into the node's box.
91
+ *
92
+ * <GeoBorder iso={["FR", "DE"]} detail={0.4} stroke={{ weight: 2, fill: "white" }} />
93
+ *
94
+ * Geometry comes from whatever `geoRegistry` has been given — a URL, a loader,
95
+ * or shapes defined by hand — and loads as a declared asset, so a frame never
96
+ * draws half a country. Fill and stroke are separate geometry: the fill is each
97
+ * region's polygons, the stroke is every border once, which is why a border two
98
+ * selected regions share is not drawn twice and why `stroke.align` does not
99
+ * apply.
100
+ */
101
+ @node({
102
+ key: "geoBorder",
103
+ parentKey: "shape",
104
+ forkable: true,
105
+ layout: {
106
+ children: "freeform",
107
+ acceptsChildren: true,
108
+ defaultWidthMode: "fixed",
109
+ defaultHeightMode: "fixed",
110
+ },
111
+ seed: { width: 480, height: 480 },
112
+ })
113
+ export class GeoBorder extends ShapeNode<GeoBorderProps> {
114
+ @property({ default: ["FR"], mapper: normalizeIso, tween: snapValue })
115
+ declare iso: string | readonly string[]
116
+
117
+ @property({ default: "country", tween: snapValue, options: ["country", "state"] })
118
+ declare level: GeoLevel
119
+
120
+ @property({ default: 0.5, min: 0, max: 1, step: 0.01 })
121
+ declare detail: number
122
+
123
+ @property({
124
+ default: "conic",
125
+ tween: snapValue,
126
+ options: ["conic", "mercator", "equirectangular", "orthographic"],
127
+ })
128
+ declare projection: GeoProjectionKind
129
+
130
+ @property({ default: "mainland", tween: snapValue, options: ["mainland", "all"] })
131
+ declare frame: GeoFrame
132
+
133
+ @property({ default: "all", tween: snapValue, options: ["all", "outline"] })
134
+ declare borders: GeoBorders
135
+
136
+ constructor(props?: NodeConfig<GeoBorder, GeoBorderProps>) {
137
+ super(props as NodeConfig<ShapeNode<GeoBorderProps>, GeoBorderProps>)
138
+ }
139
+
140
+ override declareAssets(assets: AssetScope): void {
141
+ super.declareAssets(assets)
142
+ for (const file of geoFilesFor(this.iso as string[], this.level)) {
143
+ assets.loader(`geo:${file}`, geoLoader(file), { gate: "render" })
144
+ }
145
+ }
146
+
147
+ protected override renderSelf(ctx: RenderContext2D): void {
148
+ const drawn = this._drawn()
149
+ const fill = this.fill as FillResolved[]
150
+ if (!drawn || drawn.fills.length === 0) return
151
+ const shadow = this.shadow as ShadowResolved[]
152
+ const g = new Graphics2D()
153
+ for (const chunk of drawn.fills) {
154
+ if (!this._pathOp(g, chunk)) continue
155
+ if (shadow.length > 0) g.shadow(shadow)
156
+ g.fill(fill)
157
+ }
158
+ ctx.draw(g)
159
+ }
160
+
161
+ protected override renderOverlay(ctx: RenderContext2D): void {
162
+ const overlay = this.overlay as FillResolved[]
163
+ if (overlay.length === 0) return
164
+ const drawn = this._drawn()
165
+ if (!drawn) return
166
+ const g = new Graphics2D()
167
+ for (const chunk of drawn.fills) if (this._pathOp(g, chunk)) g.fill(overlay)
168
+ ctx.draw(g)
169
+ }
170
+
171
+ protected override renderStroke(ctx: RenderContext2D): void {
172
+ const stroke = this.stroke as StrokeResolved[]
173
+ if (stroke.length === 0) return
174
+ const drawn = this._drawn()
175
+ if (!drawn || drawn.lines.length === 0) return
176
+ const centred = stroke.map((layer) => (layer.align === 0 ? layer : { ...layer, align: 0 }))
177
+ const shadow = this.shadow as ShadowResolved[]
178
+ const castsShadow = shadow.length > 0 && !(this.fill as FillResolved[]).some((layer) => (layer.opacity ?? 1) > 0)
179
+ const g = new Graphics2D()
180
+ for (const chunk of drawn.lines) {
181
+ if (!this._pathOp(g, chunk)) continue
182
+ if (castsShadow) g.shadow(shadow)
183
+ g.stroke(centred)
184
+ }
185
+ ctx.draw(g)
186
+ }
187
+
188
+ // The box, not the outline: a path clip has no winding test, and a country's
189
+ // bounding box is what the selection shows.
190
+ protected override clipSelf(): Clip {
191
+ return new Clip().rect({
192
+ x: 0,
193
+ y: 0,
194
+ width: this.layoutBounds.width,
195
+ height: this.layoutBounds.height,
196
+ })
197
+ }
198
+
199
+ private _drawnCache: Drawn | null = null
200
+ private readonly _warned = new Set<string>()
201
+
202
+ private _pathOp(g: Graphics2D, chunk: Chunk): boolean {
203
+ const start = this.start
204
+ const end = this.end
205
+ const width = this.layoutBounds.width
206
+ const height = this.layoutBounds.height
207
+ const centerBounds: PathBounds = [-width / 2, -height / 2, width / 2, height / 2]
208
+ if (start <= 0 && end >= 1) {
209
+ g.path({ data: chunk.data, centerBounds })
210
+ return true
211
+ }
212
+ const span = chunk.to - chunk.from
213
+ if (span <= 0) return false
214
+ const localStart = Math.max(0, (start - chunk.from) / span)
215
+ const localEnd = Math.min(1, (end - chunk.from) / span)
216
+ if (localEnd <= localStart) return false
217
+ g.path({ data: chunk.data, centerBounds, start: localStart, end: localEnd })
218
+ return true
219
+ }
220
+
221
+ private _drawn(): Drawn | null {
222
+ const codes = this.iso as string[]
223
+ const level = this.level
224
+ const frame = this.frame
225
+ const selection = selectGeoFeatures(codes, level, frame)
226
+ for (const code of selection.unknown) {
227
+ if (this._warned.has(code)) continue
228
+ this._warned.add(code)
229
+ console.warn(`[GeoBorder] no region with code ${code}`)
230
+ }
231
+ if (selection.features.length === 0) return null
232
+
233
+ const geometry = geoGeometry({
234
+ selectionKey: `${geoRegistry.generation}:${geoDataVersion()}:${level}:${frame}:${codes.join(",")}`,
235
+ features: selection.features,
236
+ frame,
237
+ projection: this.projection,
238
+ borders: this.borders,
239
+ detail: this.detail,
240
+ })
241
+ if (!geometry) return null
242
+
243
+ const width = this.layoutBounds.width
244
+ const height = this.layoutBounds.height
245
+ const cached = this._drawnCache
246
+ if (cached && cached.geometry === geometry && cached.width === width && cached.height === height) {
247
+ return cached
248
+ }
249
+ const scale = Math.min(width / geometry.width, height / geometry.height)
250
+ const drawn: Drawn = {
251
+ geometry,
252
+ width,
253
+ height,
254
+ fills: chunk(geometry.fills, geometry.fillLength, scale),
255
+ lines: chunk(geometry.lines, geometry.lineLength, scale),
256
+ }
257
+ this._drawnCache = drawn
258
+ return drawn
259
+ }
260
+ }