@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.
- package/CHANGELOG.md +21 -0
- package/LICENSE +201 -0
- package/dist/border/format.d.ts +73 -0
- package/dist/border/format.d.ts.map +1 -0
- package/dist/border/format.js +68 -0
- package/dist/border/format.js.map +1 -0
- package/dist/border/geo-border.d.ts +44 -0
- package/dist/border/geo-border.d.ts.map +1 -0
- package/dist/border/geo-border.js +234 -0
- package/dist/border/geo-border.js.map +1 -0
- package/dist/border/geometry.d.ts +40 -0
- package/dist/border/geometry.d.ts.map +1 -0
- package/dist/border/geometry.js +382 -0
- package/dist/border/geometry.js.map +1 -0
- package/dist/border/index.d.ts +15 -0
- package/dist/border/index.d.ts.map +1 -0
- package/dist/border/index.js +15 -0
- package/dist/border/index.js.map +1 -0
- package/dist/border/loader.d.ts +31 -0
- package/dist/border/loader.d.ts.map +1 -0
- package/dist/border/loader.js +87 -0
- package/dist/border/loader.js.map +1 -0
- package/dist/border/registry.d.ts +64 -0
- package/dist/border/registry.d.ts.map +1 -0
- package/dist/border/registry.js +152 -0
- package/dist/border/registry.js.map +1 -0
- package/dist/border/selection.d.ts +21 -0
- package/dist/border/selection.d.ts.map +1 -0
- package/dist/border/selection.js +86 -0
- package/dist/border/selection.js.map +1 -0
- package/dist/border/simplify.d.ts +12 -0
- package/dist/border/simplify.d.ts.map +1 -0
- package/dist/border/simplify.js +116 -0
- package/dist/border/simplify.js.map +1 -0
- package/dist/browser/index.js +25 -0
- package/dist/browser/index.js.map +7 -0
- package/dist/browser/manifest.json +11 -0
- package/dist/globe/atmosphere.d.ts +38 -0
- package/dist/globe/atmosphere.d.ts.map +1 -0
- package/dist/globe/atmosphere.js +91 -0
- package/dist/globe/atmosphere.js.map +1 -0
- package/dist/globe/borders.d.ts +39 -0
- package/dist/globe/borders.d.ts.map +1 -0
- package/dist/globe/borders.js +116 -0
- package/dist/globe/borders.js.map +1 -0
- package/dist/globe/data/ne-110m.d.ts +25 -0
- package/dist/globe/data/ne-110m.d.ts.map +1 -0
- package/dist/globe/data/ne-110m.js +198 -0
- package/dist/globe/data/ne-110m.js.map +1 -0
- package/dist/globe/globe-places.d.ts +140 -0
- package/dist/globe/globe-places.d.ts.map +1 -0
- package/dist/globe/globe-places.js +262 -0
- package/dist/globe/globe-places.js.map +1 -0
- package/dist/globe/globe.d.ts +193 -0
- package/dist/globe/globe.d.ts.map +1 -0
- package/dist/globe/globe.js +435 -0
- package/dist/globe/globe.js.map +1 -0
- package/dist/globe/index.d.ts +17 -0
- package/dist/globe/index.d.ts.map +1 -0
- package/dist/globe/index.js +17 -0
- package/dist/globe/index.js.map +1 -0
- package/dist/globe/places.d.ts +69 -0
- package/dist/globe/places.d.ts.map +1 -0
- package/dist/globe/places.js +94 -0
- package/dist/globe/places.js.map +1 -0
- package/dist/globe/projection.d.ts +182 -0
- package/dist/globe/projection.d.ts.map +1 -0
- package/dist/globe/projection.js +215 -0
- package/dist/globe/projection.js.map +1 -0
- package/dist/globe/world-map.d.ts +83 -0
- package/dist/globe/world-map.d.ts.map +1 -0
- package/dist/globe/world-map.js +169 -0
- package/dist/globe/world-map.js.map +1 -0
- package/dist/globe/world.d.ts +46 -0
- package/dist/globe/world.d.ts.map +1 -0
- package/dist/globe/world.js +71 -0
- package/dist/globe/world.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/nodes.d.ts +19 -0
- package/dist/nodes.d.ts.map +1 -0
- package/dist/nodes.js +19 -0
- package/dist/nodes.js.map +1 -0
- package/package.json +69 -3
- package/registry.json +31 -0
- package/src/border/format.ts +147 -0
- package/src/border/geo-border.ts +260 -0
- package/src/border/geometry.ts +463 -0
- package/src/border/index.ts +14 -0
- package/src/border/loader.ts +97 -0
- package/src/border/registry.ts +191 -0
- package/src/border/selection.ts +95 -0
- package/src/border/simplify.ts +110 -0
- package/src/globe/atmosphere.ts +98 -0
- package/src/globe/borders.ts +166 -0
- package/src/globe/data/ne-110m.ts +214 -0
- package/src/globe/globe-places.ts +352 -0
- package/src/globe/globe.ts +552 -0
- package/src/globe/index.ts +16 -0
- package/src/globe/places.ts +132 -0
- package/src/globe/projection.ts +261 -0
- package/src/globe/world-map.ts +227 -0
- package/src/globe/world.ts +111 -0
- package/src/index.ts +5 -0
- package/src/nodes.ts +19 -0
- 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/sdk"
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
lerpColor3D,
|
|
24
|
+
resolveColor3D,
|
|
25
|
+
snapFlag,
|
|
26
|
+
snapValue,
|
|
27
|
+
cameraOrbit,
|
|
28
|
+
orbitProps,
|
|
29
|
+
type OrbitTarget,
|
|
30
|
+
} from "@motionscript/sdk/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
|
+
}
|