@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,191 @@
1
+ import { geoArea } from "d3-geo"
2
+
3
+ import {
4
+ GEO_DATA_FORMAT,
5
+ type GeoFeature,
6
+ type GeoFileData,
7
+ type GeoIndex,
8
+ type GeoPolygon,
9
+ type GeoRingRef,
10
+ } from "./format"
11
+ import { ringBuckets } from "./simplify"
12
+
13
+ /** Prefix every data key carries; bumped with the file format. */
14
+ export const GEO_DATA_VERSION = `v${GEO_DATA_FORMAT}`
15
+
16
+ /**
17
+ * Answers a data key — `v1/countries/FR`, `v1/states/US`, `v1/index` — with the
18
+ * parsed file, or `null` when this source does not have it. A throw means "could
19
+ * not tell" (offline, a 500) and is retried on the next load.
20
+ */
21
+ export type GeoSource = (key: string) => Promise<unknown>
22
+
23
+ type Position = readonly [number, number] | readonly number[]
24
+ type GeoPolygonShape =
25
+ | { type: "Polygon"; coordinates: readonly (readonly Position[])[] }
26
+ | { type: "MultiPolygon"; coordinates: readonly (readonly (readonly Position[])[])[] }
27
+ /** GeoJSON in lon/lat: a Polygon, a MultiPolygon, or a Feature holding one. */
28
+ export type GeoShape = GeoPolygonShape | { type: "Feature"; geometry: GeoPolygonShape; properties?: { name?: string } | null }
29
+
30
+ export interface GeoDefinition {
31
+ shape: GeoShape
32
+ name?: string
33
+ /** The country a subdivision code expands under, when it has one. */
34
+ parent?: string
35
+ }
36
+
37
+ /** Fetches `<base>/<key>.json`; `base` may instead map a key to its URL. */
38
+ export function geoUrlSource(base: string | ((key: string) => string), init?: RequestInit): GeoSource {
39
+ return async (key) => {
40
+ const url = typeof base === "function" ? base(key) : `${base.replace(/\/+$/, "")}/${key}.json`
41
+ const response = await fetch(url, init)
42
+ // Supabase Storage answers a missing object with 400, not 404.
43
+ if (response.status === 404 || response.status === 400) return null
44
+ if (!response.ok) throw new Error(`${url} → HTTP ${response.status}`)
45
+ return response.json()
46
+ }
47
+ }
48
+
49
+ /** Wraps a function returning the file as text, bytes or parsed JSON (e.g. `fs.readFile`). */
50
+ export function geoLoaderSource(
51
+ load: (key: string) => Promise<string | ArrayBuffer | Uint8Array | object | null | undefined>
52
+ ): GeoSource {
53
+ return async (key) => {
54
+ const value = await load(key)
55
+ if (value == null) return null
56
+ if (typeof value === "string") return JSON.parse(value)
57
+ if (value instanceof ArrayBuffer || value instanceof Uint8Array) {
58
+ return JSON.parse(new TextDecoder().decode(value))
59
+ }
60
+ return value
61
+ }
62
+ }
63
+
64
+ function definedFeature(code: string, definition: GeoDefinition, serial: number): GeoFeature {
65
+ const shape = definition.shape.type === "Feature" ? definition.shape.geometry : definition.shape
66
+ const feature = definition.shape.type === "Feature" ? definition.shape : null
67
+ const polygons: readonly (readonly (readonly Position[])[])[] =
68
+ shape.type === "Polygon" ? [shape.coordinates] : shape.coordinates
69
+ let arcIndex = 0
70
+
71
+ const toRing = (ring: readonly Position[]): GeoRingRef => {
72
+ const closed = ring.length > 0 && ring[0][0] === ring[ring.length - 1][0] && ring[0][1] === ring[ring.length - 1][1]
73
+ const coordinates = closed ? ring : [...ring, ring[0]]
74
+ const points = new Float64Array(coordinates.length * 2)
75
+ coordinates.forEach(([lon, lat], i) => {
76
+ points[i * 2] = lon
77
+ points[i * 2 + 1] = lat
78
+ })
79
+ return { arc: { key: `define:${serial}:${code}:${arcIndex++}`, points, buckets: ringBuckets(points) }, reversed: false }
80
+ }
81
+
82
+ const measured: GeoPolygon[] = polygons.map((rings) => {
83
+ const resolved = rings.map((ring) => [toRing(ring)])
84
+ const ringAreas = rings.map((ring) => {
85
+ const area = geoArea({ type: "Polygon", coordinates: [ring as [number, number][]] })
86
+ return area > 2 * Math.PI ? 4 * Math.PI - area : area
87
+ })
88
+ const area = Math.max(0, ringAreas[0] - ringAreas.slice(1).reduce((a, b) => a + b, 0))
89
+ return { rings: resolved, ringAreas, area, mainland: true }
90
+ })
91
+
92
+ const name = definition.name ?? feature?.properties?.name ?? code
93
+ return {
94
+ code,
95
+ name,
96
+ nameNl: name,
97
+ parent: definition.parent ?? (code.includes("-") ? code.slice(0, 2) : null),
98
+ first: true,
99
+ listed: false,
100
+ remote: false,
101
+ polygons: measured,
102
+ }
103
+ }
104
+
105
+ class GeoRegistry {
106
+ private _sources: GeoSource[] = []
107
+ private _defined = new Map<string, { serial: number; feature: GeoFeature }>()
108
+ private _generation = 0
109
+ private _serial = 0
110
+
111
+ /** Bumps whenever a source or a definition is added or removed. */
112
+ get generation(): number {
113
+ return this._generation
114
+ }
115
+
116
+ get hasSources(): boolean {
117
+ return this._sources.length > 0
118
+ }
119
+
120
+ /** Adds a source after every source already registered. Returns its removal. */
121
+ use(source: GeoSource): () => void {
122
+ this._sources.push(source)
123
+ this._generation++
124
+ return () => {
125
+ const i = this._sources.indexOf(source)
126
+ if (i < 0) return
127
+ this._sources.splice(i, 1)
128
+ this._generation++
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Shapes (GeoJSON, lon/lat) under codes of your choosing, checked before any
134
+ * source. A definition replaces generated data for the same code.
135
+ */
136
+ define(shapes: Record<string, GeoShape | GeoDefinition>): () => void {
137
+ const serial = ++this._serial
138
+ const codes = Object.entries(shapes).map(([rawCode, value]) => {
139
+ const code = rawCode.trim().toUpperCase()
140
+ const definition = "shape" in value ? value : { shape: value }
141
+ this._defined.set(code, { serial, feature: definedFeature(code, definition, serial) })
142
+ return code
143
+ })
144
+ this._generation++
145
+ return () => {
146
+ for (const code of codes) {
147
+ if (this._defined.get(code)?.serial === serial) this._defined.delete(code)
148
+ }
149
+ this._generation++
150
+ }
151
+ }
152
+
153
+ defined(code: string): GeoFeature | undefined {
154
+ return this._defined.get(code)?.feature
155
+ }
156
+
157
+ /** Removes every source and definition — for tests. */
158
+ reset(): void {
159
+ this._sources = []
160
+ this._defined.clear()
161
+ this._generation++
162
+ }
163
+
164
+ /**
165
+ * Asks each source in registration order; the first non-null answer wins.
166
+ * Resolves `null` only when every source answered that it has no such file.
167
+ */
168
+ async resolve(key: string): Promise<GeoFileData | GeoIndex | null> {
169
+ if (this._sources.length === 0) {
170
+ throw new Error("No geo source registered — call geoRegistry.use(geoUrlSource(…)) before rendering GeoBorder")
171
+ }
172
+ let failure: unknown = null
173
+ for (const source of [...this._sources]) {
174
+ try {
175
+ const value = await source(key)
176
+ if (value != null) return value as GeoFileData | GeoIndex
177
+ } catch (error) {
178
+ failure ??= error
179
+ }
180
+ }
181
+ if (failure) throw failure
182
+ return null
183
+ }
184
+
185
+ /** Every listed region's code, names and parent, for a picker. */
186
+ async index(): Promise<GeoIndex | null> {
187
+ return (await this.resolve(`${GEO_DATA_VERSION}/index`)) as GeoIndex | null
188
+ }
189
+ }
190
+
191
+ export const geoRegistry = new GeoRegistry()
@@ -0,0 +1,95 @@
1
+ import type { GeoFeature } from "./format"
2
+ import { countryFileKey, geoFileEntry, statesFileKey } from "./loader"
3
+ import { geoRegistry } from "./registry"
4
+
5
+ export type GeoLevel = "country" | "state"
6
+ export type GeoFrame = "mainland" | "all"
7
+
8
+ const CODE = /^[A-Z]{2}(-[A-Z0-9]{1,3})?$/
9
+
10
+ /** `"fr, de"`, `["FR", "de"]` → `["FR", "DE"]`; unique, in order, empties dropped. */
11
+ export function normalizeIso(value: unknown): string[] {
12
+ const raw = Array.isArray(value) ? value : typeof value === "string" ? value.split(/[\s,;]+/) : []
13
+ const out: string[] = []
14
+ for (const item of raw) {
15
+ if (typeof item !== "string") continue
16
+ const code = item.trim().toUpperCase()
17
+ if (code && !out.includes(code)) out.push(code)
18
+ }
19
+ return out
20
+ }
21
+
22
+ function isSubdivision(code: string): boolean {
23
+ return code.includes("-")
24
+ }
25
+
26
+ /** Every data file a selection reads, for `declareAssets`. Depends on nothing but its arguments. */
27
+ export function geoFilesFor(codes: readonly string[], level: GeoLevel): string[] {
28
+ const files = new Set<string>()
29
+ for (const code of codes) {
30
+ if (!CODE.test(code) || geoRegistry.defined(code)) continue
31
+ if (isSubdivision(code)) files.add(statesFileKey(code.slice(0, 2)))
32
+ else files.add(level === "state" ? statesFileKey(code) : countryFileKey(code))
33
+ }
34
+ return [...files]
35
+ }
36
+
37
+ export interface GeoSelection {
38
+ features: GeoFeature[]
39
+ /** A file this selection needs has not landed yet. */
40
+ pending: boolean
41
+ /** Codes whose file loaded without them. */
42
+ unknown: string[]
43
+ }
44
+
45
+ /**
46
+ * The features a selection draws. A bare country code at state level is its
47
+ * first-level subdivisions — minus the remote ones when framing the mainland —
48
+ * or the country itself when it has none.
49
+ */
50
+ export function selectGeoFeatures(codes: readonly string[], level: GeoLevel, frame: GeoFrame): GeoSelection {
51
+ const features: GeoFeature[] = []
52
+ const seen = new Set<string>()
53
+ const unknown: string[] = []
54
+ let pending = false
55
+
56
+ const add = (feature: GeoFeature) => {
57
+ if (seen.has(feature.code)) return
58
+ seen.add(feature.code)
59
+ features.push(feature)
60
+ }
61
+
62
+ for (const code of codes) {
63
+ const defined = geoRegistry.defined(code)
64
+ if (defined) {
65
+ add(defined)
66
+ continue
67
+ }
68
+ if (!CODE.test(code)) {
69
+ unknown.push(code)
70
+ continue
71
+ }
72
+ const subdivision = isSubdivision(code)
73
+ const key = subdivision || level === "state" ? statesFileKey(code.slice(0, 2)) : countryFileKey(code)
74
+ const entry = geoFileEntry(key)
75
+ if (!entry || entry.status !== "ready") {
76
+ if (!entry) pending = true
77
+ continue
78
+ }
79
+ const file = entry.file.features
80
+ if (subdivision || level === "country") {
81
+ const feature = file.get(code)
82
+ if (feature) add(feature)
83
+ else unknown.push(code)
84
+ continue
85
+ }
86
+ const children = [...file.values()].filter(
87
+ (f) => f.parent === code && f.first && !(frame === "mainland" && f.remote)
88
+ )
89
+ if (children.length > 0) children.forEach(add)
90
+ else if (file.get(code)) add(file.get(code)!)
91
+ else unknown.push(code)
92
+ }
93
+
94
+ return { features, pending, unknown }
95
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Visvalingam–Whyatt weights for a hand-defined ring, in the same buckets the
3
+ * generated data carries (`-2·log2` of the effective area in steradians), so a
4
+ * shape passed to `geoRegistry.define` answers to `detail` like any other.
5
+ *
6
+ * Planar in degrees with longitude scaled by latitude: accurate enough for the
7
+ * handful of shapes an author defines, where the generated data uses the exact
8
+ * spherical area.
9
+ */
10
+ export function ringBuckets(points: Float64Array): Uint8Array {
11
+ const n = points.length / 2
12
+ const buckets = new Uint8Array(n)
13
+ if (n < 3) return buckets
14
+
15
+ const radians = Math.PI / 180
16
+ const weight = new Float64Array(n)
17
+ const prev = new Int32Array(n)
18
+ const next = new Int32Array(n)
19
+ for (let i = 0; i < n; i++) {
20
+ prev[i] = i - 1
21
+ next[i] = i + 1
22
+ }
23
+
24
+ const area = (a: number, b: number, c: number): number => {
25
+ const cos = Math.cos(points[b * 2 + 1] * radians)
26
+ const ax = points[a * 2] * cos, ay = points[a * 2 + 1]
27
+ const bx = points[b * 2] * cos, by = points[b * 2 + 1]
28
+ const cx = points[c * 2] * cos, cy = points[c * 2 + 1]
29
+ return (Math.abs((bx - ax) * (cy - ay) - (cx - ax) * (by - ay)) / 2) * radians * radians
30
+ }
31
+
32
+ const heap: number[] = []
33
+ const position = new Int32Array(n).fill(-1)
34
+ const less = (i: number, j: number) => weight[heap[i]] < weight[heap[j]]
35
+ const swap = (i: number, j: number) => {
36
+ ;[heap[i], heap[j]] = [heap[j], heap[i]]
37
+ position[heap[i]] = i
38
+ position[heap[j]] = j
39
+ }
40
+ const up = (i: number) => {
41
+ while (i > 0) {
42
+ const parent = (i - 1) >> 1
43
+ if (!less(i, parent)) break
44
+ swap(i, parent)
45
+ i = parent
46
+ }
47
+ }
48
+ const down = (i: number) => {
49
+ for (;;) {
50
+ const l = 2 * i + 1
51
+ const r = l + 1
52
+ let smallest = i
53
+ if (l < heap.length && less(l, smallest)) smallest = l
54
+ if (r < heap.length && less(r, smallest)) smallest = r
55
+ if (smallest === i) return
56
+ swap(i, smallest)
57
+ i = smallest
58
+ }
59
+ }
60
+ const push = (i: number) => {
61
+ position[i] = heap.length
62
+ heap.push(i)
63
+ up(heap.length - 1)
64
+ }
65
+ const remove = (i: number) => {
66
+ const at = position[i]
67
+ const last = heap.pop()!
68
+ position[i] = -1
69
+ if (at < heap.length) {
70
+ heap[at] = last
71
+ position[last] = at
72
+ up(at)
73
+ down(position[last])
74
+ }
75
+ }
76
+
77
+ for (let i = 1; i < n - 1; i++) {
78
+ weight[i] = area(i - 1, i, i + 1)
79
+ push(i)
80
+ }
81
+
82
+ let floor = 0
83
+ const final = new Float64Array(n)
84
+ final[0] = final[n - 1] = Infinity
85
+ while (heap.length > 0) {
86
+ const i = heap[0]
87
+ remove(i)
88
+ floor = Math.max(floor, weight[i])
89
+ final[i] = floor
90
+ const p = prev[i]
91
+ const q = next[i]
92
+ if (p >= 0) next[p] = q
93
+ if (q < n) prev[q] = p
94
+ for (const j of [p, q]) {
95
+ if (j <= 0 || j >= n - 1 || position[j] < 0) continue
96
+ remove(j)
97
+ weight[j] = area(prev[j], j, next[j])
98
+ push(j)
99
+ }
100
+ }
101
+
102
+ for (let i = 0; i < n; i++) buckets[i] = weightBucket(final[i])
103
+ return buckets
104
+ }
105
+
106
+ export function weightBucket(weight: number): number {
107
+ if (weight === Infinity) return 0
108
+ if (!(weight > 0)) return 255
109
+ return Math.min(254, Math.max(1, Math.round(-2 * Math.log2(weight))))
110
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The halo around the planet's edge — a shell, seen from the inside.
3
+ *
4
+ * The trick is old and still the best one: draw a sphere slightly larger than
5
+ * the planet, keep only its **back** faces, and light nothing. What survives is
6
+ * the far wall of the shell, which the planet hides everywhere except around the
7
+ * silhouette — so the brightness only has to be a function of how edge-on the
8
+ * surface is, and the occlusion comes free from the depth buffer.
9
+ *
10
+ * That is why it is a shader rather than a material with a gradient: the value
11
+ * needed is `1 - |n · v|`, which no material exposes, and which is *per pixel*.
12
+ * A gradient painted onto a sphere is fixed to the geometry and slides as the
13
+ * camera turns; this is anchored to the eye, so the rim stays on the horizon
14
+ * wherever the globe is looked at from.
15
+ *
16
+ * Additively blended, because atmosphere adds light rather than covering
17
+ * anything — which also means it never darkens the stars or the planet behind
18
+ * it, and needs no sorting against them.
19
+ */
20
+
21
+ import { Mat, type Material3D, type NormalizedColor } from "@motionscript/sdk"
22
+
23
+ /**
24
+ * The eye-facing rim term, and nothing else.
25
+ *
26
+ * `vView` is the fragment's direction to the camera in view space, where the eye
27
+ * is the origin — so it needs no camera uniform, and the same shader works for
28
+ * an orthographic camera where a `cameraPosition` version would not.
29
+ */
30
+ const VERTEX = /* glsl */ `
31
+ varying vec3 vNormal;
32
+ varying vec3 vView;
33
+
34
+ void main() {
35
+ vec4 viewPosition = modelViewMatrix * vec4(position, 1.0);
36
+ vNormal = normalize(normalMatrix * normal);
37
+ vView = -viewPosition.xyz;
38
+ gl_Position = projectionMatrix * viewPosition;
39
+ }
40
+ `
41
+
42
+ /**
43
+ * `abs` on the dot product, deliberately: these are back faces, so the normal
44
+ * points away from the eye and the un-absolute term would be brightest face-on —
45
+ * a disc over the planet instead of a ring around it.
46
+ */
47
+ const FRAGMENT = /* glsl */ `
48
+ uniform vec3 uColor;
49
+ uniform float uFalloff;
50
+ uniform float uIntensity;
51
+
52
+ varying vec3 vNormal;
53
+ varying vec3 vView;
54
+
55
+ void main() {
56
+ float facing = abs(dot(normalize(vNormal), normalize(vView)));
57
+ float rim = pow(clamp(1.0 - facing, 0.0, 1.0), uFalloff) * uIntensity;
58
+ gl_FragColor = vec4(uColor * rim, rim);
59
+ }
60
+ `
61
+
62
+ /**
63
+ * The atmosphere's material — including which faces it keeps and how it blends.
64
+ *
65
+ * Those two belong *on the descriptor* rather than in the mesh shorthand beside
66
+ * it, and not as a matter of taste: `resolveMaterialShorthand3D` returns an
67
+ * explicit `material` immediately and never reaches the shorthand's own `faces`
68
+ * or `blend`. Set them out there and they are silently dropped — the shell keeps
69
+ * its front faces, covers the planet in a bright disc, and the only clue is that
70
+ * the globe has gone.
71
+ *
72
+ * `color` arrives as core's resolved `[r, g, b, a]` and is handed on as three
73
+ * numbers rather than four: the uniform coercion reads a numeric array of length
74
+ * four as a `vec4`, never as a colour, so passing the tuple whole would bind
75
+ * alpha where the shader expects nothing and shift the hue. Alpha has nothing to
76
+ * say here anyway — the rim term is the opacity.
77
+ */
78
+ export function atmosphereMaterial(
79
+ color: NormalizedColor,
80
+ falloff: number,
81
+ intensity: number
82
+ ): Material3D {
83
+ return Mat.shader({
84
+ vertex: VERTEX,
85
+ fragment: FRAGMENT,
86
+ uniforms: {
87
+ uColor: [color[0], color[1], color[2]],
88
+ uFalloff: falloff,
89
+ uIntensity: intensity,
90
+ },
91
+ faces: "back",
92
+ blend: "additive",
93
+ transparent: true,
94
+ // Nothing here writes depth: the shell is a light, and one that occluded
95
+ // would hide the planet it is drawn around.
96
+ depthWrite: false,
97
+ })
98
+ }
@@ -0,0 +1,166 @@
1
+ import { world } from "./world"
2
+
3
+ /**
4
+ * Every boundary line on the map, each drawn **once**.
5
+ *
6
+ * ## The defect this exists to fix
7
+ *
8
+ * Country rings are stored per country, so a border between two of them appears
9
+ * in both — and stroking each country's own path draws that line twice. The
10
+ * claim that this is harmless with an opaque colour is *wrong*, and visibly so:
11
+ * a stroke is antialiased, so the fringe pixels either side get composited
12
+ * twice. Coverage `a` blended over itself is `2a - a²`, which turns a half-lit
13
+ * edge pixel into a three-quarters-lit one — and the eye reads that as a bolder,
14
+ * wider line. Internal borders came out looking about twice the weight of
15
+ * coastlines, which are stroked once because their other side is ocean.
16
+ *
17
+ * ## Dedupe by segment, then stitch
18
+ *
19
+ * Natural Earth's neighbours share their vertices *exactly* — measured on the
20
+ * vendored data: of 10,360 segments, 2,658 appear in precisely two rings and
21
+ * 5,020 in one — so a segment appearing twice is a shared border and one
22
+ * appearing once is a coast. That makes the dedupe an equality test rather than
23
+ * a geometric tolerance, which is the only reason this is a small function
24
+ * instead of a topology library.
25
+ *
26
+ * The unique segments are then stitched back into polylines rather than drawn as
27
+ * ten thousand two-point lines: a run of separate segments has a cap at every
28
+ * vertex instead of a join, which on a 1.5px stroke reads as a dotted, lumpy
29
+ * coastline.
30
+ *
31
+ * ## What the caller must do with them
32
+ *
33
+ * Draw them **instead of** stroking the country paths, not as well. The country
34
+ * paths are then fill-only, and every line on the map — coast or border — is one
35
+ * stroke of the same weight.
36
+ */
37
+
38
+ /** One boundary run, as flat `[lon, lat, lon, lat, …]` degrees. */
39
+ export type BorderLine = Float64Array
40
+
41
+ let lines: BorderLine[] | null = null
42
+
43
+ /** The boundary lines, computed on first use and shared thereafter. */
44
+ export function borderLines(): BorderLine[] {
45
+ return (lines ??= build())
46
+ }
47
+
48
+ /**
49
+ * A vertex's identity.
50
+ *
51
+ * The integer form, not the degrees: the decoder divides stored integers by the
52
+ * quantum, so two rings that share a vertex hold bit-identical doubles — but
53
+ * keying on the integer says that out loud rather than resting on it, and is a
54
+ * cheaper string besides.
55
+ */
56
+ function nodeKey(lon: number, lat: number): number {
57
+ // Packed into one number rather than a string: 20,000 keys built per pass,
58
+ // and a numeric Map key skips the hashing a template literal costs.
59
+ return Math.round(lon * 1000) * 1_000_000 + Math.round(lat * 1000)
60
+ }
61
+
62
+ interface Segment {
63
+ a: number
64
+ b: number
65
+ /** Endpoint coordinates, kept so a chain can be written out without a lookup. */
66
+ ax: number
67
+ ay: number
68
+ bx: number
69
+ by: number
70
+ }
71
+
72
+ function build(): BorderLine[] {
73
+ // --- Collect each distinct segment once -------------------------------
74
+ const unique = new Map<string, Segment>()
75
+ for (const ring of world().rings) {
76
+ const count = ring.points.length / 2
77
+ for (let i = 0; i < count; i++) {
78
+ const j = (i + 1) % count
79
+ const ax = ring.points[i * 2]!
80
+ const ay = ring.points[i * 2 + 1]!
81
+ const bx = ring.points[j * 2]!
82
+ const by = ring.points[j * 2 + 1]!
83
+ const a = nodeKey(ax, ay)
84
+ const b = nodeKey(bx, by)
85
+ if (a === b) continue
86
+ // Canonical, so a border walked one way by one country and the other way
87
+ // by its neighbour collides rather than surviving as two.
88
+ const key = a < b ? `${a}:${b}` : `${b}:${a}`
89
+ if (!unique.has(key)) unique.set(key, { a, b, ax, ay, bx, by })
90
+ }
91
+ }
92
+
93
+ // --- Chain them into runs ---------------------------------------------
94
+ const segments = [...unique.values()]
95
+ const at = new Map<number, number[]>()
96
+ segments.forEach((segment, index) => {
97
+ for (const node of [segment.a, segment.b]) {
98
+ const list = at.get(node)
99
+ if (list) list.push(index)
100
+ else at.set(node, [index])
101
+ }
102
+ })
103
+
104
+ const used = new Uint8Array(segments.length)
105
+ const out: BorderLine[] = []
106
+
107
+ /** The next unused segment at `node`, or −1. */
108
+ const nextAt = (node: number): number => {
109
+ for (const index of at.get(node) ?? []) if (!used[index]) return index
110
+ return -1
111
+ }
112
+
113
+ for (let start = 0; start < segments.length; start++) {
114
+ if (used[start]) continue
115
+
116
+ const first = segments[start]!
117
+ used[start] = 1
118
+ // Grown from the middle out: forward from `b`, then the backward half is
119
+ // reversed onto the front. A junction picks an arbitrary unused neighbour,
120
+ // which is fine — every line is the same colour and weight, so where one run
121
+ // ends and the next begins is not visible.
122
+ const forward: number[] = [first.ax, first.ay, first.bx, first.by]
123
+ let node = first.b
124
+ for (;;) {
125
+ const index = nextAt(node)
126
+ if (index < 0) break
127
+ used[index] = 1
128
+ const segment = segments[index]!
129
+ const far = segment.a === node ? segment.b : segment.a
130
+ const fx = segment.a === node ? segment.bx : segment.ax
131
+ const fy = segment.a === node ? segment.by : segment.ay
132
+ forward.push(fx, fy)
133
+ node = far
134
+ }
135
+
136
+ const backward: number[] = []
137
+ node = first.a
138
+ for (;;) {
139
+ const index = nextAt(node)
140
+ if (index < 0) break
141
+ used[index] = 1
142
+ const segment = segments[index]!
143
+ const far = segment.a === node ? segment.b : segment.a
144
+ const fx = segment.a === node ? segment.bx : segment.ax
145
+ const fy = segment.a === node ? segment.by : segment.ay
146
+ backward.push(fx, fy)
147
+ node = far
148
+ }
149
+
150
+ if (backward.length === 0) {
151
+ out.push(Float64Array.from(forward))
152
+ continue
153
+ }
154
+ // Reverse the backward half pairwise and prepend it.
155
+ const points = new Float64Array(backward.length + forward.length)
156
+ let write = 0
157
+ for (let i = backward.length - 2; i >= 0; i -= 2) {
158
+ points[write++] = backward[i]!
159
+ points[write++] = backward[i + 1]!
160
+ }
161
+ points.set(forward, write)
162
+ out.push(points)
163
+ }
164
+
165
+ return out
166
+ }