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.
package/src/geo-globe.js CHANGED
@@ -83,10 +83,24 @@ const DEFAULTS = {
83
83
  onCountryHover: null,
84
84
  onCountryClick: null,
85
85
  onRender: null,
86
+ effects: null,
86
87
  };
87
88
 
88
89
  const coord = (v) => (Array.isArray(v) ? [v[0], v[1]] : [v.lon ?? v.lng ?? v.longitude, v.lat ?? v.latitude]);
89
90
 
91
+ const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
92
+
93
+ /**
94
+ * An effect's own 0→1 progress at a point on the shared clock. Derived purely
95
+ * from `ms`, so the same millisecond always renders the same frame.
96
+ */
97
+ const phaseOf = (fx, ms) => {
98
+ const duration = Math.max(1, fx.duration ?? 1000);
99
+ const total = duration + (fx.hold ?? 0);
100
+ const elapsed = fx.loop === false ? Math.min(ms, total) : ((ms % total) + total) % total;
101
+ return Math.min(1, elapsed / duration);
102
+ };
103
+
90
104
  const defaultTooltip = (target, kind) => {
91
105
  if (kind === "country") return target.name || String(target.id ?? "");
92
106
  if (kind === "cluster") return `${target.count} in this area`;
@@ -141,6 +155,11 @@ export class GeoGlobe {
141
155
  this._licenseHits = [];
142
156
  this._licenseDismissed = false;
143
157
  this._licenseHovered = false;
158
+ this._fx = [];
159
+ this._clock = null;
160
+ this._t0 = now();
161
+ this._spinBase = this.lon;
162
+ this.pointer = { x: 0, y: 0, lon: 0, lat: 0, over: false };
144
163
 
145
164
  this._applyWorld();
146
165
  this._applyMarkers(this.o.markers);
@@ -152,12 +171,138 @@ export class GeoGlobe {
152
171
  this.resize();
153
172
  if (this.o.focus) this.focusOn(this.o.focus, { instant: true });
154
173
  if (this.o.showViewer) this._initViewer();
174
+ for (const fx of this.o.effects || []) this.use(fx);
155
175
  this._loop = this._loop.bind(this);
156
176
  this._raf = requestAnimationFrame(this._loop);
157
177
  }
158
178
 
159
179
  /* ------------------------------ public API ------------------------------ */
160
180
 
181
+ /* --------------------------------- effects ------------------------------- */
182
+
183
+ /** Installs an effect. Returns `this`, so calls chain. */
184
+ use(effect) {
185
+ if (!effect || typeof effect.frame !== "function") return this;
186
+ this._fx.push({ fx: effect, state: effect.setup?.(this) ?? {} });
187
+ this._fx.sort((a, b) => (a.fx.z ?? 0) - (b.fx.z ?? 0));
188
+ return this.invalidate();
189
+ }
190
+
191
+ /** Removes an effect by reference or by name. */
192
+ remove(effect) {
193
+ const i = this._fx.findIndex((e) => e.fx === effect || e.fx.name === effect);
194
+ if (i < 0) return this;
195
+ const [entry] = this._fx.splice(i, 1);
196
+ entry.fx.dispose?.(entry.state, this);
197
+ return this.invalidate();
198
+ }
199
+
200
+ clearEffects() {
201
+ while (this._fx.length) this.remove(this._fx[0].fx);
202
+ return this;
203
+ }
204
+
205
+ get effects() {
206
+ return this._fx.map((e) => e.fx);
207
+ }
208
+
209
+ /**
210
+ * Pins the effect clock to `ms`. Rendering becomes reproducible, which is
211
+ * what frame-exact export and seamless loops need. `play()` releases it.
212
+ */
213
+ seek(ms) {
214
+ // Capture where auto-rotation started from, so longitude can be derived
215
+ // from the clock instead of accumulated per frame. Only on the way in:
216
+ // seeking again while already seeked must not shift the origin.
217
+ if (this._clock == null) this._spinBase = wrapLon(this.lon - this._spinRate() * this._fxTime());
218
+ this._clock = Math.max(0, ms);
219
+ if (this._autoSpin()) this.lon = wrapLon(this._spinBase + this._spinRate() * this._clock);
220
+ return this.invalidate();
221
+ }
222
+
223
+ play() {
224
+ // Resume the timeline where it was paused rather than snapping back to 0.
225
+ if (this._clock != null) this._t0 = now() - this._clock;
226
+ this._clock = null;
227
+ return this.invalidate();
228
+ }
229
+
230
+ /** Draws the single frame that belongs at `ms` on the effect clock. */
231
+ renderFrame(ms) {
232
+ return this.seek(ms).render();
233
+ }
234
+
235
+ _fxTime() {
236
+ return this._clock != null ? this._clock : now() - this._t0;
237
+ }
238
+
239
+ /**
240
+ * Wall clock for looping scene animation. Pinned while seeking, so arcs,
241
+ * orbits and pulses land on the same phase every time a frame is redrawn.
242
+ */
243
+ _animTime() {
244
+ return this._clock != null ? this._clock : Date.now();
245
+ }
246
+
247
+ /** Degrees of auto-rotation per millisecond. */
248
+ _spinRate() {
249
+ return ((this.o.rotateSpeed || 0) * (this.o.fps || 30)) / 1000;
250
+ }
251
+
252
+ _runPasses(stage) {
253
+ if (!this._fx.length) return;
254
+ const ms = this._fxTime();
255
+ const { ctx } = this;
256
+ for (const entry of this._fx) {
257
+ if ((entry.fx.stage || "above") !== stage) continue;
258
+ ctx.save();
259
+ try {
260
+ entry.fx.frame(ctx, this, phaseOf(entry.fx, ms), entry.state);
261
+ } catch (err) {
262
+ // One bad effect must not take the render loop down with it.
263
+ if (!entry.failed) {
264
+ entry.failed = true;
265
+ console.error(`canvas-globe: effect "${entry.fx.name || "anonymous"}" failed`, err);
266
+ }
267
+ }
268
+ ctx.restore();
269
+ }
270
+ }
271
+
272
+ /** Land sample points as `[lon, lat]` pairs — what the dot matrix draws. */
273
+ landPoints(spacing) {
274
+ const prev = this.o.dotSpacing;
275
+ if (spacing != null) this.o.dotSpacing = spacing;
276
+ const flat = this._dotPoints();
277
+ this.o.dotSpacing = prev;
278
+ const out = [];
279
+ for (let i = 0; i < flat.length; i += 2) out.push([flat[i], flat[i + 1]]);
280
+ return out;
281
+ }
282
+
283
+ /** Traces a country outline into a path using the current projection. */
284
+ tracePath(shape, ctx = this.ctx, transform) {
285
+ const geom = shape?.geometry || shape;
286
+ if (!geom?.coordinates) return this;
287
+ const polys = geom.type === "Polygon" ? [geom.coordinates] : geom.coordinates;
288
+ for (const poly of polys) {
289
+ for (const ring of poly) {
290
+ let open = false;
291
+ for (const c of ring) {
292
+ const src = transform ? transform(c) : c;
293
+ const p = this.project(src[0], src[1]);
294
+ if (!p) {
295
+ open = false;
296
+ continue;
297
+ }
298
+ open ? ctx.lineTo(p.x, p.y) : (ctx.moveTo(p.x, p.y), (open = true));
299
+ }
300
+ if (open) ctx.closePath();
301
+ }
302
+ }
303
+ return this;
304
+ }
305
+
161
306
  setMarkers(markers = []) {
162
307
  this._applyMarkers(markers);
163
308
  this._focus = -1;
@@ -714,9 +859,12 @@ export class GeoGlobe {
714
859
  const { w, h } = this._size();
715
860
  ctx.setTransform(this._dpr, 0, 0, this._dpr, 0, 0);
716
861
  ctx.clearRect(0, 0, w, h);
862
+ this._runPasses("beneath");
717
863
  this.hits = this.o.mode === "map" ? this._paintMap(w, h) : this._paintGlobe(w, h);
718
864
  this._dirty = false;
865
+ this._runPasses("above");
719
866
  this.o.onRender?.(this);
867
+ this._runPasses("post");
720
868
  this._paintLicenseNotice(w, h);
721
869
  return this;
722
870
  }
@@ -734,6 +882,7 @@ export class GeoGlobe {
734
882
  destroy() {
735
883
  this._destroyed = true;
736
884
  cancelAnimationFrame(this._raf);
885
+ this.clearEffects();
737
886
  this.stopTour();
738
887
  this.stopStory();
739
888
  this.stopTimeline();
@@ -1188,6 +1337,15 @@ export class GeoGlobe {
1188
1337
  this._onMove = (e) => {
1189
1338
  const [x, y] = this._local(e);
1190
1339
  this._pointer = { x: e.clientX, y: e.clientY };
1340
+ this.pointer.x = x;
1341
+ this.pointer.y = y;
1342
+ this.pointer.over = true;
1343
+ const at = this.unproject(x, y);
1344
+ if (at) {
1345
+ this.pointer.lon = at[0];
1346
+ this.pointer.lat = at[1];
1347
+ }
1348
+ if (this._fx.length) this._dirty = true;
1191
1349
  const licenseHovered = this._licenseHitAt(x, y);
1192
1350
  if (licenseHovered !== this._licenseHovered) {
1193
1351
  this._licenseHovered = licenseHovered;
@@ -1258,6 +1416,7 @@ export class GeoGlobe {
1258
1416
  this._onLeave = (e) => {
1259
1417
  this._onUp(e);
1260
1418
  this._pointer = null;
1419
+ this.pointer.over = false;
1261
1420
  this._licenseHovered = false;
1262
1421
  if (this._hovered || this._hoveredCountry) {
1263
1422
  if (this._hovered) this.o.onHover?.(null, null);
@@ -1438,13 +1597,20 @@ export class GeoGlobe {
1438
1597
 
1439
1598
  /* --------------------------------- loop --------------------------------- */
1440
1599
 
1441
- _spinning() {
1600
+ /** Auto-rotation is configured and permitted, regardless of the clock. */
1601
+ _autoSpin() {
1442
1602
  return !!this.o.autoRotate && !this._reducedMotion() && !this._drag && !this._vel && this.o.mode === "globe";
1443
1603
  }
1444
1604
 
1605
+ /** Auto-rotation should advance on its own this frame. */
1606
+ _spinning() {
1607
+ return this._autoSpin() && this._clock == null;
1608
+ }
1609
+
1445
1610
  /** True while something on screen still needs to move. */
1446
1611
  _animating() {
1447
1612
  if (this._target || this._drag || this._vel) return true;
1613
+ if (this._fx.length && this._clock == null) return true;
1448
1614
  if (this._spinning()) return true;
1449
1615
  if (this._reducedMotion()) return false;
1450
1616
  if (this._hasLive || this._pings.length) return true;
@@ -1718,7 +1884,7 @@ export class GeoGlobe {
1718
1884
  const list = this._orbitList();
1719
1885
  if (!list.length) return;
1720
1886
  const { ctx } = this;
1721
- const spin = this._reducedMotion() ? 0 : (Date.now() / 1000) % 3600;
1887
+ const spin = this._reducedMotion() ? 0 : (this._animTime() / 1000) % 3600;
1722
1888
  ctx.lineCap = "round";
1723
1889
  for (const orbit of list) {
1724
1890
  const inc = (orbit.inclination ?? 30) * D2R;
@@ -2272,7 +2438,7 @@ export class GeoGlobe {
2272
2438
  const arcs = this.o.arcs;
2273
2439
  if (!arcs || !arcs.length) return;
2274
2440
  const { ctx } = this;
2275
- const now = Date.now();
2441
+ const now = this._animTime();
2276
2442
  const still = this._reducedMotion();
2277
2443
  ctx.lineCap = "round";
2278
2444
  ctx.lineJoin = "round";
@@ -3001,7 +3167,7 @@ export class GeoGlobe {
3001
3167
  }
3002
3168
 
3003
3169
  if (m.live && !this._reducedMotion()) {
3004
- const pulse = (Date.now() % 2200) / 2200;
3170
+ const pulse = (this._animTime() % 2200) / 2200;
3005
3171
  ctx.globalAlpha = 1 - pulse;
3006
3172
  ctx.strokeStyle = t.live;
3007
3173
  ctx.lineWidth = 1.4 * scale;
package/src/license.js CHANGED
@@ -24,9 +24,18 @@ const PRIVATE_HOST_PATTERNS = [
24
24
  /^::1$/,
25
25
  ];
26
26
 
27
+ const OFFICIAL_SITE_HOSTS = new Set([
28
+ "canvasglobe.swiftools.com",
29
+ "canvas-globe-website.pages.dev",
30
+ ]);
31
+ const OFFICIAL_PREVIEW_SUFFIX = ".canvas-globe-website.pages.dev";
32
+
27
33
  const normalizeLicenseKey = (value) =>
28
34
  typeof value === "string" ? value.trim() : "";
29
35
 
36
+ const isOfficialSiteHost = (hostname) =>
37
+ OFFICIAL_SITE_HOSTS.has(hostname) || hostname.endsWith(OFFICIAL_PREVIEW_SUFFIX);
38
+
30
39
  /** Returns development, production or unknown for a browser location. */
31
40
  export function inspectRuntime(locationValue = globalThis.location) {
32
41
  if (!locationValue) return { kind: "unknown", hostname: "", public: false };
@@ -73,7 +82,7 @@ export function getLicensePresentation(
73
82
  ) {
74
83
  const status = inspectLicenseKey(value, mode);
75
84
  const runtime = inspectRuntime(locationValue);
76
- if (!mode || status.valid || !runtime.public) {
85
+ if (!mode || status.valid || !runtime.public || isOfficialSiteHost(runtime.hostname)) {
77
86
  return { status, runtime, notice: null };
78
87
  }
79
88
  return {
@@ -93,6 +102,7 @@ export function reportLicenseStatus(value, mode = COMMERCIAL_LICENSE_MODE) {
93
102
  const presentation = getLicensePresentation(value, globalThis.location, mode);
94
103
  const { status } = presentation;
95
104
  if (typeof location === "undefined") return status;
105
+ if (!presentation.notice) return status;
96
106
  if (status.kind === "missing") {
97
107
  console.error(
98
108
  mode
package/src/places.js ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * City search.
3
+ *
4
+ * An optional table of roughly 6,700 populated places - every national and
5
+ * state capital, plus the most populous cities worldwide. Import it only if
6
+ * you need it: it is about as large as the country geometry, and the core
7
+ * library already resolves countries and ~300 major cities for free through
8
+ * `geocode()`.
9
+ *
10
+ * import { searchPlaces, placeSource } from "canvas-globe/places";
11
+ * import { searchAndFly } from "canvas-globe/controls";
12
+ *
13
+ * searchAndFly(globe, input, { source: placeSource() });
14
+ *
15
+ * For the long tail - every hamlet and village - point `source` at GeoNames,
16
+ * Nominatim or your own index instead. Nothing here needs to change.
17
+ */
18
+ import { placeTable } from "./data/places.js";
19
+
20
+ let index = null;
21
+
22
+ /** Lowercase and strip accents, so "São Paulo" answers to "sao paulo". */
23
+ export const fold = (s) =>
24
+ String(s)
25
+ .normalize("NFD")
26
+ .replace(/\p{Diacritic}/gu, "")
27
+ .toLowerCase()
28
+ .trim();
29
+
30
+ const parse = () => {
31
+ if (index) return index;
32
+ index = [];
33
+ for (const row of placeTable.split(";")) {
34
+ const [name, lon, lat, country, pop, region] = row.split("|");
35
+ if (!name) continue;
36
+ index.push({
37
+ name,
38
+ key: fold(name),
39
+ lon: Number(lon) / 100,
40
+ lat: Number(lat) / 100,
41
+ country,
42
+ region: region || "",
43
+ population: Number(pop) * 1000,
44
+ });
45
+ }
46
+ return index;
47
+ };
48
+
49
+ const decorate = (p) => ({
50
+ ...p,
51
+ label: p.region ? `${p.name}, ${p.region}, ${p.country}` : `${p.name}, ${p.country}`,
52
+ });
53
+
54
+ /**
55
+ * Places matching `query`, best first.
56
+ *
57
+ * Names that start with the query always outrank names that merely contain
58
+ * it, so "york" still puts York above New York. Population breaks ties, which
59
+ * is what makes "london" mean the British one.
60
+ *
61
+ * At this table size a linear scan costs well under a millisecond, so there
62
+ * is no prefix tree to build or keep in sync. Build one only if you swap in a
63
+ * table an order of magnitude larger.
64
+ */
65
+ export function searchPlaces(query, { limit = 8, country, contains = true } = {}) {
66
+ const q = fold(query);
67
+ if (!q) return [];
68
+ const hits = [];
69
+ for (const place of parse()) {
70
+ if (country && place.country !== country) continue;
71
+ const at = place.key.indexOf(q);
72
+ if (at < 0) continue;
73
+ if (at > 0 && !contains) continue;
74
+ hits.push({ place, tier: at === 0 ? 0 : 1, exact: place.key === q });
75
+ }
76
+ return hits
77
+ .sort((a, b) =>
78
+ a.tier - b.tier ||
79
+ b.exact - a.exact ||
80
+ b.place.population - a.place.population)
81
+ .slice(0, limit)
82
+ .map((h) => decorate(h.place));
83
+ }
84
+
85
+ /** Coordinate for an exact-ish name, matching the shape of `geocode()`. */
86
+ export function placePoint(name, options) {
87
+ const [hit] = searchPlaces(name, { contains: false, ...options, limit: 1 });
88
+ return hit ? { lon: hit.lon, lat: hit.lat } : null;
89
+ }
90
+
91
+ /** Ready-made resolver for `searchAndFly({ source })`. */
92
+ export function placeSource(options) {
93
+ return (query) => searchPlaces(query, options);
94
+ }
95
+
96
+ /** How many places the bundled table holds. */
97
+ export const placeCount = () => parse().length;
package/src/recipes.js ADDED
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Recipes: complete looks, rather than single effects.
3
+ *
4
+ * Some things in the catalogue are not frame painters at all. They are a
5
+ * theme plus a few options plus, sometimes, a call into the imperative API
6
+ * (`tour`, `ping`, `playTimeline`). Shipping those as `use()`-able effects
7
+ * would hide the composition and give two ways to do the same thing, so they
8
+ * live here instead.
9
+ *
10
+ * A recipe is plain data:
11
+ *
12
+ * { name, options, effects, start(globe) }
13
+ *
14
+ * which means you can read one, copy the parts you want, and ignore the rest.
15
+ *
16
+ * import { createGlobe } from "canvas-globe";
17
+ * import { applyRecipe, keynoteGlobe } from "canvas-globe/recipes";
18
+ *
19
+ * const globe = createGlobe(canvas);
20
+ * const stop = applyRecipe(globe, keynoteGlobe({ countries: 68 }));
21
+ */
22
+ import { counterRoll, markerBloom } from "./fx/effects/data.js";
23
+ import { glassSphere } from "./fx/effects/ambient.js";
24
+
25
+ /**
26
+ * Applies a recipe and returns a function that undoes it: options are put
27
+ * back as they were, effects removed, and any timer the recipe started stopped.
28
+ */
29
+ export function applyRecipe(globe, recipe) {
30
+ const { options, effects = [], start } = recipe;
31
+ const previous = {};
32
+ if (options) {
33
+ for (const key of Object.keys(options)) previous[key] = globe.o[key];
34
+ globe.setOptions(options);
35
+ }
36
+ for (const fx of effects) globe.use(fx);
37
+ const stop = start?.(globe);
38
+ return () => {
39
+ stop?.();
40
+ for (const fx of effects) globe.remove(fx);
41
+ if (options) globe.setOptions(previous);
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Near-black sphere, violet landmass dots, arcs that bloom as they land.
47
+ * Ports catalogue effect BA.
48
+ */
49
+ export const devPlatformGlobe = ({ hub, spokes = 9 } = {}) => ({
50
+ name: "devPlatformGlobe",
51
+ options: {
52
+ preset: "midnight",
53
+ landStyle: "dots",
54
+ dotSpacing: 2.1,
55
+ dotSize: 1.05,
56
+ autoRotate: true,
57
+ rotateSpeed: 0.055,
58
+ stars: false,
59
+ graticule: false,
60
+ theme: {
61
+ ocean: ["#0d1117", "#05070c"],
62
+ dot: "#a371f7",
63
+ land: "#a371f7",
64
+ arc: "#f778ba",
65
+ marker: "#58a6ff",
66
+ },
67
+ },
68
+ effects: [markerBloom()],
69
+ start(globe) {
70
+ const origin = hub || globe.markers[0];
71
+ if (!origin) return;
72
+ const previous = globe.o.arcs;
73
+ globe.setArcs(
74
+ globe.markers.slice(0, spokes).map((c, i) => ({
75
+ from: origin,
76
+ to: c,
77
+ duration: 1500 + i * 190,
78
+ width: 1.1,
79
+ }))
80
+ );
81
+ return () => globe.setArcs(previous);
82
+ },
83
+ });
84
+
85
+ /**
86
+ * Glossy charcoal sphere, hairline arcs, one enormous number, nothing else.
87
+ * Ports catalogue effect BB.
88
+ */
89
+ export const keynoteGlobe = ({ hub, countries = 68, caption = "COUNTRIES" } = {}) => ({
90
+ name: "keynoteGlobe",
91
+ options: {
92
+ preset: "noir",
93
+ landStyle: "fill",
94
+ autoRotate: true,
95
+ rotateSpeed: 0.03,
96
+ stars: false,
97
+ graticule: false,
98
+ shade: true,
99
+ theme: {
100
+ ocean: ["#1c1c1e", "#000"],
101
+ land: "#3a3a3c",
102
+ border: "#48484a",
103
+ arc: "#ffffff",
104
+ marker: "#fff",
105
+ },
106
+ },
107
+ effects: [
108
+ glassSphere({ gloss: 0.16, rim: 0.2 }),
109
+ counterRoll({ to: countries, caption, size: 56, position: "bottom-center", hold: 1800 }),
110
+ ],
111
+ start(globe) {
112
+ const origin = hub || globe.markers[0];
113
+ if (!origin) return;
114
+ const previous = globe.o.arcs;
115
+ globe.setArcs(
116
+ globe.markers.slice(0, 7).map((c, i) => ({
117
+ from: origin,
118
+ to: c,
119
+ width: 0.8,
120
+ duration: 2600 + i * 200,
121
+ }))
122
+ );
123
+ return () => globe.setArcs(previous);
124
+ },
125
+ });
126
+
127
+ /**
128
+ * Great-circle rings that pass behind the sphere as it turns.
129
+ * Ports catalogue effect J, which is pure configuration.
130
+ */
131
+ export const satelliteOrbits = ({ count = 3, preset = "neon", rotateSpeed = 0.08 } = {}) => ({
132
+ name: "satelliteOrbits",
133
+ options: { preset, orbits: count, autoRotate: true, rotateSpeed },
134
+ });
135
+
136
+ /**
137
+ * Markers appear as their date arrives across the range.
138
+ * Ports catalogue effect P, which drives the built-in timeline.
139
+ */
140
+ export const growthOverTime = ({ duration = 4200, loop = true, preset = "midnight" } = {}) => ({
141
+ name: "growthOverTime",
142
+ options: { preset, mode: "map", autoRotate: false, markerStyle: "bubble" },
143
+ start(globe) {
144
+ globe.playTimeline({ duration, loop });
145
+ return () => globe.stopTimeline();
146
+ },
147
+ });
148
+
149
+ /**
150
+ * A repeating particle spray with a label at a milestone location.
151
+ * Ports catalogue effect R, which drives the built-in ping API.
152
+ */
153
+ export const celebrationBurst = ({
154
+ at,
155
+ label = "10,000 users",
156
+ emoji = "🎉",
157
+ every = 3200,
158
+ burst = 26,
159
+ } = {}) => ({
160
+ name: "celebrationBurst",
161
+ options: { preset: "neon", autoRotate: true, rotateSpeed: 0.05, markerStyle: "dot" },
162
+ start(globe) {
163
+ const where = at || globe.markers[0];
164
+ if (!where) return;
165
+ const fire = () => globe.ping({ ...where, emoji, label, burst, duration: 2600 });
166
+ fire();
167
+ const id = setInterval(fire, every);
168
+ return () => {
169
+ clearInterval(id);
170
+ globe.clearPings();
171
+ };
172
+ },
173
+ });
174
+
175
+ /**
176
+ * Spring-eased hops between locations on a timer.
177
+ * Ports catalogue effect V, which drives the built-in tour API.
178
+ */
179
+ export const cityTour = ({ stops, dwell = 2000, zoom = 1.9 } = {}) => ({
180
+ name: "cityTour",
181
+ options: { preset: "atlas", autoRotate: false, labels: "markers" },
182
+ start(globe) {
183
+ const points = (stops || globe.markers).map((c) => ({ lon: c.lon, lat: c.lat }));
184
+ if (points.length < 2) return;
185
+ const handle = globe.tour(points, { dwell, zoom });
186
+ return () => handle.stop();
187
+ },
188
+ });
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Keep in sync with package.json. Release checks enforce this value.
2
- export const CANVAS_GLOBE_VERSION = "1.1.0";
2
+ export const CANVAS_GLOBE_VERSION = "1.2.0";
@@ -0,0 +1,82 @@
1
+ import type { GlobeEffect } from "./fx";
2
+
3
+ /** Values keyed by ISO alpha-2 code or country name. */
4
+ export type ValueMap = Record<string, number>;
5
+
6
+ export declare function tilegram(options?: {
7
+ values?: ValueMap;
8
+ duration?: number;
9
+ hold?: number;
10
+ columns?: number;
11
+ tile?: number;
12
+ limit?: number;
13
+ hue?: number;
14
+ }): GlobeEffect;
15
+
16
+ export declare function chordDiagram(options?: {
17
+ regions?: string[];
18
+ /** `[fromIndex, toIndex, weight]` triples. */
19
+ flows?: [number, number, number][];
20
+ duration?: number;
21
+ hold?: number;
22
+ radius?: number;
23
+ }): GlobeEffect;
24
+
25
+ export declare function beeswarm(options?: {
26
+ values?: ValueMap;
27
+ duration?: number;
28
+ hold?: number;
29
+ dot?: number;
30
+ limit?: number;
31
+ }): GlobeEffect;
32
+
33
+ export declare function barRace(options?: {
34
+ values?: ValueMap;
35
+ duration?: number;
36
+ bars?: number;
37
+ accent?: string;
38
+ caption?: string;
39
+ }): GlobeEffect;
40
+
41
+ export declare function dotDensity(options?: {
42
+ values?: ValueMap;
43
+ duration?: number;
44
+ hold?: number;
45
+ per?: number;
46
+ color?: string;
47
+ seed?: number;
48
+ }): GlobeEffect;
49
+
50
+ export declare function cartogramMorph(options?: {
51
+ values?: ValueMap;
52
+ duration?: number;
53
+ hold?: number;
54
+ strength?: number;
55
+ }): GlobeEffect;
56
+
57
+ export declare function smallMultiples(options?: {
58
+ panels?: number;
59
+ columns?: number;
60
+ duration?: number;
61
+ labels?: (index: number) => string;
62
+ accent?: string;
63
+ }): GlobeEffect;
64
+
65
+ export declare function radarProfile(options?: {
66
+ axes?: string[];
67
+ series?: number[][];
68
+ duration?: number;
69
+ hold?: number;
70
+ colors?: string[];
71
+ }): GlobeEffect;
72
+
73
+ export declare function waffle(options?: {
74
+ total?: number;
75
+ filled?: number;
76
+ columns?: number;
77
+ cell?: number;
78
+ duration?: number;
79
+ hold?: number;
80
+ accent?: string;
81
+ caption?: string;
82
+ }): GlobeEffect;