canvas-globe 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/geo.js ADDED
@@ -0,0 +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
+ };
package/src/index.js ADDED
@@ -0,0 +1,19 @@
1
+ export {
2
+ GeoGlobe,
3
+ GeoGlobe as CanvasGlobe,
4
+ createGlobe,
5
+ createGlobe as createCanvasGlobe,
6
+ world,
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";
16
+ export { Media, drawFitted } from "./media.js";
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";
package/src/license.js ADDED
@@ -0,0 +1,36 @@
1
+ /** License-key configuration helpers. */
2
+ export const DEFAULT_LICENSE_KEY = "0000-0000-000-0000";
3
+
4
+ /** Returns the configured license-key status. */
5
+ export function inspectLicenseKey(value) {
6
+ const key = typeof value === "string" ? value : "";
7
+ if (!key) return { valid: false, kind: "missing", key: "" };
8
+ if (key === DEFAULT_LICENSE_KEY) {
9
+ return { valid: false, kind: "placeholder", key };
10
+ }
11
+ return { valid: true, kind: "provided", key };
12
+ }
13
+
14
+ /** Returns whether a configured license key is available. */
15
+ export function hasLicenseKey(value) {
16
+ return inspectLicenseKey(value).valid;
17
+ }
18
+
19
+ /** Reports missing or placeholder keys in the browser console. */
20
+ export function reportLicenseStatus(value) {
21
+ const status = inspectLicenseKey(value);
22
+ // CanvasGlobe can be constructed in non-browser test and rendering
23
+ // environments. Console messaging applies when it is used in a browser.
24
+ if (typeof location === "undefined") return status;
25
+ if (status.kind === "missing") {
26
+ console.error(
27
+ "canvas-globe: please provide a valid license key. For help, email globe@swiftools.com",
28
+ );
29
+ } else if (status.kind === "placeholder") {
30
+ console.warn(
31
+ `canvas-globe: ${DEFAULT_LICENSE_KEY} license key is not valid for production use. ` +
32
+ "For help, email globe@swiftools.com",
33
+ );
34
+ }
35
+ return status;
36
+ }
package/src/media.js ADDED
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Media sources that can be painted inside a country's outline: a still, an
3
+ * animated GIF, a video, another canvas, or a live MediaStream.
4
+ *
5
+ * Everything is drawn with `drawImage`, so the only real work is keeping a
6
+ * drawable element alive and knowing whether it needs a redraw each frame.
7
+ */
8
+
9
+ const VIDEO_RE = /\.(mp4|webm|ogv|mov|m4v)(\?|#|$)/i;
10
+ const GIF_RE = /\.gif(\?|#|$)/i;
11
+
12
+ const isDrawable = (v) =>
13
+ !!v && typeof v === "object" &&
14
+ (v.nodeName === "IMG" || v.nodeName === "VIDEO" || v.nodeName === "CANVAS" ||
15
+ (typeof ImageBitmap !== "undefined" && v instanceof ImageBitmap) ||
16
+ (typeof OffscreenCanvas !== "undefined" && v instanceof OffscreenCanvas));
17
+
18
+ const isStream = (v) => typeof MediaStream !== "undefined" && v instanceof MediaStream;
19
+
20
+ /** Keeps GIFs advancing: browsers only animate images attached to the document. */
21
+ const parkOffscreen = (el) => {
22
+ if (typeof document === "undefined") return;
23
+ el.style.cssText = "position:fixed;right:0;bottom:0;width:1px;height:1px;opacity:.01;pointer-events:none;z-index:-1";
24
+ document.body.appendChild(el);
25
+ };
26
+
27
+ export class Media {
28
+ constructor(spec, onReady) {
29
+ const config = spec && typeof spec === "object" && "src" in spec && !isDrawable(spec) ? spec : { src: spec };
30
+ this.fit = config.fit || "cover";
31
+ this.opacity = config.opacity ?? 1;
32
+ this.blend = config.blend || null;
33
+ this.scale = config.scale ?? 1;
34
+ this.offset = config.offset || [0, 0];
35
+ this.ready = false;
36
+ this.error = null;
37
+ this._owned = false;
38
+ this._onReady = onReady;
39
+ this._load(config);
40
+ }
41
+
42
+ _load(config) {
43
+ const src = config.src;
44
+ if (isDrawable(src)) {
45
+ this.element = src;
46
+ this.ready = true;
47
+ this._kind = src.nodeName === "VIDEO" ? "video" : "static";
48
+ return;
49
+ }
50
+ if (isStream(src)) {
51
+ this.element = this._video(config);
52
+ if (this.element) this.element.srcObject = src;
53
+ this._kind = "video";
54
+ return;
55
+ }
56
+ if (typeof src !== "string") {
57
+ this.error = new Error("canvas-globe: unsupported media source");
58
+ return;
59
+ }
60
+ if (config.type === "video" || (config.type !== "image" && VIDEO_RE.test(src))) {
61
+ this.element = this._video(config);
62
+ if (this.element) this.element.src = src;
63
+ this._kind = "video";
64
+ return;
65
+ }
66
+ if (typeof Image === "undefined") return;
67
+ const img = new Image();
68
+ img.crossOrigin = config.crossOrigin ?? "anonymous";
69
+ img.onload = () => {
70
+ this.ready = true;
71
+ this._onReady?.(this);
72
+ };
73
+ img.onerror = () => {
74
+ this.error = new Error(`canvas-globe: could not load "${src}"`);
75
+ this._onReady?.(this);
76
+ };
77
+ img.src = src;
78
+ this.element = img;
79
+ this._owned = true;
80
+ this._kind = GIF_RE.test(src) ? "gif" : "static";
81
+ if (this._kind === "gif") parkOffscreen(img);
82
+ }
83
+
84
+ _video(config) {
85
+ if (typeof document === "undefined") {
86
+ this.error = new Error("canvas-globe: video media needs a DOM");
87
+ return null;
88
+ }
89
+ const el = document.createElement("video");
90
+ el.muted = config.muted !== false;
91
+ el.loop = config.loop !== false;
92
+ el.autoplay = true;
93
+ el.playsInline = true;
94
+ el.crossOrigin = config.crossOrigin ?? "anonymous";
95
+ el.oncanplay = () => {
96
+ this.ready = true;
97
+ this._onReady?.(this);
98
+ };
99
+ el.onerror = () => {
100
+ this.error = new Error("canvas-globe: video could not be played");
101
+ this._onReady?.(this);
102
+ };
103
+ this._owned = true;
104
+ parkOffscreen(el);
105
+ el.play?.().catch(() => {});
106
+ return el;
107
+ }
108
+
109
+ /** Natural pixel size of the current frame. */
110
+ size() {
111
+ const el = this.element;
112
+ if (!el) return null;
113
+ const w = el.videoWidth || el.naturalWidth || el.width;
114
+ const h = el.videoHeight || el.naturalHeight || el.height;
115
+ return w && h ? [w, h] : null;
116
+ }
117
+
118
+ /** True when the source changes on its own and needs a redraw every frame. */
119
+ get animated() {
120
+ if (!this.element) return false;
121
+ if (this._kind === "gif") return true;
122
+ return this._kind === "video" && !this.element.paused && !this.element.ended;
123
+ }
124
+
125
+ destroy() {
126
+ if (!this._owned || !this.element) return;
127
+ if (this._kind === "video") {
128
+ this.element.pause?.();
129
+ this.element.srcObject = null;
130
+ this.element.removeAttribute("src");
131
+ }
132
+ this.element.remove?.();
133
+ this.element = null;
134
+ this.ready = false;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Draws media into a screen rect the way CSS `object-fit` would.
140
+ * The caller is expected to have clipped to the target shape already.
141
+ */
142
+ export function drawFitted(ctx, media, box) {
143
+ const size = media.size();
144
+ if (!size) return false;
145
+ const [mw, mh] = size;
146
+ const [x, y, w, h] = box;
147
+ if (w <= 0 || h <= 0) return false;
148
+ let scale;
149
+ if (media.fit === "contain") scale = Math.min(w / mw, h / mh);
150
+ else if (media.fit === "fill") scale = null;
151
+ else scale = Math.max(w / mw, h / mh);
152
+ const k = media.scale || 1;
153
+ if (scale === null) {
154
+ ctx.drawImage(media.element, x, y, w, h);
155
+ return true;
156
+ }
157
+ const dw = mw * scale * k, dh = mh * scale * k;
158
+ ctx.drawImage(media.element, x + (w - dw) / 2 + media.offset[0], y + (h - dh) / 2 + media.offset[1], dw, dh);
159
+ return true;
160
+ }
package/src/presets.js ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Named bundles of theme + render style. `createGlobe(canvas, { preset })`
3
+ * applies one, and your own options still win over anything it sets.
4
+ */
5
+ export const presets = {
6
+ atlas: { theme: "atlas", landStyle: "fill", graticule: true, stars: true, shade: true, orbits: 0 },
7
+ midnight: { theme: "midnight", landStyle: "fill", graticule: true, stars: true, shade: true, orbits: 0 },
8
+ mono: { theme: "mono", landStyle: "fill", graticule: true, stars: false, shade: false, orbits: 0 },
9
+ political: { theme: "political", landStyle: "fill", countryColors: "auto", graticule: true, stars: false, shade: true, orbits: 0 },
10
+ hologram: { theme: "hologram", landStyle: "dots", dotSpacing: 2, dotSize: 1.15, graticule: true, stars: true, shade: false, orbits: 0 },
11
+ neon: { theme: "neon", landStyle: "glow", graticule: true, stars: true, shade: false, orbits: 0 },
12
+ blueprint: { theme: "blueprint", landStyle: "outline", graticule: true, stars: false, shade: false, orbits: 3 },
13
+ aurora: { theme: "aurora", landStyle: "dots", dotSpacing: 2.6, dotSize: 1.4, graticule: false, stars: true, shade: false, orbits: 2 },
14
+ noir: { theme: "noir", landStyle: "outline", graticule: false, stars: false, shade: false, orbits: 0 },
15
+ constellation: { theme: "hologram", landStyle: "dots", dotSpacing: 3, dotSize: 1.6, graticule: true, stars: true, shade: false, orbits: 3 },
16
+ };
17
+
18
+ /** Option keys a preset owns; anything unset falls back to the default. */
19
+ export const presetKeys = [
20
+ "theme", "landStyle", "dotSpacing", "dotSize", "graticule", "stars", "shade", "orbits", "countryColors",
21
+ ];
package/src/react.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * React binding. `import { Globe } from "canvas-globe/react"`.
3
+ * React is a peer dependency and is only required by this entry point.
4
+ */
5
+ import { createElement, forwardRef, useEffect, useImperativeHandle, useRef } from "react";
6
+ import { GeoGlobe } from "./geo-globe.js";
7
+ import { mapAspect } from "./geo.js";
8
+
9
+ const CALLBACKS = ["onHover", "onClick", "onCountryHover", "onCountryClick", "onRender"];
10
+
11
+ export const Globe = forwardRef(function Globe(props, ref) {
12
+ const { className, style, ...rest } = props;
13
+ const options = {};
14
+ for (const key of Object.keys(rest)) if (!CALLBACKS.includes(key)) options[key] = rest[key];
15
+
16
+ const canvasRef = useRef(null);
17
+ const globeRef = useRef(null);
18
+ const handlers = useRef(props);
19
+ const previous = useRef(null);
20
+ const initial = useRef(options);
21
+ handlers.current = props;
22
+
23
+ useEffect(() => {
24
+ const bound = {};
25
+ for (const name of CALLBACKS) bound[name] = (...args) => handlers.current[name]?.(...args);
26
+ const globe = new GeoGlobe(canvasRef.current, { ...initial.current, ...bound });
27
+ globeRef.current = globe;
28
+ previous.current = initial.current;
29
+ return () => {
30
+ globe.destroy();
31
+ globeRef.current = null;
32
+ };
33
+ }, []);
34
+
35
+ // Shallow diff every render so callers can pass inline objects freely.
36
+ useEffect(() => {
37
+ const globe = globeRef.current;
38
+ if (!globe) return;
39
+ const patch = {};
40
+ let changed = false;
41
+ for (const key of Object.keys(options)) {
42
+ if (previous.current && previous.current[key] === options[key]) continue;
43
+ patch[key] = options[key];
44
+ changed = true;
45
+ }
46
+ previous.current = options;
47
+ if (changed) globe.setOptions(patch);
48
+ });
49
+
50
+ useImperativeHandle(ref, () => globeRef.current);
51
+
52
+ const aspect = options.mode === "map" ? 1 / mapAspect(options.latRange, options.projection) : 1;
53
+ return createElement("canvas", {
54
+ ref: canvasRef,
55
+ className,
56
+ style: { width: "100%", aspectRatio: String(aspect), ...style },
57
+ });
58
+ });
59
+
60
+ export default Globe;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Canvas → WebM recording via MediaRecorder. No encoder dependency, no upload,
3
+ * no server: the clip is produced entirely in the tab.
4
+ */
5
+
6
+ const CANDIDATES = [
7
+ "video/webm;codecs=vp9",
8
+ "video/webm;codecs=vp8",
9
+ "video/webm",
10
+ "video/mp4",
11
+ ];
12
+
13
+ /** First container the browser can actually encode, or null. */
14
+ export function supportedRecordingType() {
15
+ if (typeof MediaRecorder === "undefined") return null;
16
+ for (const type of CANDIDATES) if (MediaRecorder.isTypeSupported?.(type)) return type;
17
+ return null;
18
+ }
19
+
20
+ export const canRecord = () => supportedRecordingType() !== null;
21
+
22
+ /**
23
+ * Records the canvas and resolves to a Blob. Pass `duration` for a fixed-length
24
+ * clip, or call `stop()` on the returned handle.
25
+ */
26
+ export function recordCanvas(canvas, { duration = 6000, fps = 30, bitrate = 6e6, type } = {}) {
27
+ const mimeType = type || supportedRecordingType();
28
+ if (!mimeType) return { promise: Promise.reject(new Error("canvas-globe: MediaRecorder is unavailable")), stop() {} };
29
+ if (!canvas.captureStream) {
30
+ return { promise: Promise.reject(new Error("canvas-globe: canvas.captureStream is unavailable")), stop() {} };
31
+ }
32
+
33
+ const stream = canvas.captureStream(fps);
34
+ const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: bitrate });
35
+ const chunks = [];
36
+ let timer = null;
37
+
38
+ const promise = new Promise((resolve, reject) => {
39
+ recorder.ondataavailable = (e) => {
40
+ if (e.data && e.data.size) chunks.push(e.data);
41
+ };
42
+ recorder.onerror = (e) => reject(e.error || new Error("canvas-globe: recording failed"));
43
+ recorder.onstop = () => {
44
+ clearTimeout(timer);
45
+ for (const track of stream.getTracks()) track.stop();
46
+ resolve(new Blob(chunks, { type: mimeType }));
47
+ };
48
+ recorder.start();
49
+ if (duration > 0) timer = setTimeout(() => recorder.state !== "inactive" && recorder.stop(), duration);
50
+ });
51
+
52
+ return {
53
+ promise,
54
+ mimeType,
55
+ stop() {
56
+ if (recorder.state !== "inactive") recorder.stop();
57
+ return promise;
58
+ },
59
+ };
60
+ }
61
+
62
+ /** Triggers a browser download for a Blob. */
63
+ export function downloadBlob(blob, filename) {
64
+ const url = URL.createObjectURL(blob);
65
+ const a = document.createElement("a");
66
+ a.href = url;
67
+ a.download = filename;
68
+ a.click();
69
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
70
+ }