@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,261 @@
1
+ /**
2
+ * Where a latitude and a longitude are, in the three spaces the Globe works in:
3
+ * the unit sphere it draws on, the equirectangular texture it paints, and the
4
+ * orbit camera it is looked at through.
5
+ *
6
+ * All three are fixed by one decision, and it is the decision every other file
7
+ * in this node depends on, so it is made here and nowhere else.
8
+ *
9
+ * ## The convention, and why it is not the obvious one
10
+ *
11
+ * Two things have to be true at once, and motion-script's camera lets you have
12
+ * either but not both by default:
13
+ *
14
+ * 1. **The stored `orbit` field reads directly as a longitude.** That would let
15
+ * the camera be three appearance numbers *and* the Fly To command be
16
+ * arithmetic on two of them with nothing in between.
17
+ * 2. **East is to the right.** Looking at the Atlantic from space, Africa is
18
+ * right of Brazil. A globe that fails this is a mirrored Earth, which no
19
+ * amount of convenience elsewhere pays for.
20
+ *
21
+ * The clash: `resolveCameraPlacement` puts a camera at azimuth `a` at
22
+ * `(sin a, ·, cos a)`, and `cameraOrbit` (`view3d-kit/shared.ts`) feeds it
23
+ * `90 - orbit`, so a camera at stored orbit θ sits at `(cos θ, ·, sin θ)` and its
24
+ * screen-right is `-Z`. Increasing θ therefore swings the scene **rightward** —
25
+ * measured, not reasoned: it is what every existing 3D node does, and the globe
26
+ * has to match or it is the one viewport whose drag goes backwards.
27
+ *
28
+ * So (1) is what gives. Longitude runs the *other* way round the Y axis —
29
+ * {@link latLonToVector} negates Z, which is what puts east on the right — and a
30
+ * camera at heading θ is consequently centred over longitude −θ. The stored
31
+ * field is a heading, labelled "Orbit" like every other viewport's, and
32
+ * {@link orbitForLongitude} is the one place the two are converted.
33
+ *
34
+ * The Fly To command is where that conversion earns its keep: its arguments
35
+ * genuinely are a latitude and a longitude, and it turns them into a heading
36
+ * here rather than anywhere else.
37
+ *
38
+ * ## The texture
39
+ *
40
+ * Plain equirectangular, the orientation any world map image already has:
41
+ * longitude −180…180 left to right, latitude +90…−90 top to bottom. That falls
42
+ * out of the convention above rather than being chosen — see {@link latLonToUV},
43
+ * which derives it from three's own sphere generator — and it is the strongest
44
+ * evidence the convention is right: get the handedness wrong and the map that
45
+ * wraps correctly is a mirrored one.
46
+ */
47
+
48
+ /** A point on the unit sphere. */
49
+ export interface Vec3 {
50
+ x: number
51
+ y: number
52
+ z: number
53
+ }
54
+
55
+ /** A position on the globe, in degrees. */
56
+ export interface LatLon {
57
+ /** Degrees north of the equator, −90…90. */
58
+ lat: number
59
+ /** Degrees east of the prime meridian, −180…180. */
60
+ lon: number
61
+ }
62
+
63
+ /**
64
+ * The bounding box every country path is centred against.
65
+ *
66
+ * A `Path` centres on its *own* bounding box unless it is given one, so drawing
67
+ * the world as one path per country and leaving this off stacks all 177 of them
68
+ * on the origin. Passing the same frame to each is what keeps them in place —
69
+ * the same reason the LaTeX renderer passes whole-formula bounds to every glyph.
70
+ */
71
+ export const WORLD_BOUNDS = [-180, -90, 180, 90] as const
72
+
73
+ const DEG = Math.PI / 180
74
+
75
+ /**
76
+ * The unit-sphere direction of a latitude and longitude.
77
+ *
78
+ * `z` is negated against the textbook spherical formula, which is the whole of
79
+ * the convention above: it is what puts east on the right.
80
+ */
81
+ export function latLonToVector({ lat, lon }: LatLon): Vec3 {
82
+ const phi = lat * DEG
83
+ const lambda = lon * DEG
84
+ const ring = Math.cos(phi)
85
+ return {
86
+ x: ring * Math.cos(lambda),
87
+ y: Math.sin(phi),
88
+ z: -ring * Math.sin(lambda),
89
+ }
90
+ }
91
+
92
+ /** {@link latLonToVector}, scaled off the surface — markers, arc endpoints. */
93
+ export function latLonToPosition(
94
+ at: LatLon,
95
+ radius: number
96
+ ): [number, number, number] {
97
+ const { x, y, z } = latLonToVector(at)
98
+ return [x * radius, y * radius, z * radius]
99
+ }
100
+
101
+ /**
102
+ * Where a latitude and longitude land in the equirectangular texture, as `uv`
103
+ * in `[0,1]²`.
104
+ *
105
+ * Derived rather than chosen, from the two pieces of machinery underneath:
106
+ *
107
+ * - three's `SphereGeometry` puts vertex `u` at `(-cos 2πu, ·, sin 2πu)` and
108
+ * writes `uv = (u, 1 - v)`, with `v = 0` at the north pole.
109
+ * - motion-script uploads a rasterized surface with `flipY = true`
110
+ * (`skia-render/src/three/handlers/texture.ts`), so the buffer's **first row
111
+ * is `uv.y = 1`**.
112
+ *
113
+ * Solving `(-cos 2πu, sin 2πu)` against {@link latLonToVector}'s `(x, z)` gives
114
+ * `2πu = π + λ`, and the latitude falls straight out of `y = cos θ`. The result
115
+ * is the ordinary world-map layout, which is the point.
116
+ */
117
+ export function latLonToUV({ lat, lon }: LatLon): { u: number; v: number } {
118
+ return { u: (lon + 180) / 360, v: 0.5 + lat / 180 }
119
+ }
120
+
121
+ /**
122
+ * Where a latitude and longitude land in the **drawing space** of the baked
123
+ * texture: pixels, **y upward**, origin at the buffer's centre.
124
+ *
125
+ * Two of motion-script's conventions meet here, and neither is guessable from a
126
+ * picture that comes out wrong, so both are written down:
127
+ *
128
+ * - **The origin is the buffer's centre**, because `SkiaRenderContext.rasterize`
129
+ * translates the offscreen canvas by half its size before handing it to the
130
+ * source. Drawn from a top-left origin the map lands a quarter of the world
131
+ * off the edge — and still wraps onto the sphere without complaint.
132
+ * - **`y` is up.** A `PathCommand[]`, and a line's `points`, are authored y-up
133
+ * and mirrored by the backend on the way to Skia (`flipPathY`, and the
134
+ * `points` map beside it in `render-context.ts`). Drawn y-down the world is
135
+ * simply upside down — and every continent still has its own shape, so it
136
+ * reads as a texture flipped somewhere in the pipeline rather than as a sign
137
+ * in the one function that decides.
138
+ *
139
+ * Deliberately *not* the raw buffer's own row order. The backend's flip lands
140
+ * north on the first row, and `flipY` on the uploaded texture maps that row to
141
+ * `uv.y = 1` — which is where {@link latLonToUV} independently puts the north
142
+ * pole. A test ties the two together rather than trusting them to agree.
143
+ */
144
+ export function latLonToCanvas(
145
+ { lat, lon }: LatLon,
146
+ width: number,
147
+ height: number
148
+ ): { x: number; y: number } {
149
+ return { x: (lon / 360) * width, y: (lat / 180) * height }
150
+ }
151
+
152
+ /**
153
+ * The frame every country path is centred against, in that same space.
154
+ *
155
+ * See {@link WORLD_BOUNDS} for why passing it to each path matters.
156
+ */
157
+ export function canvasBounds(
158
+ width: number,
159
+ height: number
160
+ ): [number, number, number, number] {
161
+ return [-width / 2, -height / 2, width / 2, height / 2]
162
+ }
163
+
164
+ /**
165
+ * The stored `orbit` heading that puts `lon` at the centre of the frame.
166
+ *
167
+ * **A negation, and it is the price of two things that matter more than the
168
+ * field reading as a longitude.**
169
+ *
170
+ * `orbit` has to behave exactly as it does on every other 3D node: the canvas
171
+ * drag adds to it (`orbitByDrag`), and increasing it must swing the scene the
172
+ * same way it swings a molecule or a surface, or the globe is the one viewport
173
+ * whose drag goes backwards. That pins `orbit` to `cameraOrbit` with no
174
+ * conversion in between.
175
+ *
176
+ * East also has to be on the right, which pins {@link latLonToVector}'s sign.
177
+ *
178
+ * Those two together force the third: a camera at heading θ is centred over
179
+ * longitude −θ. So the stored field is a **heading**, not a longitude — which is
180
+ * why the panel labels it "Orbit" like every other viewport's, and why the Fly
181
+ * To command, whose arguments genuinely are a latitude and a longitude,
182
+ * converts through here.
183
+ */
184
+ export function orbitForLongitude(lon: number): number {
185
+ return -lon
186
+ }
187
+
188
+ /** The longitude a stored `orbit` heading is centred over. Its own inverse. */
189
+ export function longitudeForOrbit(orbit: number): number {
190
+ return -orbit
191
+ }
192
+
193
+ /**
194
+ * Points along the great circle from `from` to `to`, `segments` spans of them.
195
+ *
196
+ * Spherical linear interpolation rather than a lerp of the two lat/lons: the
197
+ * latter is a rhumb line, which crosses the antimeridian the long way and bulges
198
+ * badly at high latitudes — a "London to Tokyo" arc drawn that way runs through
199
+ * Kazakhstan instead of over the pole, which is the one thing the picture exists
200
+ * to show.
201
+ *
202
+ * `lift` raises the middle of the arc off the surface, as a fraction of the
203
+ * radius, on a sine so both ends meet the sphere flush. Zero draws the path on
204
+ * the ground.
205
+ */
206
+ export function greatCircle(
207
+ from: LatLon,
208
+ to: LatLon,
209
+ options: { radius?: number; lift?: number; segments?: number } = {}
210
+ ): [number, number, number][] {
211
+ const radius = options.radius ?? 1
212
+ const lift = options.lift ?? 0
213
+ const segments = Math.max(1, Math.floor(options.segments ?? 64))
214
+
215
+ const a = latLonToVector(from)
216
+ const b = latLonToVector(to)
217
+
218
+ const dot = clamp(a.x * b.x + a.y * b.y + a.z * b.z, -1, 1)
219
+ const omega = Math.acos(dot)
220
+
221
+ const points: [number, number, number][] = []
222
+ for (let i = 0; i <= segments; i++) {
223
+ const t = i / segments
224
+ // Antipodal or coincident endpoints leave the plane of the circle
225
+ // undefined; a straight blend is the honest answer for both, and
226
+ // renormalising below puts it back on the sphere either way.
227
+ const [wa, wb] =
228
+ Math.abs(omega) < 1e-6
229
+ ? [1 - t, t]
230
+ : [
231
+ Math.sin((1 - t) * omega) / Math.sin(omega),
232
+ Math.sin(t * omega) / Math.sin(omega),
233
+ ]
234
+
235
+ const x = a.x * wa + b.x * wb
236
+ const y = a.y * wa + b.y * wb
237
+ const z = a.z * wa + b.z * wb
238
+ const length = Math.hypot(x, y, z) || 1
239
+ const scale = (radius * (1 + lift * Math.sin(Math.PI * t))) / length
240
+
241
+ points.push([x * scale, y * scale, z * scale])
242
+ }
243
+ return points
244
+ }
245
+
246
+ /**
247
+ * The signed turn from one heading to another, taking whichever way round is
248
+ * shorter — so 170° to −170° is a 20° nudge east rather than 340° west.
249
+ *
250
+ * The same arithmetic `camera-kit`'s unused `shortestTurn` states; it is
251
+ * duplicated rather than imported because that one is private to a module this
252
+ * package's 3D nodes do not otherwise depend on, and because a Fly To that got
253
+ * this wrong would be the command's single most visible failure.
254
+ */
255
+ export function shortestTurn(from: number, to: number): number {
256
+ return ((((to - from) % 360) + 540) % 360) - 180
257
+ }
258
+
259
+ function clamp(value: number, min: number, max: number): number {
260
+ return value < min ? min : value > max ? max : value
261
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The world as a flat drawing — the one `Graphics2D` that becomes the sphere's
3
+ * texture.
4
+ *
5
+ * Baking the map in 2D rather than tessellating it in 3D is the node's central
6
+ * decision, and it is not a shortcut. A WebGL line ignores any width above one
7
+ * pixel (motion-script says so outright on `LineStroke3D`), so borders drawn as
8
+ * 3D lines are hairlines at every zoom and every resolution, and the only way to
9
+ * thicken one is to sweep a tube along it — three hundred tubes, for a picture
10
+ * that is flat. Drawn here, a border is an ordinary stroke: any weight, any
11
+ * colour, joined and antialiased by Skia, and it costs one rasterize rather than
12
+ * one per frame.
13
+ *
14
+ * Everything here is therefore *style*, and the node's cache key is exactly the
15
+ * arguments below. Nothing that moves — a marker, an arc, the camera — is drawn
16
+ * into this, because baking something that moves would put every frame of its
17
+ * animation through a full re-rasterize.
18
+ *
19
+ * ## One path per country, and this is the whole performance story
20
+ *
21
+ * The obvious shape is one `path` op holding all 289 rings — the nonzero fill
22
+ * rule sorts out what is land, and it is one op instead of 177. **Do not do
23
+ * that.** Skia's cost for filling and stroking a single path is badly
24
+ * superlinear in its *contour* count, and the difference is not marginal:
25
+ *
26
+ * one path, 289 contours, 10,649 commands 10,691 ms
27
+ * one path per country (177 of them) 205 ms
28
+ *
29
+ * Measured in the browser, on the same data, at the same resolution — a 52×
30
+ * difference, and the reason adding a Globe to a scene used to lock the app for
31
+ * ten seconds. It is also almost independent of the texture's resolution (256²
32
+ * and 2048² are within 10% of each other), which is the tell that the cost is
33
+ * geometry, not pixels, and that turning the map down would not have saved it.
34
+ *
35
+ * Splitting per country is safe for the fill: each country's rings — exterior
36
+ * and holes together — stay in *its* path, so the nonzero rule still cuts
37
+ * Lesotho out of South Africa, and Lesotho's own path paints it back.
38
+ *
39
+ * ## The lines are a separate pass, and have to be
40
+ *
41
+ * The country paths are filled and **not** stroked. Stroking them would draw
42
+ * every international border twice — once from each side, since both countries
43
+ * carry it — and a doubly-composited antialiased fringe reads as a line half
44
+ * again as wide as the coastlines beside it. `borders.ts` deduplicates the
45
+ * segments and stitches them into runs; this file strokes those instead, so
46
+ * every line on the map is drawn exactly once at exactly one weight.
47
+ */
48
+
49
+ import { Graphics2D, type PathCommand } from "@motionscript/sdk"
50
+
51
+ import { canvasBounds, latLonToCanvas } from "./projection"
52
+ import { borderLines } from "./borders"
53
+ import { world, type Ring } from "./world"
54
+
55
+ /** Everything that changes the baked pixels. Also the node's cache key. */
56
+ export interface WorldMapStyle {
57
+ /** Buffer width in pixels. Height is half of it — the equirectangular ratio. */
58
+ width: number
59
+ ocean: string
60
+ land: string
61
+ /**
62
+ * The colour of every coast and border.
63
+ *
64
+ * Alpha is fine here, and that is worth saying because it briefly was not:
65
+ * while the country paths were stroked, a shared border was drawn twice and a
66
+ * translucent one came out at double density. `drawBoundaries` draws each line
67
+ * exactly once, so the colour means what it says.
68
+ */
69
+ border: string
70
+ borderWidth: number
71
+ graticule: boolean
72
+ graticuleColor: string
73
+ /** Degrees between graticule lines. */
74
+ graticuleStep: number
75
+ graticuleWidth: number
76
+ }
77
+
78
+ /** The buffer's height for a given width — equirectangular is always 2:1. */
79
+ export function mapHeight(width: number): number {
80
+ return Math.round(width / 2)
81
+ }
82
+
83
+ /**
84
+ * The map, drawn.
85
+ *
86
+ * **Hoist the result.** motion-script identifies a surface texture by its source
87
+ * object, so a fresh `Graphics2D` every frame misses the raster cache, redraws
88
+ * ten thousand paths, reads the buffer back off the GPU and uploads it again —
89
+ * sixty times a second, to produce the same image. `Globe` holds one against
90
+ * this style; nothing else should call this.
91
+ */
92
+ export function worldMapGraphics(style: WorldMapStyle): Graphics2D {
93
+ const width = style.width
94
+ const height = mapHeight(width)
95
+ const bounds = canvasBounds(width, height)
96
+
97
+ const g = new Graphics2D()
98
+
99
+ // The ocean is the whole buffer rather than a sphere-coloured backdrop,
100
+ // because the texture has to be opaque: the sphere is lit, and a transparent
101
+ // texel would show the inside of the far side through it.
102
+ g.rect({ width, height }).fill(style.ocean)
103
+
104
+ // Land, filled and **not** stroked — see `drawBoundaries` for where the lines
105
+ // come from and why they cannot come from here.
106
+ //
107
+ // One path per country, not one for the world: see the module note for what
108
+ // that costs.
109
+ for (const country of world().countries) {
110
+ const commands: PathCommand[] = []
111
+ for (const ring of country.rings) appendRing(commands, ring, width, height)
112
+ if (commands.length === 0) continue
113
+ g.path({ data: commands, centerBounds: bounds }).fill(style.land)
114
+ }
115
+
116
+ if (style.borderWidth > 0) drawBoundaries(g, style, width, height, bounds)
117
+
118
+ // Last, and over the land. The graticule is reference rather than art
119
+ // direction — the same reading the Canvas 3D viewport's grid takes — so it
120
+ // reads as an overlay on whatever it crosses rather than as terrain.
121
+ if (style.graticule) {
122
+ for (const line of graticuleLines(style.graticuleStep, width, height)) {
123
+ g.line({ points: line }).stroke({
124
+ fill: style.graticuleColor,
125
+ weight: style.graticuleWidth,
126
+ })
127
+ }
128
+ }
129
+
130
+ return g
131
+ }
132
+
133
+ function appendRing(
134
+ commands: PathCommand[],
135
+ ring: Ring,
136
+ width: number,
137
+ height: number
138
+ ): void {
139
+ const count = ring.points.length / 2
140
+ for (let i = 0; i < count; i++) {
141
+ const { x, y } = latLonToCanvas(
142
+ { lon: ring.points[i * 2]!, lat: ring.points[i * 2 + 1]! },
143
+ width,
144
+ height
145
+ )
146
+ commands.push(i === 0 ? { type: "M", x, y } : { type: "L", x, y })
147
+ }
148
+ // Closed explicitly: the ring's repeated last point was dropped when it was
149
+ // vendored, so without this the stroke leaves a notch at every country's
150
+ // starting vertex and the fill's last edge is implied rather than drawn.
151
+ commands.push({ type: "Z" })
152
+ }
153
+
154
+ /**
155
+ * Every coast and border, stroked once each.
156
+ *
157
+ * **One path per run**, for exactly the reason the countries are one path each:
158
+ * a path's stroke cost is superlinear in its contour count. Gathering the 277
159
+ * runs into two paths of ~200 contours put this straight back on the cliff the
160
+ * countries were moved off — measured at 2.5 s of stall, against none at one
161
+ * run per path.
162
+ *
163
+ * One-per-run rather than a tuned chunk size because there is no number to
164
+ * defend: 277 paths is the same order as the 177 the countries already use, and
165
+ * a constant here would be a knob whose safe range nobody could state.
166
+ */
167
+ function drawBoundaries(
168
+ g: Graphics2D,
169
+ style: WorldMapStyle,
170
+ width: number,
171
+ height: number,
172
+ bounds: readonly [number, number, number, number]
173
+ ): void {
174
+ const paint = { fill: style.border, weight: style.borderWidth }
175
+
176
+ for (const line of borderLines()) {
177
+ const count = line.length / 2
178
+ if (count < 2) continue
179
+ const commands: PathCommand[] = []
180
+ for (let i = 0; i < count; i++) {
181
+ const { x, y } = latLonToCanvas(
182
+ { lon: line[i * 2]!, lat: line[i * 2 + 1]! },
183
+ width,
184
+ height
185
+ )
186
+ commands.push(i === 0 ? { type: "M", x, y } : { type: "L", x, y })
187
+ }
188
+ // Left open: a boundary run is a line, not a ring, and closing it would draw
189
+ // a chord back to wherever the run happened to start.
190
+ g.path({ data: commands, centerBounds: bounds }).stroke(paint)
191
+ }
192
+ }
193
+
194
+ /**
195
+ * The meridians and parallels, as polylines in drawing space.
196
+ *
197
+ * Straight lines, because an equirectangular projection is exactly the one where
198
+ * meridians and parallels are straight — the grid is curved on the sphere only
199
+ * because the sphere curves. Meridians run pole to pole and parallels edge to
200
+ * edge, and both stop short of the poles by one step so the drawing does not
201
+ * pile every meridian onto the same two points.
202
+ */
203
+ function graticuleLines(
204
+ step: number,
205
+ width: number,
206
+ height: number
207
+ ): { x: number; y: number }[][] {
208
+ const spacing = Math.max(5, Math.min(90, step))
209
+ const lines: { x: number; y: number }[][] = []
210
+
211
+ for (let lon = -180; lon <= 180; lon += spacing) {
212
+ const meridian: { x: number; y: number }[] = []
213
+ for (let lat = -90; lat <= 90; lat += 5) {
214
+ meridian.push(latLonToCanvas({ lat, lon }, width, height))
215
+ }
216
+ lines.push(meridian)
217
+ }
218
+
219
+ for (let lat = -90 + spacing; lat <= 90 - spacing; lat += spacing) {
220
+ lines.push([
221
+ latLonToCanvas({ lat, lon: -180 }, width, height),
222
+ latLonToCanvas({ lat, lon: 180 }, width, height),
223
+ ])
224
+ }
225
+
226
+ return lines
227
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The vendored boundaries, decoded once and held.
3
+ *
4
+ * The generated module beside it is a string and a table of ring sizes; this
5
+ * turns them into flat coordinate arrays and memoises the result, so a document
6
+ * with a globe pays for the decode once per process and a document without one
7
+ * never pays at all. That laziness is the whole reason the data can be a plain
8
+ * static import rather than something threaded through a resolver: the 40 KB is
9
+ * transferred either way, but nothing is parsed, allocated or walked until a
10
+ * globe actually draws.
11
+ *
12
+ * Coordinates stay in **degrees**, not pixels and not sphere positions. The
13
+ * texture resolution is a prop and the projection is `projection.ts`'s business;
14
+ * baking either into the data would mean re-vendoring to change a number.
15
+ */
16
+
17
+ import { COUNTRIES, POINTS, PRECISION, type CountryRow } from "./data/ne-110m"
18
+
19
+ /**
20
+ * One closed ring, as `[lon, lat, lon, lat, …]` degrees.
21
+ *
22
+ * Flat rather than an array of pairs for the reason the Protein's structure is
23
+ * flat: the map is redrawn whenever its style changes and a pair object per
24
+ * vertex would put ten thousand allocations behind every colour edit.
25
+ */
26
+ export interface Ring {
27
+ points: Float64Array
28
+ /**
29
+ * Whether this ring cuts rather than fills.
30
+ *
31
+ * It is drawn either way — the nonzero fill rule does the cutting, off the
32
+ * winding the vendoring script enforced. This is here so a caller that wants
33
+ * to *outline* only the coastlines can skip it.
34
+ */
35
+ hole: boolean
36
+ }
37
+
38
+ export interface Country {
39
+ id: string
40
+ name: string
41
+ rings: Ring[]
42
+ }
43
+
44
+ export interface World {
45
+ countries: readonly Country[]
46
+ /** Every ring of every country, in one list — the order the map draws in. */
47
+ rings: readonly Ring[]
48
+ }
49
+
50
+ let decoded: World | null = null
51
+
52
+ /** The world, decoded on first use and shared thereafter. */
53
+ export function world(): World {
54
+ return (decoded ??= decode())
55
+ }
56
+
57
+ function decode(): World {
58
+ const countries: Country[] = []
59
+ const rings: Ring[] = []
60
+
61
+ const cursor = { at: 0 }
62
+ for (const row of COUNTRIES as readonly CountryRow[]) {
63
+ const own: Ring[] = []
64
+ for (const size of row.rings) {
65
+ const ring = { points: readRing(cursor, Math.abs(size)), hole: size < 0 }
66
+ own.push(ring)
67
+ rings.push(ring)
68
+ }
69
+ countries.push({ id: row.id, name: row.name, rings: own })
70
+ }
71
+
72
+ // A short read leaves the tail unconsumed and every ring after the mistake
73
+ // shifted, which draws as a plausible-looking but wrong world rather than as
74
+ // an error — so the two halves of the generated file are checked against each
75
+ // other here, once, where the cost is nothing.
76
+ if (cursor.at !== POINTS.length) {
77
+ throw new Error(
78
+ `globe: boundary data is inconsistent — decoded ${cursor.at} of ${POINTS.length} characters`
79
+ )
80
+ }
81
+
82
+ return { countries, rings }
83
+ }
84
+
85
+ /** `count` points, delta-decoded from the ring's own origin. */
86
+ function readRing(cursor: { at: number }, count: number): Float64Array {
87
+ const points = new Float64Array(count * 2)
88
+ let lon = 0
89
+ let lat = 0
90
+ for (let i = 0; i < count; i++) {
91
+ lon += readValue(cursor)
92
+ lat += readValue(cursor)
93
+ points[i * 2] = lon / PRECISION
94
+ points[i * 2 + 1] = lat / PRECISION
95
+ }
96
+ return points
97
+ }
98
+
99
+ /** One zig-zagged varint. The inverse of the script's `encodeValue`. */
100
+ function readValue(cursor: { at: number }): number {
101
+ let shift = 0
102
+ let result = 0
103
+ for (;;) {
104
+ const byte = POINTS.charCodeAt(cursor.at++) - 63
105
+ result |= (byte & 0x1f) << shift
106
+ // The continuation bit: set on every chunk but the last.
107
+ if (byte < 0x20) break
108
+ shift += 5
109
+ }
110
+ return result & 1 ? ~(result >> 1) : result >> 1
111
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./border";
2
+ export * from "./globe";
3
+ // Not through ./globe: that barrel is copied with the globe item, and world.ts is not.
4
+ export * from "./globe/world";
5
+ export { NODES } from "./nodes";
package/src/nodes.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { GeoBorder } from "./border";
2
+ import { Globe } from "./globe";
3
+
4
+ /**
5
+ * Every node type this package publishes.
6
+ *
7
+ * A host registers these by handing the array to an engine —
8
+ * `new Engine(platform, { nodes: NODES })` — which reads each class's `@node()`
9
+ * key. The decorator only *declares* that key: nothing is registered by the mere
10
+ * act of importing a module, so a registry holds exactly what its owner asked
11
+ * for and two engines in one process can differ about it.
12
+ *
13
+ * Listing class **values** is also what survives bundling. This package declares
14
+ * `sideEffects: false`, which licenses a bundler to drop a module nothing
15
+ * imports a value from, and a document names a node type by string — so
16
+ * `import "./globe"` for its side effect is exactly what gets shaken out, where an
17
+ * array of bindings is a data dependency that cannot be.
18
+ */
19
+ export const NODES = [Globe, GeoBorder];
package/README.md DELETED
@@ -1,4 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
4
- If no other versions are published within 30 days, this package and version will be deleted.