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/CHANGELOG.md +30 -0
- package/README.md +76 -4
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/codemeta.json +1 -1
- package/dist/canvas-globe.umd.js +182 -6
- package/package.json +34 -2
- package/src/charts.js +458 -0
- package/src/controls.js +187 -0
- package/src/data/places.js +5 -0
- package/src/fx/effects/ambient.js +355 -0
- package/src/fx/effects/data.js +438 -0
- package/src/fx/effects/entrances.js +111 -0
- package/src/fx/effects/interaction.js +850 -0
- package/src/fx/effects/scene.js +571 -0
- package/src/fx/effects/transitions.js +209 -0
- package/src/fx/index.js +52 -0
- package/src/fx/pointer.js +94 -0
- package/src/fx/runtime.js +161 -0
- package/src/geo-globe.js +170 -4
- package/src/license.js +11 -1
- package/src/places.js +97 -0
- package/src/recipes.js +188 -0
- package/src/version.js +1 -1
- package/types/charts.d.ts +82 -0
- package/types/controls.d.ts +54 -0
- package/types/fx-interaction.d.ts +16 -0
- package/types/fx.d.ts +313 -0
- package/types/index.d.ts +48 -0
- package/types/places.d.ts +43 -0
- package/types/recipes.d.ts +54 -0
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
|
-
|
|
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 : (
|
|
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 =
|
|
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 = (
|
|
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.
|
|
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;
|