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,209 @@
1
+ /**
2
+ * Transitions and camera moves.
3
+ *
4
+ * Camera effects run at the `beneath` stage so the move lands on the frame
5
+ * being drawn rather than the next one, and they derive the view purely from
6
+ * the clock, which keeps them seekable.
7
+ */
8
+ import { clamp01, drawPath, easeInOut, label, lerp, panel, TAU } from "../runtime.js";
9
+
10
+ const clampZoom = (globe, z) => {
11
+ if (Math.abs(globe.zoom - z) > 1e-4) globe.setZoom(z);
12
+ };
13
+
14
+ /**
15
+ * Stroke start and end travel independently, so a line snakes into place.
16
+ * Ports catalogue effect BT.
17
+ */
18
+ export const trimPaths = ({
19
+ from,
20
+ to,
21
+ duration = 3400,
22
+ hold = 900,
23
+ steps = 70,
24
+ hue = 196,
25
+ width = 2.2,
26
+ } = {}) => ({
27
+ name: "trimPaths",
28
+ stage: "above",
29
+ duration,
30
+ hold,
31
+ setup(globe) {
32
+ const hub = from || globe.markers[0];
33
+ const targets = to || globe.markers.slice(1, 7);
34
+ const routes = targets.map((c) =>
35
+ Array.from({ length: steps + 1 }, (_, j) => {
36
+ const f = j / steps;
37
+ return [lerp(hub.lon, c.lon, f), lerp(hub.lat, c.lat, f)];
38
+ })
39
+ );
40
+ return { routes };
41
+ },
42
+ frame(ctx, globe, t, state) {
43
+ ctx.lineCap = "round";
44
+ ctx.lineWidth = width;
45
+ state.routes.forEach((pts, i) => {
46
+ const k = clamp01(t * 1.5 - i * 0.07);
47
+ const end = easeInOut(clamp01(k * 1.6));
48
+ const start = easeInOut(clamp01(k * 1.6 - 0.38));
49
+ const i0 = Math.floor(pts.length * start), i1 = Math.ceil(pts.length * end);
50
+ ctx.strokeStyle = `hsl(${hue + i * 12} 92% 62%)`;
51
+ ctx.beginPath();
52
+ let open = false;
53
+ for (let j = i0; j < i1; j++) {
54
+ const p = globe.project(pts[j][0], pts[j][1]);
55
+ if (!p) {
56
+ open = false;
57
+ continue;
58
+ }
59
+ open ? ctx.lineTo(p.x, p.y) : (ctx.moveTo(p.x, p.y), (open = true));
60
+ }
61
+ ctx.stroke();
62
+ });
63
+ },
64
+ });
65
+
66
+ /**
67
+ * A wobbling blob swallows the frame instead of a hard geometric wipe.
68
+ * Ports catalogue effect BV.
69
+ */
70
+ export const liquidWipe = ({ duration = 2600, hold = 800, lobes = 5, wobble = 0.13 } = {}) => ({
71
+ name: "liquidWipe",
72
+ stage: "post",
73
+ z: 80,
74
+ duration,
75
+ hold,
76
+ frame(ctx, globe, t) {
77
+ const w = globe.canvas.clientWidth, h = globe.canvas.clientHeight;
78
+ const dpr = globe.canvas.width / Math.max(1, w);
79
+ const cx = w / 2, cy = h / 2;
80
+ const base = t * Math.hypot(w, h) * 0.78;
81
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
82
+ ctx.globalCompositeOperation = "destination-in";
83
+ ctx.beginPath();
84
+ for (let a = 0; a <= 72; a++) {
85
+ const ang = (a / 72) * TAU;
86
+ const wob = 1 + Math.sin(ang * lobes + t * 9) * wobble + Math.sin(ang * 3 - t * 6) * 0.09;
87
+ const r = base * wob;
88
+ const x = cx + Math.cos(ang) * r, y = cy + Math.sin(ang) * r;
89
+ a ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
90
+ }
91
+ ctx.closePath();
92
+ ctx.fill();
93
+ ctx.globalCompositeOperation = "source-over";
94
+ },
95
+ });
96
+
97
+ /**
98
+ * Continuous push-in where each scale hands off to the next without a seam.
99
+ * Ports catalogue effect BU.
100
+ */
101
+ export const matchCut = ({
102
+ duration = 5200,
103
+ from = { lon: 20, lat: 14 },
104
+ to = { lon: 72.58, lat: 23.03 },
105
+ cycles = 2,
106
+ maxZoom = 7.8,
107
+ fromLabel = "Origin",
108
+ toLabel = "Destination",
109
+ color = "#38bdf8",
110
+ } = {}) => ({
111
+ name: "matchCut",
112
+ stage: "above",
113
+ z: 52,
114
+ duration,
115
+ frame(ctx, globe, t) {
116
+ const p = (t * cycles) % 1;
117
+ clampZoom(globe, Math.min(maxZoom, Math.pow(2, p * 2.2) * 0.9));
118
+ const e = easeInOut(t);
119
+ globe.lon = lerp(from.lon, to.lon, e);
120
+ globe.lat = lerp(from.lat, to.lat, e);
121
+ const path = Array.from({ length: 65 }, (_, i) => {
122
+ const k = i / 64;
123
+ return [lerp(from.lon, to.lon, k), lerp(from.lat, to.lat, k) + Math.sin(k * Math.PI) * 10];
124
+ });
125
+ ctx.save();
126
+ ctx.strokeStyle = color;
127
+ ctx.globalAlpha = 0.62;
128
+ ctx.lineWidth = 1.8;
129
+ ctx.setLineDash([5, 5]);
130
+ ctx.beginPath();
131
+ drawPath(ctx, globe, path);
132
+ ctx.stroke();
133
+ ctx.setLineDash([]);
134
+ const drawPoint = (point, labelText, active) => {
135
+ const q = globe.project(point.lon, point.lat);
136
+ if (!q) return;
137
+ ctx.globalAlpha = active ? 1 : 0.6;
138
+ ctx.fillStyle = active ? "#fff" : color;
139
+ ctx.strokeStyle = color;
140
+ ctx.lineWidth = 2;
141
+ ctx.beginPath();
142
+ ctx.arc(q.x, q.y, active ? 6 : 4, 0, TAU);
143
+ ctx.fill();
144
+ ctx.beginPath();
145
+ ctx.arc(q.x, q.y, active ? 13 : 9, 0, TAU);
146
+ ctx.stroke();
147
+ label(ctx, labelText, q.x, q.y - 17, { color: "#fff", size: 9, weight: 800, align: "center" });
148
+ };
149
+ drawPoint(from, fromLabel, t < 0.5);
150
+ drawPoint(to, toLabel, t >= 0.5);
151
+ const w = globe.canvas.clientWidth, h = globe.canvas.clientHeight;
152
+ panel(ctx, Math.max(12, w / 2 - 150), h - 58, Math.min(300, w - 24), 44, { fill: "rgba(5,10,20,.9)", stroke: "rgba(232,236,245,.18)", radius: 8 });
153
+ ctx.globalAlpha = 1;
154
+ label(ctx, fromLabel, Math.max(25, w / 2 - 134), h - 31, { color: t < 0.5 ? "#fff" : "rgba(232,236,245,.55)", size: 10, weight: 700 });
155
+ label(ctx, "→", w / 2, h - 31, { color, size: 15, weight: 800, align: "center" });
156
+ label(ctx, toLabel, Math.min(w - 25, w / 2 + 134), h - 31, { color: t >= 0.5 ? "#fff" : "rgba(232,236,245,.55)", size: 10, weight: 700, align: "right" });
157
+ ctx.restore();
158
+ },
159
+ });
160
+
161
+ /**
162
+ * Punches in from a distant speck to a framed region.
163
+ * Ports catalogue effect D.
164
+ */
165
+ export const dropFromOrbit = ({
166
+ duration = 3000,
167
+ hold = 800,
168
+ at,
169
+ fromZoom = 0.55,
170
+ toZoom = 2.75,
171
+ } = {}) => ({
172
+ name: "dropFromOrbit",
173
+ stage: "beneath",
174
+ z: -90,
175
+ duration,
176
+ hold,
177
+ loop: false,
178
+ setup(globe) {
179
+ return { target: at || globe.markers[0] || { lon: globe.lon, lat: globe.lat } };
180
+ },
181
+ frame(_ctx, globe, t, state) {
182
+ const e = easeInOut(t);
183
+ clampZoom(globe, lerp(fromZoom, toZoom, e));
184
+ globe.lon = lerp(state.target.lon - 40, state.target.lon, e);
185
+ globe.lat = lerp(state.target.lat - 18, state.target.lat, e);
186
+ },
187
+ });
188
+
189
+ /**
190
+ * A full twenty-four hours of terminator in one cycle. Needs `terminator: true`.
191
+ * Ports catalogue effect I.
192
+ */
193
+ export const dayNightSweep = ({ duration = 6000, start = Date.UTC(2024, 5, 21, 0) } = {}) => ({
194
+ name: "dayNightSweep",
195
+ stage: "beneath",
196
+ z: -95,
197
+ duration,
198
+ setup(globe) {
199
+ const previous = { terminator: globe.o.terminator, time: globe.o.time };
200
+ globe.setOptions({ terminator: true });
201
+ return previous;
202
+ },
203
+ frame(_ctx, globe, t) {
204
+ globe.o.time = start + t * 86400000;
205
+ },
206
+ dispose(previous, globe) {
207
+ globe.setOptions(previous);
208
+ },
209
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Effect kit for canvas-globe. Import only what you use - every effect is a
3
+ * separate export so a bundler can drop the rest.
4
+ *
5
+ * import { createGlobe } from "canvas-globe";
6
+ * import { scanlines, counterRoll } from "canvas-globe/fx";
7
+ *
8
+ * const globe = createGlobe(canvas, { preset: "midnight" });
9
+ * globe.use(scanlines());
10
+ * globe.use(counterRoll({ to: 21947, caption: "customers" }));
11
+ */
12
+
13
+ export { particleAssemble, radarSweep, markerCascade } from "./effects/entrances.js";
14
+ export {
15
+ scanlines, breathe, lightTrails, torch, meshGradient, cityLights,
16
+ starfield, shineSweep, glassSphere, neonFlicker,
17
+ } from "./effects/ambient.js";
18
+ export {
19
+ counterRoll, shockwave, choroplethCascade, routeDashes, tally,
20
+ arcLaunch, spikesRising, pinDrop, orbitSubject, markerBloom,
21
+ } from "./effects/data.js";
22
+ export {
23
+ trimPaths, liquidWipe, matchCut, dropFromOrbit, dayNightSweep,
24
+ } from "./effects/transitions.js";
25
+ export {
26
+ mapUnfold, firework, glitch, lowerThird, splitFlap, textOnCircle, countryMatte,
27
+ ghostTrail, windField, aurora, jellySquash, dataDesk,
28
+ } from "./effects/scene.js";
29
+ export {
30
+ magneticMarkers,
31
+ measureTool,
32
+ lassoSelect,
33
+ hoverLift,
34
+ pingProbe,
35
+ parallaxTilt,
36
+ cursorLight,
37
+ drillDown,
38
+ radialMenu,
39
+ spinToWin,
40
+ serviceRadius,
41
+ timezoneOverlap,
42
+ compareCountries,
43
+ geoQuiz,
44
+ } from "./effects/interaction.js";
45
+
46
+ export {
47
+ linear, easeIn, easeOut, easeInOut, backOut, pingPong, clamp01, lerp, stagger,
48
+ rng, scratch, releaseScratch, destination, ring, drawPath, centroid,
49
+ panel, label, readout, bar, particles, TAU,
50
+ } from "./runtime.js";
51
+
52
+ export { onTap, onDragPath, nearest, pointInPath } from "./pointer.js";
@@ -0,0 +1,94 @@
1
+ /** Pointer helpers for interaction effects. Each returns an unbind function. */
2
+
3
+ const local = (canvas, e) => {
4
+ const r = canvas.getBoundingClientRect();
5
+ return { x: e.clientX - r.left, y: e.clientY - r.top };
6
+ };
7
+
8
+ /** Click that did not turn into a drag, so it cannot fight globe panning. */
9
+ export function onTap(globe, handler, slop = 4) {
10
+ const c = globe.canvas;
11
+ let from = null;
12
+ const down = (e) => (from = local(c, e));
13
+ const up = (e) => {
14
+ if (!from) return;
15
+ const p = local(c, e);
16
+ if (Math.abs(p.x - from.x) <= slop && Math.abs(p.y - from.y) <= slop) {
17
+ handler(p, globe.unproject(p.x, p.y), e);
18
+ }
19
+ from = null;
20
+ };
21
+ c.addEventListener("pointerdown", down);
22
+ c.addEventListener("pointerup", up);
23
+ return () => {
24
+ c.removeEventListener("pointerdown", down);
25
+ c.removeEventListener("pointerup", up);
26
+ };
27
+ }
28
+
29
+ /** Freehand drag path in canvas pixels, for lassos and brushes. */
30
+ export function onDragPath(globe, { start, move, end }, enabled = () => true) {
31
+ const c = globe.canvas;
32
+ let path = null;
33
+ const down = (e) => {
34
+ if (!enabled()) return;
35
+ e.preventDefault?.();
36
+ e.stopImmediatePropagation?.();
37
+ c.setPointerCapture?.(e.pointerId);
38
+ path = [local(c, e)];
39
+ start?.(path);
40
+ };
41
+ const drag = (e) => {
42
+ if (!path) return;
43
+ e.preventDefault?.();
44
+ e.stopImmediatePropagation?.();
45
+ path.push(local(c, e));
46
+ move?.(path);
47
+ };
48
+ const stop = (e) => {
49
+ if (!path) return;
50
+ e?.preventDefault?.();
51
+ e?.stopImmediatePropagation?.();
52
+ end?.(path);
53
+ path = null;
54
+ c.releasePointerCapture?.(e?.pointerId);
55
+ };
56
+ // Capture before the globe's own drag handlers so drawing a lasso never
57
+ // rotates the camera underneath the selection.
58
+ c.addEventListener("pointerdown", down, true);
59
+ c.addEventListener("pointermove", drag, true);
60
+ c.addEventListener("pointerup", stop, true);
61
+ c.addEventListener("pointercancel", stop, true);
62
+ c.addEventListener("pointerleave", stop, true);
63
+ return () => {
64
+ c.removeEventListener("pointerdown", down, true);
65
+ c.removeEventListener("pointermove", drag, true);
66
+ c.removeEventListener("pointerup", stop, true);
67
+ c.removeEventListener("pointercancel", stop, true);
68
+ c.removeEventListener("pointerleave", stop, true);
69
+ };
70
+ }
71
+
72
+ /** Nearest item to a canvas point, within a pixel radius. */
73
+ export function nearest(globe, items, x, y, radius = 28) {
74
+ let best = null, bd = radius;
75
+ for (const item of items) {
76
+ const p = globe.project(item.lon, item.lat);
77
+ if (!p) continue;
78
+ const d = Math.hypot(p.x - x, p.y - y);
79
+ if (d < bd) {
80
+ bd = d;
81
+ best = item;
82
+ }
83
+ }
84
+ return best;
85
+ }
86
+
87
+ export const pointInPath = (x, y, path) => {
88
+ let inside = false;
89
+ for (let i = 0, j = path.length - 1; i < path.length; j = i++) {
90
+ const a = path[i], b = path[j];
91
+ if (a.y > y !== b.y > y && x < ((b.x - a.x) * (y - a.y)) / (b.y - a.y) + a.x) inside = !inside;
92
+ }
93
+ return inside;
94
+ };
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Shared machinery for effects. Everything here is deliberately stateless with
3
+ * respect to wall-clock time: an effect is handed a 0→1 phase and must derive
4
+ * its whole appearance from that, so `globe.renderFrame(ms)` is reproducible.
5
+ */
6
+
7
+ /* --------------------------------- easing -------------------------------- */
8
+
9
+ export const linear = (t) => t;
10
+ export const easeIn = (t) => t * t * t;
11
+ export const easeOut = (t) => 1 - Math.pow(1 - t, 3);
12
+ export const easeInOut = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
13
+ /** Overshoots past 1 and settles - the "lands with weight" feel. */
14
+ export const backOut = (t) => 1 + 2.70158 * Math.pow(t - 1, 3) + 1.70158 * Math.pow(t - 1, 2);
15
+ export const pingPong = (t) => (t < 0.5 ? t * 2 : 1 - (t - 0.5) * 2);
16
+ export const clamp01 = (t) => (t < 0 ? 0 : t > 1 ? 1 : t);
17
+ export const lerp = (a, b, t) => a + (b - a) * t;
18
+
19
+ /** Progress of one item in a staggered sequence. */
20
+ export const stagger = (t, index, count, overlap = 0.55) => {
21
+ const step = (1 - overlap) / Math.max(1, count);
22
+ return clamp01((t - index * step) / Math.max(0.0001, 1 - index * step));
23
+ };
24
+
25
+ /* ------------------------------- randomness ------------------------------- */
26
+
27
+ /**
28
+ * Deterministic RNG. Effects must never call Math.random() per frame or the
29
+ * same millisecond would render differently on every pass.
30
+ */
31
+ export function rng(seed = 1) {
32
+ let s = seed >>> 0 || 1;
33
+ return () => {
34
+ s ^= s << 13;
35
+ s ^= s >>> 17;
36
+ s ^= s << 5;
37
+ return ((s >>> 0) % 100000) / 100000;
38
+ };
39
+ }
40
+
41
+ /* --------------------------------- buffers -------------------------------- */
42
+
43
+ const pools = new WeakMap();
44
+
45
+ /**
46
+ * A scratch canvas matching the globe's backing size, reused across frames.
47
+ * Post-processing effects each asking for their own would cost a full canvas
48
+ * allocation per effect per resize.
49
+ */
50
+ export function scratch(globe, key = "default") {
51
+ let pool = pools.get(globe);
52
+ if (!pool) pools.set(globe, (pool = new Map()));
53
+ let entry = pool.get(key);
54
+ if (!entry) {
55
+ if (typeof document === "undefined") return null;
56
+ const canvas = document.createElement("canvas");
57
+ entry = { canvas, ctx: canvas.getContext("2d") };
58
+ pool.set(key, entry);
59
+ }
60
+ const { width, height } = globe.canvas;
61
+ if (entry.canvas.width !== width || entry.canvas.height !== height) {
62
+ entry.canvas.width = width;
63
+ entry.canvas.height = height;
64
+ }
65
+ return entry;
66
+ }
67
+
68
+ /** Drops every scratch canvas an effect took for a globe. */
69
+ export function releaseScratch(globe, key) {
70
+ const pool = pools.get(globe);
71
+ if (!pool) return;
72
+ key ? pool.delete(key) : pool.clear();
73
+ }
74
+
75
+ /* -------------------------------- geometry -------------------------------- */
76
+
77
+ const D2R = Math.PI / 180;
78
+ export const TAU = Math.PI * 2;
79
+
80
+ /** Point `degrees` away from a coordinate along a bearing. */
81
+ export function destination(lon, lat, bearing, degrees) {
82
+ const f1 = lat * D2R, l1 = lon * D2R, b = bearing * D2R, d = degrees * D2R;
83
+ const f2 = Math.asin(Math.sin(f1) * Math.cos(d) + Math.cos(f1) * Math.sin(d) * Math.cos(b));
84
+ const l2 = l1 + Math.atan2(Math.sin(b) * Math.sin(d) * Math.cos(f1), Math.cos(d) - Math.sin(f1) * Math.sin(f2));
85
+ return [((l2 / D2R + 540) % 360) - 180, f2 / D2R];
86
+ }
87
+
88
+ /** Ring of coordinates a fixed angular distance from a centre. */
89
+ export function ring(lon, lat, degrees, steps = 72) {
90
+ const out = [];
91
+ for (let i = 0; i <= steps; i++) out.push(destination(lon, lat, (i / steps) * 360, degrees));
92
+ return out;
93
+ }
94
+
95
+ /** Strokes or fills a lon/lat path, breaking it where the globe hides it. */
96
+ export function drawPath(ctx, globe, points, close = false) {
97
+ let open = false;
98
+ for (const [lon, lat] of points) {
99
+ const p = globe.project(lon, lat);
100
+ if (!p) {
101
+ open = false;
102
+ continue;
103
+ }
104
+ open ? ctx.lineTo(p.x, p.y) : (ctx.moveTo(p.x, p.y), (open = true));
105
+ }
106
+ if (open && close) ctx.closePath();
107
+ }
108
+
109
+ export const centroid = (shape) => {
110
+ const geom = shape?.geometry || shape;
111
+ const polys = geom.type === "Polygon" ? [geom.coordinates] : geom.coordinates;
112
+ let x = 0, y = 0, n = 0;
113
+ for (const poly of polys) for (const r of poly) for (const c of r) { x += c[0]; y += c[1]; n++; }
114
+ return [x / n, y / n];
115
+ };
116
+
117
+ /* ----------------------------------- HUD ---------------------------------- */
118
+
119
+ /** Rounded panel used by nearly every readout. */
120
+ export function panel(ctx, x, y, w, h, { fill = "rgba(7,8,13,.9)", stroke, radius = 8 } = {}) {
121
+ ctx.beginPath();
122
+ ctx.roundRect(x, y, w, h, radius);
123
+ ctx.fillStyle = fill;
124
+ ctx.fill();
125
+ if (stroke) {
126
+ ctx.strokeStyle = stroke;
127
+ ctx.lineWidth = 1;
128
+ ctx.stroke();
129
+ }
130
+ }
131
+
132
+ export function label(ctx, text, x, y, { size = 11, weight = 600, color = "#e8ecf5", align = "left", font = "Inter, system-ui, sans-serif" } = {}) {
133
+ ctx.fillStyle = color;
134
+ ctx.font = `${weight} ${size}px ${font}`;
135
+ ctx.textAlign = align;
136
+ ctx.fillText(text, x, y);
137
+ ctx.textAlign = "left";
138
+ }
139
+
140
+ /** Caption above a value - the readout pattern used across the data effects. */
141
+ export function readout(ctx, x, y, caption, value, { accent = "#34d399", width = 168 } = {}) {
142
+ panel(ctx, x, y, width, 38, { stroke: accent });
143
+ label(ctx, caption, x + 10, y + 16, { size: 9, weight: 600, color: accent });
144
+ label(ctx, value, x + 10, y + 31, { size: 15, weight: 800, color: "#fff" });
145
+ }
146
+
147
+ export function bar(ctx, x, y, w, h, fraction, { fill = "#34d399", track = "rgba(232,236,245,.1)", radius = 3 } = {}) {
148
+ panel(ctx, x, y, w, h, { fill: track, radius });
149
+ if (fraction > 0) panel(ctx, x, y, Math.max(2, w * clamp01(fraction)), h, { fill, radius });
150
+ }
151
+
152
+ /* -------------------------------- particles ------------------------------- */
153
+
154
+ /**
155
+ * Fixed-size particle set. Positions are a pure function of phase, so a
156
+ * particle effect still exports frame-for-frame.
157
+ */
158
+ export function particles(count, seed = 7, make) {
159
+ const random = rng(seed);
160
+ return Array.from({ length: count }, (_, i) => make(random, i));
161
+ }