canvas-globe 0.1.0 → 0.1.2

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/src/geo.js CHANGED
@@ -1,286 +1,286 @@
1
- /**
2
- * Pure geometry, projection and colour helpers. No DOM, no canvas: everything
3
- * here is testable in isolation and safe to run in Node.
4
- */
5
-
6
- export const D2R = Math.PI / 180;
7
- export const R2D = 180 / Math.PI;
8
- export const TAU = Math.PI * 2;
9
-
10
- export const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
11
-
12
- /** Wraps a longitude delta into -180…180. */
13
- export const wrapLon = (d) => {
14
- let x = d;
15
- while (x > 180) x -= 360;
16
- while (x < -180) x += 360;
17
- return x;
18
- };
19
-
20
- /* -------------------------------------------------------------------------- */
21
- /* map projections */
22
- /* -------------------------------------------------------------------------- */
23
-
24
- const MERCATOR_LIMIT = 85.0511287798;
25
-
26
- /**
27
- * Each projection maps lon/lat to abstract planar units where +y points south,
28
- * so a bounding box can be normalised to the canvas without special-casing.
29
- */
30
- export const projections = {
31
- equirectangular: {
32
- forward: (lon, lat) => [lon, -lat],
33
- inverse: (x, y) => [x, -y],
34
- },
35
- mercator: {
36
- forward: (lon, lat) => {
37
- const p = clamp(lat, -MERCATOR_LIMIT, MERCATOR_LIMIT) * D2R;
38
- return [lon, -R2D * Math.log(Math.tan(Math.PI / 4 + p / 2))];
39
- },
40
- inverse: (x, y) => [x, R2D * (2 * Math.atan(Math.exp(-y * D2R)) - Math.PI / 2)],
41
- },
42
- naturalEarth: {
43
- forward: (lon, lat) => {
44
- const p = lat * D2R, l = lon * D2R, p2 = p * p, p4 = p2 * p2;
45
- const x = l * (0.8707 - 0.131979 * p2 + p4 * (-0.013791 + p4 * (0.003971 * p2 - 0.001529 * p4)));
46
- const y = p * (1.007226 + p2 * (0.015085 + p4 * (-0.044475 + 0.028874 * p2 - 0.005916 * p4)));
47
- return [x * R2D, -y * R2D];
48
- },
49
- inverse: (x, y) => {
50
- let p = -y * D2R;
51
- for (let i = 0; i < 24; i++) {
52
- const p2 = p * p, p4 = p2 * p2;
53
- const f = p * (1.007226 + p2 * (0.015085 + p4 * (-0.044475 + 0.028874 * p2 - 0.005916 * p4))) + y * D2R;
54
- const d = f / (1.007226 + p2 * (0.045255 + p4 * (-0.311325 + 0.259866 * p2 - 0.065076 * p4)));
55
- p -= d;
56
- if (Math.abs(d) < 1e-12) break;
57
- }
58
- const p2 = p * p, p4 = p2 * p2;
59
- const k = 0.8707 - 0.131979 * p2 + p4 * (-0.013791 + p4 * (0.003971 * p2 - 0.001529 * p4));
60
- return [(x * D2R) / k * R2D, p * R2D];
61
- },
62
- },
63
- };
64
-
65
- export const resolveProjection = (name) => projections[name] || projections.equirectangular;
66
-
67
- /** Planar bounds of a projection for a `[north, south]` latitude window. */
68
- export const projectionBounds = (name, latRange) => {
69
- const p = resolveProjection(name);
70
- const [north, south] = latRange;
71
- const x0 = p.forward(-180, 0)[0], x1 = p.forward(180, 0)[0];
72
- const y0 = p.forward(0, north)[1], y1 = p.forward(0, south)[1];
73
- return { x0, x1, y0, y1, dx: x1 - x0, dy: y1 - y0 };
74
- };
75
-
76
- /** Height / width ratio a flat map should use for a latitude range. */
77
- export const mapAspect = (latRange = [83, -56], projection = "equirectangular") => {
78
- const b = projectionBounds(projection, latRange);
79
- return b.dy / b.dx;
80
- };
81
-
82
- /* -------------------------------------------------------------------------- */
83
- /* orthographic (globe) */
84
- /* -------------------------------------------------------------------------- */
85
-
86
- /**
87
- * Orthographic forward projection. Returns `[x, y, cos]` in screen units with
88
- * y pointing down; `cos < 0` means the point sits behind the globe.
89
- */
90
- export const ortho = (lon, lat, lon0, lat0, r) => {
91
- const l = (lon - lon0) * D2R, f = lat * D2R, f0 = lat0 * D2R;
92
- const sf = Math.sin(f), cf = Math.cos(f), sf0 = Math.sin(f0), cf0 = Math.cos(f0), cl = Math.cos(l);
93
- return [r * cf * Math.sin(l), -r * (cf0 * sf - sf0 * cf * cl), sf0 * sf + cf0 * cf * cl];
94
- };
95
-
96
- /** Inverse orthographic. Returns `[lon, lat]`, or null outside the disc. */
97
- export const orthoInverse = (x, y, lon0, lat0, r) => {
98
- const rho = Math.hypot(x, y);
99
- if (rho > r) return null;
100
- const c = Math.asin(clamp(rho / r, -1, 1));
101
- const sc = Math.sin(c), cc = Math.cos(c);
102
- const f0 = lat0 * D2R, ym = -y;
103
- if (rho < 1e-9) return [lon0, lat0];
104
- const lat = Math.asin(clamp(cc * Math.sin(f0) + (ym * sc * Math.cos(f0)) / rho, -1, 1)) * R2D;
105
- const lon = lon0 + Math.atan2(x * sc, rho * cc * Math.cos(f0) - ym * sc * Math.sin(f0)) * R2D;
106
- return [wrapLon(lon), lat];
107
- };
108
-
109
- /* -------------------------------------------------------------------------- */
110
- /* great circles */
111
- /* -------------------------------------------------------------------------- */
112
-
113
- const toVec = (lon, lat) => {
114
- const l = lon * D2R, f = lat * D2R, cf = Math.cos(f);
115
- return [cf * Math.cos(l), cf * Math.sin(l), Math.sin(f)];
116
- };
117
-
118
- /** Angular distance between two coordinates, in degrees. */
119
- export const angularDistance = (lon1, lat1, lon2, lat2) => {
120
- const a = toVec(lon1, lat1), b = toVec(lon2, lat2);
121
- return Math.acos(clamp(a[0] * b[0] + a[1] * b[1] + a[2] * b[2], -1, 1)) * R2D;
122
- };
123
-
124
- /** Samples the shorter great-circle path between two coordinates. */
125
- export const greatCircle = (lon1, lat1, lon2, lat2, steps = 64) => { const a = toVec(lon1, lat1), b = toVec(lon2, lat2);
126
- const dot = clamp(a[0] * b[0] + a[1] * b[1] + a[2] * b[2], -1, 1);
127
- const omega = Math.acos(dot);
128
- const out = [];
129
- if (omega < 1e-6) return [[lon1, lat1], [lon2, lat2]];
130
- const so = Math.sin(omega);
131
- for (let i = 0; i <= steps; i++) {
132
- const t = i / steps;
133
- const k1 = Math.sin((1 - t) * omega) / so, k2 = Math.sin(t * omega) / so;
134
- const x = a[0] * k1 + b[0] * k2, y = a[1] * k1 + b[1] * k2, z = a[2] * k1 + b[2] * k2;
135
- const h = Math.hypot(x, y);
136
- out.push([Math.atan2(y, x) * R2D, Math.atan2(z, h) * R2D]);
137
- }
138
- return out;
139
- };
140
-
141
- /** Metres between two coordinates, on a spherical earth. */
142
- export const distanceMeters = (lon1, lat1, lon2, lat2) => angularDistance(lon1, lat1, lon2, lat2) * D2R * 6371008.8;
143
-
144
- /** Ring of points a fixed distance from a centre: a circle on the sphere. */
145
- export const circleAround = (lon, lat, meters, steps = 72) => {
146
- const theta = clamp(meters / 6371008.8, 0, Math.PI * 0.85);
147
- const f1 = lat * D2R, l1 = lon * D2R;
148
- const sf1 = Math.sin(f1), cf1 = Math.cos(f1), st = Math.sin(theta), ct = Math.cos(theta);
149
- const out = [];
150
- for (let i = 0; i <= steps; i++) {
151
- const b = (i / steps) * TAU;
152
- const f2 = Math.asin(clamp(sf1 * ct + cf1 * st * Math.cos(b), -1, 1));
153
- const l2 = l1 + Math.atan2(Math.sin(b) * st * cf1, ct - sf1 * Math.sin(f2));
154
- out.push([wrapLon(l2 * R2D), f2 * R2D]);
155
- }
156
- return out;
157
- };
158
-
159
- /* -------------------------------------------------------------------------- */
160
- /* sun position */
161
- /* -------------------------------------------------------------------------- */
162
-
163
- /** Subsolar point (the coordinate where the sun is directly overhead). */
164
- export const subsolarPoint = (when = Date.now()) => {
165
- const ms = when instanceof Date ? when.getTime() : Number(when);
166
- const n = ms / 86400000 + 2440587.5 - 2451545.0;
167
- const meanLon = (280.46 + 0.9856474 * n) * D2R;
168
- const meanAnom = (357.528 + 0.9856003 * n) * D2R;
169
- const ecl = meanLon + (1.915 * Math.sin(meanAnom) + 0.02 * Math.sin(2 * meanAnom)) * D2R;
170
- const obl = (23.439 - 0.0000004 * n) * D2R;
171
- const lat = Math.asin(Math.sin(obl) * Math.sin(ecl)) * R2D;
172
- const ra = Math.atan2(Math.cos(obl) * Math.sin(ecl), Math.cos(ecl)) * R2D;
173
- const gmst = ((18.697374558 + 24.06570982441908 * n) % 24 + 24) % 24;
174
- return { lon: wrapLon(ra - gmst * 15), lat };
175
- };
176
-
177
- /* -------------------------------------------------------------------------- */
178
- /* polygon utilities */
179
- /* -------------------------------------------------------------------------- */
180
-
181
- const ringContains = (ring, lon, lat) => {
182
- let inside = false;
183
- for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
184
- const xi = ring[i][0], yi = ring[i][1], xj = ring[j][0], yj = ring[j][1];
185
- if (yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) inside = !inside;
186
- }
187
- return inside;
188
- };
189
-
190
- /** Ray-casting hit test against a GeoJSON Polygon or MultiPolygon. */
191
- export const pointInGeometry = (geom, lon, lat) => {
192
- if (!geom) return false;
193
- const polys = geom.type === "Polygon" ? [geom.coordinates] : geom.coordinates;
194
- for (const poly of polys) {
195
- if (!poly.length || !ringContains(poly[0], lon, lat)) continue;
196
- let hole = false;
197
- for (let i = 1; i < poly.length; i++) if (ringContains(poly[i], lon, lat)) { hole = true; break; }
198
- if (!hole) return true;
199
- }
200
- return false;
201
- };
202
-
203
- /** `[west, south, east, north]` bounds of a Polygon or MultiPolygon. */
204
- export const geometryBounds = (geom) => {
205
- let w = 180, s = 90, e = -180, n = -90;
206
- const polys = geom.type === "Polygon" ? [geom.coordinates] : geom.coordinates;
207
- for (const poly of polys) {
208
- for (const ring of poly) {
209
- for (const c of ring) {
210
- if (c[0] < w) w = c[0];
211
- if (c[0] > e) e = c[0];
212
- if (c[1] < s) s = c[1];
213
- if (c[1] > n) n = c[1];
214
- }
215
- }
216
- }
217
- return [w, s, e, n];
218
- };
219
-
220
- /* -------------------------------------------------------------------------- */
221
- /* shapes & colours */
222
- /* -------------------------------------------------------------------------- */
223
-
224
- /** Accepts GeoJSON of any flavour or the bundled `{ id, name, geometry }[]`. */
225
- export const normalizeShapes = (input) => {
226
- if (!input) return null;
227
- if (Array.isArray(input)) return input;
228
- if (input.type === "FeatureCollection") {
229
- return input.features.map((f) => ({
230
- id: f.id,
231
- name: f.properties?.name ?? f.properties?.NAME ?? f.properties?.ADMIN,
232
- iso: f.properties?.iso ?? f.properties?.ISO_A2 ?? f.properties?.iso_a2,
233
- geometry: f.geometry,
234
- }));
235
- }
236
- if (input.type === "Feature") return [{ id: input.id, name: input.properties?.name, geometry: input.geometry }];
237
- if (input.type) return [{ geometry: input }];
238
- return null;
239
- };
240
-
241
- /** Converts `#rgb`/`#rrggbb` to `rgba()`; other formats pass through. */
242
- export const withAlpha = (color, a) => {
243
- if (typeof color !== "string") return color;
244
- if (color.startsWith("#") && (color.length === 7 || color.length === 4)) {
245
- const hex = color.length === 4 ? color.replace(/#(.)(.)(.)/, "#$1$1$2$2$3$3") : color;
246
- const n = parseInt(hex.slice(1), 16);
247
- return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
248
- }
249
- const m = color.match(/^rgba?\(([^)]+)\)$/i);
250
- if (m) {
251
- const p = m[1].split(",").map((s) => s.trim());
252
- return `rgba(${p[0]},${p[1]},${p[2]},${a})`;
253
- }
254
- return color;
255
- };
256
-
257
- const parseRGB = (color) => {
258
- if (color.startsWith("#")) {
259
- const hex = color.length === 4 ? color.replace(/#(.)(.)(.)/, "#$1$1$2$2$3$3") : color;
260
- const n = parseInt(hex.slice(1), 16);
261
- return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
262
- }
263
- const m = color.match(/^rgba?\(([^)]+)\)$/i);
264
- if (m) return m[1].split(",").slice(0, 3).map((s) => parseFloat(s));
265
- return [128, 128, 128];
266
- };
267
-
268
- /**
269
- * Builds a linear colour ramp: `colorScale([0, 100], ["#eef", "#22c"])`.
270
- * Domains longer than two entries create multi-stop scales.
271
- */
272
- export const colorScale = (domain = [0, 1], range = ["#e0f2fe", "#0369a1"]) => {
273
- const stops = range.map(parseRGB);
274
- return (value) => {
275
- const v = Number(value);
276
- if (!Number.isFinite(v)) return null;
277
- if (v <= domain[0]) return `rgb(${stops[0].join(",")})`;
278
- const last = domain.length - 1;
279
- if (v >= domain[last]) return `rgb(${stops[stops.length - 1].join(",")})`;
280
- let i = 0;
281
- while (i < last - 1 && v > domain[i + 1]) i++;
282
- const t = (v - domain[i]) / (domain[i + 1] - domain[i] || 1);
283
- const a = stops[Math.min(i, stops.length - 1)], b = stops[Math.min(i + 1, stops.length - 1)];
284
- return `rgb(${Math.round(a[0] + (b[0] - a[0]) * t)},${Math.round(a[1] + (b[1] - a[1]) * t)},${Math.round(a[2] + (b[2] - a[2]) * t)})`;
285
- };
286
- };
1
+ /**
2
+ * Pure geometry, projection and colour helpers. No DOM, no canvas: everything
3
+ * here is testable in isolation and safe to run in Node.
4
+ */
5
+
6
+ export const D2R = Math.PI / 180;
7
+ export const R2D = 180 / Math.PI;
8
+ export const TAU = Math.PI * 2;
9
+
10
+ export const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
11
+
12
+ /** Wraps a longitude delta into -180…180. */
13
+ export const wrapLon = (d) => {
14
+ let x = d;
15
+ while (x > 180) x -= 360;
16
+ while (x < -180) x += 360;
17
+ return x;
18
+ };
19
+
20
+ /* -------------------------------------------------------------------------- */
21
+ /* map projections */
22
+ /* -------------------------------------------------------------------------- */
23
+
24
+ const MERCATOR_LIMIT = 85.0511287798;
25
+
26
+ /**
27
+ * Each projection maps lon/lat to abstract planar units where +y points south,
28
+ * so a bounding box can be normalised to the canvas without special-casing.
29
+ */
30
+ export const projections = {
31
+ equirectangular: {
32
+ forward: (lon, lat) => [lon, -lat],
33
+ inverse: (x, y) => [x, -y],
34
+ },
35
+ mercator: {
36
+ forward: (lon, lat) => {
37
+ const p = clamp(lat, -MERCATOR_LIMIT, MERCATOR_LIMIT) * D2R;
38
+ return [lon, -R2D * Math.log(Math.tan(Math.PI / 4 + p / 2))];
39
+ },
40
+ inverse: (x, y) => [x, R2D * (2 * Math.atan(Math.exp(-y * D2R)) - Math.PI / 2)],
41
+ },
42
+ naturalEarth: {
43
+ forward: (lon, lat) => {
44
+ const p = lat * D2R, l = lon * D2R, p2 = p * p, p4 = p2 * p2;
45
+ const x = l * (0.8707 - 0.131979 * p2 + p4 * (-0.013791 + p4 * (0.003971 * p2 - 0.001529 * p4)));
46
+ const y = p * (1.007226 + p2 * (0.015085 + p4 * (-0.044475 + 0.028874 * p2 - 0.005916 * p4)));
47
+ return [x * R2D, -y * R2D];
48
+ },
49
+ inverse: (x, y) => {
50
+ let p = -y * D2R;
51
+ for (let i = 0; i < 24; i++) {
52
+ const p2 = p * p, p4 = p2 * p2;
53
+ const f = p * (1.007226 + p2 * (0.015085 + p4 * (-0.044475 + 0.028874 * p2 - 0.005916 * p4))) + y * D2R;
54
+ const d = f / (1.007226 + p2 * (0.045255 + p4 * (-0.311325 + 0.259866 * p2 - 0.065076 * p4)));
55
+ p -= d;
56
+ if (Math.abs(d) < 1e-12) break;
57
+ }
58
+ const p2 = p * p, p4 = p2 * p2;
59
+ const k = 0.8707 - 0.131979 * p2 + p4 * (-0.013791 + p4 * (0.003971 * p2 - 0.001529 * p4));
60
+ return [(x * D2R) / k * R2D, p * R2D];
61
+ },
62
+ },
63
+ };
64
+
65
+ export const resolveProjection = (name) => projections[name] || projections.equirectangular;
66
+
67
+ /** Planar bounds of a projection for a `[north, south]` latitude window. */
68
+ export const projectionBounds = (name, latRange) => {
69
+ const p = resolveProjection(name);
70
+ const [north, south] = latRange;
71
+ const x0 = p.forward(-180, 0)[0], x1 = p.forward(180, 0)[0];
72
+ const y0 = p.forward(0, north)[1], y1 = p.forward(0, south)[1];
73
+ return { x0, x1, y0, y1, dx: x1 - x0, dy: y1 - y0 };
74
+ };
75
+
76
+ /** Height / width ratio a flat map should use for a latitude range. */
77
+ export const mapAspect = (latRange = [83, -56], projection = "equirectangular") => {
78
+ const b = projectionBounds(projection, latRange);
79
+ return b.dy / b.dx;
80
+ };
81
+
82
+ /* -------------------------------------------------------------------------- */
83
+ /* orthographic (globe) */
84
+ /* -------------------------------------------------------------------------- */
85
+
86
+ /**
87
+ * Orthographic forward projection. Returns `[x, y, cos]` in screen units with
88
+ * y pointing down; `cos < 0` means the point sits behind the globe.
89
+ */
90
+ export const ortho = (lon, lat, lon0, lat0, r) => {
91
+ const l = (lon - lon0) * D2R, f = lat * D2R, f0 = lat0 * D2R;
92
+ const sf = Math.sin(f), cf = Math.cos(f), sf0 = Math.sin(f0), cf0 = Math.cos(f0), cl = Math.cos(l);
93
+ return [r * cf * Math.sin(l), -r * (cf0 * sf - sf0 * cf * cl), sf0 * sf + cf0 * cf * cl];
94
+ };
95
+
96
+ /** Inverse orthographic. Returns `[lon, lat]`, or null outside the disc. */
97
+ export const orthoInverse = (x, y, lon0, lat0, r) => {
98
+ const rho = Math.hypot(x, y);
99
+ if (rho > r) return null;
100
+ const c = Math.asin(clamp(rho / r, -1, 1));
101
+ const sc = Math.sin(c), cc = Math.cos(c);
102
+ const f0 = lat0 * D2R, ym = -y;
103
+ if (rho < 1e-9) return [lon0, lat0];
104
+ const lat = Math.asin(clamp(cc * Math.sin(f0) + (ym * sc * Math.cos(f0)) / rho, -1, 1)) * R2D;
105
+ const lon = lon0 + Math.atan2(x * sc, rho * cc * Math.cos(f0) - ym * sc * Math.sin(f0)) * R2D;
106
+ return [wrapLon(lon), lat];
107
+ };
108
+
109
+ /* -------------------------------------------------------------------------- */
110
+ /* great circles */
111
+ /* -------------------------------------------------------------------------- */
112
+
113
+ const toVec = (lon, lat) => {
114
+ const l = lon * D2R, f = lat * D2R, cf = Math.cos(f);
115
+ return [cf * Math.cos(l), cf * Math.sin(l), Math.sin(f)];
116
+ };
117
+
118
+ /** Angular distance between two coordinates, in degrees. */
119
+ export const angularDistance = (lon1, lat1, lon2, lat2) => {
120
+ const a = toVec(lon1, lat1), b = toVec(lon2, lat2);
121
+ return Math.acos(clamp(a[0] * b[0] + a[1] * b[1] + a[2] * b[2], -1, 1)) * R2D;
122
+ };
123
+
124
+ /** Samples the shorter great-circle path between two coordinates. */
125
+ export const greatCircle = (lon1, lat1, lon2, lat2, steps = 64) => { const a = toVec(lon1, lat1), b = toVec(lon2, lat2);
126
+ const dot = clamp(a[0] * b[0] + a[1] * b[1] + a[2] * b[2], -1, 1);
127
+ const omega = Math.acos(dot);
128
+ const out = [];
129
+ if (omega < 1e-6) return [[lon1, lat1], [lon2, lat2]];
130
+ const so = Math.sin(omega);
131
+ for (let i = 0; i <= steps; i++) {
132
+ const t = i / steps;
133
+ const k1 = Math.sin((1 - t) * omega) / so, k2 = Math.sin(t * omega) / so;
134
+ const x = a[0] * k1 + b[0] * k2, y = a[1] * k1 + b[1] * k2, z = a[2] * k1 + b[2] * k2;
135
+ const h = Math.hypot(x, y);
136
+ out.push([Math.atan2(y, x) * R2D, Math.atan2(z, h) * R2D]);
137
+ }
138
+ return out;
139
+ };
140
+
141
+ /** Metres between two coordinates, on a spherical earth. */
142
+ export const distanceMeters = (lon1, lat1, lon2, lat2) => angularDistance(lon1, lat1, lon2, lat2) * D2R * 6371008.8;
143
+
144
+ /** Ring of points a fixed distance from a centre: a circle on the sphere. */
145
+ export const circleAround = (lon, lat, meters, steps = 72) => {
146
+ const theta = clamp(meters / 6371008.8, 0, Math.PI * 0.85);
147
+ const f1 = lat * D2R, l1 = lon * D2R;
148
+ const sf1 = Math.sin(f1), cf1 = Math.cos(f1), st = Math.sin(theta), ct = Math.cos(theta);
149
+ const out = [];
150
+ for (let i = 0; i <= steps; i++) {
151
+ const b = (i / steps) * TAU;
152
+ const f2 = Math.asin(clamp(sf1 * ct + cf1 * st * Math.cos(b), -1, 1));
153
+ const l2 = l1 + Math.atan2(Math.sin(b) * st * cf1, ct - sf1 * Math.sin(f2));
154
+ out.push([wrapLon(l2 * R2D), f2 * R2D]);
155
+ }
156
+ return out;
157
+ };
158
+
159
+ /* -------------------------------------------------------------------------- */
160
+ /* sun position */
161
+ /* -------------------------------------------------------------------------- */
162
+
163
+ /** Subsolar point (the coordinate where the sun is directly overhead). */
164
+ export const subsolarPoint = (when = Date.now()) => {
165
+ const ms = when instanceof Date ? when.getTime() : Number(when);
166
+ const n = ms / 86400000 + 2440587.5 - 2451545.0;
167
+ const meanLon = (280.46 + 0.9856474 * n) * D2R;
168
+ const meanAnom = (357.528 + 0.9856003 * n) * D2R;
169
+ const ecl = meanLon + (1.915 * Math.sin(meanAnom) + 0.02 * Math.sin(2 * meanAnom)) * D2R;
170
+ const obl = (23.439 - 0.0000004 * n) * D2R;
171
+ const lat = Math.asin(Math.sin(obl) * Math.sin(ecl)) * R2D;
172
+ const ra = Math.atan2(Math.cos(obl) * Math.sin(ecl), Math.cos(ecl)) * R2D;
173
+ const gmst = ((18.697374558 + 24.06570982441908 * n) % 24 + 24) % 24;
174
+ return { lon: wrapLon(ra - gmst * 15), lat };
175
+ };
176
+
177
+ /* -------------------------------------------------------------------------- */
178
+ /* polygon utilities */
179
+ /* -------------------------------------------------------------------------- */
180
+
181
+ const ringContains = (ring, lon, lat) => {
182
+ let inside = false;
183
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
184
+ const xi = ring[i][0], yi = ring[i][1], xj = ring[j][0], yj = ring[j][1];
185
+ if (yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) inside = !inside;
186
+ }
187
+ return inside;
188
+ };
189
+
190
+ /** Ray-casting hit test against a GeoJSON Polygon or MultiPolygon. */
191
+ export const pointInGeometry = (geom, lon, lat) => {
192
+ if (!geom) return false;
193
+ const polys = geom.type === "Polygon" ? [geom.coordinates] : geom.coordinates;
194
+ for (const poly of polys) {
195
+ if (!poly.length || !ringContains(poly[0], lon, lat)) continue;
196
+ let hole = false;
197
+ for (let i = 1; i < poly.length; i++) if (ringContains(poly[i], lon, lat)) { hole = true; break; }
198
+ if (!hole) return true;
199
+ }
200
+ return false;
201
+ };
202
+
203
+ /** `[west, south, east, north]` bounds of a Polygon or MultiPolygon. */
204
+ export const geometryBounds = (geom) => {
205
+ let w = 180, s = 90, e = -180, n = -90;
206
+ const polys = geom.type === "Polygon" ? [geom.coordinates] : geom.coordinates;
207
+ for (const poly of polys) {
208
+ for (const ring of poly) {
209
+ for (const c of ring) {
210
+ if (c[0] < w) w = c[0];
211
+ if (c[0] > e) e = c[0];
212
+ if (c[1] < s) s = c[1];
213
+ if (c[1] > n) n = c[1];
214
+ }
215
+ }
216
+ }
217
+ return [w, s, e, n];
218
+ };
219
+
220
+ /* -------------------------------------------------------------------------- */
221
+ /* shapes & colours */
222
+ /* -------------------------------------------------------------------------- */
223
+
224
+ /** Accepts GeoJSON of any flavour or the bundled `{ id, name, geometry }[]`. */
225
+ export const normalizeShapes = (input) => {
226
+ if (!input) return null;
227
+ if (Array.isArray(input)) return input;
228
+ if (input.type === "FeatureCollection") {
229
+ return input.features.map((f) => ({
230
+ id: f.id,
231
+ name: f.properties?.name ?? f.properties?.NAME ?? f.properties?.ADMIN,
232
+ iso: f.properties?.iso ?? f.properties?.ISO_A2 ?? f.properties?.iso_a2,
233
+ geometry: f.geometry,
234
+ }));
235
+ }
236
+ if (input.type === "Feature") return [{ id: input.id, name: input.properties?.name, geometry: input.geometry }];
237
+ if (input.type) return [{ geometry: input }];
238
+ return null;
239
+ };
240
+
241
+ /** Converts `#rgb`/`#rrggbb` to `rgba()`; other formats pass through. */
242
+ export const withAlpha = (color, a) => {
243
+ if (typeof color !== "string") return color;
244
+ if (color.startsWith("#") && (color.length === 7 || color.length === 4)) {
245
+ const hex = color.length === 4 ? color.replace(/#(.)(.)(.)/, "#$1$1$2$2$3$3") : color;
246
+ const n = parseInt(hex.slice(1), 16);
247
+ return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
248
+ }
249
+ const m = color.match(/^rgba?\(([^)]+)\)$/i);
250
+ if (m) {
251
+ const p = m[1].split(",").map((s) => s.trim());
252
+ return `rgba(${p[0]},${p[1]},${p[2]},${a})`;
253
+ }
254
+ return color;
255
+ };
256
+
257
+ const parseRGB = (color) => {
258
+ if (color.startsWith("#")) {
259
+ const hex = color.length === 4 ? color.replace(/#(.)(.)(.)/, "#$1$1$2$2$3$3") : color;
260
+ const n = parseInt(hex.slice(1), 16);
261
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
262
+ }
263
+ const m = color.match(/^rgba?\(([^)]+)\)$/i);
264
+ if (m) return m[1].split(",").slice(0, 3).map((s) => parseFloat(s));
265
+ return [128, 128, 128];
266
+ };
267
+
268
+ /**
269
+ * Builds a linear colour ramp: `colorScale([0, 100], ["#eef", "#22c"])`.
270
+ * Domains longer than two entries create multi-stop scales.
271
+ */
272
+ export const colorScale = (domain = [0, 1], range = ["#e0f2fe", "#0369a1"]) => {
273
+ const stops = range.map(parseRGB);
274
+ return (value) => {
275
+ const v = Number(value);
276
+ if (!Number.isFinite(v)) return null;
277
+ if (v <= domain[0]) return `rgb(${stops[0].join(",")})`;
278
+ const last = domain.length - 1;
279
+ if (v >= domain[last]) return `rgb(${stops[stops.length - 1].join(",")})`;
280
+ let i = 0;
281
+ while (i < last - 1 && v > domain[i + 1]) i++;
282
+ const t = (v - domain[i]) / (domain[i + 1] - domain[i] || 1);
283
+ const a = stops[Math.min(i, stops.length - 1)], b = stops[Math.min(i + 1, stops.length - 1)];
284
+ return `rgb(${Math.round(a[0] + (b[0] - a[0]) * t)},${Math.round(a[1] + (b[1] - a[1]) * t)},${Math.round(a[2] + (b[2] - a[2]) * t)})`;
285
+ };
286
+ };
package/src/index.js CHANGED
@@ -5,15 +5,15 @@ export {
5
5
  createGlobe as createCanvasGlobe,
6
6
  world,
7
7
  } from "./geo-globe.js";
8
- export { themes, countryPalette } from "./themes.js";
9
- export { presets } from "./presets.js";
10
- export { scenes } from "./scenes.js";
11
- export { exportPresets, exportSize } from "./export.js";
12
- export { fromCSV, fromRows, parseCSV, geocode, countryPoint } from "./csv.js";
13
- export { locateViewer, locateViewerPrecise, timeZoneLocation, countryLocation, placeLocation } from "./viewer.js";
14
- export { recordCanvas, downloadBlob, canRecord, supportedRecordingType } from "./recorder.js";
15
- export { SphereTexture } from "./texture.js";
8
+ export { themes, countryPalette } from "./themes.js";
9
+ export { presets } from "./presets.js";
10
+ export { scenes } from "./scenes.js";
11
+ export { exportPresets, exportSize } from "./export.js";
12
+ export { fromCSV, fromRows, parseCSV, geocode, countryPoint } from "./csv.js";
13
+ export { locateViewer, locateViewerPrecise, timeZoneLocation, countryLocation, placeLocation } from "./viewer.js";
14
+ export { recordCanvas, downloadBlob, canRecord, supportedRecordingType } from "./recorder.js";
15
+ export { SphereTexture } from "./texture.js";
16
16
  export { Media, drawFitted } from "./media.js";
17
17
  export { DEFAULT_LICENSE_KEY, inspectLicenseKey, hasLicenseKey } from "./license.js";
18
- export { mapAspect, colorScale, subsolarPoint, greatCircle, angularDistance, pointInGeometry, geometryBounds, projections } from "./geo.js";
19
- export { default } from "./geo-globe.js";
18
+ export { mapAspect, colorScale, subsolarPoint, greatCircle, angularDistance, pointInGeometry, geometryBounds, projections } from "./geo.js";
19
+ export { default } from "./geo-globe.js";