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.
@@ -0,0 +1,2856 @@
1
+ /**
2
+ * canvas-globe: interactive globe & world map on a 2D canvas.
3
+ * No dependencies, no WebGL, no network calls, no API keys.
4
+ */
5
+ import { world as bundledWorld } from "./data/world.js";
6
+ import { themes, countryPalette } from "./themes.js";
7
+ import { presets, presetKeys } from "./presets.js";
8
+ import { locateViewer, locateViewerPrecise } from "./viewer.js";
9
+ import { recordCanvas, downloadBlob, canRecord } from "./recorder.js";
10
+ import { SphereTexture } from "./texture.js";
11
+ import { Media, drawFitted } from "./media.js";
12
+ import { scenes, sceneKeys } from "./scenes.js";
13
+ import { exportSize } from "./export.js";
14
+ import { DEFAULT_LICENSE_KEY, reportLicenseStatus } from "./license.js";
15
+ import { D2R, R2D, TAU, clamp, wrapLon, resolveProjection, projectionBounds, ortho, orthoInverse, greatCircle, circleAround, distanceMeters, subsolarPoint, pointInGeometry, geometryBounds, normalizeShapes, withAlpha } from "./geo.js";
16
+
17
+ const DEFAULTS = {
18
+ licenseKey: DEFAULT_LICENSE_KEY,
19
+ mode: "globe",
20
+ projection: "equirectangular",
21
+ theme: "atlas",
22
+ preset: null,
23
+ scene: null,
24
+ landStyle: "fill",
25
+ dotSpacing: 2,
26
+ dotSize: 1.15,
27
+ orbits: 0,
28
+ countryPalette: null,
29
+ texture: null,
30
+ textureQuality: "auto",
31
+ focus: null,
32
+ countryMedia: null,
33
+ annotations: null,
34
+ counter: null,
35
+ title: null,
36
+ watermark: null,
37
+ timeline: null,
38
+ transparentBackground: false,
39
+ heatmap: false,
40
+ spikes: false,
41
+ labels: false,
42
+ legend: null,
43
+ showViewer: false,
44
+ momentum: true,
45
+ markers: [],
46
+ arcs: [],
47
+ center: { lon: 10, lat: 20 },
48
+ zoom: 1,
49
+ minZoom: 1,
50
+ maxZoom: 8,
51
+ zoomable: true,
52
+ autoRotate: true,
53
+ rotateSpeed: 0.09,
54
+ interactive: true,
55
+ keyboard: true,
56
+ graticule: true,
57
+ stars: true,
58
+ shade: true,
59
+ terminator: false,
60
+ time: null,
61
+ markerStyle: "auto",
62
+ markerScale: 1,
63
+ renderMarker: null,
64
+ cluster: false,
65
+ clusterRadius: 42,
66
+ arcLift: 0.28,
67
+ arcSpeed: 1,
68
+ countryColors: null,
69
+ countryColor: null,
70
+ countryKey: null,
71
+ radiusRatio: 0.4,
72
+ latRange: [83, -56],
73
+ world: null,
74
+ fps: 30,
75
+ tooltip: false,
76
+ respectReducedMotion: true,
77
+ ariaLabel: "Interactive world map",
78
+ onHover: null,
79
+ onClick: null,
80
+ onCountryHover: null,
81
+ onCountryClick: null,
82
+ onRender: null,
83
+ };
84
+
85
+ const coord = (v) => (Array.isArray(v) ? [v[0], v[1]] : [v.lon ?? v.lng ?? v.longitude, v.lat ?? v.latitude]);
86
+
87
+ const defaultTooltip = (target, kind) => {
88
+ if (kind === "country") return target.name || String(target.id ?? "");
89
+ if (kind === "cluster") return `${target.count} in this area`;
90
+ const name = target.city || target.name || target.label;
91
+ const count = target.count != null ? `: ${target.count}` : "";
92
+ return name ? `${name}${count}` : `${target.lat.toFixed(2)}, ${target.lon.toFixed(2)}${count}`;
93
+ };
94
+
95
+ export class GeoGlobe {
96
+ constructor(canvas, options = {}) {
97
+ if (!canvas || !canvas.getContext) throw new TypeError("canvas-globe: first argument must be a <canvas> element");
98
+ this.canvas = canvas;
99
+ this.ctx = canvas.getContext("2d");
100
+ // Scene first, then its preset, then anything the caller passed.
101
+ const scene = scenes[options.scene] || null;
102
+ const presetName = options.preset || scene?.preset;
103
+ this.o = { ...DEFAULTS, ...(presets[presetName] || null), ...scene, ...options };
104
+ this.o.center = { ...DEFAULTS.center, ...(options.center || {}) };
105
+ reportLicenseStatus(this.o.licenseKey);
106
+
107
+ this.lon = this.o.center.lon;
108
+ this.lat = this.o.center.lat;
109
+ this._zoom = clamp(this.o.zoom, this.o.minZoom, this.o.maxZoom);
110
+ this.hits = [];
111
+ this._panX = 0;
112
+ this._panY = 0;
113
+ this._drag = null;
114
+ this._dragMoved = false;
115
+ this._target = null;
116
+ this._pointers = new Map();
117
+ this._pinch = null;
118
+ this._last = 0;
119
+ this._raf = null;
120
+ this._dpr = 1;
121
+ this._hovered = null;
122
+ this._hoveredCountry = null;
123
+ this._focus = -1;
124
+ this._pointer = null;
125
+ this._dirty = true;
126
+ this._destroyed = false;
127
+ this._arcPts = new WeakMap();
128
+ this._shapeBounds = new WeakMap();
129
+ this._pings = [];
130
+ this._vel = null;
131
+ this._tour = null;
132
+ this._story = null;
133
+ this._viewer = null;
134
+ this._texture = null;
135
+ this._media = new Map();
136
+ this._markerMedia = new Map();
137
+ this._counterShown = null;
138
+
139
+ this._applyWorld();
140
+ this._applyMarkers(this.o.markers);
141
+ this._applyTexture();
142
+ this._applyMedia();
143
+ this._watchMotion();
144
+ this._bind();
145
+ this._buildA11y();
146
+ this.resize();
147
+ if (this.o.focus) this.focusOn(this.o.focus, { instant: true });
148
+ if (this.o.showViewer) this._initViewer();
149
+ this._loop = this._loop.bind(this);
150
+ this._raf = requestAnimationFrame(this._loop);
151
+ }
152
+
153
+ /* ------------------------------ public API ------------------------------ */
154
+
155
+ setMarkers(markers = []) {
156
+ this._applyMarkers(markers);
157
+ this._focus = -1;
158
+ return this.render();
159
+ }
160
+
161
+ setArcs(arcs = []) {
162
+ this.o.arcs = arcs;
163
+ return this.invalidate();
164
+ }
165
+
166
+ setOptions(patch = {}) {
167
+ patch = this._expandLooks(patch);
168
+ Object.assign(this.o, patch);
169
+ if ("licenseKey" in patch) reportLicenseStatus(patch.licenseKey);
170
+ if ("world" in patch) this._applyWorld();
171
+ if ("markers" in patch) this._applyMarkers(patch.markers || []);
172
+ if ("zoom" in patch) this._zoom = clamp(patch.zoom, this.o.minZoom, this.o.maxZoom);
173
+ // An explicit ceiling takes over from the one focus raised.
174
+ if ("maxZoom" in patch) this._maxZoomBase = null;
175
+ if (patch.center) {
176
+ this.lon = patch.center.lon ?? this.lon;
177
+ this.lat = patch.center.lat ?? this.lat;
178
+ }
179
+ if ("ariaLabel" in patch) this.canvas.setAttribute("aria-label", this.o.ariaLabel);
180
+ if ("projection" in patch || "latRange" in patch) this._bbox = null;
181
+ if ("texture" in patch) this._applyTexture();
182
+ if ("countryMedia" in patch) this._applyMedia();
183
+ if ("theme" in patch) this._cssCache = null;
184
+ if ("focus" in patch) this._resolveFocus();
185
+ if ("showViewer" in patch) {
186
+ this._viewer = null;
187
+ if (patch.showViewer) this._initViewer();
188
+ }
189
+ this._cursor();
190
+ return this.resize();
191
+ }
192
+
193
+ /**
194
+ * A bare `preset` or `scene` in a patch is meaningless on its own: it has to
195
+ * expand into the keys it owns, or `setOptions({ preset })` would rename the
196
+ * look without changing it. Explicit keys in the patch always win.
197
+ */
198
+ _expandLooks(patch) {
199
+ const under = [];
200
+ const scene = "scene" in patch ? scenes[patch.scene] : null;
201
+ if (scene) {
202
+ const base = {};
203
+ for (const key of sceneKeys) base[key] = key in scene ? scene[key] : DEFAULTS[key];
204
+ // The scene's preset fills in every look key the scene does not name itself.
205
+ const scenePreset = presets[scene.preset];
206
+ if (scenePreset) {
207
+ for (const key of presetKeys) {
208
+ if (!(key in scene)) base[key] = key in scenePreset ? scenePreset[key] : DEFAULTS[key];
209
+ }
210
+ }
211
+ under.push(base);
212
+ }
213
+ const preset = "preset" in patch ? presets[patch.preset] : null;
214
+ if (preset) {
215
+ const base = {};
216
+ for (const key of presetKeys) base[key] = key in preset ? preset[key] : DEFAULTS[key];
217
+ under.push(base);
218
+ }
219
+ return under.length ? Object.assign({}, ...under, patch) : patch;
220
+ }
221
+
222
+ setMode(mode) {
223
+ return this.setOptions({ mode });
224
+ }
225
+
226
+ setTheme(theme) {
227
+ return this.setOptions({ theme });
228
+ }
229
+
230
+ setProjection(projection) {
231
+ return this.setOptions({ projection });
232
+ }
233
+
234
+ /** Applies a named look. Keys the preset omits return to their defaults. */
235
+ setPreset(name) {
236
+ if (!presets[name]) return this;
237
+ return this.setOptions({ preset: name });
238
+ }
239
+
240
+ setLandStyle(landStyle) {
241
+ return this.setOptions({ landStyle });
242
+ }
243
+
244
+ /** Equirectangular image painted onto the sphere. Pass null to remove it. */
245
+ setTexture(source) {
246
+ return this.setOptions({ texture: source });
247
+ }
248
+
249
+ /* ---------------------------- country focus ---------------------------- */
250
+
251
+ /**
252
+ * Frames a single country and, with `isolate`, drops the rest of the world
253
+ * away: the setup for a country-shaped hero graphic.
254
+ */
255
+ focusOn(country, opts = {}) {
256
+ const spec = typeof country === "string" || !country ? { country } : country;
257
+ this.o.focus = country ? { ...spec, ...opts } : null;
258
+ this._resolveFocus();
259
+ const shape = this._focusShape;
260
+ if (!shape) return this.invalidate();
261
+ const box = this._shapeBox(shape);
262
+ const padding = spec.padding ?? opts.padding ?? 0.82;
263
+ // Focusing is an explicit request, so let it past the normal zoom ceiling.
264
+ if (this._maxZoomBase == null) this._maxZoomBase = this.o.maxZoom;
265
+ this.o.maxZoom = Math.max(this._maxZoomBase, this._zoomToFit(box, padding));
266
+ return this.fitTo(box, { padding, ...opts });
267
+ }
268
+
269
+ /** Aspect ratio (height / width) that frames a country without letterboxing. */
270
+ countryAspect(country) {
271
+ const shape = this._countryShape(country);
272
+ if (!shape) return null;
273
+ const [west, south, east, north] = this._shapeBox(shape);
274
+ const p = resolveProjection(this.o.projection);
275
+ const a = p.forward(west, north), b = p.forward(east, south);
276
+ return Math.abs(b[1] - a[1]) / (Math.abs(b[0] - a[0]) || 1);
277
+ }
278
+
279
+ clearFocus() {
280
+ return this.setOptions({ focus: null });
281
+ }
282
+
283
+ /** Media painted inside a country's outline. Pass null to clear one. */
284
+ setCountryMedia(code, source) {
285
+ const next = { ...(this.o.countryMedia || {}) };
286
+ if (source == null) delete next[code];
287
+ else next[code] = source;
288
+ return this.setOptions({ countryMedia: next });
289
+ }
290
+
291
+ /* -------------------------------- scenes -------------------------------- */
292
+
293
+ /** Applies a whole composition. Keys the scene omits return to defaults. */
294
+ setScene(name, overrides = {}) {
295
+ if (!scenes[name]) return this;
296
+ return this.setOptions({ scene: name, ...overrides });
297
+ }
298
+
299
+ /* -------------------------------- export -------------------------------- */
300
+
301
+ /**
302
+ * Renders one frame at an arbitrary size: social crops, OG images, print.
303
+ * The live canvas is untouched.
304
+ */
305
+ exportImage(opts = {}) {
306
+ const { w, h } = this._size();
307
+ const [width, height] = exportSize(opts.preset || opts, [Math.round(w), Math.round(h)]);
308
+ return this._offscreen(width, height, opts, (surface) =>
309
+ surface.toDataURL(opts.type || "image/png", opts.quality),
310
+ );
311
+ }
312
+
313
+ /** Same as `exportImage`, resolved as a Blob. */
314
+ exportBlob(opts = {}) {
315
+ const { w, h } = this._size();
316
+ const [width, height] = exportSize(opts.preset || opts, [Math.round(w), Math.round(h)]);
317
+ return this._offscreen(width, height, opts, (surface) =>
318
+ new Promise((resolve) => surface.toBlob(resolve, opts.type || "image/png", opts.quality)),
319
+ );
320
+ }
321
+
322
+ _offscreen(width, height, opts, take) {
323
+ if (typeof document === "undefined") return null;
324
+ const surface = document.createElement("canvas");
325
+ surface.width = width;
326
+ surface.height = height;
327
+ const canvas = this.canvas, ctx = this.ctx, dpr = this._dpr;
328
+ const transparent = this.o.transparentBackground;
329
+ this.canvas = surface;
330
+ this.ctx = surface.getContext("2d");
331
+ this._dpr = 1;
332
+ if (opts.transparent) this.o.transparentBackground = true;
333
+ try {
334
+ this.render();
335
+ return take(surface);
336
+ } finally {
337
+ this.canvas = canvas;
338
+ this.ctx = ctx;
339
+ this._dpr = dpr;
340
+ this.o.transparentBackground = transparent;
341
+ this.render();
342
+ }
343
+ }
344
+
345
+ /* ------------------------------- timeline ------------------------------- */
346
+
347
+ /** Reveals markers whose `date` has arrived. Pass null to show everything. */
348
+ setTimelineAt(at) {
349
+ return this.setOptions({ timeline: at == null ? null : { ...(this.o.timeline || {}), at } });
350
+ }
351
+
352
+ /**
353
+ * Animates the timeline across a date range: "our growth, 2020 to now".
354
+ * Returns a handle with `stop()`.
355
+ */
356
+ playTimeline({ from, to, duration = 6000, loop = false, onTick } = {}) {
357
+ this.stopTimeline();
358
+ const start = +new Date(from ?? this._timelineBounds()[0]);
359
+ const end = +new Date(to ?? this._timelineBounds()[1]);
360
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return { stop() {} };
361
+ const began = Date.now();
362
+ const tick = () => {
363
+ const p = (Date.now() - began) / duration;
364
+ const at = start + (end - start) * (loop ? p % 1 : Math.min(1, p));
365
+ this.setOptions({ timeline: { at } });
366
+ onTick?.(at);
367
+ if (!loop && p >= 1) this.stopTimeline();
368
+ };
369
+ tick();
370
+ this._timeline = setInterval(tick, 1000 / 30);
371
+ return { stop: () => this.stopTimeline() };
372
+ }
373
+
374
+ stopTimeline() {
375
+ if (this._timeline) clearInterval(this._timeline);
376
+ this._timeline = null;
377
+ return this;
378
+ }
379
+
380
+ _timelineBounds() {
381
+ let min = Infinity, max = -Infinity;
382
+ for (const m of this.markers) {
383
+ const t = m.date == null ? NaN : +new Date(m.date);
384
+ if (!Number.isFinite(t)) continue;
385
+ if (t < min) min = t;
386
+ if (t > max) max = t;
387
+ }
388
+ return Number.isFinite(min) ? [min, max] : [Date.now(), Date.now()];
389
+ }
390
+
391
+ /* ------------------------------ live pings ------------------------------ */
392
+
393
+ /**
394
+ * Fires a one-shot expanding ring: the "someone in Berlin just signed up"
395
+ * moment. Returns `this`, so it chains.
396
+ */
397
+ ping(input, extra = {}) {
398
+ const spec = typeof input === "number" ? { lat: input, lon: extra.lon, ...extra } : { ...input, ...extra };
399
+ if (spec.lat == null || spec.lon == null) return this;
400
+ const now = Date.now();
401
+ this._pings.push({
402
+ lat: spec.lat,
403
+ lon: spec.lon,
404
+ label: spec.label || null,
405
+ emoji: spec.emoji || null,
406
+ color: spec.color || null,
407
+ rings: spec.rings ?? 3,
408
+ radius: spec.radius ?? 46,
409
+ burst: spec.burst ?? false,
410
+ burstColor: spec.burstColor || null,
411
+ start: now,
412
+ duration: spec.duration ?? 2600,
413
+ });
414
+ if (this._pings.length > 60) this._pings.splice(0, this._pings.length - 60);
415
+ if (spec.flyTo) this.flyTo(spec.lon, spec.lat, spec.flyToOptions);
416
+ return this.invalidate();
417
+ }
418
+
419
+ /**
420
+ * Replays a list of pings on a timer: a live-activity feed without a server.
421
+ * Returns a handle with `stop()`.
422
+ */
423
+ pingFeed(items, { interval = 2200, loop = true, flyTo = false, onPing } = {}) {
424
+ let i = 0;
425
+ const tick = () => {
426
+ if (this._destroyed) return stop();
427
+ const item = items[i % items.length];
428
+ if (item) {
429
+ this.ping({ ...item, flyTo });
430
+ onPing?.(item);
431
+ }
432
+ i++;
433
+ if (!loop && i >= items.length) stop();
434
+ };
435
+ const timer = setInterval(tick, interval);
436
+ const stop = () => clearInterval(timer);
437
+ tick();
438
+ this._feeds = this._feeds || [];
439
+ this._feeds.push(stop);
440
+ return { stop };
441
+ }
442
+
443
+ clearPings() {
444
+ this._pings.length = 0;
445
+ return this.invalidate();
446
+ }
447
+
448
+ /* --------------------------------- tour --------------------------------- */
449
+
450
+ /** Flies between points on a timer. Returns a handle with `stop()`. */
451
+ tour(points, { dwell = 2800, zoom, loop = true, onStep } = {}) {
452
+ this.stopTour();
453
+ if (!points?.length) return { stop() {} };
454
+ let i = 0;
455
+ const step = () => {
456
+ const point = points[i % points.length];
457
+ const [lon, lat] = Array.isArray(point) ? point : [point.lon, point.lat];
458
+ this.flyTo(lon, lat, zoom != null ? { zoom } : undefined);
459
+ onStep?.(point, i % points.length);
460
+ i++;
461
+ if (!loop && i >= points.length) this.stopTour();
462
+ };
463
+ step();
464
+ this._tour = setInterval(step, dwell);
465
+ return { stop: () => this.stopTour() };
466
+ }
467
+
468
+ stopTour() {
469
+ if (this._tour) clearInterval(this._tour);
470
+ this._tour = null;
471
+ return this;
472
+ }
473
+
474
+ /* ----------------------------- scrollytelling ---------------------------- */
475
+
476
+ /**
477
+ * Drives the view from an element's scroll progress. Each step needs an `at`
478
+ * (0-1); `center`, `zoom` and `mode` interpolate, everything else applies at
479
+ * the step boundary.
480
+ */
481
+ story(element, steps, { onStep } = {}) {
482
+ this.stopStory();
483
+ if (!element || !steps?.length || typeof window === "undefined") return this;
484
+ const ordered = [...steps].sort((a, b) => a.at - b.at);
485
+ let active = -1;
486
+ let queued = false;
487
+
488
+ const apply = () => {
489
+ queued = false;
490
+ const box = element.getBoundingClientRect();
491
+ const span = box.height - window.innerHeight;
492
+ const p = clamp(span > 0 ? -box.top / span : 0, 0, 1);
493
+ let i = 0;
494
+ while (i < ordered.length - 1 && p >= ordered[i + 1].at) i++;
495
+ const a = ordered[i], b = ordered[i + 1] || a;
496
+ const gap = b.at - a.at;
497
+ const t = gap > 0 ? clamp((p - a.at) / gap, 0, 1) : 0;
498
+ const ca = a.center || [this.lon, this.lat];
499
+ const cb = b.center || ca;
500
+ this.lon = wrapLon(ca[0] + wrapLon(cb[0] - ca[0]) * t);
501
+ this.lat = ca[1] + (cb[1] - ca[1]) * t;
502
+ this._zoom = clamp((a.zoom ?? 1) + ((b.zoom ?? a.zoom ?? 1) - (a.zoom ?? 1)) * t, this.o.minZoom, this.o.maxZoom);
503
+ this._target = null;
504
+ if (i !== active) {
505
+ active = i;
506
+ const { at, center, zoom, ...rest } = a;
507
+ if (Object.keys(rest).length) this.setOptions(rest);
508
+ onStep?.(a, i);
509
+ }
510
+ this._dirty = true;
511
+ };
512
+
513
+ this._onScroll = () => {
514
+ if (queued) return;
515
+ queued = true;
516
+ requestAnimationFrame(apply);
517
+ };
518
+ addEventListener("scroll", this._onScroll, { passive: true });
519
+ addEventListener("resize", this._onScroll, { passive: true });
520
+ this._story = () => {
521
+ removeEventListener("scroll", this._onScroll);
522
+ removeEventListener("resize", this._onScroll);
523
+ };
524
+ apply();
525
+ return this;
526
+ }
527
+
528
+ stopStory() {
529
+ this._story?.();
530
+ this._story = null;
531
+ return this;
532
+ }
533
+
534
+ /* ------------------------------- recording ------------------------------- */
535
+
536
+ /** True when this browser can encode a clip from the canvas. */
537
+ static get canRecord() {
538
+ return canRecord();
539
+ }
540
+
541
+ /**
542
+ * Records the canvas to a WebM Blob, entirely in the tab. Pass `filename` to
543
+ * download it automatically.
544
+ */
545
+ record({ duration = 6000, fps = 30, filename, ...rest } = {}) {
546
+ const handle = recordCanvas(this.canvas, { duration, fps, ...rest });
547
+ if (filename) handle.promise.then((blob) => downloadBlob(blob, filename)).catch(() => {});
548
+ return handle;
549
+ }
550
+
551
+ /* -------------------------------- viewer -------------------------------- */
552
+
553
+ /**
554
+ * Where the current viewer is, from their time zone: no permission prompt,
555
+ * no network call. Pass `{ precise: true }` for a Promise that upgrades to
556
+ * GPS if they allow it.
557
+ */
558
+ locateViewer({ precise = false } = {}) {
559
+ return precise ? locateViewerPrecise() : locateViewer();
560
+ }
561
+
562
+ /** Sets the clock used by the day/night terminator. Pass null to track now. */
563
+ setTime(time) {
564
+ return this.setOptions({ time });
565
+ }
566
+
567
+ get zoom() {
568
+ return this._zoom;
569
+ }
570
+
571
+ setZoom(zoom) {
572
+ this._zoom = clamp(zoom, this.o.minZoom, this.o.maxZoom);
573
+ return this.invalidate();
574
+ }
575
+
576
+ zoomBy(factor) {
577
+ return this.setZoom(this._zoom * factor);
578
+ }
579
+
580
+ /** Current view centre. */
581
+ getCenter() {
582
+ return { lon: this.lon, lat: this.lat };
583
+ }
584
+
585
+ /** Eases the view to a coordinate. Pass `{ instant: true }` to jump. */
586
+ flyTo(lon, lat, opts = {}) {
587
+ if (opts.zoom != null) this._zoom = clamp(opts.zoom, this.o.minZoom, this.o.maxZoom);
588
+ if (opts.instant || this._reducedMotion()) {
589
+ this.lon = wrapLon(lon);
590
+ this.lat = clamp(lat, -80, 80);
591
+ this._target = null;
592
+ } else {
593
+ this._target = { lon, lat: clamp(lat, -70, 70) };
594
+ }
595
+ this._announce(`Centred on ${lat.toFixed(1)}, ${lon.toFixed(1)}`);
596
+ return this.invalidate();
597
+ }
598
+
599
+ /** Zoom level needed to frame a `[west, south, east, north]` box. */
600
+ _zoomToFit(bounds, padding = 0.9) {
601
+ const [west, south, east, north] = bounds;
602
+ const { w, h } = this._size();
603
+ if (this.o.mode === "map") {
604
+ const p = resolveProjection(this.o.projection);
605
+ const a = p.forward(west, north), b = p.forward(east, south);
606
+ const bb = this._bounds();
607
+ const base = Math.min(w / bb.dx, h / bb.dy);
608
+ const dx = Math.abs(b[0] - a[0]) || 1, dy = Math.abs(b[1] - a[1]) || 1;
609
+ return (Math.min(w / dx, h / dy) / base) * padding;
610
+ }
611
+ const span = Math.max(Math.abs(wrapLon(east - west)), Math.abs(north - south)) || 1;
612
+ return (150 / span) * padding;
613
+ }
614
+
615
+ /** Frames a `[west, south, east, north]` bounding box. */
616
+ fitTo(bounds, opts = {}) {
617
+ const [west, south, east, north] = bounds;
618
+ const lon = wrapLon(west + wrapLon(east - west) / 2);
619
+ const lat = (south + north) / 2;
620
+ this._zoom = clamp(this._zoomToFit(bounds, opts.padding ?? 0.9), this.o.minZoom, this.o.maxZoom);
621
+ return this.flyTo(lon, lat, opts);
622
+ }
623
+
624
+ /** Frames every marker, picking the shortest longitude arc that covers them. */
625
+ fitToMarkers(opts = {}) {
626
+ const points = this.markers.concat(this._viewer ? [this._viewer] : []);
627
+ if (!points.length) return this;
628
+ if (points.length === 1) return this.flyTo(points[0].lon, points[0].lat, { zoom: opts.zoom ?? 2.5, ...opts });
629
+ let south = 90, north = -90;
630
+ const lons = [];
631
+ for (const m of points) {
632
+ if (m.lat < south) south = m.lat;
633
+ if (m.lat > north) north = m.lat;
634
+ lons.push(wrapLon(m.lon));
635
+ }
636
+ lons.sort((a, b) => a - b);
637
+ // The widest gap between consecutive longitudes is the part to leave out.
638
+ let gap = lons[0] + 360 - lons[lons.length - 1], west = lons[0];
639
+ for (let i = 1; i < lons.length; i++) {
640
+ const d = lons[i] - lons[i - 1];
641
+ if (d > gap) {
642
+ gap = d;
643
+ west = lons[i];
644
+ }
645
+ }
646
+ const east = west + (360 - gap);
647
+ const pad = opts.padding ?? 0.72;
648
+ return this.fitTo([wrapLon(west), south, wrapLon(east), north], { ...opts, padding: pad });
649
+ }
650
+
651
+ /** Screen position of a coordinate, or null when it is behind the globe. */
652
+ project(lon, lat) {
653
+ const { w, h } = this._size();
654
+ if (this.o.mode === "map") {
655
+ const [x, y] = this._view(w, h).fwd(lon, lat);
656
+ return { x, y, visible: x >= 0 && x <= w && y >= 0 && y <= h };
657
+ }
658
+ const r = this._radius(w, h);
659
+ const [x, y, c] = ortho(lon, lat, this.lon, this.lat, r);
660
+ return c < 0 ? null : { x: w / 2 + x, y: h / 2 + y, visible: true };
661
+ }
662
+
663
+ /** Coordinate `[lon, lat]` under a canvas pixel, or null when it misses. */
664
+ unproject(x, y) {
665
+ const { w, h } = this._size();
666
+ if (this.o.mode === "map") return this._view(w, h).inv(x, y);
667
+ return orthoInverse(x - w / 2, y - h / 2, this.lon, this.lat, this._radius(w, h));
668
+ }
669
+
670
+ /** Country shape at a canvas pixel, or null. */
671
+ countryAt(x, y) {
672
+ const g = this.unproject(x, y);
673
+ if (!g) return null;
674
+ const [lon, lat] = g;
675
+ for (const shape of this.world) {
676
+ const b = this._shapeBox(shape);
677
+ if (lon < b[0] || lon > b[2] || lat < b[1] || lat > b[3]) continue;
678
+ if (pointInGeometry(shape.geometry, lon, lat)) return shape;
679
+ }
680
+ return null;
681
+ }
682
+
683
+ /** Marks the next frame as needing a redraw. */
684
+ invalidate() {
685
+ this._dirty = true;
686
+ return this;
687
+ }
688
+
689
+ resize() {
690
+ const { canvas } = this;
691
+ const dpr = Math.min(2, globalThis.devicePixelRatio || 1);
692
+ const w = canvas.clientWidth || canvas.width || 400;
693
+ const h = canvas.clientHeight || canvas.height || 400;
694
+ canvas.width = Math.round(w * dpr);
695
+ canvas.height = Math.round(h * dpr);
696
+ this._dpr = dpr;
697
+ return this.render();
698
+ }
699
+
700
+ /** Draws one frame immediately (useful when autoRotate is off). */
701
+ render() {
702
+ if (this._destroyed) return this;
703
+ const { ctx } = this;
704
+ const { w, h } = this._size();
705
+ ctx.setTransform(this._dpr, 0, 0, this._dpr, 0, 0);
706
+ ctx.clearRect(0, 0, w, h);
707
+ this.hits = this.o.mode === "map" ? this._paintMap(w, h) : this._paintGlobe(w, h);
708
+ this._dirty = false;
709
+ this.o.onRender?.(this);
710
+ return this;
711
+ }
712
+
713
+ /** PNG data URL of the current frame: handy for share images. */
714
+ snapshot(type = "image/png", quality) {
715
+ return this.canvas.toDataURL(type, quality);
716
+ }
717
+
718
+ /** Resolves to a Blob of the current frame. */
719
+ toBlob(type = "image/png", quality) {
720
+ return new Promise((resolve) => this.canvas.toBlob(resolve, type, quality));
721
+ }
722
+
723
+ destroy() {
724
+ this._destroyed = true;
725
+ cancelAnimationFrame(this._raf);
726
+ this.stopTour();
727
+ this.stopStory();
728
+ this.stopTimeline();
729
+ for (const stop of this._feeds || []) stop();
730
+ this._feeds = null;
731
+ this._unbind();
732
+ this._ro?.disconnect();
733
+ this._motion?.removeEventListener?.("change", this._onMotion);
734
+ this._tip?.remove();
735
+ this._live?.remove();
736
+ for (const media of this._media.values()) media.destroy?.();
737
+ this._media.clear();
738
+ for (const media of this._markerMedia.values()) media.destroy?.();
739
+ this._markerMedia.clear();
740
+ this._tip = null;
741
+ this._live = null;
742
+ return this;
743
+ }
744
+
745
+ /* ------------------------------ setup bits ------------------------------ */
746
+
747
+ _applyWorld() {
748
+ this.world = normalizeShapes(this.o.world) || bundledWorld;
749
+ this._bbox = null;
750
+ this._mask = undefined;
751
+ this._dotPts = null;
752
+ this._isoIndex = null;
753
+ this._autoFor = null;
754
+ }
755
+
756
+ _applyMarkers(markers) {
757
+ this.markers = markers.slice();
758
+ this._hasLive = this.markers.some((m) => m.live);
759
+ this._maxCount = Math.max(1, ...this.markers.map((m) => m.count || 1));
760
+ this._dirty = true;
761
+ }
762
+
763
+ /** User markers plus the viewer pin, which survives `setMarkers`. */
764
+ _allMarkers() {
765
+ return this._viewer ? this.markers.concat([this._viewer]) : this.markers;
766
+ }
767
+
768
+ /** Markers that have "happened" yet, per the timeline clock. */
769
+ _visibleMarkers() {
770
+ const at = this.o.timeline?.at;
771
+ const list = this._allMarkers();
772
+ if (at == null) return list;
773
+ const cutoff = +new Date(at);
774
+ return list.filter((m) => {
775
+ if (m.date == null) return true;
776
+ const t = +new Date(m.date);
777
+ return !Number.isFinite(t) || t <= cutoff;
778
+ });
779
+ }
780
+
781
+ _markerImage(src) {
782
+ let media = this._markerMedia.get(src);
783
+ if (!media) {
784
+ media = new Media(src, () => this.invalidate());
785
+ this._markerMedia.set(src, media);
786
+ }
787
+ return media;
788
+ }
789
+
790
+ _applyTexture() {
791
+ const source = this.o.texture;
792
+ if (!source) {
793
+ this._texture = null;
794
+ return;
795
+ }
796
+ if (source instanceof SphereTexture) {
797
+ this._texture = source;
798
+ return;
799
+ }
800
+ if (this._textureFor === source) return;
801
+ this._textureFor = source;
802
+ this._texture = new SphereTexture(source, { onLoad: () => this.invalidate() });
803
+ }
804
+
805
+ /** Rebuilds the per-country media map, reusing sources that did not change. */
806
+ _applyMedia() {
807
+ const spec = this.o.countryMedia || {};
808
+ const next = new Map();
809
+ for (const [key, source] of Object.entries(spec)) {
810
+ if (!source) continue;
811
+ const existing = this._media.get(key);
812
+ if (existing && existing._spec === source) {
813
+ next.set(key, existing);
814
+ continue;
815
+ }
816
+ // Text fills reuse the same clip path, so they live in the same map.
817
+ if (source && typeof source === "object" && source.text) {
818
+ next.set(key, { ...source, isText: true, _spec: source });
819
+ continue;
820
+ }
821
+ const media = new Media(source, () => this.invalidate());
822
+ media._spec = source;
823
+ next.set(key, media);
824
+ }
825
+ for (const [key, media] of this._media) if (next.get(key) !== media) media.destroy?.();
826
+ this._media = next;
827
+ this._dirty = true;
828
+ }
829
+
830
+ _mediaFor(shape) {
831
+ if (!this._media.size) return null;
832
+ const keys = [shape.iso, shape.id, shape.name];
833
+ for (const key of keys) {
834
+ if (key == null) continue;
835
+ const hit = this._media.get(String(key)) || this._media.get(String(key).toUpperCase());
836
+ if (hit) return hit;
837
+ }
838
+ return null;
839
+ }
840
+
841
+ _resolveFocus() {
842
+ const spec = this.o.focus;
843
+ if (!spec) {
844
+ if (this._maxZoomBase != null) {
845
+ this.o.maxZoom = this._maxZoomBase;
846
+ this._maxZoomBase = null;
847
+ this._zoom = clamp(this._zoom, this.o.minZoom, this.o.maxZoom);
848
+ }
849
+ this._focusShape = null;
850
+ this._focusSpec = null;
851
+ this._dirty = true;
852
+ return;
853
+ }
854
+ const config = typeof spec === "string" ? { country: spec } : spec;
855
+ const shape = this._countryShape(config.country);
856
+ this._focusShape = shape;
857
+ // An unresolvable target must not dim or hide the whole world.
858
+ this._focusSpec = shape ? config : null;
859
+ this._dirty = true;
860
+ }
861
+
862
+ /**
863
+ * Screen-space box of a shape, sampled around its geographic bounds. Exact
864
+ * enough to position media, which is clipped to the real outline anyway.
865
+ */
866
+ _screenBox(shape) {
867
+ const [west, south, east, north] = this._shapeBox(shape);
868
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
869
+ const steps = 16;
870
+ for (let i = 0; i <= steps; i++) {
871
+ const t = i / steps;
872
+ const lon = west + (east - west) * t;
873
+ const lat = south + (north - south) * t;
874
+ for (const [a, b] of [[lon, south], [lon, north], [west, lat], [east, lat]]) {
875
+ const p = this.project(a, b);
876
+ if (!p) continue;
877
+ if (p.x < minX) minX = p.x;
878
+ if (p.x > maxX) maxX = p.x;
879
+ if (p.y < minY) minY = p.y;
880
+ if (p.y > maxY) maxY = p.y;
881
+ }
882
+ }
883
+ if (!Number.isFinite(minX)) return null;
884
+ return [minX, minY, maxX - minX, maxY - minY];
885
+ }
886
+
887
+ _textureOptions() {
888
+ const q = this.o.textureQuality;
889
+ const { w, h } = this._size();
890
+ const step = q === "auto" || q == null ? (Math.min(w, h) > 420 ? 2 : 1) : q;
891
+ return { step, shade: this.o.shade !== false };
892
+ }
893
+
894
+ /** Places the viewer's pin from their time zone, then optionally upgrades it. */
895
+ _initViewer() {
896
+ const spec = this.o.showViewer === true ? {} : this.o.showViewer || {};
897
+ const place = (found) => {
898
+ if (!found || this._destroyed) return;
899
+ this.setViewerLocation(found, spec);
900
+ spec.onLocate?.(this._viewer);
901
+ if (spec.flyTo) this.flyTo(this._viewer.lon, this._viewer.lat, spec.flyToOptions);
902
+ if (spec.ping) this.ping({ lat: this._viewer.lat, lon: this._viewer.lon, label: spec.label ?? "You" });
903
+ };
904
+ place(locateViewer());
905
+ if (spec.precise) locateViewerPrecise(spec).then(place);
906
+ }
907
+
908
+ /** Applies a resolved location as the viewer pin. */
909
+ setViewerLocation(found, spec = this.o.showViewer === true ? {} : this.o.showViewer || {}) {
910
+ if (!found) return this;
911
+ const anchored = this._anchorViewer(found, spec.anchor || "auto");
912
+ this._viewer = {
913
+ ...found,
914
+ lat: anchored.lat,
915
+ lon: anchored.lon,
916
+ accuracyMeters: anchored.accuracyMeters,
917
+ anchor: anchored.anchor,
918
+ viewer: true,
919
+ emoji: spec.emoji ?? "\u{1F4CD}",
920
+ label: spec.label ?? "You",
921
+ live: spec.live !== false,
922
+ color: spec.color,
923
+ count: spec.count,
924
+ };
925
+ return this.invalidate();
926
+ }
927
+
928
+ /**
929
+ * A time zone only narrows you to a region, and its published coordinate is
930
+ * one representative city: Asia/Kolkata for all of India. For countries
931
+ * that wide, the country centroid is a much better guess, and either way the
932
+ * radius reflects how much is actually unknown.
933
+ */
934
+ _anchorViewer(found, mode) {
935
+ if (found.source === "geolocation") {
936
+ return { lat: found.lat, lon: found.lon, accuracyMeters: found.accuracyMeters ?? 50, anchor: "gps" };
937
+ }
938
+ const shape = found.country ? this._countryShape(found.country) : null;
939
+ if (!shape) return { lat: found.lat, lon: found.lon, accuracyMeters: 600000, anchor: "timezone" };
940
+ const box = this._shapeBox(shape);
941
+ const radius = distanceMeters(box[0], box[1], box[2], box[3]) / 2;
942
+ const span = Math.max(box[2] - box[0], box[3] - box[1]);
943
+ if (mode === "country" || (mode === "auto" && span > 8)) {
944
+ const c = this._countryCentroid(shape);
945
+ return { lat: c[1], lon: c[0], accuracyMeters: radius, anchor: "country" };
946
+ }
947
+ return { lat: found.lat, lon: found.lon, accuracyMeters: radius, anchor: "timezone" };
948
+ }
949
+
950
+ /** Looks a country up by ISO code, numeric id or name, case-insensitively. */
951
+ _countryShape(key) {
952
+ if (key == null) return null;
953
+ if (this._isoIndex?.world !== this.world) {
954
+ const map = new Map();
955
+ const add = (k, shape) => {
956
+ if (k != null && !map.has(String(k).toLowerCase())) map.set(String(k).toLowerCase(), shape);
957
+ };
958
+ for (const shape of this.world) {
959
+ add(shape.iso, shape);
960
+ add(shape.id, shape);
961
+ add(shape.name, shape);
962
+ }
963
+ this._isoIndex = { world: this.world, map };
964
+ }
965
+ return this._isoIndex.map.get(String(key).toLowerCase()) || null;
966
+ }
967
+
968
+ _countryCentroid(shape) {
969
+ this._centroids = this._centroids || new WeakMap();
970
+ let c = this._centroids.get(shape);
971
+ if (c) return c;
972
+ const geom = shape.geometry;
973
+ const polys = geom.type === "Polygon" ? [geom.coordinates] : geom.coordinates;
974
+ let sx = 0, sy = 0, n = 0;
975
+ for (const poly of polys) for (const ring of poly) for (const p of ring) {
976
+ sx += p[0];
977
+ sy += p[1];
978
+ n++;
979
+ }
980
+ c = n ? [sx / n, sy / n] : [0, 0];
981
+ this._centroids.set(shape, c);
982
+ return c;
983
+ }
984
+
985
+ /** Theme colours pulled from CSS custom properties on the canvas. */
986
+ _cssTheme() {
987
+ if (this._cssCache) return this._cssCache;
988
+ const out = {};
989
+ if (typeof getComputedStyle !== "undefined") {
990
+ const style = getComputedStyle(this.canvas);
991
+ const read = (name) => style.getPropertyValue(`--geo-${name}`).trim() || null;
992
+ for (const key of Object.keys(themes.atlas)) {
993
+ if (key === "ocean" || key === "shade") continue;
994
+ const value = read(key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`));
995
+ if (value) out[key] = value;
996
+ }
997
+ const from = read("ocean-from"), to = read("ocean-to");
998
+ if (from || to) out.ocean = [from || to, to || from];
999
+ }
1000
+ this._cssCache = out;
1001
+ return out;
1002
+ }
1003
+
1004
+ _watchMotion() {
1005
+ if (typeof matchMedia === "undefined") return;
1006
+ this._motion = matchMedia("(prefers-reduced-motion: reduce)");
1007
+ this._reduced = this._motion.matches;
1008
+ this._onMotion = (e) => {
1009
+ this._reduced = e.matches;
1010
+ this._dirty = true;
1011
+ };
1012
+ this._motion.addEventListener?.("change", this._onMotion);
1013
+ }
1014
+
1015
+ _reducedMotion() {
1016
+ return !!(this.o.respectReducedMotion && this._reduced);
1017
+ }
1018
+
1019
+ _buildA11y() {
1020
+ const c = this.canvas;
1021
+ c.setAttribute("role", "img");
1022
+ if (!c.hasAttribute("aria-label")) c.setAttribute("aria-label", this.o.ariaLabel);
1023
+ if (this.o.keyboard && this.o.interactive && !c.hasAttribute("tabindex")) c.tabIndex = 0;
1024
+ if (typeof document === "undefined") return;
1025
+ this._live = document.createElement("div");
1026
+ this._live.setAttribute("aria-live", "polite");
1027
+ this._live.style.cssText = "position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap";
1028
+ (c.parentNode || document.body).appendChild(this._live);
1029
+ }
1030
+
1031
+ _announce(text) {
1032
+ if (this._live) this._live.textContent = text;
1033
+ }
1034
+
1035
+ /* ------------------------------- geometry ------------------------------- */
1036
+
1037
+ _size() {
1038
+ return { w: this.canvas.width / this._dpr, h: this.canvas.height / this._dpr };
1039
+ }
1040
+
1041
+ _radius(w, h) {
1042
+ return Math.min(w, h) * this.o.radiusRatio * this._zoom;
1043
+ }
1044
+
1045
+ _bounds() {
1046
+ if (!this._bbox) this._bbox = projectionBounds(this.o.projection, this.o.latRange);
1047
+ return this._bbox;
1048
+ }
1049
+
1050
+ _shapeBox(shape) {
1051
+ let b = this._shapeBounds.get(shape);
1052
+ if (!b) this._shapeBounds.set(shape, (b = geometryBounds(shape.geometry)));
1053
+ return b;
1054
+ }
1055
+
1056
+ /**
1057
+ * Map-mode transform. The base scale fits the projected world into the
1058
+ * canvas; zoom and the current centre supply the pan, clamped so the map
1059
+ * can never be dragged away from the viewport.
1060
+ */
1061
+ _view(w, h) {
1062
+ const bb = this._bounds();
1063
+ const p = resolveProjection(this.o.projection);
1064
+ const s = Math.min(w / bb.dx, h / bb.dy) * this._zoom;
1065
+ const midX = (bb.x0 + bb.x1) / 2, midY = (bb.y0 + bb.y1) / 2;
1066
+ const [rx, ry] = p.forward(this.lon, this.lat);
1067
+ // Once the world is wider than the viewport, x panning is free so the
1068
+ // antimeridian can be crossed; otherwise the map stays centred.
1069
+ const maxX = s * bb.dx > w + 0.5 ? Infinity : 0;
1070
+ const maxY = Math.max(0, (s * bb.dy - h) / 2);
1071
+ const panX = clamp((midX - rx) * s, -maxX, maxX);
1072
+ const panY = clamp((midY - ry) * s, -maxY, maxY);
1073
+ this._panX = panX;
1074
+ this._panY = panY;
1075
+ const ox = w / 2 + panX - midX * s;
1076
+ const oy = h / 2 + panY - midY * s;
1077
+ // Longitudes are wrapped around the visible centre, so the seam always
1078
+ // falls half a world away: off screen whenever the map is zoomed in.
1079
+ const lonC = maxX === 0 ? midX : this.lon;
1080
+ const wrap = (lon) => lonC + wrapLon(lon - lonC);
1081
+ return {
1082
+ s, ox, oy, maxX, maxY, midX, midY, lonC, wrap,
1083
+ fwd: (lon, lat) => {
1084
+ const q = p.forward(wrap(lon), lat);
1085
+ return [ox + q[0] * s, oy + q[1] * s];
1086
+ },
1087
+ inv: (x, y) => {
1088
+ const g = p.inverse((x - ox) / s, (y - oy) / s);
1089
+ return [wrapLon(g[0]), g[1]];
1090
+ },
1091
+ };
1092
+ }
1093
+
1094
+ /** Turns a pixel pan offset back into a view centre. */
1095
+ _centreFromPan(panX, panY, v) {
1096
+ const p = resolveProjection(this.o.projection);
1097
+ const px = clamp(panX, -v.maxX, v.maxX), py = clamp(panY, -v.maxY, v.maxY);
1098
+ const g = p.inverse(v.midX - px / v.s, v.midY - py / v.s);
1099
+ this.lon = wrapLon(g[0]);
1100
+ this.lat = clamp(g[1], -89, 89);
1101
+ }
1102
+
1103
+ get theme() {
1104
+ const t = this.o.theme;
1105
+ if (t === "auto") {
1106
+ const dark = typeof matchMedia !== "undefined" && matchMedia("(prefers-color-scheme: dark)").matches;
1107
+ return themes[dark ? "midnight" : "atlas"];
1108
+ }
1109
+ if (t === "css") return { ...themes.atlas, ...this._cssTheme() };
1110
+ return typeof t === "string" ? themes[t] || themes.atlas : { ...themes.atlas, ...t };
1111
+ }
1112
+
1113
+ /* ------------------------------ interaction ----------------------------- */
1114
+
1115
+ _cursor() {
1116
+ const c = this.canvas;
1117
+ if (!this.o.interactive) {
1118
+ c.style.cursor = "default";
1119
+ return;
1120
+ }
1121
+ c.style.cursor = this._hovered || this._hoveredCountry ? "pointer" : this._drag ? "grabbing" : "grab";
1122
+ }
1123
+
1124
+ _local(e) {
1125
+ const rect = this.canvas.getBoundingClientRect();
1126
+ return [e.clientX - rect.left, e.clientY - rect.top];
1127
+ }
1128
+
1129
+ _hitAt(x, y) {
1130
+ for (let i = this.hits.length - 1; i >= 0; i--) {
1131
+ const m = this.hits[i];
1132
+ if (Math.hypot(m.x - x, m.y - y) <= m.r + 3) return m;
1133
+ }
1134
+ return null;
1135
+ }
1136
+
1137
+ _bind() {
1138
+ const c = this.canvas;
1139
+
1140
+ this._onDown = (e) => {
1141
+ if (!this.o.interactive) return;
1142
+ this._pointers.set(e.pointerId, this._local(e));
1143
+ c.setPointerCapture?.(e.pointerId);
1144
+ if (this._pointers.size === 2) {
1145
+ const [a, b] = [...this._pointers.values()];
1146
+ this._pinch = { d: Math.hypot(a[0] - b[0], a[1] - b[1]), zoom: this._zoom };
1147
+ this._drag = null;
1148
+ return;
1149
+ }
1150
+ this._dragMoved = false;
1151
+ this._vel = null;
1152
+ this._drag = { x: e.clientX, y: e.clientY, lon: this.lon, lat: this.lat, panX: this._panX, panY: this._panY, t: 0 };
1153
+ this._target = null;
1154
+ this._cursor();
1155
+ };
1156
+
1157
+ this._onMove = (e) => {
1158
+ const [x, y] = this._local(e);
1159
+ this._pointer = { x: e.clientX, y: e.clientY };
1160
+ if (this._pointers.has(e.pointerId)) this._pointers.set(e.pointerId, [x, y]);
1161
+
1162
+ if (this._pinch && this._pointers.size === 2) {
1163
+ const [a, b] = [...this._pointers.values()];
1164
+ const d = Math.hypot(a[0] - b[0], a[1] - b[1]);
1165
+ if (this._pinch.d > 0 && this.o.zoomable) this.setZoom(this._pinch.zoom * (d / this._pinch.d));
1166
+ return;
1167
+ }
1168
+
1169
+ if (this._drag) {
1170
+ const dx = e.clientX - this._drag.x, dy = e.clientY - this._drag.y;
1171
+ if (Math.abs(dx) + Math.abs(dy) > 3) this._dragMoved = true;
1172
+ const beforeLon = this.lon, beforeLat = this.lat;
1173
+ if (this.o.mode === "map") {
1174
+ this.lon = this._drag.lon;
1175
+ this.lat = this._drag.lat;
1176
+ const { w, h } = this._size();
1177
+ this._centreFromPan(this._drag.panX + dx, this._drag.panY + dy, this._view(w, h));
1178
+ } else {
1179
+ const k = 0.38 / Math.max(1, this._zoom);
1180
+ this.lon = this._drag.lon - dx * k;
1181
+ this.lat = clamp(this._drag.lat + dy * k, -78, 78);
1182
+ }
1183
+ this._track(beforeLon, beforeLat);
1184
+ this._dirty = true;
1185
+ return;
1186
+ }
1187
+
1188
+ const hit = this._hitAt(x, y);
1189
+ const marker = hit?.marker || null;
1190
+ let changed = false;
1191
+ if (marker !== this._hovered) {
1192
+ this._hovered = marker;
1193
+ changed = true;
1194
+ this.o.onHover?.(marker, hit ? { x: hit.x, y: hit.y } : null);
1195
+ }
1196
+ if (this.o.onCountryHover || this.o.onCountryClick) {
1197
+ const country = marker ? null : this.countryAt(x, y);
1198
+ if (country !== this._hoveredCountry) {
1199
+ this._hoveredCountry = country;
1200
+ changed = true;
1201
+ this.o.onCountryHover?.(country, country ? { x, y } : null);
1202
+ }
1203
+ }
1204
+ if (changed) {
1205
+ this._cursor();
1206
+ this._dirty = true;
1207
+ const target = marker || this._hoveredCountry;
1208
+ this._showTip(marker ? (marker.cluster ? "cluster" : "marker") : this._hoveredCountry ? "country" : null, target);
1209
+ } else if (this._tipVisible) {
1210
+ this._placeTip();
1211
+ }
1212
+ };
1213
+
1214
+ this._onUp = (e) => {
1215
+ if (e) this._pointers.delete(e.pointerId);
1216
+ else this._pointers.clear();
1217
+ if (this._pointers.size < 2) this._pinch = null;
1218
+ this._drag = null;
1219
+ this._cursor();
1220
+ };
1221
+
1222
+ this._onLeave = (e) => {
1223
+ this._onUp(e);
1224
+ this._pointer = null;
1225
+ if (this._hovered || this._hoveredCountry) {
1226
+ if (this._hovered) this.o.onHover?.(null, null);
1227
+ if (this._hoveredCountry) this.o.onCountryHover?.(null, null);
1228
+ this._hovered = null;
1229
+ this._hoveredCountry = null;
1230
+ this._dirty = true;
1231
+ }
1232
+ this._hideTip();
1233
+ this._cursor();
1234
+ };
1235
+
1236
+ this._onClick = (e) => {
1237
+ if (this._dragMoved) return;
1238
+ const [x, y] = this._local(e);
1239
+ const hit = this._hitAt(x, y);
1240
+ if (hit) {
1241
+ this.o.onClick?.(hit.marker, { x: hit.x, y: hit.y });
1242
+ return;
1243
+ }
1244
+ if (this.o.onCountryClick) {
1245
+ const country = this.countryAt(x, y);
1246
+ if (country) this.o.onCountryClick(country, { x, y });
1247
+ }
1248
+ };
1249
+
1250
+ this._onWheel = (e) => {
1251
+ if (!this.o.interactive || !this.o.zoomable) return;
1252
+ e.preventDefault();
1253
+ const factor = Math.exp(-e.deltaY * (e.deltaMode === 1 ? 0.02 : 0.0015));
1254
+ const next = clamp(this._zoom * factor, this.o.minZoom, this.o.maxZoom);
1255
+ if (next === this._zoom) return;
1256
+ if (this.o.mode === "map") {
1257
+ const { w, h } = this._size();
1258
+ const [x, y] = this._local(e);
1259
+ const g = this._view(w, h).inv(x, y);
1260
+ this._zoom = next;
1261
+ const v = this._view(w, h);
1262
+ const q = resolveProjection(this.o.projection).forward(g[0], g[1]);
1263
+ this._centreFromPan(x - w / 2 - (q[0] - v.midX) * v.s, y - h / 2 - (q[1] - v.midY) * v.s, v);
1264
+ } else {
1265
+ this._zoom = next;
1266
+ }
1267
+ this._dirty = true;
1268
+ };
1269
+
1270
+ this._onKey = (e) => {
1271
+ if (!this.o.keyboard || !this.o.interactive) return;
1272
+ const step = e.shiftKey ? 15 : 5;
1273
+ const k = e.key;
1274
+ if (k === "ArrowLeft" || k === "ArrowRight") {
1275
+ this.lon = wrapLon(this.lon + (k === "ArrowRight" ? step : -step));
1276
+ } else if (k === "ArrowUp" || k === "ArrowDown") {
1277
+ this.lat = clamp(this.lat + (k === "ArrowUp" ? step : -step), -80, 80);
1278
+ } else if (k === "+" || k === "=") {
1279
+ this.zoomBy(1.3);
1280
+ } else if (k === "-" || k === "_") {
1281
+ this.zoomBy(1 / 1.3);
1282
+ } else if (k === "0") {
1283
+ this._zoom = clamp(this.o.zoom, this.o.minZoom, this.o.maxZoom);
1284
+ this.lon = this.o.center.lon;
1285
+ this.lat = this.o.center.lat;
1286
+ } else if (k === "PageDown" || k === "PageUp") {
1287
+ this._cycleMarker(k === "PageDown" ? 1 : -1);
1288
+ } else if ((k === "Enter" || k === " ") && this.markers[this._focus]) {
1289
+ const m = this.markers[this._focus];
1290
+ this.o.onClick?.(m, this.project(m.lon, m.lat) || { x: 0, y: 0 });
1291
+ } else return;
1292
+ e.preventDefault();
1293
+ // Cycling sets its own fly-to target; every other key cancels one.
1294
+ if (k !== "PageDown" && k !== "PageUp") this._target = null;
1295
+ this._dirty = true;
1296
+ };
1297
+
1298
+ this._onBlur = () => {
1299
+ this._focus = -1;
1300
+ this._dirty = true;
1301
+ };
1302
+
1303
+ c.addEventListener("pointerdown", this._onDown);
1304
+ c.addEventListener("pointermove", this._onMove);
1305
+ c.addEventListener("pointerup", this._onUp);
1306
+ c.addEventListener("pointercancel", this._onUp);
1307
+ c.addEventListener("pointerleave", this._onLeave);
1308
+ c.addEventListener("click", this._onClick);
1309
+ c.addEventListener("wheel", this._onWheel, { passive: false });
1310
+ c.addEventListener("keydown", this._onKey);
1311
+ c.addEventListener("blur", this._onBlur);
1312
+ c.style.touchAction = "none";
1313
+ this._cursor();
1314
+
1315
+ if (typeof ResizeObserver !== "undefined") {
1316
+ this._ro = new ResizeObserver(() => this.resize());
1317
+ this._ro.observe(c);
1318
+ }
1319
+ }
1320
+
1321
+ _unbind() {
1322
+ const c = this.canvas;
1323
+ c.removeEventListener("pointerdown", this._onDown);
1324
+ c.removeEventListener("pointermove", this._onMove);
1325
+ c.removeEventListener("pointerup", this._onUp);
1326
+ c.removeEventListener("pointercancel", this._onUp);
1327
+ c.removeEventListener("pointerleave", this._onLeave);
1328
+ c.removeEventListener("click", this._onClick);
1329
+ c.removeEventListener("wheel", this._onWheel);
1330
+ c.removeEventListener("keydown", this._onKey);
1331
+ c.removeEventListener("blur", this._onBlur);
1332
+ }
1333
+
1334
+ /** Samples drag speed so the globe can coast when the pointer lifts. */
1335
+ _track(fromLon, fromLat) {
1336
+ if (!this.o.momentum) return;
1337
+ const now = Date.now();
1338
+ const dt = this._drag.t ? now - this._drag.t : 0;
1339
+ if (dt > 4) {
1340
+ const k = 16 / dt;
1341
+ this._vel = {
1342
+ lon: clamp(wrapLon(this.lon - fromLon) * k, -12, 12),
1343
+ lat: clamp((this.lat - fromLat) * k, -8, 8),
1344
+ };
1345
+ }
1346
+ this._drag.t = now;
1347
+ }
1348
+
1349
+ _cycleMarker(dir) { if (!this.markers.length) return;
1350
+ this._focus = (this._focus + dir + this.markers.length) % this.markers.length;
1351
+ const m = this.markers[this._focus];
1352
+ this.flyTo(m.lon, m.lat);
1353
+ this._announce(defaultTooltip(m, "marker"));
1354
+ }
1355
+
1356
+ /* -------------------------------- tooltip ------------------------------- */
1357
+
1358
+ _showTip(kind, target) {
1359
+ if (!this.o.tooltip || typeof document === "undefined" || !kind || !target) return this._hideTip();
1360
+ const fmt = typeof this.o.tooltip === "function" ? this.o.tooltip : defaultTooltip;
1361
+ const text = fmt(target, kind);
1362
+ if (!text) return this._hideTip();
1363
+ if (!this._tip) {
1364
+ this._tip = document.createElement("div");
1365
+ this._tip.style.cssText =
1366
+ "position:fixed;z-index:2147483647;pointer-events:none;padding:6px 9px;border-radius:8px;" +
1367
+ "background:rgba(15,23,42,.92);color:#f8fafc;font:500 12px/1.35 Inter,system-ui,sans-serif;" +
1368
+ "max-width:240px;box-shadow:0 6px 20px rgba(0,0,0,.25);transform:translate(-50%,-140%)";
1369
+ document.body.appendChild(this._tip);
1370
+ }
1371
+ // textContent, never innerHTML: formatter output is treated as plain text.
1372
+ this._tip.textContent = String(text);
1373
+ this._tip.style.display = "block";
1374
+ this._tipVisible = true;
1375
+ this._placeTip();
1376
+ }
1377
+
1378
+ _placeTip() {
1379
+ if (!this._tip || !this._pointer) return;
1380
+ this._tip.style.left = `${this._pointer.x}px`;
1381
+ this._tip.style.top = `${this._pointer.y}px`;
1382
+ }
1383
+
1384
+ _hideTip() {
1385
+ if (this._tip) this._tip.style.display = "none";
1386
+ this._tipVisible = false;
1387
+ }
1388
+
1389
+ /* --------------------------------- loop --------------------------------- */
1390
+
1391
+ _spinning() {
1392
+ return !!this.o.autoRotate && !this._reducedMotion() && !this._drag && !this._vel && this.o.mode === "globe";
1393
+ }
1394
+
1395
+ /** True while something on screen still needs to move. */
1396
+ _animating() {
1397
+ if (this._target || this._drag || this._vel) return true;
1398
+ if (this._spinning()) return true;
1399
+ if (this._reducedMotion()) return false;
1400
+ if (this._hasLive || this._pings.length) return true;
1401
+ if (this.o.orbits && this.o.mode === "globe") return true;
1402
+ if (this.o.counter && this._counterShown !== this.o.counter.value) return true;
1403
+ for (const media of this._media.values()) if (media.animated) return true;
1404
+ return this.o.arcs.length > 0 && this.o.arcs.some((a) => a.animate !== false);
1405
+ }
1406
+
1407
+ _loop(ts) {
1408
+ if (this._destroyed) return;
1409
+ this._raf = requestAnimationFrame(this._loop);
1410
+ const step = 1000 / (this.o.fps || 30);
1411
+ if (ts - this._last < step) return;
1412
+ this._last = ts;
1413
+
1414
+ if (this._pings.length) {
1415
+ const now = Date.now();
1416
+ for (let i = this._pings.length - 1; i >= 0; i--) {
1417
+ if (now - this._pings[i].start > this._pings[i].duration) this._pings.splice(i, 1);
1418
+ }
1419
+ }
1420
+
1421
+ const counter = this.o.counter;
1422
+ if (counter && counter.value != null) {
1423
+ if (this._counterShown == null) this._counterShown = counter.value;
1424
+ else if (this._counterShown !== counter.value) {
1425
+ const step = (counter.value - this._counterShown) * 0.14;
1426
+ this._counterShown = Math.abs(step) < 0.5 ? counter.value : this._counterShown + step;
1427
+ this._dirty = true;
1428
+ }
1429
+ }
1430
+
1431
+ if (this._target) {
1432
+ // Critically-ish damped spring: settles fast without feeling linear.
1433
+ const dLon = wrapLon(this._target.lon - this.lon);
1434
+ const dLat = this._target.lat - this.lat;
1435
+ this._tv = this._tv || { lon: 0, lat: 0 };
1436
+ this._tv.lon = (this._tv.lon + dLon * 0.16) * 0.72;
1437
+ this._tv.lat = (this._tv.lat + dLat * 0.16) * 0.72;
1438
+ this.lon += this._tv.lon;
1439
+ this.lat += this._tv.lat;
1440
+ if (Math.abs(dLon) < 0.25 && Math.abs(dLat) < 0.25 && Math.hypot(this._tv.lon, this._tv.lat) < 0.25) {
1441
+ this.lon = this._target.lon;
1442
+ this.lat = this._target.lat;
1443
+ this._target = null;
1444
+ this._tv = null;
1445
+ }
1446
+ this._dirty = true;
1447
+ } else if (this._vel) {
1448
+ this.lon += this._vel.lon;
1449
+ this.lat = clamp(this.lat + this._vel.lat, -78, 78);
1450
+ this._vel.lon *= 0.9;
1451
+ this._vel.lat *= 0.9;
1452
+ if (Math.hypot(this._vel.lon, this._vel.lat) < 0.02) this._vel = null;
1453
+ this._dirty = true;
1454
+ } else if (this._spinning()) {
1455
+ this.lon += this.o.rotateSpeed;
1456
+ this._dirty = true;
1457
+ }
1458
+ this.lon = wrapLon(this.lon);
1459
+ if (this._dirty || this._animating()) this.render();
1460
+ }
1461
+
1462
+ /* ------------------------------ path tracing ---------------------------- */
1463
+
1464
+ /**
1465
+ * Traces geometry clipped to the visible hemisphere. Fully hidden rings are
1466
+ * skipped and gaps behind the horizon are closed along the limb, which keeps
1467
+ * shapes stable while the globe spins.
1468
+ */
1469
+ _traceSphere(geom, cx, cy, r) {
1470
+ const { ctx } = this;
1471
+ const polys = geom.type === "Polygon" ? [geom.coordinates] : geom.coordinates;
1472
+ const limb = (a, b) => {
1473
+ const t = a[2] / (a[2] - b[2]);
1474
+ const x = a[0] + (b[0] - a[0]) * t, y = a[1] + (b[1] - a[1]) * t;
1475
+ const m = Math.hypot(x, y) || 1;
1476
+ return [(x / m) * r, (y / m) * r];
1477
+ };
1478
+ for (const poly of polys) {
1479
+ for (const ring of poly) {
1480
+ const n = ring.length;
1481
+ if (n < 3) continue;
1482
+ const P = new Array(n);
1483
+ let vis = 0;
1484
+ for (let i = 0; i < n; i++) {
1485
+ P[i] = ortho(ring[i][0], ring[i][1], this.lon, this.lat, r);
1486
+ if (P[i][2] >= 0) vis++;
1487
+ }
1488
+ if (!vis) continue;
1489
+ if (vis === n) {
1490
+ for (let i = 0; i < n; i++) {
1491
+ i ? ctx.lineTo(cx + P[i][0], cy + P[i][1]) : ctx.moveTo(cx + P[0][0], cy + P[0][1]);
1492
+ }
1493
+ ctx.closePath();
1494
+ continue;
1495
+ }
1496
+ let started = false, exit = null;
1497
+ for (let i = 0; i < n; i++) {
1498
+ const a = P[i], b = P[(i + 1) % n];
1499
+ if (a[2] >= 0) {
1500
+ started ? ctx.lineTo(cx + a[0], cy + a[1]) : (ctx.moveTo(cx + a[0], cy + a[1]), (started = true));
1501
+ if (b[2] < 0) {
1502
+ const e = limb(a, b);
1503
+ ctx.lineTo(cx + e[0], cy + e[1]);
1504
+ exit = Math.atan2(e[1], e[0]);
1505
+ }
1506
+ } else if (b[2] >= 0) {
1507
+ const s = limb(b, a);
1508
+ const enter = Math.atan2(s[1], s[0]);
1509
+ if (started && exit !== null) {
1510
+ let d = enter - exit;
1511
+ while (d > Math.PI) d -= TAU;
1512
+ while (d < -Math.PI) d += TAU;
1513
+ ctx.arc(cx, cy, r, exit, enter, d < 0);
1514
+ } else {
1515
+ ctx.moveTo(cx + s[0], cy + s[1]);
1516
+ started = true;
1517
+ }
1518
+ }
1519
+ }
1520
+ if (started) ctx.closePath();
1521
+ }
1522
+ }
1523
+ }
1524
+
1525
+ /** Flat tracing; breaks the path where a ring wraps past the seam. */
1526
+ _traceFlat(geom, fwd, wrap, ctx = this.ctx) {
1527
+ const polys = geom.type === "Polygon" ? [geom.coordinates] : geom.coordinates;
1528
+ for (const poly of polys) {
1529
+ for (const ring of poly) {
1530
+ let prev = null, open = false;
1531
+ for (const c of ring) {
1532
+ const lon = wrap ? wrap(c[0]) : c[0];
1533
+ const [x, y] = fwd(c[0], c[1]);
1534
+ if (!open || Math.abs(lon - prev) > 180) {
1535
+ if (open) ctx.closePath();
1536
+ ctx.moveTo(x, y);
1537
+ open = true;
1538
+ } else ctx.lineTo(x, y);
1539
+ prev = lon;
1540
+ }
1541
+ if (open) ctx.closePath();
1542
+ }
1543
+ }
1544
+ }
1545
+
1546
+ /* ------------------------------ dot matrix ------------------------------ */
1547
+
1548
+ /**
1549
+ * Rasterises the land into an equirectangular bitmap once, so the dot grid
1550
+ * can be sampled in O(1) per point instead of testing every polygon.
1551
+ */
1552
+ _landMask() {
1553
+ if (this._mask !== undefined) return this._mask;
1554
+ this._mask = null;
1555
+ const W = 1024, H = 512;
1556
+ let surface = null;
1557
+ if (typeof OffscreenCanvas !== "undefined") surface = new OffscreenCanvas(W, H);
1558
+ else if (typeof document !== "undefined") {
1559
+ surface = document.createElement("canvas");
1560
+ surface.width = W;
1561
+ surface.height = H;
1562
+ }
1563
+ if (!surface) return this._mask;
1564
+ let ctx;
1565
+ try {
1566
+ ctx = surface.getContext("2d", { willReadFrequently: true });
1567
+ } catch {
1568
+ return this._mask;
1569
+ }
1570
+ if (!ctx || !ctx.getImageData) return this._mask;
1571
+ const fwd = (lon, lat) => [((lon + 180) / 360) * W, ((90 - lat) / 180) * H];
1572
+ ctx.fillStyle = "#fff";
1573
+ ctx.beginPath();
1574
+ for (const shape of this.world) this._traceFlat(shape.geometry, fwd, null, ctx);
1575
+ ctx.fill();
1576
+ try {
1577
+ const data = ctx.getImageData(0, 0, W, H).data;
1578
+ const bits = new Uint8Array(W * H);
1579
+ for (let i = 0; i < bits.length; i++) bits[i] = data[i * 4 + 3] > 128 ? 1 : 0;
1580
+ this._mask = { W, H, bits };
1581
+ } catch {
1582
+ this._mask = null;
1583
+ }
1584
+ return this._mask;
1585
+ }
1586
+
1587
+ /** Land points on a roughly equidistant lat/lon grid, as a flat lon,lat array. */
1588
+ _dotPoints() {
1589
+ const spacing = Math.max(0.8, this.o.dotSpacing || 2);
1590
+ if (this._dotPts && this._dotKey === spacing) return this._dotPts;
1591
+ const mask = this._landMask();
1592
+ const onLand = mask
1593
+ ? (lon, lat) => {
1594
+ const x = clamp(Math.floor(((lon + 180) / 360) * mask.W), 0, mask.W - 1);
1595
+ const y = clamp(Math.floor(((90 - lat) / 180) * mask.H), 0, mask.H - 1);
1596
+ return mask.bits[y * mask.W + x] === 1;
1597
+ }
1598
+ : (lon, lat) => {
1599
+ for (const shape of this.world) {
1600
+ const b = this._shapeBox(shape);
1601
+ if (lon < b[0] || lon > b[2] || lat < b[1] || lat > b[3]) continue;
1602
+ if (pointInGeometry(shape.geometry, lon, lat)) return true;
1603
+ }
1604
+ return false;
1605
+ };
1606
+ const out = [];
1607
+ for (let lat = -84; lat <= 84; lat += spacing) {
1608
+ const step = spacing / Math.max(0.12, Math.cos(lat * D2R));
1609
+ for (let lon = -180; lon < 180; lon += step) if (onLand(lon, lat)) out.push(lon, lat);
1610
+ }
1611
+ this._dotPts = Float64Array.from(out);
1612
+ this._dotKey = spacing;
1613
+ return this._dotPts;
1614
+ }
1615
+
1616
+ /** One path for every dot, so the whole matrix costs a single fill. */
1617
+ _paintDotsGlobe(cx, cy, r, t) {
1618
+ const { ctx } = this;
1619
+ const pts = this._dotPoints();
1620
+ const size = this.o.dotSize || 1.15;
1621
+ ctx.beginPath();
1622
+ for (let i = 0; i < pts.length; i += 2) {
1623
+ const [x, y, c] = ortho(pts[i], pts[i + 1], this.lon, this.lat, r);
1624
+ if (c < 0.04) continue;
1625
+ const rad = size * (0.4 + c * 0.6);
1626
+ ctx.moveTo(cx + x + rad, cy + y);
1627
+ ctx.arc(cx + x, cy + y, rad, 0, TAU);
1628
+ }
1629
+ ctx.fillStyle = t.dot;
1630
+ ctx.fill();
1631
+ }
1632
+
1633
+ _paintDotsMap(t, fwd, w, h) {
1634
+ const { ctx } = this;
1635
+ const pts = this._dotPoints();
1636
+ const rad = this.o.dotSize || 1.15;
1637
+ ctx.beginPath();
1638
+ for (let i = 0; i < pts.length; i += 2) {
1639
+ const [x, y] = fwd(pts[i], pts[i + 1]);
1640
+ if (x < -4 || x > w + 4 || y < -4 || y > h + 4) continue;
1641
+ ctx.moveTo(x + rad, y);
1642
+ ctx.arc(x, y, rad, 0, TAU);
1643
+ }
1644
+ ctx.fillStyle = t.dot;
1645
+ ctx.fill();
1646
+ }
1647
+
1648
+ /* -------------------------------- orbits -------------------------------- */
1649
+
1650
+ _orbitList() {
1651
+ const spec = this.o.orbits;
1652
+ if (!spec) return [];
1653
+ if (Array.isArray(spec)) return spec;
1654
+ const n = clamp(spec | 0, 0, 6);
1655
+ if (this._orbitsFor !== n) {
1656
+ const out = [];
1657
+ for (let i = 0; i < n; i++) {
1658
+ out.push({ inclination: 22 + i * 34, phase: i * 53, radius: 1.14 + i * 0.1, speed: (i % 2 ? -1 : 1) * (0.5 + i * 0.18) });
1659
+ }
1660
+ this._orbitsFor = n;
1661
+ this._orbitCache = out;
1662
+ }
1663
+ return this._orbitCache;
1664
+ }
1665
+
1666
+ /** Great-circle rings around the sphere; the far half is drawn behind it. */
1667
+ _paintOrbits(t, cx, cy, r, front) {
1668
+ const list = this._orbitList();
1669
+ if (!list.length) return;
1670
+ const { ctx } = this;
1671
+ const spin = this._reducedMotion() ? 0 : (Date.now() / 1000) % 3600;
1672
+ ctx.lineCap = "round";
1673
+ for (const orbit of list) {
1674
+ const inc = (orbit.inclination ?? 30) * D2R;
1675
+ const node = (orbit.phase ?? 0) + spin * (orbit.speed ?? 0.5) * 6;
1676
+ const radius = r * (orbit.radius ?? 1.15);
1677
+ ctx.strokeStyle = front ? orbit.color || t.orbit : withAlpha(orbit.color || t.orbit, 0.35);
1678
+ ctx.lineWidth = orbit.width || 1.1;
1679
+ ctx.beginPath();
1680
+ let pen = false;
1681
+ for (let u = 0; u <= 360; u += 3) {
1682
+ const ur = u * D2R;
1683
+ const lat = Math.asin(Math.sin(inc) * Math.sin(ur)) * R2D;
1684
+ const lon = node + Math.atan2(Math.cos(inc) * Math.sin(ur), Math.cos(ur)) * R2D;
1685
+ const [x, y, c] = ortho(lon, lat, this.lon, this.lat, radius);
1686
+ if (c >= 0 !== front) {
1687
+ pen = false;
1688
+ continue;
1689
+ }
1690
+ pen ? ctx.lineTo(cx + x, cy + y) : (ctx.moveTo(cx + x, cy + y), (pen = true));
1691
+ }
1692
+ ctx.stroke();
1693
+ }
1694
+ }
1695
+
1696
+ /* ------------------------------ choropleth ------------------------------ */
1697
+
1698
+ _choropleth() {
1699
+ return !!(this.o.countryColors || this.o.countryColor);
1700
+ }
1701
+
1702
+ _lookup(map) {
1703
+ if (this._ciFor !== map) {
1704
+ const ci = Object.create(null);
1705
+ for (const k of Object.keys(map)) ci[k.toLowerCase()] = map[k];
1706
+ this._ciFor = map;
1707
+ this._ci = ci;
1708
+ }
1709
+ return this._ci;
1710
+ }
1711
+
1712
+ /**
1713
+ * Greedy graph colouring over bounding-box adjacency, so neighbouring
1714
+ * countries never share a fill in the printed-atlas look.
1715
+ */
1716
+ _autoColors() {
1717
+ const palette = this.o.countryPalette || countryPalette;
1718
+ const shapes = this.world;
1719
+ if (this._autoFor === this.world && this._autoPalette === palette) return this._autoMap;
1720
+ const boxes = shapes.map((s) => this._shapeBox(s));
1721
+ const adjacency = shapes.map(() => []);
1722
+ for (let i = 0; i < shapes.length; i++) {
1723
+ for (let j = i + 1; j < shapes.length; j++) {
1724
+ const a = boxes[i], b = boxes[j];
1725
+ if (a[0] > b[2] + 1 || b[0] > a[2] + 1 || a[1] > b[3] + 1 || b[1] > a[3] + 1) continue;
1726
+ adjacency[i].push(j);
1727
+ adjacency[j].push(i);
1728
+ }
1729
+ }
1730
+ const order = shapes.map((_, i) => i).sort((x, y) => adjacency[y].length - adjacency[x].length);
1731
+ const assigned = new Array(shapes.length).fill(-1);
1732
+ for (const i of order) {
1733
+ const used = new Set();
1734
+ for (const j of adjacency[i]) if (assigned[j] >= 0) used.add(assigned[j]);
1735
+ let c = 0;
1736
+ while (used.has(c) && c < palette.length) c++;
1737
+ assigned[i] = c % palette.length;
1738
+ }
1739
+ const map = new Map();
1740
+ shapes.forEach((shape, i) => map.set(shape, palette[assigned[i]]));
1741
+ this._autoFor = this.world;
1742
+ this._autoPalette = palette;
1743
+ this._autoMap = map;
1744
+ return map;
1745
+ }
1746
+
1747
+ /** Resolves a country fill from `countryColor` or the `countryColors` map. */
1748
+ _fillFor(shape) {
1749
+ const o = this.o;
1750
+ if (o.countryColor) return o.countryColor(shape) || null;
1751
+ if (!o.countryColors) return null;
1752
+ if (o.countryColors === "auto") {
1753
+ const palette = o.countryPalette || countryPalette;
1754
+ return this._autoColors().get(shape) || palette[0];
1755
+ }
1756
+ const ci = this._lookup(o.countryColors);
1757
+ const keys = [o.countryKey ? o.countryKey(shape) : null, shape.iso, shape.id, shape.name];
1758
+ for (const k of keys) {
1759
+ if (k == null) continue;
1760
+ const hit = ci[String(k).toLowerCase()];
1761
+ if (hit) return hit;
1762
+ }
1763
+ return null;
1764
+ }
1765
+
1766
+ /** Applies the current landStyle to an already-built path. */
1767
+ _paintLand(t, fill, textured) {
1768
+ const { ctx } = this;
1769
+ const style = this.o.landStyle;
1770
+ if (style === "outline" || style === "glow" || textured) {
1771
+ if (!textured) {
1772
+ ctx.fillStyle = t.land;
1773
+ ctx.fill();
1774
+ }
1775
+ ctx.strokeStyle = t.border;
1776
+ ctx.lineWidth = style === "glow" ? 1.3 : 1;
1777
+ if (style === "glow") {
1778
+ ctx.save();
1779
+ ctx.shadowColor = t.glow || t.border;
1780
+ ctx.shadowBlur = 14;
1781
+ ctx.stroke();
1782
+ ctx.stroke();
1783
+ ctx.restore();
1784
+ }
1785
+ ctx.stroke();
1786
+ return;
1787
+ }
1788
+ ctx.fillStyle = fill;
1789
+ ctx.fill();
1790
+ ctx.strokeStyle = t.border;
1791
+ ctx.lineWidth = 0.7;
1792
+ ctx.stroke();
1793
+ }
1794
+
1795
+ /** Paints media clipped to a country's outline. Returns true if it drew. */
1796
+ _paintCountryMedia(shape, trace) {
1797
+ const media = this._mediaFor(shape);
1798
+ if (!media) return false;
1799
+ const box = this._screenBox(shape);
1800
+ if (!box) return false;
1801
+ const { ctx } = this;
1802
+ if (media.isText) {
1803
+ this._paintShapeText(media, box, trace, shape);
1804
+ return true;
1805
+ }
1806
+ if (!media.ready) return false;
1807
+ ctx.save();
1808
+ ctx.beginPath();
1809
+ trace(shape.geometry);
1810
+ ctx.clip();
1811
+ ctx.globalAlpha = media.opacity;
1812
+ if (media.blend) ctx.globalCompositeOperation = media.blend;
1813
+ const drew = drawFitted(ctx, media, box);
1814
+ ctx.restore();
1815
+ return drew;
1816
+ }
1817
+
1818
+ /** Type cut out of a country's outline, auto-sized to its width. */
1819
+ _paintShapeText(spec, box, trace, shape) {
1820
+ const { ctx } = this;
1821
+ const [x, y, w, h] = box;
1822
+ ctx.save();
1823
+ ctx.beginPath();
1824
+ trace(shape.geometry);
1825
+ ctx.clip();
1826
+ if (spec.background) {
1827
+ ctx.fillStyle = spec.background;
1828
+ ctx.fillRect(x, y, w, h);
1829
+ }
1830
+ const family = spec.font || "Inter,system-ui,sans-serif";
1831
+ const weight = spec.weight ?? 800;
1832
+ let size = spec.size ?? Math.round(h * 0.34);
1833
+ ctx.font = `${weight} ${size}px ${family}`;
1834
+ const target = w * (spec.fill ?? 0.86);
1835
+ const measured = ctx.measureText(spec.text).width || 1;
1836
+ size = Math.max(6, Math.round(size * (target / measured)));
1837
+ ctx.font = `${weight} ${size}px ${family}`;
1838
+ ctx.textAlign = "center";
1839
+ ctx.textBaseline = "middle";
1840
+ ctx.globalAlpha = spec.opacity ?? 1;
1841
+ ctx.fillStyle = spec.color || "#ffffff";
1842
+ ctx.fillText(spec.text, x + w / 2 + (spec.offset?.[0] || 0), y + h / 2 + (spec.offset?.[1] || 0));
1843
+ ctx.restore();
1844
+ }
1845
+
1846
+ /** Leader lines with a label, for callouts on an explainer graphic. */
1847
+ _paintAnnotations(t) {
1848
+ const list = this.o.annotations;
1849
+ if (!list || !list.length) return;
1850
+ const { ctx } = this;
1851
+ for (const note of list) {
1852
+ const p = this.project(note.lon, note.lat);
1853
+ if (!p) continue;
1854
+ const dx = note.dx ?? 46, dy = note.dy ?? -46;
1855
+ const tx = p.x + dx, ty = p.y + dy;
1856
+ const color = note.color || t.label;
1857
+ ctx.strokeStyle = withAlpha(color, 0.6);
1858
+ ctx.lineWidth = 1.2;
1859
+ ctx.beginPath();
1860
+ ctx.moveTo(p.x, p.y);
1861
+ ctx.lineTo(tx, ty);
1862
+ ctx.stroke();
1863
+ ctx.fillStyle = color;
1864
+ ctx.beginPath();
1865
+ ctx.arc(p.x, p.y, 3, 0, TAU);
1866
+ ctx.fill();
1867
+ if (!note.text) continue;
1868
+ ctx.font = `600 ${note.size || 12}px Inter,system-ui,sans-serif`;
1869
+ const tw = ctx.measureText(note.text).width;
1870
+ const left = dx < 0 ? tx - tw - 16 : tx;
1871
+ ctx.fillStyle = withAlpha(t.bubble, 0.94);
1872
+ ctx.beginPath();
1873
+ ctx.roundRect ? ctx.roundRect(left, ty - 12, tw + 16, 24, 8) : ctx.rect(left, ty - 12, tw + 16, 24);
1874
+ ctx.fill();
1875
+ ctx.fillStyle = color;
1876
+ ctx.textBaseline = "middle";
1877
+ ctx.fillText(note.text, left + 8, ty);
1878
+ ctx.textBaseline = "alphabetic";
1879
+ }
1880
+ }
1881
+
1882
+ /** Odometer-style headline number that rolls when the value changes. */
1883
+ _paintCounter(t, w, h) {
1884
+ const spec = this.o.counter;
1885
+ if (!spec || spec.value == null) return;
1886
+ const { ctx } = this;
1887
+ if (this._counterShown == null) this._counterShown = spec.value;
1888
+ const shown = this._counterShown;
1889
+ const text = spec.format ? spec.format(shown) : Math.round(shown).toLocaleString();
1890
+ const size = spec.size ?? Math.round(Math.min(w, h) * 0.09);
1891
+ const pad = spec.padding ?? 20;
1892
+ const pos = spec.position || "top-left";
1893
+ ctx.save();
1894
+ ctx.font = `800 ${size}px Inter,system-ui,sans-serif`;
1895
+ const tw = ctx.measureText(text).width;
1896
+ const x = pos.includes("right") ? w - pad - tw : pad;
1897
+ const y = pos.includes("bottom") ? h - pad - (spec.label ? size * 0.6 : 0) : pad + size;
1898
+ ctx.fillStyle = spec.color || t.label;
1899
+ ctx.shadowColor = "rgba(0,0,0,.35)";
1900
+ ctx.shadowBlur = 10;
1901
+ ctx.fillText(text, x, y);
1902
+ if (spec.label) {
1903
+ ctx.font = `600 ${Math.round(size * 0.28)}px Inter,system-ui,sans-serif`;
1904
+ ctx.fillStyle = withAlpha(spec.color || t.label, 0.75);
1905
+ ctx.fillText(spec.label, x, y + size * 0.34);
1906
+ }
1907
+ ctx.restore();
1908
+ }
1909
+
1910
+ /** Resolves a corner keyword to a top-left origin for a box. */
1911
+ _anchor(position = "top-left", w, h, boxW, boxH, pad = 20) {
1912
+ const x = position.includes("right")
1913
+ ? w - pad - boxW
1914
+ : position.includes("center")
1915
+ ? (w - boxW) / 2
1916
+ : pad;
1917
+ const y = position.includes("bottom") ? h - pad - boxH : pad;
1918
+ return [x, y];
1919
+ }
1920
+
1921
+ /** Headline and subheadline drawn straight onto the canvas. */
1922
+ _paintTitle(t, w, h) {
1923
+ const spec = this.o.title;
1924
+ if (!spec || !spec.text) return;
1925
+ const { ctx } = this;
1926
+ const size = spec.size ?? Math.round(Math.min(w, h) * 0.062);
1927
+ const subSize = spec.subtitleSize ?? Math.round(size * 0.42);
1928
+ const gap = spec.subtitle ? Math.round(subSize * 1.5) : 0;
1929
+ const pad = spec.padding ?? 22;
1930
+ const pos = spec.position || "top-left";
1931
+
1932
+ ctx.save();
1933
+ ctx.font = `${spec.weight ?? 800} ${size}px ${spec.font || "Inter,system-ui,sans-serif"}`;
1934
+ const titleWidth = ctx.measureText(spec.text).width;
1935
+ let boxWidth = titleWidth;
1936
+ if (spec.subtitle) {
1937
+ ctx.font = `600 ${subSize}px ${spec.font || "Inter,system-ui,sans-serif"}`;
1938
+ boxWidth = Math.max(boxWidth, ctx.measureText(spec.subtitle).width);
1939
+ }
1940
+ const [x, y] = this._anchor(pos, w, h, boxWidth, size + gap, pad);
1941
+ const centred = pos.includes("center");
1942
+ ctx.textAlign = centred ? "center" : "start";
1943
+ const originX = centred ? x + boxWidth / 2 : x;
1944
+
1945
+ ctx.shadowColor = "rgba(0,0,0,.4)";
1946
+ ctx.shadowBlur = 12;
1947
+ ctx.fillStyle = spec.color || t.label;
1948
+ ctx.font = `${spec.weight ?? 800} ${size}px ${spec.font || "Inter,system-ui,sans-serif"}`;
1949
+ ctx.fillText(spec.text, originX, y + size);
1950
+ if (spec.subtitle) {
1951
+ ctx.font = `600 ${subSize}px ${spec.font || "Inter,system-ui,sans-serif"}`;
1952
+ ctx.fillStyle = spec.subtitleColor || withAlpha(spec.color || t.label, 0.72);
1953
+ ctx.fillText(spec.subtitle, originX, y + size + gap);
1954
+ }
1955
+ ctx.textAlign = "start";
1956
+ ctx.restore();
1957
+ }
1958
+
1959
+ /** Logo or wordmark, so an exported asset comes out branded. */
1960
+ _paintWatermark(t, w, h) {
1961
+ const spec = this.o.watermark;
1962
+ if (!spec) return;
1963
+ const { ctx } = this;
1964
+ const pad = spec.padding ?? 18;
1965
+ const pos = spec.position || "bottom-right";
1966
+ ctx.save();
1967
+ ctx.globalAlpha = spec.opacity ?? 0.85;
1968
+
1969
+ if (spec.image) {
1970
+ const media = this._markerImage(spec.image);
1971
+ const size = media.ready && media.size();
1972
+ if (size) {
1973
+ const height = spec.height ?? Math.round(Math.min(w, h) * 0.07);
1974
+ const width = (size[0] / size[1]) * height;
1975
+ const [x, y] = this._anchor(pos, w, h, width, height, pad);
1976
+ ctx.drawImage(media.element, x, y, width, height);
1977
+ }
1978
+ }
1979
+ if (spec.text) {
1980
+ const size = spec.size ?? Math.round(Math.min(w, h) * 0.032);
1981
+ ctx.font = `${spec.weight ?? 700} ${size}px ${spec.font || "Inter,system-ui,sans-serif"}`;
1982
+ const width = ctx.measureText(spec.text).width;
1983
+ const offset = spec.image ? (spec.height ?? Math.round(Math.min(w, h) * 0.07)) + 8 : 0;
1984
+ const [x, y] = this._anchor(pos, w, h, width, size + offset, pad);
1985
+ ctx.fillStyle = spec.color || t.label;
1986
+ ctx.fillText(spec.text, x, y + size + offset);
1987
+ }
1988
+ ctx.restore();
1989
+ }
1990
+
1991
+ _paintCountries(t, trace, textured) {
1992
+ if (this.o.landStyle === "dots" || this.o.landStyle === "none") return;
1993
+ const { ctx } = this;
1994
+ const focus = this._focusSpec;
1995
+ const hasMedia = this._media.size > 0;
1996
+
1997
+ if (focus || hasMedia || this._choropleth()) {
1998
+ const dim = focus?.dim ?? 0.16;
1999
+ for (const shape of this.world) {
2000
+ const focused = !focus || shape === this._focusShape;
2001
+ if (focus && !focused && focus.isolate) continue;
2002
+ ctx.save();
2003
+ if (!focused) ctx.globalAlpha = dim;
2004
+ ctx.beginPath();
2005
+ trace(shape.geometry);
2006
+ if (focused && this._paintCountryMedia(shape, trace)) {
2007
+ this._outline(t, focus && focused ? focus.outlineWidth ?? 1.6 : 0.7);
2008
+ } else {
2009
+ this._paintLand(t, this._fillFor(shape) || t.land, textured);
2010
+ }
2011
+ ctx.restore();
2012
+ }
2013
+ return;
2014
+ }
2015
+
2016
+ ctx.beginPath();
2017
+ for (const shape of this.world) trace(shape.geometry);
2018
+ this._paintLand(t, t.land, textured);
2019
+ }
2020
+
2021
+ _outline(t, width) {
2022
+ const { ctx } = this;
2023
+ ctx.strokeStyle = t.border;
2024
+ ctx.lineWidth = width;
2025
+ ctx.stroke();
2026
+ }
2027
+
2028
+ _paintHighlight(t, trace) {
2029
+ if (!this._hoveredCountry) return;
2030
+ const { ctx } = this;
2031
+ ctx.beginPath();
2032
+ trace(this._hoveredCountry.geometry);
2033
+ ctx.fillStyle = t.countryHover;
2034
+ ctx.fill();
2035
+ }
2036
+
2037
+ /* --------------------------------- arcs --------------------------------- */
2038
+
2039
+ _arcPoints(arc) {
2040
+ let pts = this._arcPts.get(arc);
2041
+ if (!pts) {
2042
+ const [lon1, lat1] = coord(arc.from);
2043
+ const [lon2, lat2] = coord(arc.to);
2044
+ pts = greatCircle(lon1, lat1, lon2, lat2, arc.steps || 72);
2045
+ this._arcPts.set(arc, pts);
2046
+ }
2047
+ return pts;
2048
+ }
2049
+
2050
+ _paintArcs(t, toScreen) {
2051
+ const arcs = this.o.arcs;
2052
+ if (!arcs || !arcs.length) return;
2053
+ const { ctx } = this;
2054
+ const now = Date.now();
2055
+ const still = this._reducedMotion();
2056
+ ctx.lineCap = "round";
2057
+ ctx.lineJoin = "round";
2058
+ for (const arc of arcs) {
2059
+ const pts = this._arcPoints(arc);
2060
+ const n = pts.length - 1;
2061
+ const lift = arc.lift ?? this.o.arcLift;
2062
+ const S = new Array(pts.length);
2063
+ for (let i = 0; i <= n; i++) {
2064
+ S[i] = toScreen(pts[i][0], pts[i][1], lift * Math.sin((i / n) * Math.PI), i > 0 ? pts[i - 1][0] : null);
2065
+ }
2066
+ const color = arc.color || t.arc;
2067
+ const width = arc.width || 1.6;
2068
+ if (arc.animate === false || still) {
2069
+ this._strokeArc(S, 0, n, color, width);
2070
+ continue;
2071
+ }
2072
+ this._strokeArc(S, 0, n, withAlpha(color, arc.baseAlpha ?? 0.28), width);
2073
+ const dur = arc.duration || 2400;
2074
+ const head = ((now * (this.o.arcSpeed || 1)) % dur) / dur;
2075
+ const len = arc.headLength ?? 0.22;
2076
+ this._strokeArc(S, (head - len) * n, head * n, arc.headColor || color, width * 1.7);
2077
+ const tip = S[Math.round(clamp(head * n, 0, n))];
2078
+ if (tip && tip.v) {
2079
+ if (arc.icon) {
2080
+ ctx.font = `${(width * 7) | 0}px "Segoe UI Emoji","Apple Color Emoji",sans-serif`;
2081
+ ctx.textAlign = "center";
2082
+ ctx.textBaseline = "middle";
2083
+ ctx.fillText(arc.icon, tip.x, tip.y);
2084
+ ctx.textAlign = "start";
2085
+ ctx.textBaseline = "alphabetic";
2086
+ } else {
2087
+ ctx.fillStyle = arc.headColor || t.arcHead;
2088
+ ctx.beginPath();
2089
+ ctx.arc(tip.x, tip.y, width * 1.5, 0, TAU);
2090
+ ctx.fill();
2091
+ }
2092
+ }
2093
+ }
2094
+ }
2095
+
2096
+ _strokeArc(S, i0, i1, color, width) {
2097
+ const { ctx } = this;
2098
+ const a = Math.max(0, Math.ceil(i0)), b = Math.min(S.length - 1, Math.floor(i1));
2099
+ if (b < a) return;
2100
+ ctx.strokeStyle = color;
2101
+ ctx.lineWidth = width;
2102
+ ctx.beginPath();
2103
+ let pen = false;
2104
+ for (let i = a; i <= b; i++) {
2105
+ const p = S[i];
2106
+ if (!p.v || p.brk) pen = false;
2107
+ if (!p.v) continue;
2108
+ pen ? ctx.lineTo(p.x, p.y) : (ctx.moveTo(p.x, p.y), (pen = true));
2109
+ }
2110
+ ctx.stroke();
2111
+ }
2112
+
2113
+ /* ------------------------------ terminator ------------------------------ */
2114
+
2115
+ _sun() {
2116
+ return subsolarPoint(this.o.time ?? Date.now());
2117
+ }
2118
+
2119
+ /**
2120
+ * The terminator is a great circle, so under orthographic projection its
2121
+ * visible half is sampled directly and closed along the night-side limb.
2122
+ */
2123
+ _paintTerminatorGlobe(cx, cy, r, t) {
2124
+ const { ctx } = this;
2125
+ const sun = this._sun();
2126
+ const [sxs, sys, sz] = ortho(sun.lon, sun.lat, this.lon, this.lat, 1);
2127
+ const sx = sxs, sy = -sys;
2128
+ const m = Math.hypot(sx, sy);
2129
+ ctx.fillStyle = t.night;
2130
+ ctx.beginPath();
2131
+ if (m < 1e-6) {
2132
+ if (sz < 0) ctx.arc(cx, cy, r, 0, TAU);
2133
+ } else {
2134
+ const ux = sx / m, uy = sy / m, vx = -uy, vy = ux;
2135
+ const N = 96;
2136
+ for (let i = 0; i <= N; i++) {
2137
+ const phi = (i / N) * Math.PI;
2138
+ const au = -r * sz * Math.sin(phi), av = r * Math.cos(phi);
2139
+ const X = ux * au + vx * av, Y = uy * au + vy * av;
2140
+ i ? ctx.lineTo(cx + X, cy - Y) : ctx.moveTo(cx + X, cy - Y);
2141
+ }
2142
+ const th0 = Math.atan2(uy, ux);
2143
+ const M = 64;
2144
+ for (let i = 1; i <= M; i++) {
2145
+ const a = th0 - Math.PI / 2 - (i / M) * Math.PI;
2146
+ ctx.lineTo(cx + r * Math.cos(a), cy - r * Math.sin(a));
2147
+ }
2148
+ ctx.closePath();
2149
+ }
2150
+ ctx.fill();
2151
+ }
2152
+
2153
+ _paintTerminatorMap(t, fwd, w, h, lonC = 0) {
2154
+ const { ctx } = this;
2155
+ const sun = this._sun();
2156
+ const latS = Math.abs(sun.lat) < 0.15 ? (sun.lat >= 0 ? 0.15 : -0.15) : sun.lat;
2157
+ const tanS = Math.tan(latS * D2R);
2158
+ const edge = sun.lat >= 0 ? -90 : 90;
2159
+ // Swept around the view centre so the ring never crosses the seam.
2160
+ const ring = [];
2161
+ for (let d = -180; d <= 180; d += 2) {
2162
+ const lon = lonC + d;
2163
+ ring.push([lon, Math.atan(-Math.cos((lon - sun.lon) * D2R) / tanS) * R2D]);
2164
+ }
2165
+ for (let d = 180; d >= -180; d -= 2) ring.push([lonC + d, edge]);
2166
+ ctx.save();
2167
+ ctx.beginPath();
2168
+ ctx.rect(0, 0, w, h);
2169
+ ctx.clip();
2170
+ ctx.beginPath();
2171
+ this._traceFlat({ type: "Polygon", coordinates: [ring] }, fwd);
2172
+ ctx.fillStyle = t.night;
2173
+ ctx.fill();
2174
+ ctx.restore();
2175
+ }
2176
+
2177
+ /* -------------------------------- layers -------------------------------- */
2178
+
2179
+ /** Additive blobs: density without a per-frame pixel pass. */
2180
+ _paintHeatmap(pts, t) {
2181
+ const o = this.o.heatmap === true ? {} : this.o.heatmap;
2182
+ const { ctx } = this;
2183
+ const base = o.radius ?? 30;
2184
+ const intensity = o.intensity ?? 0.5;
2185
+ const color = o.color || t.marker;
2186
+ ctx.save();
2187
+ ctx.globalCompositeOperation = "lighter";
2188
+ for (const p of pts) {
2189
+ const weight = Math.min(1, (p.m.count || 1) / this._maxCount + 0.25);
2190
+ const r = base * weight * p.depth * (this.o.markerScale || 1);
2191
+ const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r);
2192
+ g.addColorStop(0, withAlpha(color, intensity * weight));
2193
+ g.addColorStop(0.55, withAlpha(color, intensity * weight * 0.32));
2194
+ g.addColorStop(1, withAlpha(color, 0));
2195
+ ctx.fillStyle = g;
2196
+ ctx.beginPath();
2197
+ ctx.arc(p.x, p.y, r, 0, TAU);
2198
+ ctx.fill();
2199
+ }
2200
+ ctx.restore();
2201
+ }
2202
+
2203
+ /** Spike height for a marker, as a fraction of the globe radius. */
2204
+ _spikeLift(m) {
2205
+ if (!this.o.spikes) return 0;
2206
+ const o = this.o.spikes === true ? {} : this.o.spikes;
2207
+ const weight = (m.count || 1) / this._maxCount;
2208
+ return (o.height ?? 0.28) * (0.25 + weight * 0.75);
2209
+ }
2210
+
2211
+ /** Bars standing off the surface; markers ride the tip. */
2212
+ _paintSpikes(t, cx, cy, r, w, h, fwd) {
2213
+ const o = this.o.spikes === true ? {} : this.o.spikes;
2214
+ const { ctx } = this;
2215
+ const width = o.width ?? 2.4;
2216
+ ctx.lineCap = "round";
2217
+ for (const m of this._allMarkers()) {
2218
+ const lift = this._spikeLift(m);
2219
+ let bx, by, tx, ty;
2220
+ if (this.o.mode === "map") {
2221
+ [bx, by] = fwd(m.lon, m.lat);
2222
+ tx = bx;
2223
+ ty = by - lift * Math.min(w, h) * 0.9;
2224
+ } else {
2225
+ const b = ortho(m.lon, m.lat, this.lon, this.lat, r);
2226
+ if (b[2] < 0.02) continue;
2227
+ const tip = ortho(m.lon, m.lat, this.lon, this.lat, r * (1 + lift));
2228
+ bx = cx + b[0];
2229
+ by = cy + b[1];
2230
+ tx = cx + tip[0];
2231
+ ty = cy + tip[1];
2232
+ }
2233
+ const color = m.color || t.marker;
2234
+ const grad = ctx.createLinearGradient(bx, by, tx, ty);
2235
+ grad.addColorStop(0, withAlpha(color, 0.15));
2236
+ grad.addColorStop(1, color);
2237
+ ctx.strokeStyle = grad;
2238
+ ctx.lineWidth = width * (this.o.markerScale || 1);
2239
+ ctx.beginPath();
2240
+ ctx.moveTo(bx, by);
2241
+ ctx.lineTo(tx, ty);
2242
+ ctx.stroke();
2243
+ ctx.fillStyle = withAlpha(color, 0.5);
2244
+ ctx.beginPath();
2245
+ ctx.arc(bx, by, width * 0.7, 0, TAU);
2246
+ ctx.fill();
2247
+ }
2248
+ }
2249
+
2250
+ /** Greedy label placement: highest weight wins, overlaps are dropped. */
2251
+ _paintLabels(pts, t, cx, cy, r, w, h) {
2252
+ const mode = this.o.labels === true ? "markers" : this.o.labels;
2253
+ const { ctx } = this;
2254
+ const boxes = [];
2255
+ const fits = (x, y, tw, th) => {
2256
+ if (x < 2 || y < 2 || x + tw > w - 2 || y + th > h - 2) return false;
2257
+ for (const b of boxes) {
2258
+ if (x < b[2] && x + tw > b[0] && y < b[3] && y + th > b[1]) return false;
2259
+ }
2260
+ boxes.push([x, y, x + tw, y + th]);
2261
+ return true;
2262
+ };
2263
+ const write = (text, x, y, color, size, weight) => {
2264
+ ctx.font = `${weight} ${size}px Inter,system-ui,sans-serif`;
2265
+ const tw = ctx.measureText(text).width;
2266
+ const th = size * 1.2;
2267
+ if (!fits(x - tw / 2, y - th / 2, tw, th)) return;
2268
+ ctx.lineWidth = 3;
2269
+ ctx.strokeStyle = withAlpha(t.ocean[1], 0.85);
2270
+ ctx.textAlign = "center";
2271
+ ctx.textBaseline = "middle";
2272
+ ctx.strokeText(text, x, y);
2273
+ ctx.fillStyle = color;
2274
+ ctx.fillText(text, x, y);
2275
+ ctx.textAlign = "start";
2276
+ ctx.textBaseline = "alphabetic";
2277
+ };
2278
+
2279
+ if (mode === "countries" || mode === "both") {
2280
+ const shapes = [...this.world].sort((a, b) => {
2281
+ const ba = this._shapeBox(a), bb = this._shapeBox(b);
2282
+ return (bb[2] - bb[0]) * (bb[3] - bb[1]) - (ba[2] - ba[0]) * (ba[3] - ba[1]);
2283
+ });
2284
+ for (const shape of shapes) {
2285
+ if (!shape.name) continue;
2286
+ const box = this._shapeBox(shape);
2287
+ const p = this.project((box[0] + box[2]) / 2, (box[1] + box[3]) / 2);
2288
+ if (!p || !p.visible) continue;
2289
+ const span = Math.abs(box[2] - box[0]);
2290
+ const size = clamp(span * 0.35, 8, 13);
2291
+ if (span < 6) continue;
2292
+ write(shape.name, p.x, p.y, t.label, size, 500);
2293
+ }
2294
+ }
2295
+ if (mode === "markers" || mode === "both") {
2296
+ const ordered = [...pts].sort((a, b) => (b.m.count || 1) - (a.m.count || 1));
2297
+ for (const p of ordered) {
2298
+ const text = p.m.label || p.m.name || p.m.city;
2299
+ if (!text) continue;
2300
+ write(String(text), p.x, p.y + 22 * p.depth, t.label, 12, 600);
2301
+ }
2302
+ }
2303
+ }
2304
+
2305
+ /** Small legend card; `items` draws swatches, `scale` draws a gradient bar. */
2306
+ _paintLegend(t, w, h) {
2307
+ const spec = this.o.legend;
2308
+ if (!spec) return;
2309
+ const { ctx } = this;
2310
+ const pad = 12;
2311
+ const items = spec.items || null;
2312
+ const width = spec.width ?? (items ? 132 : 156);
2313
+ const height = spec.height ?? (items ? 22 + items.length * 17 : 54);
2314
+ const pos = spec.position || "bottom-left";
2315
+ const x = pos.includes("right") ? w - width - pad : pad;
2316
+ const y = pos.includes("top") ? pad : h - height - pad;
2317
+
2318
+ ctx.save();
2319
+ ctx.fillStyle = withAlpha(t.bubble, 0.9);
2320
+ ctx.strokeStyle = withAlpha(t.label, 0.16);
2321
+ ctx.lineWidth = 1;
2322
+ ctx.beginPath();
2323
+ ctx.roundRect ? ctx.roundRect(x, y, width, height, 9) : ctx.rect(x, y, width, height);
2324
+ ctx.fill();
2325
+ ctx.stroke();
2326
+ ctx.fillStyle = t.label;
2327
+ ctx.textBaseline = "middle";
2328
+ if (spec.title) {
2329
+ ctx.font = "700 11px Inter,system-ui,sans-serif";
2330
+ ctx.fillText(spec.title, x + 10, y + 14);
2331
+ }
2332
+ ctx.font = "500 10.5px Inter,system-ui,sans-serif";
2333
+ if (items) {
2334
+ items.forEach((item, i) => {
2335
+ const iy = y + 30 + i * 17;
2336
+ ctx.fillStyle = item.color;
2337
+ ctx.beginPath();
2338
+ ctx.arc(x + 15, iy, 5, 0, TAU);
2339
+ ctx.fill();
2340
+ ctx.fillStyle = t.label;
2341
+ ctx.fillText(item.label, x + 26, iy);
2342
+ });
2343
+ } else if (spec.scale) {
2344
+ const [min, max] = spec.scale.domain || [0, 1];
2345
+ const colors = spec.scale.range || ["#e0f2fe", "#0369a1"];
2346
+ const bar = ctx.createLinearGradient(x + 10, 0, x + width - 10, 0);
2347
+ colors.forEach((c, i) => bar.addColorStop(i / Math.max(1, colors.length - 1), c));
2348
+ ctx.fillStyle = bar;
2349
+ ctx.fillRect(x + 10, y + 24, width - 20, 9);
2350
+ ctx.fillStyle = t.label;
2351
+ ctx.fillText(String(min), x + 10, y + 43);
2352
+ ctx.textAlign = "right";
2353
+ ctx.fillText(String(max), x + width - 10, y + 43);
2354
+ ctx.textAlign = "start";
2355
+ }
2356
+ ctx.textBaseline = "alphabetic";
2357
+ ctx.restore();
2358
+ }
2359
+
2360
+ /** Expanding rings for one-shot "something just happened here" moments. */
2361
+ _paintPings(t) {
2362
+ if (!this._pings.length) return;
2363
+ const { ctx } = this;
2364
+ const now = Date.now();
2365
+ const still = this._reducedMotion();
2366
+ for (const ping of this._pings) {
2367
+ const p = this.project(ping.lon, ping.lat);
2368
+ if (!p) continue;
2369
+ const life = clamp((now - ping.start) / ping.duration, 0, 1);
2370
+ const color = ping.color || t.live;
2371
+ const rings = still ? 1 : ping.rings;
2372
+ for (let i = 0; i < rings; i++) {
2373
+ const phase = life - i * 0.16;
2374
+ if (phase <= 0 || phase > 1) continue;
2375
+ ctx.globalAlpha = (1 - phase) * 0.9;
2376
+ ctx.strokeStyle = color;
2377
+ ctx.lineWidth = 2 - phase;
2378
+ ctx.beginPath();
2379
+ ctx.arc(p.x, p.y, 4 + phase * ping.radius, 0, TAU);
2380
+ ctx.stroke();
2381
+ }
2382
+ ctx.globalAlpha = 1;
2383
+ ctx.fillStyle = color;
2384
+ ctx.beginPath();
2385
+ ctx.arc(p.x, p.y, 3.4, 0, TAU);
2386
+ ctx.fill();
2387
+
2388
+ // Milestone burst: particles thrown outward on a fixed seed per ping.
2389
+ if (ping.burst && !still) {
2390
+ const count = ping.burst === true ? 14 : ping.burst;
2391
+ for (let i = 0; i < count; i++) {
2392
+ const angle = (i / count) * TAU + ping.start;
2393
+ const reach = (0.4 + ((i * 37) % 10) / 14) * ping.radius * life;
2394
+ ctx.globalAlpha = (1 - life) * 0.9;
2395
+ ctx.fillStyle = ping.burstColor || color;
2396
+ ctx.beginPath();
2397
+ ctx.arc(p.x + Math.cos(angle) * reach, p.y + Math.sin(angle) * reach, 2.2 * (1 - life) + 0.6, 0, TAU);
2398
+ ctx.fill();
2399
+ }
2400
+ ctx.globalAlpha = 1;
2401
+ }
2402
+
2403
+ if (!ping.label && !ping.emoji) continue;
2404
+ const text = `${ping.emoji ? `${ping.emoji} ` : ""}${ping.label || ""}`.trim();
2405
+ ctx.globalAlpha = clamp(life < 0.12 ? life / 0.12 : (1 - life) / 0.3, 0, 1);
2406
+ ctx.font = "600 12px Inter,system-ui,sans-serif";
2407
+ const tw = ctx.measureText(text).width;
2408
+ const bx = p.x - tw / 2 - 9, by = p.y - 42 - life * 8;
2409
+ ctx.fillStyle = withAlpha(t.bubble, 0.94);
2410
+ ctx.beginPath();
2411
+ ctx.roundRect ? ctx.roundRect(bx, by, tw + 18, 24, 12) : ctx.rect(bx, by, tw + 18, 24);
2412
+ ctx.fill();
2413
+ ctx.fillStyle = t.label;
2414
+ ctx.textAlign = "center";
2415
+ ctx.textBaseline = "middle";
2416
+ ctx.fillText(text, p.x, by + 12);
2417
+ ctx.textAlign = "start";
2418
+ ctx.textBaseline = "alphabetic";
2419
+ ctx.globalAlpha = 1;
2420
+ }
2421
+ }
2422
+
2423
+ /**
2424
+ * The uncertainty around the viewer pin. A time-zone fix is region-wide, so
2425
+ * drawing a bare dot would claim precision that does not exist.
2426
+ */
2427
+ _paintViewerAccuracy(t, cx, cy, r, fwd) {
2428
+ const v = this._viewer;
2429
+ if (!v || !v.accuracyMeters) return;
2430
+ const spec = this.o.showViewer === true ? {} : this.o.showViewer || {};
2431
+ if (spec.accuracyCircle === false) return;
2432
+ const { ctx } = this;
2433
+ const ring = circleAround(v.lon, v.lat, v.accuracyMeters, 96);
2434
+ const color = spec.accuracyColor || v.color || t.live;
2435
+ ctx.beginPath();
2436
+ let pen = false, prevLon = null;
2437
+ for (const [lon, lat] of ring) {
2438
+ let x, y, ok = true;
2439
+ if (this.o.mode === "map") {
2440
+ [x, y] = fwd(lon, lat);
2441
+ if (prevLon !== null && Math.abs(lon - prevLon) > 180) pen = false;
2442
+ } else {
2443
+ const p = ortho(lon, lat, this.lon, this.lat, r);
2444
+ ok = p[2] >= 0;
2445
+ x = cx + p[0];
2446
+ y = cy + p[1];
2447
+ }
2448
+ prevLon = lon;
2449
+ if (!ok) {
2450
+ pen = false;
2451
+ continue;
2452
+ }
2453
+ pen ? ctx.lineTo(x, y) : (ctx.moveTo(x, y), (pen = true));
2454
+ }
2455
+ if (!pen && this.o.mode !== "map") return;
2456
+ ctx.closePath();
2457
+ ctx.fillStyle = withAlpha(color, 0.12);
2458
+ ctx.fill();
2459
+ ctx.strokeStyle = withAlpha(color, 0.55);
2460
+ ctx.lineWidth = 1.2;
2461
+ ctx.setLineDash([4, 4]);
2462
+ ctx.stroke();
2463
+ ctx.setLineDash([]);
2464
+ }
2465
+
2466
+ /* ------------------------------- painting ------------------------------- */
2467
+
2468
+ _paintGlobe(w, h) {
2469
+ const { ctx } = this;
2470
+ const t = this.theme;
2471
+ const cx = w / 2, cy = h / 2;
2472
+ const r = this._radius(w, h);
2473
+ const trace = (geom) => this._traceSphere(geom, cx, cy, r);
2474
+
2475
+ if (this.o.stars && t.stars && r * 1.22 < Math.hypot(w, h) / 2) {
2476
+ let seed = 9;
2477
+ const rnd = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff);
2478
+ for (let i = 0; i < 140; i++) {
2479
+ const x = rnd() * w, y = rnd() * h, s = rnd();
2480
+ if (Math.hypot(x - cx, y - cy) < r * 1.22) continue;
2481
+ ctx.globalAlpha = 0.2 + s * 0.7;
2482
+ ctx.fillStyle = t.stars;
2483
+ ctx.beginPath();
2484
+ ctx.arc(x, y, s * 1.3 + 0.3, 0, TAU);
2485
+ ctx.fill();
2486
+ }
2487
+ ctx.globalAlpha = 1;
2488
+ }
2489
+
2490
+ this._paintOrbits(t, cx, cy, r, false);
2491
+
2492
+ const atm = ctx.createRadialGradient(cx, cy, r * 0.82, cx, cy, r * 1.3);
2493
+ atm.addColorStop(0, t.atmosphere);
2494
+ atm.addColorStop(1, "rgba(0,0,0,0)");
2495
+ if (!this.o.transparentBackground) {
2496
+ ctx.fillStyle = atm;
2497
+ ctx.beginPath();
2498
+ ctx.arc(cx, cy, r * 1.3, 0, TAU);
2499
+ ctx.fill();
2500
+ }
2501
+
2502
+ ctx.save();
2503
+ ctx.beginPath();
2504
+ ctx.arc(cx, cy, r, 0, TAU);
2505
+ ctx.clip();
2506
+
2507
+ const oc = ctx.createRadialGradient(cx - r * 0.38, cy - r * 0.42, r * 0.05, cx, cy, r * 1.05);
2508
+ oc.addColorStop(0, t.ocean[0]);
2509
+ oc.addColorStop(1, t.ocean[1]);
2510
+ if (!this.o.transparentBackground) {
2511
+ ctx.fillStyle = oc;
2512
+ ctx.fillRect(cx - r, cy - r, r * 2, r * 2);
2513
+ }
2514
+
2515
+ const textured = this._texture && this._texture.draw(ctx, cx, cy, r, this.lon, this.lat, this._textureOptions());
2516
+
2517
+ if (this.o.graticule) {
2518
+ ctx.strokeStyle = t.graticule;
2519
+ ctx.lineWidth = 1;
2520
+ const line = (pts) => {
2521
+ ctx.beginPath();
2522
+ let pen = false;
2523
+ for (const [lon, lat] of pts) {
2524
+ const [x, y, c] = ortho(lon, lat, this.lon, this.lat, r);
2525
+ if (c < 0) { pen = false; continue; }
2526
+ pen ? ctx.lineTo(cx + x, cy + y) : (ctx.moveTo(cx + x, cy + y), (pen = true));
2527
+ }
2528
+ ctx.stroke();
2529
+ };
2530
+ for (let lat = -60; lat <= 60; lat += 30) {
2531
+ const pts = [];
2532
+ for (let lon = -180; lon <= 180; lon += 4) pts.push([lon, lat]);
2533
+ line(pts);
2534
+ }
2535
+ for (let lon = -180; lon < 180; lon += 30) {
2536
+ const pts = [];
2537
+ for (let lat = -85; lat <= 85; lat += 4) pts.push([lon, lat]);
2538
+ line(pts);
2539
+ }
2540
+ }
2541
+
2542
+ this._paintCountries(t, trace, textured);
2543
+ if (this.o.landStyle === "dots") this._paintDotsGlobe(cx, cy, r, t);
2544
+ this._paintHighlight(t, trace);
2545
+ if (this.o.terminator) this._paintTerminatorGlobe(cx, cy, r, t);
2546
+ ctx.restore();
2547
+
2548
+ ctx.strokeStyle = t.rim;
2549
+ ctx.lineWidth = 1.1;
2550
+ ctx.beginPath();
2551
+ ctx.arc(cx, cy, r, 0, TAU);
2552
+ ctx.stroke();
2553
+
2554
+ if (this.o.shade && t.shade) {
2555
+ ctx.save();
2556
+ ctx.beginPath();
2557
+ ctx.arc(cx, cy, r, 0, TAU);
2558
+ ctx.clip();
2559
+ const sh = ctx.createRadialGradient(cx - r * 0.3, cy - r * 0.35, r * 0.1, cx, cy, r * 1.15);
2560
+ sh.addColorStop(0, "rgba(255,255,255,.16)");
2561
+ sh.addColorStop(0.55, "rgba(255,255,255,0)");
2562
+ sh.addColorStop(1, "rgba(10,20,40,.4)");
2563
+ ctx.fillStyle = sh;
2564
+ ctx.fillRect(cx - r, cy - r, r * 2, r * 2);
2565
+ ctx.restore();
2566
+ }
2567
+
2568
+ this._paintOrbits(t, cx, cy, r, true);
2569
+
2570
+ this._paintArcs(t, (lon, lat, lift) => {
2571
+ const [x, y, c] = ortho(lon, lat, this.lon, this.lat, r * (1 + lift));
2572
+ return { x: cx + x, y: cy + y, v: c >= 0 || Math.sqrt(Math.max(0, 1 - c * c)) * (1 + lift) >= 1, brk: false };
2573
+ });
2574
+
2575
+ const pts = [];
2576
+ for (const m of this._visibleMarkers()) {
2577
+ const lift = this._spikeLift(m);
2578
+ const [x, y, c] = ortho(m.lon, m.lat, this.lon, this.lat, r * (1 + lift));
2579
+ if (c < 0.02) continue;
2580
+ pts.push({ m, x: cx + x, y: cy + y, depth: 0.65 + c * 0.35 });
2581
+ }
2582
+ if (this.o.heatmap) this._paintHeatmap(pts, t);
2583
+ if (this.o.spikes) this._paintSpikes(t, cx, cy, r, w, h, null);
2584
+ this._paintViewerAccuracy(t, cx, cy, r, null);
2585
+ const hits = this._paintMarkers(pts, t);
2586
+ if (this.o.labels) this._paintLabels(pts, t, cx, cy, r, w, h);
2587
+ this._paintAnnotations(t);
2588
+ this._paintPings(t);
2589
+ this._paintLegend(t, w, h);
2590
+ this._paintCounter(t, w, h);
2591
+ this._paintTitle(t, w, h);
2592
+ this._paintWatermark(t, w, h);
2593
+ return hits;
2594
+ }
2595
+
2596
+ _paintMap(w, h) {
2597
+ const { ctx } = this;
2598
+ const t = this.theme;
2599
+ const v = this._view(w, h);
2600
+ const { fwd, lonC } = v;
2601
+ const trace = (geom) => this._traceFlat(geom, fwd, v.wrap);
2602
+ const [north, south] = this.o.latRange;
2603
+
2604
+ const oc = ctx.createLinearGradient(0, 0, 0, h);
2605
+ oc.addColorStop(0, t.ocean[0]);
2606
+ oc.addColorStop(1, t.ocean[1]);
2607
+ if (!this.o.transparentBackground) {
2608
+ ctx.fillStyle = oc;
2609
+ ctx.fillRect(0, 0, w, h);
2610
+ }
2611
+
2612
+ const textured = this._texture && this._texture.drawFlat(ctx, fwd, w, h);
2613
+
2614
+ if (this.o.graticule) {
2615
+ ctx.strokeStyle = t.graticule;
2616
+ ctx.lineWidth = 1;
2617
+ // Sampled rather than drawn as straight segments so meridians bend
2618
+ // correctly under mercator and Natural Earth.
2619
+ const line = (pts) => {
2620
+ ctx.beginPath();
2621
+ pts.forEach(([lon, lat], i) => {
2622
+ const [x, y] = fwd(lon, lat);
2623
+ i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
2624
+ });
2625
+ ctx.stroke();
2626
+ };
2627
+ for (let lon = -150; lon <= 150; lon += 30) {
2628
+ const pts = [];
2629
+ for (let lat = south; lat <= north; lat += 4) pts.push([lon, lat]);
2630
+ pts.push([lon, north]);
2631
+ line(pts);
2632
+ }
2633
+ // Parallels are swept around the view centre so they never cross the seam.
2634
+ for (let lat = Math.ceil(south / 20) * 20; lat <= north; lat += 20) {
2635
+ const pts = [];
2636
+ for (let d = -179.9; d <= 179.9; d += 10) pts.push([lonC + d, lat]);
2637
+ line(pts);
2638
+ }
2639
+ }
2640
+
2641
+ this._paintCountries(t, trace, textured);
2642
+ if (this.o.landStyle === "dots") this._paintDotsMap(t, fwd, w, h);
2643
+ this._paintHighlight(t, trace);
2644
+ if (this.o.terminator) this._paintTerminatorMap(t, fwd, w, h, lonC);
2645
+
2646
+ this._paintArcs(t, (lon, lat, _lift, prevLon) => {
2647
+ const [x, y] = fwd(lon, lat);
2648
+ return { x, y, v: true, brk: prevLon !== null && Math.abs(lon - prevLon) > 180 };
2649
+ });
2650
+
2651
+ const pts = [];
2652
+ const margin = 80;
2653
+ for (const m of this._visibleMarkers()) {
2654
+ const [x, y0] = fwd(m.lon, m.lat);
2655
+ const y = y0 - this._spikeLift(m) * Math.min(w, h) * 0.9;
2656
+ if (x < -margin || x > w + margin || y < -margin || y > h + margin) continue;
2657
+ pts.push({ m, x, y, depth: 1 });
2658
+ }
2659
+ if (this.o.heatmap) this._paintHeatmap(pts, t);
2660
+ if (this.o.spikes) this._paintSpikes(t, 0, 0, 0, w, h, fwd);
2661
+ this._paintViewerAccuracy(t, 0, 0, 0, fwd);
2662
+ const hits = this._paintMarkers(pts, t);
2663
+ if (this.o.labels) this._paintLabels(pts, t, 0, 0, 0, w, h);
2664
+ this._paintAnnotations(t);
2665
+ this._paintPings(t);
2666
+ this._paintLegend(t, w, h);
2667
+ this._paintCounter(t, w, h);
2668
+ this._paintTitle(t, w, h);
2669
+ this._paintWatermark(t, w, h);
2670
+ return hits;
2671
+ }
2672
+
2673
+ /* -------------------------------- markers ------------------------------- */
2674
+
2675
+ /** Grid clustering in screen space, so density adapts to the current zoom. */
2676
+ _cluster(pts) {
2677
+ const R = Math.max(8, this.o.clusterRadius);
2678
+ const cells = new Map();
2679
+ for (const p of pts) {
2680
+ const key = `${Math.floor(p.x / R)}:${Math.floor(p.y / R)}`;
2681
+ let c = cells.get(key);
2682
+ if (!c) cells.set(key, (c = { x: 0, y: 0, lon: 0, lat: 0, wsum: 0, count: 0, items: [], depth: 0 }));
2683
+ const wgt = p.m.count || 1;
2684
+ c.x += p.x * wgt;
2685
+ c.y += p.y * wgt;
2686
+ c.lon += p.m.lon * wgt;
2687
+ c.lat += p.m.lat * wgt;
2688
+ c.wsum += wgt;
2689
+ c.count += wgt;
2690
+ c.items.push(p.m);
2691
+ if (p.depth > c.depth) c.depth = p.depth;
2692
+ }
2693
+ const out = [];
2694
+ for (const c of cells.values()) {
2695
+ const x = c.x / c.wsum, y = c.y / c.wsum;
2696
+ if (c.items.length === 1) out.push({ m: c.items[0], x, y, depth: c.depth });
2697
+ else out.push({ m: { cluster: true, count: c.count, markers: c.items, lon: c.lon / c.wsum, lat: c.lat / c.wsum }, x, y, depth: c.depth });
2698
+ }
2699
+ return out;
2700
+ }
2701
+
2702
+ _paintMarkers(pts, t) {
2703
+ const list = this.o.cluster ? this._cluster(pts) : pts;
2704
+ const max = this.o.cluster ? Math.max(1, ...list.map((p) => p.m.count || 1)) : this._maxCount;
2705
+ const focused = this._focus >= 0 ? this.markers[this._focus] : null;
2706
+ const { ctx } = this;
2707
+ const hits = [];
2708
+ for (const p of list) {
2709
+ const hit = this._marker(p.x, p.y, p.m, max, p.depth, t);
2710
+ if (focused && p.m === focused) {
2711
+ ctx.strokeStyle = t.focus;
2712
+ ctx.lineWidth = 2.5;
2713
+ ctx.beginPath();
2714
+ ctx.arc(p.x, p.y, hit.r + 5, 0, TAU);
2715
+ ctx.stroke();
2716
+ }
2717
+ hits.push(hit);
2718
+ }
2719
+ return hits;
2720
+ }
2721
+
2722
+ _marker(x, y, m, max, depth, t) {
2723
+ const { ctx } = this;
2724
+ const scale = this.o.markerScale;
2725
+ if (this.o.renderMarker) {
2726
+ const r = this.o.renderMarker(ctx, m, { x, y, depth, scale, theme: t, max, globe: this });
2727
+ return { marker: m, x, y, r: typeof r === "number" ? r : 12 * scale };
2728
+ }
2729
+ const base = (m.size || 3.4) * (0.6 + ((m.count || 1) / max) * 0.85) * depth * scale;
2730
+ const color = m.cluster ? t.cluster : m.color || t.marker;
2731
+
2732
+ // Logo and avatar markers: a circular crop with a ring and a count badge.
2733
+ if (m.image && !m.cluster) {
2734
+ const media = this._markerImage(m.image);
2735
+ if (media.ready) {
2736
+ const R = (m.imageSize || 19) * depth * scale;
2737
+ ctx.save();
2738
+ ctx.shadowColor = "rgba(0,0,0,.4)";
2739
+ ctx.shadowBlur = 8;
2740
+ ctx.fillStyle = t.bubble;
2741
+ ctx.beginPath();
2742
+ ctx.arc(x, y, R, 0, TAU);
2743
+ ctx.fill();
2744
+ ctx.restore();
2745
+ ctx.save();
2746
+ ctx.beginPath();
2747
+ ctx.arc(x, y, R - 1, 0, TAU);
2748
+ ctx.clip();
2749
+ drawFitted(ctx, media, [x - R, y - R, R * 2, R * 2]);
2750
+ ctx.restore();
2751
+ ctx.strokeStyle = m.live ? t.live : color;
2752
+ ctx.lineWidth = 2.2 * scale;
2753
+ ctx.beginPath();
2754
+ ctx.arc(x, y, R, 0, TAU);
2755
+ ctx.stroke();
2756
+ if (m.count > 1) {
2757
+ ctx.fillStyle = color;
2758
+ ctx.beginPath();
2759
+ ctx.arc(x + R * 0.75, y - R * 0.75, 8.5 * scale, 0, TAU);
2760
+ ctx.fill();
2761
+ ctx.fillStyle = "#fff";
2762
+ ctx.font = `700 ${Math.round(10 * scale)}px Inter,system-ui,sans-serif`;
2763
+ ctx.textAlign = "center";
2764
+ ctx.textBaseline = "middle";
2765
+ ctx.fillText(String(m.count), x + R * 0.75, y - R * 0.75 + 1);
2766
+ ctx.textAlign = "start";
2767
+ ctx.textBaseline = "alphabetic";
2768
+ }
2769
+ return { marker: m, x, y, r: R };
2770
+ }
2771
+ }
2772
+ if (!m.cluster) {
2773
+ const glow = ctx.createRadialGradient(x, y, 0, x, y, base * 5);
2774
+ glow.addColorStop(0, m.color ? withAlpha(color, 0.45) : t.markerGlow);
2775
+ glow.addColorStop(1, "rgba(0,0,0,0)");
2776
+ ctx.fillStyle = glow;
2777
+ ctx.beginPath();
2778
+ ctx.arc(x, y, base * 5, 0, TAU);
2779
+ ctx.fill();
2780
+ }
2781
+
2782
+ if (m.live && !this._reducedMotion()) {
2783
+ const pulse = (Date.now() % 2200) / 2200;
2784
+ ctx.globalAlpha = 1 - pulse;
2785
+ ctx.strokeStyle = t.live;
2786
+ ctx.lineWidth = 1.4 * scale;
2787
+ ctx.beginPath();
2788
+ ctx.arc(x, y, base + pulse * base * 4.5, 0, TAU);
2789
+ ctx.stroke();
2790
+ ctx.globalAlpha = 1;
2791
+ }
2792
+
2793
+ const bubble = m.cluster || this.o.markerStyle === "bubble" || (this.o.markerStyle === "auto" && (m.emoji || m.count > 1));
2794
+ if (bubble && depth > 0.25) {
2795
+ // Cluster bubbles are capped to the grid cell so neighbours don't overlap.
2796
+ const R = (m.cluster ? Math.min(this.o.clusterRadius * 0.46, 13 + Math.log2(m.count + 1) * 2.2) : 15) * depth * scale;
2797
+ ctx.save();
2798
+ ctx.shadowColor = "rgba(0,0,0,.45)";
2799
+ ctx.shadowBlur = m.cluster ? 6 : 10;
2800
+ ctx.shadowOffsetY = 2;
2801
+ ctx.fillStyle = m.cluster ? color : t.bubble;
2802
+ ctx.beginPath();
2803
+ ctx.arc(x, y, R, 0, TAU);
2804
+ ctx.fill();
2805
+ ctx.restore();
2806
+ ctx.strokeStyle = m.cluster ? "rgba(255,255,255,.7)" : m.live ? t.live : color;
2807
+ ctx.lineWidth = 2.2 * scale;
2808
+ ctx.beginPath();
2809
+ ctx.arc(x, y, R, 0, TAU);
2810
+ ctx.stroke();
2811
+ ctx.textAlign = "center";
2812
+ ctx.textBaseline = "middle";
2813
+ if (m.cluster) {
2814
+ ctx.fillStyle = t.clusterLabel;
2815
+ ctx.font = `700 ${Math.round(R * 0.66)}px Inter,system-ui,sans-serif`;
2816
+ ctx.fillText(m.count > 999 ? `${Math.round(m.count / 100) / 10}k` : String(m.count), x, y + 1);
2817
+ } else if (m.emoji) {
2818
+ ctx.font = `${Math.round(R * 1.25)}px "Segoe UI Emoji","Apple Color Emoji",sans-serif`;
2819
+ ctx.fillText(m.emoji, x, y + 1);
2820
+ } else {
2821
+ ctx.fillStyle = t.label;
2822
+ ctx.font = `700 ${Math.round(R * 0.8)}px Inter,system-ui,sans-serif`;
2823
+ ctx.fillText(String(m.count || ""), x, y + 1);
2824
+ }
2825
+ if (!m.cluster && m.count > 1 && m.emoji) {
2826
+ ctx.fillStyle = color;
2827
+ ctx.beginPath();
2828
+ ctx.arc(x + R * 0.78, y - R * 0.78, 8.5 * scale, 0, TAU);
2829
+ ctx.fill();
2830
+ ctx.fillStyle = "#fff";
2831
+ ctx.font = `700 ${Math.round(10 * scale)}px Inter,system-ui,sans-serif`;
2832
+ ctx.fillText(String(m.count), x + R * 0.78, y - R * 0.78 + 1);
2833
+ }
2834
+ ctx.textAlign = "start";
2835
+ ctx.textBaseline = "alphabetic";
2836
+ return { marker: m, x, y, r: R };
2837
+ }
2838
+
2839
+ ctx.fillStyle = color;
2840
+ ctx.beginPath();
2841
+ ctx.arc(x, y, base, 0, TAU);
2842
+ ctx.fill();
2843
+ ctx.strokeStyle = "rgba(255,255,255,.9)";
2844
+ ctx.lineWidth = 0.8 * scale;
2845
+ ctx.stroke();
2846
+ return { marker: m, x, y, r: Math.max(base, 6) };
2847
+ }
2848
+ }
2849
+
2850
+ /** Convenience factory: `createGlobe(canvas, options)`. */
2851
+ export function createGlobe(canvas, options) {
2852
+ return new GeoGlobe(canvas, options);
2853
+ }
2854
+
2855
+ export { bundledWorld as world };
2856
+ export default createGlobe;