@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,552 @@
1
+ import {
2
+ command,
3
+ driveCommand,
4
+ easeInOut,
5
+ node,
6
+ type Command,
7
+ type CommandArgs,
8
+ Canvas3D,
9
+ Geo,
10
+ Graphics3D,
11
+ Mat,
12
+ Scene3D,
13
+ property,
14
+ type Canvas3DProps,
15
+ type Color,
16
+ type NodeConfig,
17
+ type NormalizedColor,
18
+ type SurfaceTexture3D,
19
+ type Transform3D,
20
+ } from "@motionscript/core"
21
+
22
+ import {
23
+ lerpColor3D,
24
+ resolveColor3D,
25
+ snapFlag,
26
+ snapValue,
27
+ cameraOrbit,
28
+ orbitProps,
29
+ type OrbitTarget,
30
+ } from "@motionscript/core/component"
31
+ import { atmosphereMaterial } from "./atmosphere"
32
+ import { arcSegments, drawableArcs, drawableMarkers } from "./places"
33
+ import { greatCircle, latLonToPosition } from "./projection"
34
+ import { mapHeight, worldMapGraphics, type WorldMapStyle } from "./world-map"
35
+
36
+ /**
37
+ * The globe's radius, in scene units.
38
+ *
39
+ * One, and not a prop. Every other length the node takes — a marker's size, an
40
+ * arc's width and lift, the camera's `zoom` — is a fraction or a multiple of it,
41
+ * which is what makes those numbers mean the same thing at any framing and is
42
+ * why there is nothing for a radius prop to be relative *to*. The Protein's
43
+ * radius is a prop because a molecule's size is a fact about the molecule; the
44
+ * Earth's is a choice about units.
45
+ */
46
+ const RADIUS = 1
47
+
48
+ /** How far off the surface a marker's centre sits, as a fraction of its size. */
49
+ const MARKER_LIFT = 0.35
50
+
51
+ export interface GlobeProps extends Canvas3DProps {
52
+ // ── The map ──
53
+ resolution: number
54
+ ocean: Color
55
+ land: Color
56
+ border: Color
57
+ borderWidth: number
58
+ graticule: boolean
59
+ graticuleColor: Color
60
+ graticuleStep: number
61
+ graticuleWidth: number
62
+
63
+ // ── The camera ──
64
+ orbit: number
65
+ elevation: number
66
+ zoom: number
67
+ fov: number
68
+
69
+ // ── Lighting ──
70
+ ambientIntensity: number
71
+ ambientColor: Color
72
+ keyIntensity: number
73
+ keyColor: Color
74
+
75
+ // ── The atmosphere ──
76
+ atmosphere: boolean
77
+ atmosphereColor: Color
78
+ atmosphereSize: number
79
+ atmosphereFalloff: number
80
+ atmosphereIntensity: number
81
+
82
+ // ── What is placed on it ──
83
+ markers: string
84
+ arcs: string
85
+ }
86
+
87
+ /** What the map cache is holding, keyed by the style that produced it. */
88
+ interface BakedMap {
89
+ texture: SurfaceTexture3D
90
+ }
91
+
92
+ /**
93
+ * A globe: country boundaries on a lit sphere, flown by an orbit camera.
94
+ *
95
+ * <Globe width="fill" height="fill" orbit={20} elevation={25} zoom={2.6} />
96
+ *
97
+ * Like the Protein and unlike the Canvas 3D viewport, it draws **one subject**
98
+ * derived from its own props rather than being a room the author fills. Four
99
+ * things about it are worth knowing before reading the parts:
100
+ *
101
+ * **The map is a texture, drawn in 2D.** See `world-map.ts`: a WebGL line
102
+ * ignores any width above one pixel, so borders drawn as 3D lines are hairlines
103
+ * forever. Baked, a border is an ordinary Skia stroke at any weight.
104
+ *
105
+ * **That texture is cached against its style, and the cache is not an
106
+ * optimisation.** motion-script re-rasterizes a surface every frame unless the
107
+ * descriptor says otherwise, and re-rasterizing this one means redrawing ten
108
+ * thousand paths, stalling on a GPU read-back and re-uploading a few megabytes —
109
+ * sixty times a second, for an identical image. {@link bakedMap} holds one
110
+ * descriptor per style and marks it `static`; the schema then refuses to animate
111
+ * any field that style is made of, so an export cannot mint a raster per frame.
112
+ *
113
+ * **The camera is three tweenable numbers, and two of them are places.** `orbit`
114
+ * is a longitude and `elevation` is a latitude — see `projection.ts`, which is
115
+ * where that convention is established and defended. There is no `OrbitControls`
116
+ * here for the reason the other 3D nodes have none: a rendered timeline has no
117
+ * pointer, and every frame has to be reproducible under scrubbing and export.
118
+ *
119
+ * **Markers and arcs are real 3D, never baked.** They are the animated content;
120
+ * putting them in the texture would run every frame of a marker's fade through a
121
+ * full re-rasterize.
122
+ */
123
+ @node({
124
+ key: "globe",
125
+ parentKey: "node",
126
+ forkable: true,
127
+ layout: {
128
+ children: "freeform",
129
+ acceptsChildren: false,
130
+ defaultWidthMode: "fixed",
131
+ defaultHeightMode: "fixed",
132
+ },
133
+ seed: {
134
+ width: 640,
135
+ height: 640,
136
+ },
137
+ })
138
+ export class Globe extends Canvas3D<GlobeProps> {
139
+ // ── The map ──
140
+ //
141
+ // Every prop in this group is part of the texture's cache key, and every one
142
+ // of them is `animatable: false` in the appearance schema. They are plain
143
+ // strings rather than resolved colours for the same reason: a tween would need
144
+ // a mapper, and there is deliberately no tween to have.
145
+
146
+ /**
147
+ * Texture width in pixels; the height is half of it.
148
+ *
149
+ * Structural, and capped in the schema rather than here. The buffer is
150
+ * re-uploaded to the GPU every frame even when its pixels are cached, so this
151
+ * is a per-frame bandwidth number as much as a sharpness one — 2048 is 8 MB a
152
+ * frame and already finer than the 110m boundaries it draws.
153
+ */
154
+ @property({ default: 2048, tween: snapValue }) declare resolution: number
155
+
156
+ @property({ default: "#0b1a2b", tween: snapValue }) declare ocean: Color
157
+ @property({ default: "#2c3e50", tween: snapValue }) declare land: Color
158
+ /**
159
+ * The colour of every coast and border.
160
+ *
161
+ * Alpha works. It did not while the country paths carried the stroke — a
162
+ * border belongs to two countries, so it was drawn twice and came out at
163
+ * double density beside the coastlines. `borders.ts` deduplicates the lines
164
+ * and `world-map.ts` strokes them once.
165
+ */
166
+ @property({ default: "#4a6076", tween: snapValue }) declare border: Color
167
+ @property({ default: 1.5, tween: snapValue }) declare borderWidth: number
168
+
169
+ @property({ default: true, tween: snapFlag }) declare graticule: boolean
170
+ @property({ default: "#ffffff20", tween: snapValue })
171
+ declare graticuleColor: Color
172
+ @property({ default: 15, tween: snapValue }) declare graticuleStep: number
173
+ @property({ default: 1, tween: snapValue }) declare graticuleWidth: number
174
+
175
+ // ── The camera ──
176
+
177
+ /**
178
+ * The camera's heading, in degrees, in exactly the sense every other 3D node
179
+ * uses it — which is what keeps the canvas drag consistent with them.
180
+ *
181
+ * It is *not* a longitude: a camera at heading θ is centred over longitude −θ
182
+ * (see `orbitForLongitude` in `projection.ts`). Unbounded, like every other
183
+ * viewport's, because the drag wraps it into `[0, 360)` and a range here would
184
+ * clamp half of them.
185
+ */
186
+ @property({ default: -10 }) declare orbit: number
187
+ /**
188
+ * Degrees above the equator, which on a globe *is* the latitude the camera
189
+ * stands over — no conversion, unlike the heading. Held short of the poles by
190
+ * the schema, as every orbit camera's is.
191
+ */
192
+ @property({ default: 25 }) declare elevation: number
193
+ /** Distance from the centre, as a multiple of the globe's radius. */
194
+ @property({ default: 2.8 }) declare zoom: number
195
+ @property({ default: 45 }) declare fov: number
196
+
197
+ // ── Lighting ──
198
+
199
+ @property({ default: 0.55 }) declare ambientIntensity: number
200
+ @property({ default: "#ffffff", mapper: resolveColor3D, tween: lerpColor3D })
201
+ declare ambientColor: Color
202
+ @property({ default: 1.9 }) declare keyIntensity: number
203
+ @property({ default: "#ffffff", mapper: resolveColor3D, tween: lerpColor3D })
204
+ declare keyColor: Color
205
+
206
+ // ── The atmosphere ──
207
+ //
208
+ // Shading rather than pixels, so unlike the map group these all animate.
209
+
210
+ @property({ default: true, tween: snapFlag }) declare atmosphere: boolean
211
+ @property({ default: "#4da3ff", mapper: resolveColor3D, tween: lerpColor3D })
212
+ declare atmosphereColor: Color
213
+ /** The shell's radius as a multiple of the globe's. */
214
+ @property({ default: 1.06 }) declare atmosphereSize: number
215
+ /** How tightly the glow hugs the edge. Higher is a thinner rim. */
216
+ @property({ default: 3 }) declare atmosphereFalloff: number
217
+ @property({ default: 1.1 }) declare atmosphereIntensity: number
218
+
219
+ // ── What is placed on it ──
220
+ //
221
+ // Serialized lists, as the graphs store their equations: an appearance value
222
+ // is a number, a string or a boolean, so a list arrives as a string a mapper
223
+ // reads. Parsing is in `places.ts` and is deliberately tolerant — the field is
224
+ // invalid far more often than valid while somebody is typing into it.
225
+
226
+ @property({ default: "[]", tween: snapValue }) declare markers: string
227
+ @property({ default: "[]", tween: snapValue }) declare arcs: string
228
+
229
+ constructor(props?: NodeConfig<Globe, GlobeProps>) {
230
+ super(props as NodeConfig<Canvas3D<GlobeProps>, GlobeProps>)
231
+ }
232
+
233
+ /**
234
+ * Frame the scene at a spherical camera placement. Every axis is optional,
235
+ * so "pull back" and "spin round" stay separate intentions — see
236
+ * {@link OrbitTarget}.
237
+ */
238
+ @command({
239
+ args: [
240
+ {
241
+ key: "target", kind: "orbit", properties: {
242
+ orbit: { kind: "number", step: 1, unit: "°" },
243
+ elevation: { kind: "number", min: -89, max: 89, step: 1, unit: "°" },
244
+ zoom: { kind: "number", min: 0.2, max: 24, step: 0.1, unit: "×" },
245
+ },
246
+ },
247
+ ],
248
+ })
249
+ orbitTo(args: CommandArgs<{ target: OrbitTarget }> & { duration: number }): Command<GlobeProps> {
250
+ return this.to({
251
+ data: orbitProps(args.data?.target ?? {}) as Partial<GlobeProps>,
252
+ duration: args.duration,
253
+ easing: args.easing ?? easeInOut(),
254
+ })
255
+ }
256
+
257
+ /**
258
+ * Fly the camera to a place on the globe. `latitude`/`longitude` are the
259
+ * point to bring under the camera and `framing` the distance to settle at.
260
+ *
261
+ * **`arc` is what makes it a flight rather than a pan** — above `0` the
262
+ * distance bows outward at the midpoint by that fraction, so a long journey
263
+ * rises off the surface and settles back down rather than scraping the
264
+ * horizon the whole way. Longitude takes the **short way round**.
265
+ */
266
+ @command({ args: [{ key: "latitude", kind: "number", default: 0, min: -89, max: 89, step: 1, unit: "°" }, { key: "longitude", kind: "number", default: 0, min: -180, max: 180, step: 1, unit: "°" }, { key: "framing", kind: "number", default: 2.2, min: 1.05, max: 12, step: 0.1, unit: "×" }, { key: "arc", kind: "number", default: 0.35, min: 0, max: 3, step: 0.05, scale: 100, unit: "%" }] })
267
+ flyTo(args: CommandArgs<{ latitude: number; longitude: number; framing?: number; arc?: number }> & { duration?: number }): Command<GlobeProps> {
268
+ const data = args.data ?? { latitude: 0, longitude: 0 }
269
+ const { latitude, longitude, framing = 2.2, arc = 0.35 } = data
270
+ const duration = args.duration ?? 1.6
271
+ const easing = args.easing ?? easeInOut()
272
+
273
+ const fromOrbit = this.orbit
274
+ const fromElevation = this.elevation
275
+ const fromZoom = this.zoom
276
+
277
+ // The globe spins under a fixed camera, so a longitude is reached by
278
+ // orbiting to its negation — and the shortest signed turn is what keeps a
279
+ // crossing of the antimeridian from unwinding the long way.
280
+ const toOrbit = fromOrbit + shortestTurn(fromOrbit, -longitude)
281
+
282
+ return driveCommand(duration, (t) => {
283
+ const eased = easing(t)
284
+ this.set({
285
+ orbit: fromOrbit + (toOrbit - fromOrbit) * eased,
286
+ elevation: fromElevation + (latitude - fromElevation) * eased,
287
+ // The bow: a half-sine that is 0 at both ends, so the command still
288
+ // lands exactly on `framing` however large `arc` is.
289
+ zoom: (fromZoom + (framing - fromZoom) * eased) * (1 + arc * Math.sin(Math.PI * eased)),
290
+ } as Partial<GlobeProps>)
291
+ }) as Command<GlobeProps>
292
+ }
293
+
294
+ protected override buildScene3D(): Scene3D {
295
+ const scene = new Scene3D()
296
+ const distance = RADIUS * Math.max(1.02, this.zoom)
297
+
298
+ // Cut to the globe rather than left to the camera's own derivation from the
299
+ // scene bounds: the atmosphere shell is the outermost thing in the scene and
300
+ // sizing the planes to it would spend depth precision on the halo. The near
301
+ // plane has to survive `zoom` coming right down to the surface, which is
302
+ // what the fraction of the gap is for.
303
+ scene.perspective({
304
+ fov: this.fov,
305
+ near: Math.max(0.01, (distance - RADIUS) * 0.5),
306
+ far: distance + RADIUS * 4,
307
+ // Exactly what every other 3D viewport does — the quarter turn between
308
+ // the stored zero and the camera's, and nothing else. Anything extra here
309
+ // is a globe whose canvas drag runs backwards against every other node's;
310
+ // the longitude convention is handled in the *geometry* instead. See
311
+ // `projection.ts`.
312
+ orbit: cameraOrbit(this.orbit),
313
+ elevation: this.elevation,
314
+ distance,
315
+ })
316
+
317
+ // The same rig the Protein and the 3D viewport use, at the globe's scale: a
318
+ // key, a rim that is the key from the other side at a fraction of its
319
+ // strength, and enough ambient that the night side is a planet rather than a
320
+ // silhouette. The backdrop is the node's own Fills stack — a `Canvas3D`
321
+ // composites its 3D pass over its 2D fill layers — so there is no space to
322
+ // paint here and no clear colour to set.
323
+ scene
324
+ .light({
325
+ type: "ambient",
326
+ intensity: this.ambientIntensity,
327
+ color: this.ambientColor,
328
+ })
329
+ .light(
330
+ { type: "directional", intensity: this.keyIntensity, color: this.keyColor },
331
+ { position: [RADIUS * 3, RADIUS * 2, RADIUS * 3] }
332
+ )
333
+ .light(
334
+ {
335
+ type: "directional",
336
+ intensity: this.keyIntensity * 0.35,
337
+ color: this.keyColor,
338
+ },
339
+ { position: [-RADIUS * 3, -RADIUS, -RADIUS * 2] }
340
+ )
341
+
342
+ const g3 = new Graphics3D()
343
+ this.addPlanet(g3)
344
+ this.addArcs(g3)
345
+ this.addMarkers(g3)
346
+ if (this.atmosphere) this.addAtmosphere(g3)
347
+
348
+ scene.draw(g3)
349
+ return scene
350
+ }
351
+
352
+ /** The sphere, wearing the baked map. */
353
+ private addPlanet(g3: Graphics3D): void {
354
+ g3.sphere({
355
+ radius: RADIUS,
356
+ // Longitude segments matter more than latitude: the silhouette is a
357
+ // circle of longitude, and a coarse one shows as a faceted rim against
358
+ // the atmosphere's own smooth curve.
359
+ segments: [128, 64],
360
+ fill: this.bakedMap().texture,
361
+ // Land and sea are matte at this distance; a specular highlight on an
362
+ // ocean this size reads as a lens flare rather than as water.
363
+ roughness: 0.95,
364
+ metalness: 0,
365
+ })
366
+ }
367
+
368
+ /** The halo. A shell, kept for its back faces — see `atmosphere.ts`. */
369
+ private addAtmosphere(g3: Graphics3D): void {
370
+ g3.sphere({
371
+ radius: RADIUS * Math.max(1.001, this.atmosphereSize),
372
+ segments: [96, 48],
373
+ material: atmosphereMaterial(
374
+ this.atmosphereColor as unknown as NormalizedColor,
375
+ this.atmosphereFalloff,
376
+ this.atmosphereIntensity
377
+ ),
378
+ })
379
+ }
380
+
381
+ /**
382
+ * The markers, as one instanced draw.
383
+ *
384
+ * Instanced rather than a mesh apiece because a marker list is the one thing
385
+ * here with no ceiling — a network map is thousands of points, and a draw call
386
+ * each would not keep up. One geometry, one material, one upload.
387
+ *
388
+ * Note the material carries **no** `vertexColors`: an instanced mesh that asks
389
+ * for it makes the shader read a per-vertex `color` attribute the geometry does
390
+ * not have, gets zeroes, and every marker renders black. The per-instance
391
+ * colours ride the `colors` option instead, which is a different channel.
392
+ */
393
+ private addMarkers(g3: Graphics3D): void {
394
+ const markers = drawableMarkers(this.markers)
395
+ if (markers.length === 0) return
396
+
397
+ const placements: Transform3D[] = []
398
+ const colors: Color[] = []
399
+
400
+ for (const marker of markers) {
401
+ const size = marker.size * RADIUS
402
+ placements.push({
403
+ // Lifted by a fraction of its own size so a marker sits *on* the
404
+ // surface rather than half-buried in it, at any size.
405
+ position: latLonToPosition(marker, RADIUS + size * MARKER_LIFT),
406
+ scale: size,
407
+ })
408
+ colors.push(marker.color)
409
+ }
410
+
411
+ g3.instances(
412
+ Geo.sphere({ radius: 1, segments: [16, 12] }),
413
+ // Unlit, and deliberately: a marker is a signal rather than an object, and
414
+ // one shaded by the scene's key light would go dark exactly when it
415
+ // rotated to the night side — which is where it most needs to be seen.
416
+ Mat.basic({ transparent: true }),
417
+ placements,
418
+ { colors }
419
+ )
420
+ }
421
+
422
+ /** The arcs, one swept tube each. */
423
+ private addArcs(g3: Graphics3D): void {
424
+ // Resolved against the marker list, because an end may be *pinned* to one —
425
+ // which is what keeps an arc attached when the place it points at moves.
426
+ for (const arc of drawableArcs(this.arcs, this.markers)) {
427
+ const points = greatCircle(arc.from, arc.to, {
428
+ radius: RADIUS,
429
+ lift: arc.lift,
430
+ segments: arcSegments(arc),
431
+ })
432
+
433
+ // `progress` trims the point list rather than scaling the geometry, so a
434
+ // half-drawn arc is the first half of the real path — the same shape the
435
+ // finished arc has, stopping early. Two points is the floor a tube can be
436
+ // swept along.
437
+ const drawn = Math.max(2, Math.round(points.length * arc.progress))
438
+
439
+ g3.mesh(
440
+ Geo.tube({
441
+ points: points.slice(0, drawn),
442
+ radius: arc.width * RADIUS,
443
+ segments: [drawn, 8],
444
+ }),
445
+ // Unlit for the reason the markers are: an arc is an annotation, and one
446
+ // that dimmed over the night side would vanish halfway across.
447
+ Mat.basic({ color: arc.color, transparent: true })
448
+ )
449
+ }
450
+ }
451
+
452
+ // --- The map cache -------------------------------------------------------
453
+
454
+ /**
455
+ * The baked map, and the descriptor that lets it be baked once.
456
+ *
457
+ * Two caches are being kept in step here, and the key is what keeps them in
458
+ * step. This one holds the `Graphics2D` and its descriptor so the node hands
459
+ * the renderer the *same object* every frame — surface identity is object
460
+ * identity, so a fresh one each frame would defeat the texture cache and
461
+ * orphan a GPU texture per frame. The renderer's own `staticRasters` holds the
462
+ * rasterized pixels against the `identity` string below.
463
+ *
464
+ * They share one key, which is the point: edit a colour and this cache misses
465
+ * (a new `Graphics2D`) *and* that one misses (a new identity string). Derive
466
+ * the identity from anything else — a counter, the node id — and a colour
467
+ * change would serve the old pixels forever, because nothing invalidates
468
+ * `staticRasters` short of tearing down the render context.
469
+ *
470
+ * The descriptor is written out rather than built with `Tex.surface` because
471
+ * the builder's options do not include `static` or `identity`. Handing a bare
472
+ * `Graphics2D` to `fill` instead takes `resolveFill3D`'s surface path, which
473
+ * sets neither — and that is the per-frame read-back this whole arrangement
474
+ * exists to avoid.
475
+ */
476
+ private cache: { key: string; map: BakedMap } | null = null
477
+
478
+ private bakedMap(): BakedMap {
479
+ const style = this.mapStyle()
480
+ const key = [
481
+ style.width,
482
+ style.ocean,
483
+ style.land,
484
+ style.border,
485
+ style.borderWidth,
486
+ style.graticule,
487
+ style.graticuleColor,
488
+ style.graticuleStep,
489
+ style.graticuleWidth,
490
+ ].join("|")
491
+
492
+ const cached = this.cache
493
+ if (cached?.key === key) return cached.map
494
+
495
+ const map: BakedMap = {
496
+ texture: {
497
+ source: worldMapGraphics(style),
498
+ width: style.width,
499
+ height: mapHeight(style.width),
500
+ static: true,
501
+ identity: `globe:${key}`,
502
+ },
503
+ }
504
+ this.cache = { key, map }
505
+ return map
506
+ }
507
+
508
+ /** The style props as the one value the baker takes. Also the cache key. */
509
+ private mapStyle(): WorldMapStyle {
510
+ return {
511
+ // Rounded to an even number so the 2:1 height is a whole one; a half-pixel
512
+ // buffer height is rounded by the rasterizer and the map then samples a
513
+ // fraction of a row off, which reads as a seam at the equator.
514
+ width: Math.max(256, Math.round(this.resolution / 2) * 2),
515
+ ocean: hexOf(this.ocean),
516
+ land: hexOf(this.land),
517
+ border: hexOf(this.border),
518
+ borderWidth: this.borderWidth,
519
+ graticule: this.graticule,
520
+ graticuleColor: hexOf(this.graticuleColor),
521
+ graticuleStep: this.graticuleStep,
522
+ graticuleWidth: this.graticuleWidth,
523
+ }
524
+ }
525
+ }
526
+
527
+ /**
528
+ * The signed turn from `from` to `to` that travels less than half a circle.
529
+ *
530
+ * Degrees. What stops a flight from 170° to −170° unwinding 340° the wrong way
531
+ * when it could cross the antimeridian in 20°.
532
+ */
533
+ function shortestTurn(from: number, to: number): number {
534
+ return ((((to - from) % 360) + 540) % 360) - 180
535
+ }
536
+
537
+
538
+ /**
539
+ * A colour prop as the string the baker and the cache key both want.
540
+ *
541
+ * These props carry no mapper — they are not animatable, so there is no resolved
542
+ * form to hold — but a caller writing JSX is still free to hand one a tuple, and
543
+ * a tuple in a cache key would stringify to something that compares equal for
544
+ * two different colours.
545
+ */
546
+ function hexOf(value: Color): string {
547
+ if (typeof value === "string") return value
548
+ const [r, g, b, a = 1] = value as unknown as NormalizedColor
549
+ const byte = (channel: number) =>
550
+ Math.round(Math.min(1, Math.max(0, channel)) * 255)
551
+ return `rgba(${byte(r)}, ${byte(g)}, ${byte(b)}, ${a})`
552
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The globe: a shaded sphere with borders, graticules, markers and the
3
+ * great-circle arcs between them.
4
+ *
5
+ * These files are what `ms add globe` copies. The Natural Earth outline it
6
+ * draws is **not** among them, nor `world.ts` that decodes it — it is data, and
7
+ * it stays behind the package so a copy does not write tens of thousands of
8
+ * coordinates into your `src/`. A copy reads `world` from `@motionscript/geo`.
9
+ */
10
+ export * from "./atmosphere";
11
+ export * from "./borders";
12
+ export * from "./globe";
13
+ export * from "./globe-places";
14
+ export * from "./places";
15
+ export * from "./projection";
16
+ export * from "./world-map";
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The marker and arc lists, resolved into the things a frame draws.
3
+ *
4
+ * The authored shape and its serialization live in `model/globe-places.ts`,
5
+ * shared with the inspector's tiles. What is here is the other half: turning a
6
+ * pinned arc end into a coordinate, dropping what cannot draw, and deciding how
7
+ * finely a tube is swept. None of it is meaningful to the panel, and all of it
8
+ * runs on every rebuild — which is why the two halves are separate modules
9
+ * rather than one with a flag.
10
+ */
11
+
12
+ import {
13
+ arcsOf,
14
+ markersOf,
15
+ markerColorAt,
16
+ type ArcEnd,
17
+ type GlobeArc,
18
+ type GlobeMarker,
19
+ } from "./globe-places"
20
+
21
+ /** A marker with everything the render needs decided. */
22
+ export interface DrawableMarker {
23
+ id: string
24
+ lat: number
25
+ lon: number
26
+ color: string
27
+ size: number
28
+ opacity: number
29
+ }
30
+
31
+ /** An arc with both ends resolved to real coordinates. */
32
+ export interface DrawableArc {
33
+ id: string
34
+ from: { lat: number; lon: number }
35
+ to: { lat: number; lon: number }
36
+ color: string
37
+ width: number
38
+ lift: number
39
+ progress: number
40
+ }
41
+
42
+ /**
43
+ * The markers a frame draws, in list order.
44
+ *
45
+ * The palette is applied **here** rather than left to the drawing code, so the
46
+ * one place that decides what colour an uncoloured marker is also the place the
47
+ * swatch in the panel reads — see `markerColorAt`. Position in the list is the
48
+ * palette index, which is why a reorder recolours: the list is a ranking, and
49
+ * the first place being the loudest is the point.
50
+ */
51
+ export function drawableMarkers(value: string): DrawableMarker[] {
52
+ return markersOf(value).flatMap((marker, index) =>
53
+ marker.enabled && marker.opacity > 0
54
+ ? [
55
+ {
56
+ id: marker.id,
57
+ lat: marker.lat,
58
+ lon: marker.lon,
59
+ color: marker.color === "" ? markerColorAt(index) : marker.color,
60
+ size: clamp(marker.size, 0.001, 0.5),
61
+ opacity: marker.opacity,
62
+ },
63
+ ]
64
+ : []
65
+ )
66
+ }
67
+
68
+ /**
69
+ * The arcs a frame draws, with pinned ends looked up in the marker list.
70
+ *
71
+ * An end pinned to a marker that is gone drops the whole arc rather than falling
72
+ * back to the origin. A line to null island is a picture of something that is
73
+ * not true, and it is *harder* to notice than a missing one: an arc that
74
+ * vanishes when its endpoint is deleted reads as a consequence, where one that
75
+ * swings to the Gulf of Guinea reads as a bug in the projection.
76
+ *
77
+ * A **disabled** marker still anchors an arc, deliberately. Switching a dot off
78
+ * is about the dot; an arc that also wanted to go is switched off itself.
79
+ */
80
+ export function drawableArcs(
81
+ arcsValue: string,
82
+ markersValue: string
83
+ ): DrawableArc[] {
84
+ const byId = new Map<string, GlobeMarker>()
85
+ for (const marker of markersOf(markersValue)) byId.set(marker.id, marker)
86
+
87
+ return arcsOf(arcsValue).flatMap((arc) => {
88
+ if (!arc.enabled || arc.progress <= 0) return []
89
+ const from = resolveEnd(arc.from, byId)
90
+ const to = resolveEnd(arc.to, byId)
91
+ if (!from || !to) return []
92
+ return [
93
+ {
94
+ id: arc.id,
95
+ from,
96
+ to,
97
+ color: arc.color,
98
+ width: clamp(arc.width, 0.0005, 0.1),
99
+ lift: arc.lift,
100
+ progress: arc.progress,
101
+ },
102
+ ]
103
+ })
104
+ }
105
+
106
+ function resolveEnd(
107
+ end: ArcEnd,
108
+ markers: Map<string, GlobeMarker>
109
+ ): { lat: number; lon: number } | null {
110
+ if (end.kind === "place") return { lat: end.lat, lon: end.lon }
111
+ const marker = markers.get(end.markerId)
112
+ return marker ? { lat: marker.lat, lon: marker.lon } : null
113
+ }
114
+
115
+ /**
116
+ * How many segments an arc's tube is swept along.
117
+ *
118
+ * Proportional to how far it goes, so a hop between neighbours is not paying for
119
+ * a pole-to-pole path's resolution and a long haul does not visibly chord. The
120
+ * floor keeps a very short arc from degenerating into a two-point tube, which
121
+ * three cannot frame.
122
+ */
123
+ export function arcSegments(arc: Pick<GlobeArc | DrawableArc, "from" | "to">): number {
124
+ const from = arc.from as { lat: number; lon: number }
125
+ const to = arc.to as { lat: number; lon: number }
126
+ const span = Math.abs(to.lat - from.lat) + Math.abs(to.lon - from.lon)
127
+ return Math.max(8, Math.min(128, Math.round(span * 0.7)))
128
+ }
129
+
130
+ function clamp(value: number, min: number, max: number): number {
131
+ return value < min ? min : value > max ? max : value
132
+ }