canvas-globe 1.1.0 → 1.2.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,850 @@
1
+ /** Pointer-driven effects. These respond to input rather than the clock. */
2
+ import { bar, clamp01, drawPath, easeOut, label, panel, particles, readout, ring, TAU } from "../runtime.js";
3
+ import { nearest, onDragPath, onTap, pointInPath } from "../pointer.js";
4
+
5
+ /** Monotonic milliseconds, for input-driven transitions that no clock owns. */
6
+ const stamp = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
7
+
8
+ /**
9
+ * Pins lean toward the cursor and label themselves as it closes in.
10
+ * Ports catalogue effect CA.
11
+ */
12
+ export const magneticMarkers = ({ reach = 120, pull = 0.42, color = "#34d399" } = {}) => ({
13
+ name: "magneticMarkers",
14
+ stage: "above",
15
+ duration: 1000,
16
+ frame(ctx, globe) {
17
+ const { x, y, over } = globe.pointer;
18
+ for (const m of globe.markers) {
19
+ const p = globe.project(m.lon, m.lat);
20
+ if (!p) continue;
21
+ const strength = over ? clamp01(1 - Math.hypot(p.x - x, p.y - y) / reach) : 0;
22
+ const px = p.x + (x - p.x) * strength * pull;
23
+ const py = p.y + (y - p.y) * strength * pull;
24
+ if (strength > 0.08) {
25
+ ctx.globalAlpha = strength * 0.65;
26
+ ctx.strokeStyle = m.color || color;
27
+ ctx.lineWidth = 1;
28
+ ctx.beginPath();
29
+ ctx.moveTo(p.x, p.y);
30
+ ctx.lineTo(px, py);
31
+ ctx.stroke();
32
+ ctx.globalAlpha = 1;
33
+ }
34
+ ctx.fillStyle = m.color || color;
35
+ ctx.shadowColor = m.color || color;
36
+ ctx.shadowBlur = strength * 20;
37
+ ctx.beginPath();
38
+ ctx.arc(px, py, 2.6 + strength * 7, 0, TAU);
39
+ ctx.fill();
40
+ if (strength > 0.16) {
41
+ ctx.shadowBlur = 0;
42
+ ctx.globalAlpha = strength * 0.7;
43
+ ctx.strokeStyle = m.color || color;
44
+ ctx.lineWidth = 1.2;
45
+ ctx.beginPath();
46
+ ctx.arc(px, py, 9 + strength * 12, 0, TAU);
47
+ ctx.stroke();
48
+ ctx.globalAlpha = 1;
49
+ }
50
+ const name = m.city || m.label || m.name;
51
+ if (strength > 0.38 && name) {
52
+ ctx.shadowBlur = 0;
53
+ ctx.font = "700 10px Inter, system-ui, sans-serif";
54
+ const width = ctx.measureText(name).width + 16;
55
+ panel(ctx, px - width / 2, py - 35, width, 21, { fill: "rgba(5,10,20,.9)", stroke: m.color || color, radius: 6 });
56
+ label(ctx, name, px, py - 21, { color: "#f8fafc", size: 10, weight: 700, align: "center" });
57
+ }
58
+ }
59
+ if (!over) {
60
+ label(ctx, "Move near a marker", 12, globe.canvas.clientHeight - 16,
61
+ { color: "rgba(232,236,245,.62)", size: 11, weight: 600 });
62
+ }
63
+ },
64
+ });
65
+
66
+ /**
67
+ * Click two points for a great-circle path and the real distance.
68
+ * Ports catalogue effect CJ.
69
+ */
70
+ export const measureTool = ({ color = "#34d399", accent = "#fbbf24" } = {}) => ({
71
+ name: "measureTool",
72
+ stage: "above",
73
+ duration: 1000,
74
+ setup(globe) {
75
+ const state = { a: null, b: null };
76
+ state.unbind = onTap(globe, (_p, at) => {
77
+ if (!at) return;
78
+ const point = { lon: at[0], lat: at[1] };
79
+ if (!state.a || state.b) {
80
+ state.a = point;
81
+ state.b = null;
82
+ } else {
83
+ state.b = point;
84
+ }
85
+ globe.invalidate();
86
+ });
87
+ return state;
88
+ },
89
+ frame(ctx, globe, _t, state) {
90
+ const dot = (point, fill) => {
91
+ const p = globe.project(point.lon, point.lat);
92
+ if (!p) return null;
93
+ ctx.fillStyle = fill;
94
+ ctx.beginPath();
95
+ ctx.arc(p.x, p.y, 4.5, 0, TAU);
96
+ ctx.fill();
97
+ return p;
98
+ };
99
+ if (state.a) dot(state.a, color);
100
+ if (!state.a || !state.b) return;
101
+ const end = dot(state.b, accent);
102
+ const path = [];
103
+ for (let i = 0; i <= 60; i++) {
104
+ const f = i / 60;
105
+ path.push([
106
+ state.a.lon + (state.b.lon - state.a.lon) * f,
107
+ state.a.lat + (state.b.lat - state.a.lat) * f,
108
+ ]);
109
+ }
110
+ ctx.strokeStyle = color;
111
+ ctx.lineWidth = 2;
112
+ ctx.setLineDash([5, 4]);
113
+ ctx.beginPath();
114
+ drawPath(ctx, globe, path);
115
+ ctx.stroke();
116
+ ctx.setLineDash([]);
117
+ if (!end) return;
118
+ const km = Math.round(greatCircleKm(state.a, state.b));
119
+ const text = `${km.toLocaleString()} km`;
120
+ ctx.font = "700 12px Inter, system-ui, sans-serif";
121
+ const w = ctx.measureText(text).width + 16;
122
+ ctx.fillStyle = "rgba(7,8,13,.92)";
123
+ ctx.beginPath();
124
+ ctx.roundRect(end.x - w / 2, end.y - 32, w, 20, 5);
125
+ ctx.fill();
126
+ ctx.strokeStyle = color;
127
+ ctx.lineWidth = 1;
128
+ ctx.stroke();
129
+ ctx.fillStyle = color;
130
+ ctx.textAlign = "center";
131
+ ctx.fillText(text, end.x, end.y - 18);
132
+ ctx.textAlign = "left";
133
+ },
134
+ dispose(state) {
135
+ state.unbind?.();
136
+ },
137
+ });
138
+
139
+ const greatCircleKm = (a, b) => {
140
+ const r = Math.PI / 180;
141
+ const c = Math.sin(a.lat * r) * Math.sin(b.lat * r) +
142
+ Math.cos(a.lat * r) * Math.cos(b.lat * r) * Math.cos((b.lon - a.lon) * r);
143
+ return Math.acos(Math.max(-1, Math.min(1, c))) * 6371.0088;
144
+ };
145
+
146
+ /**
147
+ * Drag a loop and it reports the aggregate of whatever it caught.
148
+ * Ports catalogue effect CK.
149
+ */
150
+ export const lassoSelect = ({
151
+ drawMode = false,
152
+ color = "#34d399",
153
+ accent = "#fbbf24",
154
+ caption = "SELECTED",
155
+ value = (hits) => `${hits.reduce((a, m) => a + (m.count || 1), 0).toLocaleString()} total`,
156
+ onSelect,
157
+ } = {}) => ({
158
+ name: "lassoSelect",
159
+ stage: "above",
160
+ duration: 1000,
161
+ setup(globe) {
162
+ const state = { path: null, hits: [], drawing: false };
163
+ state.unbind = onDragPath(globe, {
164
+ start: (path) => {
165
+ state.path = path;
166
+ state.drawing = true;
167
+ state.hits = [];
168
+ },
169
+ move: () => globe.invalidate(),
170
+ end: (path) => {
171
+ state.drawing = false;
172
+ state.hits = path.length > 4
173
+ ? globe.markers.filter((m) => {
174
+ const p = globe.project(m.lon, m.lat);
175
+ return p && pointInPath(p.x, p.y, path);
176
+ })
177
+ : [];
178
+ onSelect?.(state.hits);
179
+ globe.invalidate();
180
+ },
181
+ }, () => drawMode);
182
+ return state;
183
+ },
184
+ frame(ctx, globe, _t, state) {
185
+ const h = globe.canvas.clientHeight;
186
+ if (!drawMode) {
187
+ label(ctx, "ROTATE MODE Drag to turn the globe", 12, h - 16,
188
+ { color: "rgba(232,236,245,.68)", size: 10, weight: 700 });
189
+ } else if (!state.drawing && !state.hits.length) {
190
+ label(ctx, "DRAW MODE Drag a loop around markers", 12, h - 16,
191
+ { color, size: 10, weight: 700 });
192
+ }
193
+ if (state.path?.length > 1) {
194
+ ctx.beginPath();
195
+ state.path.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
196
+ if (!state.drawing) ctx.closePath();
197
+ ctx.fillStyle = "rgba(52,211,153,.13)";
198
+ ctx.strokeStyle = color;
199
+ ctx.lineWidth = 1.6;
200
+ ctx.setLineDash(state.drawing ? [4, 4] : []);
201
+ if (!state.drawing) ctx.fill();
202
+ ctx.stroke();
203
+ ctx.setLineDash([]);
204
+ }
205
+ for (const m of state.hits) {
206
+ const p = globe.project(m.lon, m.lat);
207
+ if (!p) continue;
208
+ ctx.strokeStyle = accent;
209
+ ctx.lineWidth = 2;
210
+ ctx.beginPath();
211
+ ctx.arc(p.x, p.y, 7, 0, TAU);
212
+ ctx.stroke();
213
+ }
214
+ if (state.hits.length) {
215
+ readout(ctx, 10, h - 54, `${state.hits.length} ${caption}`, value(state.hits), { accent: color });
216
+ }
217
+ },
218
+ dispose(state) {
219
+ state.unbind?.();
220
+ },
221
+ });
222
+
223
+ /**
224
+ * The hovered country lifts, glows and names itself.
225
+ * Ports catalogue effect CE.
226
+ */
227
+ export const hoverLift = ({ color = "52,211,153", scale = 1.12 } = {}) => ({
228
+ name: "hoverLift",
229
+ stage: "above",
230
+ duration: 1000,
231
+ setup(globe) {
232
+ const state = { shape: null };
233
+ const previous = globe.o.onCountryHover;
234
+ globe.setOptions({
235
+ onCountryHover: (shape, event) => {
236
+ state.shape = shape;
237
+ previous?.(shape, event);
238
+ globe.invalidate();
239
+ },
240
+ });
241
+ state.restore = () => globe.setOptions({ onCountryHover: previous });
242
+ return state;
243
+ },
244
+ frame(ctx, globe, _t, state) {
245
+ if (!state.shape) {
246
+ label(ctx, "Hover a country", 12, globe.canvas.clientHeight - 16,
247
+ { color: "rgba(232,236,245,.62)", size: 11, weight: 600 });
248
+ return;
249
+ }
250
+ const bounds = globe._shapeBox(state.shape);
251
+ const cx = (bounds[0] + bounds[2]) / 2, cy = (bounds[1] + bounds[3]) / 2;
252
+ ctx.save();
253
+ ctx.translate(0, -5);
254
+ ctx.beginPath();
255
+ globe.tracePath(state.shape, ctx, (c) => [cx + (c[0] - cx) * scale, cy + (c[1] - cy) * scale]);
256
+ ctx.fillStyle = `rgba(${color},.72)`;
257
+ ctx.shadowColor = `rgba(${color},1)`;
258
+ ctx.shadowBlur = 26;
259
+ ctx.fill();
260
+ ctx.shadowBlur = 0;
261
+ ctx.strokeStyle = "#ecfdf5";
262
+ ctx.lineWidth = 2;
263
+ ctx.stroke();
264
+ ctx.restore();
265
+ const p = globe.project(cx, cy);
266
+ if (!p || !state.shape.name) return;
267
+ ctx.font = "700 11px Inter, system-ui, sans-serif";
268
+ const w = ctx.measureText(state.shape.name).width + 14;
269
+ ctx.fillStyle = "#a7f3d0";
270
+ ctx.beginPath();
271
+ ctx.roundRect(p.x - w / 2, p.y - 38, w, 20, 5);
272
+ ctx.fill();
273
+ ctx.fillStyle = "#05231a";
274
+ ctx.textAlign = "center";
275
+ ctx.fillText(state.shape.name, p.x, p.y - 24);
276
+ ctx.textAlign = "left";
277
+ },
278
+ dispose(state) {
279
+ state.restore?.();
280
+ },
281
+ });
282
+
283
+ /**
284
+ * Click a location to fire a round-trip pulse and read its latency.
285
+ * Ports catalogue effect CT.
286
+ */
287
+ export const pingProbe = ({ from, color = "#34d399", head = "#fbbf24", duration = 1400 } = {}) => ({
288
+ name: "pingProbe",
289
+ stage: "above",
290
+ duration,
291
+ loop: false,
292
+ setup(globe) {
293
+ const state = { target: null, startedAt: 0 };
294
+ state.unbind = onTap(globe, (p) => {
295
+ const hit = nearest(globe, globe.markers, p.x, p.y, 34);
296
+ if (!hit) return;
297
+ state.target = hit;
298
+ state.startedAt = stamp();
299
+ globe.invalidate();
300
+ });
301
+ return state;
302
+ },
303
+ frame(ctx, globe, _t, state) {
304
+ const origin = from || globe.markers[0];
305
+ if (!origin) return;
306
+ const h = globe.canvas.clientHeight;
307
+ if (!state.target) {
308
+ for (const marker of globe.markers.slice(0, 6)) {
309
+ const p = globe.project(marker.lon, marker.lat);
310
+ if (!p) continue;
311
+ ctx.globalAlpha = 0.42;
312
+ ctx.strokeStyle = color;
313
+ ctx.beginPath();
314
+ ctx.arc(p.x, p.y, 8, 0, TAU);
315
+ ctx.stroke();
316
+ }
317
+ ctx.globalAlpha = 1;
318
+ label(ctx, "Click a marker to send a probe", 12, h - 16,
319
+ { color: "rgba(232,236,245,.65)", size: 11, weight: 600 });
320
+ return;
321
+ }
322
+ const t = clamp01((stamp() - state.startedAt) / duration);
323
+ const path = [];
324
+ for (let i = 0; i <= 50; i++) {
325
+ const f = i / 50;
326
+ path.push([
327
+ origin.lon + (state.target.lon - origin.lon) * f,
328
+ origin.lat + (state.target.lat - origin.lat) * f,
329
+ ]);
330
+ }
331
+ ctx.strokeStyle = `rgba(52,211,153,.4)`;
332
+ ctx.lineWidth = 1.4;
333
+ ctx.beginPath();
334
+ drawPath(ctx, globe, path);
335
+ ctx.stroke();
336
+ const travel = t < 0.5 ? t * 2 : 1 - (t - 0.5) * 2;
337
+ const at = path[Math.floor(travel * (path.length - 1))];
338
+ const p = globe.project(at[0], at[1]);
339
+ if (p) {
340
+ ctx.fillStyle = head;
341
+ ctx.shadowColor = head;
342
+ ctx.shadowBlur = 12;
343
+ ctx.beginPath();
344
+ ctx.arc(p.x, p.y, 3.6, 0, TAU);
345
+ ctx.fill();
346
+ ctx.shadowBlur = 0;
347
+ }
348
+ const name = state.target.label || state.target.city || state.target.code || "region";
349
+ const latency = state.target.ms ?? 28 + Math.abs(Math.round(state.target.lon * 1.7 + state.target.lat)) % 94;
350
+ readout(ctx, 10, h - 50, String(name).toUpperCase(),
351
+ t >= 1 ? `${latency} ms` : "Pinging...", { accent: color });
352
+ if (t < 1) globe.invalidate();
353
+ },
354
+ dispose(state) {
355
+ state.unbind?.();
356
+ },
357
+ });
358
+
359
+ /**
360
+ * Background stars track the cursor at a shallower rate than the sphere, so a
361
+ * flat canvas reads as depth. Ports catalogue effect CC.
362
+ */
363
+ export const parallaxTilt = ({ count = 70, depth = 26, seed = 12, camera = 0 } = {}) => ({
364
+ name: "parallaxTilt",
365
+ stage: "beneath",
366
+ z: -20,
367
+ duration: 1000,
368
+ setup() {
369
+ return { field: particles(count, seed, (r) => ({ x: r(), y: r(), z: 0.3 + r() })) };
370
+ },
371
+ frame(ctx, globe, _t, state) {
372
+ const w = globe.canvas.clientWidth, h = globe.canvas.clientHeight;
373
+ const { x, y, over } = globe.pointer;
374
+ const dx = over ? (x / w - 0.5) * 2 : 0;
375
+ const dy = over ? (y / h - 0.5) * 2 : 0;
376
+ for (const s of state.field) {
377
+ ctx.fillStyle = `rgba(255,255,255,${0.2 + s.z * 0.5})`;
378
+ ctx.beginPath();
379
+ ctx.arc(s.x * w - dx * depth * s.z, s.y * h - dy * depth * s.z, s.z * 1.3, 0, TAU);
380
+ ctx.fill();
381
+ }
382
+ // Nudging the camera as well sells the tilt, but it fights dragging.
383
+ if (camera && over && !globe._drag) {
384
+ globe.lon += (dx * camera - (state.lon ?? 0)) * 0.12;
385
+ state.lon = dx * camera;
386
+ }
387
+ },
388
+ });
389
+
390
+ /**
391
+ * Specular highlight and terminator follow the pointer, relighting the sphere
392
+ * in real time. Ports catalogue effect CD.
393
+ */
394
+ export const cursorLight = ({ warmth = 0.26, darkness = 0.82, spec = 0.5 } = {}) => ({
395
+ name: "cursorLight",
396
+ stage: "above",
397
+ z: 40,
398
+ duration: 1000,
399
+ frame(ctx, globe) {
400
+ if (globe.o.mode !== "globe") return;
401
+ const w = globe.canvas.clientWidth, h = globe.canvas.clientHeight;
402
+ const cx = w / 2, cy = h / 2, r = Math.min(w, h) * 0.4;
403
+ const { x, y, over } = globe.pointer;
404
+ const lx = over ? x : cx - r * 0.5, ly = over ? y : cy - r * 0.5;
405
+ ctx.save();
406
+ ctx.beginPath();
407
+ ctx.arc(cx, cy, r, 0, TAU);
408
+ ctx.clip();
409
+ const shade = ctx.createRadialGradient(lx, ly, r * 0.1, lx, ly, r * 1.9);
410
+ shade.addColorStop(0, `rgba(255,255,255,${warmth})`);
411
+ shade.addColorStop(0.35, "rgba(0,0,0,0)");
412
+ shade.addColorStop(1, `rgba(0,0,0,${darkness})`);
413
+ ctx.fillStyle = shade;
414
+ ctx.fillRect(cx - r, cy - r, r * 2, r * 2);
415
+ const hot = ctx.createRadialGradient(lx, ly, 0, lx, ly, r * 0.34);
416
+ hot.addColorStop(0, `rgba(255,255,255,${spec})`);
417
+ hot.addColorStop(1, "rgba(255,255,255,0)");
418
+ ctx.fillStyle = hot;
419
+ ctx.fillRect(cx - r, cy - r, r * 2, r * 2);
420
+ ctx.restore();
421
+ },
422
+ });
423
+
424
+ /**
425
+ * Tap a country to frame it, tap again to pull back out.
426
+ * Ports catalogue effect CF.
427
+ */
428
+ export const drillDown = ({ padding = 0.72, color = "#34d399", onEnter, onExit } = {}) => ({
429
+ name: "drillDown",
430
+ stage: "above",
431
+ duration: 1000,
432
+ setup(globe) {
433
+ const state = { inside: null, home: { lon: globe.lon, lat: globe.lat, zoom: globe.zoom } };
434
+ state.unbind = onTap(globe, (p) => {
435
+ if (state.inside) {
436
+ state.inside = null;
437
+ globe.clearFocus();
438
+ globe.flyTo(state.home.lon, state.home.lat, { zoom: state.home.zoom });
439
+ onExit?.(globe);
440
+ } else {
441
+ const shape = globe.countryAt(p.x, p.y);
442
+ if (!shape) return;
443
+ state.inside = shape;
444
+ globe.focusOn(shape.code || shape.name, { padding });
445
+ onEnter?.(shape, globe);
446
+ }
447
+ globe.invalidate();
448
+ });
449
+ return state;
450
+ },
451
+ frame(ctx, globe, _t, state) {
452
+ if (!state.inside) return;
453
+ const h = globe.canvas.clientHeight;
454
+ label(ctx, `${state.inside.name} - tap to go back`, 12, h - 14, { color, size: 12, weight: 700 });
455
+ },
456
+ dispose(state) {
457
+ state.unbind?.();
458
+ },
459
+ });
460
+
461
+ /**
462
+ * Tap the globe for a ring of actions drawn around the point.
463
+ * Ports catalogue effect CH.
464
+ */
465
+ export const radialMenu = ({
466
+ items = ["Zoom", "Pin", "Share", "Details"],
467
+ radius = 52,
468
+ color = "#34d399",
469
+ onPick,
470
+ } = {}) => ({
471
+ name: "radialMenu",
472
+ stage: "above",
473
+ z: 50,
474
+ duration: 260,
475
+ loop: false,
476
+ setup(globe) {
477
+ const state = { at: null, openedAt: 0 };
478
+ state.unbind = onTap(globe, (p, at) => {
479
+ if (state.at) {
480
+ // A second tap either picks a wedge or dismisses the menu.
481
+ const dx = p.x - state.at.x, dy = p.y - state.at.y;
482
+ const dist = Math.hypot(dx, dy);
483
+ if (dist > radius * 0.4 && dist < radius * 1.6) {
484
+ const step = TAU / items.length;
485
+ const angle = (Math.atan2(dy, dx) + TAU + step / 2) % TAU;
486
+ onPick?.(items[Math.floor(angle / step) % items.length], state.at.geo, globe);
487
+ }
488
+ state.at = null;
489
+ } else {
490
+ state.at = { x: p.x, y: p.y, geo: at };
491
+ state.openedAt = stamp();
492
+ }
493
+ globe.invalidate();
494
+ });
495
+ return state;
496
+ },
497
+ frame(ctx, globe, _t, state) {
498
+ if (!state.at) return;
499
+ const grow = easeOut(clamp01((stamp() - state.openedAt) / 220));
500
+ if (grow < 1) globe.invalidate();
501
+ const { x, y } = state.at;
502
+ const step = TAU / items.length;
503
+ ctx.fillStyle = color;
504
+ ctx.beginPath();
505
+ ctx.arc(x, y, 4, 0, TAU);
506
+ ctx.fill();
507
+ items.forEach((item, i) => {
508
+ const a = i * step - Math.PI / 2;
509
+ const r = radius * grow;
510
+ const ix = x + Math.cos(a) * r, iy = y + Math.sin(a) * r;
511
+ ctx.font = "700 10px Inter, system-ui, sans-serif";
512
+ const w = ctx.measureText(item).width + 18;
513
+ panel(ctx, ix - w / 2, iy - 11, w, 22, { stroke: color, radius: 11 });
514
+ ctx.globalAlpha = grow;
515
+ label(ctx, item, ix, iy + 3.5, { color, size: 10, weight: 700, align: "center" });
516
+ ctx.globalAlpha = 1;
517
+ });
518
+ },
519
+ dispose(state) {
520
+ state.unbind?.();
521
+ },
522
+ });
523
+
524
+ /**
525
+ * Tap to spin the globe; it coasts to a stop on one of the markers.
526
+ * Ports catalogue effect CI.
527
+ */
528
+ export const spinToWin = ({
529
+ color = "#fbbf24",
530
+ caption = "LANDED ON",
531
+ duration = 2600,
532
+ turns = 2.75,
533
+ onLand,
534
+ } = {}) => ({
535
+ name: "spinToWin",
536
+ stage: "above",
537
+ duration: 1000,
538
+ setup(globe) {
539
+ const state = { winner: null, target: null, seed: 1, startedAt: 0, fromLon: 0, fromLat: 0 };
540
+ state.unbind = onTap(globe, () => {
541
+ if (state.target || !globe.markers.length) return;
542
+ // A deterministic pick keeps the demo reproducible across reloads.
543
+ state.seed = (state.seed * 1103515245 + 12345) & 0x7fffffff;
544
+ const pick = globe.markers[state.seed % globe.markers.length];
545
+ state.target = pick;
546
+ state.winner = null;
547
+ state.startedAt = stamp();
548
+ state.fromLon = globe.lon;
549
+ state.fromLat = globe.lat;
550
+ globe.invalidate();
551
+ });
552
+ return state;
553
+ },
554
+ frame(ctx, globe, _t, state) {
555
+ const w = globe.canvas.clientWidth, h = globe.canvas.clientHeight;
556
+ if (state.target) {
557
+ const k = clamp01((stamp() - state.startedAt) / duration);
558
+ const coast = 1 - Math.pow(1 - k, 3);
559
+ const delta = ((state.target.lon - state.fromLon + 540) % 360) - 180;
560
+ globe.lon = state.fromLon + (turns * 360 + delta) * coast;
561
+ globe.lat = state.fromLat + (state.target.lat - state.fromLat) * easeOut(k);
562
+ panel(ctx, Math.max(12, w / 2 - 145), h - 62, Math.min(290, w - 24), 48,
563
+ { fill: "rgba(5,10,20,.92)", stroke: "rgba(251,191,36,.38)", radius: 8 });
564
+ label(ctx, "SPINNING", Math.max(26, w / 2 - 130), h - 40,
565
+ { color, size: 9, weight: 800 });
566
+ bar(ctx, Math.max(26, w / 2 - 130), h - 31, Math.min(260, w - 52), 4, k,
567
+ { fill: color, track: "rgba(255,255,255,.12)" });
568
+ if (k >= 1) {
569
+ globe.lon = state.target.lon;
570
+ globe.lat = state.target.lat;
571
+ state.winner = state.target;
572
+ state.target = null;
573
+ onLand?.(state.winner, globe);
574
+ } else {
575
+ globe.invalidate();
576
+ return;
577
+ }
578
+ }
579
+ if (!state.winner) {
580
+ ctx.font = "700 11px Inter, system-ui, sans-serif";
581
+ const copy = "Click globe to spin";
582
+ const chipW = ctx.measureText(copy).width + 30;
583
+ panel(ctx, w / 2 - chipW / 2, h - 54, chipW, 34,
584
+ { fill: "rgba(5,10,20,.9)", stroke: "rgba(251,191,36,.38)", radius: 17 });
585
+ label(ctx, copy, w / 2, h - 32, { color, size: 11, weight: 700, align: "center" });
586
+ return;
587
+ }
588
+ const name = state.winner.city || state.winner.label || state.winner.name || "";
589
+ readout(ctx, 10, h - 52, caption, name, { accent: color });
590
+ const p = globe.project(state.winner.lon, state.winner.lat);
591
+ if (!p) return;
592
+ ctx.strokeStyle = color;
593
+ ctx.shadowColor = color;
594
+ ctx.shadowBlur = 16;
595
+ ctx.lineWidth = 2;
596
+ ctx.beginPath();
597
+ ctx.arc(p.x, p.y, 10, 0, TAU);
598
+ ctx.stroke();
599
+ ctx.shadowBlur = 0;
600
+ ring(ctx, p.x, p.y, 18, color, 0.55, 1.4);
601
+ },
602
+ dispose(state) {
603
+ state.unbind?.();
604
+ },
605
+ });
606
+
607
+ /**
608
+ * Tap to drop a centre and see a true great-circle coverage ring around it.
609
+ * Ports catalogue effect CL.
610
+ */
611
+ export const serviceRadius = ({
612
+ km = 1200,
613
+ color = "#34d399",
614
+ caption = "WITHIN RADIUS",
615
+ at,
616
+ } = {}) => ({
617
+ name: "serviceRadius",
618
+ stage: "above",
619
+ duration: 1000,
620
+ setup(globe) {
621
+ const state = { centre: at || null, km };
622
+ state.unbind = onTap(globe, (_p, geo) => {
623
+ if (!geo) return;
624
+ state.centre = { lon: geo[0], lat: geo[1] };
625
+ globe.invalidate();
626
+ });
627
+ return state;
628
+ },
629
+ frame(ctx, globe, _t, state) {
630
+ if (!state.centre) {
631
+ label(ctx, "tap to drop a centre", 12, globe.canvas.clientHeight - 16,
632
+ { color: "rgba(232,236,245,.6)", size: 12, weight: 600 });
633
+ return;
634
+ }
635
+ const degrees = (state.km / 6371.0088) * (180 / Math.PI);
636
+ const loop = ring(state.centre.lon, state.centre.lat, degrees);
637
+ ctx.fillStyle = "rgba(52,211,153,.12)";
638
+ ctx.strokeStyle = color;
639
+ ctx.lineWidth = 1.6;
640
+ ctx.beginPath();
641
+ drawPath(ctx, globe, loop, true);
642
+ ctx.fill();
643
+ ctx.stroke();
644
+ const p = globe.project(state.centre.lon, state.centre.lat);
645
+ if (p) {
646
+ ctx.fillStyle = color;
647
+ ctx.beginPath();
648
+ ctx.arc(p.x, p.y, 4, 0, TAU);
649
+ ctx.fill();
650
+ }
651
+ const inside = globe.markers.filter(
652
+ (m) => greatCircleKm(state.centre, m) <= state.km
653
+ );
654
+ readout(ctx, 10, globe.canvas.clientHeight - 52, caption,
655
+ `${inside.length} of ${globe.markers.length}`, { accent: color });
656
+ },
657
+ dispose(state) {
658
+ state.unbind?.();
659
+ },
660
+ });
661
+
662
+ /**
663
+ * Tap cities to build a set, and read the working hours they share.
664
+ * Ports catalogue effect CN.
665
+ */
666
+ export const timezoneOverlap = ({
667
+ color = "#34d399",
668
+ warn = "#fbbf24",
669
+ dayStart = 9,
670
+ dayEnd = 18,
671
+ } = {}) => ({
672
+ name: "timezoneOverlap",
673
+ stage: "above",
674
+ duration: 1000,
675
+ setup(globe) {
676
+ const state = { picked: [] };
677
+ state.unbind = onTap(globe, (p) => {
678
+ const hit = nearest(globe, globe.markers, p.x, p.y, 30);
679
+ if (!hit) return;
680
+ const i = state.picked.indexOf(hit);
681
+ if (i < 0) state.picked.push(hit);
682
+ else state.picked.splice(i, 1);
683
+ globe.invalidate();
684
+ });
685
+ return state;
686
+ },
687
+ frame(ctx, globe, _t, state) {
688
+ const w = globe.canvas.clientWidth, h = globe.canvas.clientHeight;
689
+ for (const m of state.picked) {
690
+ const p = globe.project(m.lon, m.lat);
691
+ if (!p) continue;
692
+ ctx.strokeStyle = color;
693
+ ctx.lineWidth = 2;
694
+ ctx.beginPath();
695
+ ctx.arc(p.x, p.y, 8, 0, TAU);
696
+ ctx.stroke();
697
+ }
698
+ if (!state.picked.length) {
699
+ label(ctx, "tap cities to compare hours", 12, h - 16,
700
+ { color: "rgba(232,236,245,.6)", size: 12, weight: 600 });
701
+ return;
702
+ }
703
+ // Longitude is a good enough stand-in for an offset without a tz database.
704
+ const offsets = state.picked.map((m) => Math.round(m.lon / 15));
705
+ const shared = [];
706
+ for (let utc = 0; utc < 24; utc++) {
707
+ if (offsets.every((o) => {
708
+ const local = (utc + o + 24) % 24;
709
+ return local >= dayStart && local < dayEnd;
710
+ })) shared.push(utc);
711
+ }
712
+ const bx = 12, by = h - 46, bw = Math.min(280, w - 24), cell = bw / 24;
713
+ panel(ctx, bx - 6, by - 18, bw + 12, 46, { stroke: "rgba(232,236,245,.12)" });
714
+ label(ctx, shared.length ? `${shared.length} h overlap` : "no shared hours",
715
+ bx, by - 4, { color: shared.length ? color : warn, size: 11, weight: 700 });
716
+ for (let utc = 0; utc < 24; utc++) {
717
+ ctx.fillStyle = shared.includes(utc) ? color : "rgba(232,236,245,.12)";
718
+ ctx.fillRect(bx + utc * cell, by + 4, cell - 1, 12);
719
+ }
720
+ label(ctx, "00", bx, by + 28, { color: "rgba(232,236,245,.45)", size: 9 });
721
+ label(ctx, "24 UTC", bx + bw - 30, by + 28, { color: "rgba(232,236,245,.45)", size: 9 });
722
+ },
723
+ dispose(state) {
724
+ state.unbind?.();
725
+ },
726
+ });
727
+
728
+ /**
729
+ * Tap two countries for a side-by-side readout of whatever you supply.
730
+ * Ports catalogue effect CP.
731
+ */
732
+ export const compareCountries = ({
733
+ color = "#34d399",
734
+ accent = "#fbbf24",
735
+ metric = "value",
736
+ values = {},
737
+ } = {}) => ({
738
+ name: "compareCountries",
739
+ stage: "above",
740
+ duration: 1000,
741
+ setup(globe) {
742
+ const state = { a: null, b: null };
743
+ state.unbind = onTap(globe, (p) => {
744
+ const shape = globe.countryAt(p.x, p.y);
745
+ if (!shape) return;
746
+ if (!state.a || state.b) {
747
+ state.a = shape;
748
+ state.b = null;
749
+ } else if (shape !== state.a) {
750
+ state.b = shape;
751
+ }
752
+ globe.invalidate();
753
+ });
754
+ return state;
755
+ },
756
+ frame(ctx, globe, _t, state) {
757
+ const h = globe.canvas.clientHeight;
758
+ const tint = (shape, stroke) => {
759
+ if (!shape) return;
760
+ ctx.strokeStyle = stroke;
761
+ ctx.lineWidth = 1.6;
762
+ ctx.beginPath();
763
+ globe.tracePath(shape, ctx);
764
+ ctx.stroke();
765
+ };
766
+ tint(state.a, color);
767
+ tint(state.b, accent);
768
+ if (!state.a) {
769
+ label(ctx, "tap two countries", 12, h - 16,
770
+ { color: "rgba(232,236,245,.6)", size: 12, weight: 600 });
771
+ return;
772
+ }
773
+ const read = (shape) => values[shape.code] ?? values[shape.name] ?? 0;
774
+ const va = read(state.a), vb = state.b ? read(state.b) : 0;
775
+ const peak = Math.max(va, vb, 1);
776
+ panel(ctx, 10, h - 78, 236, 68, { stroke: "rgba(232,236,245,.12)" });
777
+ label(ctx, metric.toUpperCase(), 20, h - 60, { color: "rgba(232,236,245,.5)", size: 9, weight: 700 });
778
+ const row = (shape, value, fill, y) => {
779
+ if (!shape) return;
780
+ label(ctx, shape.name, 20, y, { size: 11, weight: 600 });
781
+ label(ctx, value.toLocaleString(), 236, y, { size: 11, weight: 700, color: fill, align: "right" });
782
+ bar(ctx, 20, y + 5, 216, 4, value / peak, { fill });
783
+ };
784
+ row(state.a, va, color, h - 42);
785
+ row(state.b, vb, accent, h - 22);
786
+ },
787
+ dispose(state) {
788
+ state.unbind?.();
789
+ },
790
+ });
791
+
792
+ /**
793
+ * Names a country and asks the viewer to find it. Ports catalogue effect CS.
794
+ */
795
+ export const geoQuiz = ({
796
+ pool,
797
+ color = "#34d399",
798
+ wrong = "#f87171",
799
+ rounds = 5,
800
+ onAnswer,
801
+ } = {}) => ({
802
+ name: "geoQuiz",
803
+ stage: "above",
804
+ duration: 1000,
805
+ setup(globe) {
806
+ const names = pool && pool.length ? pool : globe.world.slice(0, 40).map((s) => s.name);
807
+ const state = { names, ask: names[0], score: 0, asked: 0, flash: null };
808
+ state.unbind = onTap(globe, (p) => {
809
+ if (state.asked >= rounds) return;
810
+ const shape = globe.countryAt(p.x, p.y);
811
+ if (!shape) return;
812
+ const right = shape.name === state.ask;
813
+ if (right) state.score++;
814
+ state.asked++;
815
+ state.flash = { shape, right, at: stamp() };
816
+ onAnswer?.(right, shape, globe);
817
+ state.ask = state.names[(state.names.indexOf(state.ask) + 7) % state.names.length];
818
+ globe.invalidate();
819
+ });
820
+ return state;
821
+ },
822
+ frame(ctx, globe, _t, state) {
823
+ const w = globe.canvas.clientWidth;
824
+ const done = state.asked >= rounds;
825
+ panel(ctx, w / 2 - 110, 12, 220, 44, { stroke: "rgba(232,236,245,.14)" });
826
+ label(ctx, done ? "FINAL SCORE" : `FIND - ${state.asked + 1}/${rounds}`, w / 2, 29,
827
+ { color: "rgba(232,236,245,.5)", size: 9, weight: 700, align: "center" });
828
+ label(ctx, done ? `${state.score} / ${rounds}` : state.ask, w / 2, 47,
829
+ { color, size: 14, weight: 700, align: "center" });
830
+ if (!state.flash) return;
831
+ const age = (stamp() - state.flash.at) / 700;
832
+ if (age >= 1) {
833
+ state.flash = null;
834
+ return;
835
+ }
836
+ globe.invalidate();
837
+ ctx.globalAlpha = 1 - age;
838
+ ctx.fillStyle = state.flash.right ? "rgba(52,211,153,.35)" : "rgba(248,113,113,.35)";
839
+ ctx.strokeStyle = state.flash.right ? color : wrong;
840
+ ctx.lineWidth = 2;
841
+ ctx.beginPath();
842
+ globe.tracePath(state.flash.shape, ctx);
843
+ ctx.fill();
844
+ ctx.stroke();
845
+ ctx.globalAlpha = 1;
846
+ },
847
+ dispose(state) {
848
+ state.unbind?.();
849
+ },
850
+ });