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/charts.js
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chart layers.
|
|
3
|
+
*
|
|
4
|
+
* These use the same effect contract as `canvas-globe/fx` - install them with
|
|
5
|
+
* `globe.use()` - but they are data-visualisation layouts rather than motion,
|
|
6
|
+
* so they live behind their own import and their own dependency cost.
|
|
7
|
+
*
|
|
8
|
+
* import { createGlobe } from "canvas-globe";
|
|
9
|
+
* import { tilegram } from "canvas-globe/charts";
|
|
10
|
+
*
|
|
11
|
+
* globe.use(tilegram({ values: { IN: 214, GB: 164 } }));
|
|
12
|
+
*
|
|
13
|
+
* Every chart reads `globe.markers` or `globe.world` by default, so they draw
|
|
14
|
+
* something sensible before you supply your own data.
|
|
15
|
+
*/
|
|
16
|
+
import {
|
|
17
|
+
centroid, clamp01, easeInOut, easeOut, label, lerp, panel, pingPong, TAU,
|
|
18
|
+
} from "./fx/runtime.js";
|
|
19
|
+
|
|
20
|
+
/** Countries that have a value, paired with their screen centroid. */
|
|
21
|
+
const valued = (globe, values, limit) => {
|
|
22
|
+
const rows = [];
|
|
23
|
+
for (const shape of globe.world) {
|
|
24
|
+
const v = values?.[shape.code] ?? values?.[shape.name];
|
|
25
|
+
if (v == null) continue;
|
|
26
|
+
rows.push({ shape, code: shape.code || shape.name, name: shape.name, value: v });
|
|
27
|
+
}
|
|
28
|
+
rows.sort((a, b) => b.value - a.value);
|
|
29
|
+
return limit ? rows.slice(0, limit) : rows;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Falls back to markers so a chart is never blank out of the box. */
|
|
33
|
+
const fromMarkers = (globe) =>
|
|
34
|
+
globe.markers.map((m, i) => ({
|
|
35
|
+
code: m.code || (m.city || m.label || `#${i + 1}`).slice(0, 3).toUpperCase(),
|
|
36
|
+
name: m.city || m.label || m.name || `#${i + 1}`,
|
|
37
|
+
value: m.count || 1,
|
|
38
|
+
lon: m.lon,
|
|
39
|
+
lat: m.lat,
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
const rows = (globe, values, limit) => {
|
|
43
|
+
const fromWorld = values ? valued(globe, values, limit) : [];
|
|
44
|
+
if (fromWorld.length) {
|
|
45
|
+
return fromWorld.map((r) => {
|
|
46
|
+
const c = centroid(r.shape);
|
|
47
|
+
return { ...r, lon: c[0], lat: c[1] };
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const list = fromMarkers(globe).sort((a, b) => b.value - a.value);
|
|
51
|
+
return limit ? list.slice(0, limit) : list;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Countries fly out of their real positions into an equal-area grid.
|
|
56
|
+
* Ports catalogue effect BK.
|
|
57
|
+
*/
|
|
58
|
+
export const tilegram = ({
|
|
59
|
+
values,
|
|
60
|
+
duration = 3400,
|
|
61
|
+
hold = 700,
|
|
62
|
+
columns = 5,
|
|
63
|
+
tile = 38,
|
|
64
|
+
limit = 25,
|
|
65
|
+
hue = 258,
|
|
66
|
+
} = {}) => ({
|
|
67
|
+
name: "tilegram",
|
|
68
|
+
stage: "above",
|
|
69
|
+
duration,
|
|
70
|
+
hold,
|
|
71
|
+
frame(ctx, globe, t) {
|
|
72
|
+
const list = rows(globe, values, limit);
|
|
73
|
+
if (!list.length) return;
|
|
74
|
+
const w = globe.canvas.clientWidth, h = globe.canvas.clientHeight;
|
|
75
|
+
const e = easeInOut(pingPong(t) * 2 > 1 ? 1 : pingPong(t) * 2);
|
|
76
|
+
const max = Math.max(...list.map((r) => r.value));
|
|
77
|
+
const gridW = columns * tile;
|
|
78
|
+
const ox = (w - gridW) / 2, oy = h / 2 - tile * Math.ceil(list.length / columns) / 2;
|
|
79
|
+
list.forEach((r, i) => {
|
|
80
|
+
const p = globe.project(r.lon, r.lat);
|
|
81
|
+
if (!p) return;
|
|
82
|
+
const col = i % columns, row = Math.floor(i / columns);
|
|
83
|
+
const tx = ox + col * tile + tile / 2, ty = oy + row * tile + tile / 2;
|
|
84
|
+
const x = lerp(p.x, tx, e), y = lerp(p.y, ty, e);
|
|
85
|
+
const size = lerp(8, tile - 5, e);
|
|
86
|
+
ctx.fillStyle = `hsl(${hue} 85% ${68 - (r.value / max) * 38}%)`;
|
|
87
|
+
ctx.beginPath();
|
|
88
|
+
ctx.roundRect(x - size / 2, y - size / 2, size, size, 4);
|
|
89
|
+
ctx.fill();
|
|
90
|
+
if (e > 0.45) {
|
|
91
|
+
ctx.globalAlpha = (e - 0.45) / 0.55;
|
|
92
|
+
label(ctx, r.code, x, y + 3.5, { size: 10, weight: 700, align: "center" });
|
|
93
|
+
ctx.globalAlpha = 1;
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Regions on the rim, ribbons across the middle for what moves between them.
|
|
101
|
+
* Ports catalogue effect BL.
|
|
102
|
+
*/
|
|
103
|
+
export const chordDiagram = ({
|
|
104
|
+
regions = ["APAC", "EMEA", "AMER", "LATAM", "AFRICA"],
|
|
105
|
+
flows = [[0, 1, 0.9], [0, 2, 0.7], [1, 2, 0.55], [2, 3, 0.4], [1, 4, 0.32], [0, 4, 0.25]],
|
|
106
|
+
duration = 3600,
|
|
107
|
+
hold = 1600,
|
|
108
|
+
radius = 0.42,
|
|
109
|
+
} = {}) => ({
|
|
110
|
+
name: "chordDiagram",
|
|
111
|
+
stage: "above",
|
|
112
|
+
duration,
|
|
113
|
+
hold,
|
|
114
|
+
frame(ctx, globe, t) {
|
|
115
|
+
const w = globe.canvas.clientWidth, h = globe.canvas.clientHeight;
|
|
116
|
+
const cx = w / 2, cy = h / 2, R = Math.min(w, h) * radius;
|
|
117
|
+
const seg = TAU / regions.length, gap = 0.07;
|
|
118
|
+
regions.forEach((name, i) => {
|
|
119
|
+
const a0 = i * seg - Math.PI / 2 + gap / 2, a1 = (i + 1) * seg - Math.PI / 2 - gap / 2;
|
|
120
|
+
ctx.strokeStyle = `hsl(${(i * 67) % 360} 85% 62%)`;
|
|
121
|
+
ctx.lineWidth = 7;
|
|
122
|
+
ctx.beginPath();
|
|
123
|
+
ctx.arc(cx, cy, R, a0, a1);
|
|
124
|
+
ctx.stroke();
|
|
125
|
+
const am = (a0 + a1) / 2;
|
|
126
|
+
ctx.save();
|
|
127
|
+
ctx.translate(cx + Math.cos(am) * (R + 15), cy + Math.sin(am) * (R + 15));
|
|
128
|
+
ctx.rotate(am + (Math.cos(am) < 0 ? Math.PI : 0));
|
|
129
|
+
label(ctx, name, 0, 3, { size: 9, weight: 700, align: "center", color: "#cbd5e1" });
|
|
130
|
+
ctx.restore();
|
|
131
|
+
});
|
|
132
|
+
flows.forEach((f, i) => {
|
|
133
|
+
const grow = clamp01(t * 2.4 - i * 0.16);
|
|
134
|
+
if (grow <= 0) return;
|
|
135
|
+
const a = f[0] * seg - Math.PI / 2 + seg / 2, b = f[1] * seg - Math.PI / 2 + seg / 2;
|
|
136
|
+
ctx.globalAlpha = 0.42 * easeOut(grow);
|
|
137
|
+
ctx.strokeStyle = `hsl(${(f[0] * 67) % 360} 85% 62%)`;
|
|
138
|
+
ctx.lineWidth = 2 + f[2] * 12;
|
|
139
|
+
ctx.beginPath();
|
|
140
|
+
ctx.moveTo(cx + Math.cos(a) * R, cy + Math.sin(a) * R);
|
|
141
|
+
ctx.quadraticCurveTo(cx, cy, cx + Math.cos(b) * R, cy + Math.sin(b) * R);
|
|
142
|
+
ctx.stroke();
|
|
143
|
+
});
|
|
144
|
+
ctx.globalAlpha = 1;
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Points leave the map and settle into a value distribution, then go home.
|
|
150
|
+
* Ports catalogue effect BN.
|
|
151
|
+
*/
|
|
152
|
+
export const beeswarm = ({ values, duration = 4200, hold = 500, dot = 1.9, limit } = {}) => ({
|
|
153
|
+
name: "beeswarm",
|
|
154
|
+
stage: "above",
|
|
155
|
+
duration,
|
|
156
|
+
hold,
|
|
157
|
+
setup() {
|
|
158
|
+
return { lanes: new Array(220).fill(0) };
|
|
159
|
+
},
|
|
160
|
+
frame(ctx, globe, t, state) {
|
|
161
|
+
const list = rows(globe, values, limit);
|
|
162
|
+
if (!list.length) return;
|
|
163
|
+
const w = globe.canvas.clientWidth, h = globe.canvas.clientHeight;
|
|
164
|
+
const e = easeInOut(pingPong(t) * 2 > 1 ? 1 : pingPong(t) * 2);
|
|
165
|
+
const max = Math.max(...list.map((r) => r.value));
|
|
166
|
+
state.lanes.fill(0);
|
|
167
|
+
for (const r of list) {
|
|
168
|
+
const p = globe.project(r.lon, r.lat);
|
|
169
|
+
if (!p) continue;
|
|
170
|
+
const bx = 26 + (r.value / max) * (w - 52);
|
|
171
|
+
const slot = Math.max(0, Math.min(state.lanes.length - 1, Math.round(bx)));
|
|
172
|
+
const n = state.lanes[slot]++;
|
|
173
|
+
const by = h * 0.62 + (n % 2 ? 1 : -1) * Math.ceil(n / 2) * 3.4;
|
|
174
|
+
ctx.fillStyle = `hsl(${196 - (r.value / max) * 120} 88% 62%)`;
|
|
175
|
+
ctx.beginPath();
|
|
176
|
+
ctx.arc(lerp(p.x, bx, e), lerp(p.y, by, e), dot, 0, TAU);
|
|
177
|
+
ctx.fill();
|
|
178
|
+
}
|
|
179
|
+
if (e <= 0.5) return;
|
|
180
|
+
ctx.globalAlpha = (e - 0.5) * 2;
|
|
181
|
+
ctx.strokeStyle = "#2b3346";
|
|
182
|
+
ctx.beginPath();
|
|
183
|
+
ctx.moveTo(26, h * 0.62 + 34);
|
|
184
|
+
ctx.lineTo(w - 26, h * 0.62 + 34);
|
|
185
|
+
ctx.stroke();
|
|
186
|
+
label(ctx, "low", 26, h * 0.62 + 47, { size: 9, color: "#8b93a7" });
|
|
187
|
+
label(ctx, "high", w - 26, h * 0.62 + 47, { size: 9, color: "#8b93a7", align: "right" });
|
|
188
|
+
ctx.globalAlpha = 1;
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Bars overtake each other as the value changes over time.
|
|
194
|
+
* Ports catalogue effect AJ.
|
|
195
|
+
*/
|
|
196
|
+
export const barRace = ({
|
|
197
|
+
values,
|
|
198
|
+
duration = 6000,
|
|
199
|
+
bars = 8,
|
|
200
|
+
accent = "#34d399",
|
|
201
|
+
caption = "",
|
|
202
|
+
} = {}) => ({
|
|
203
|
+
name: "barRace",
|
|
204
|
+
stage: "above",
|
|
205
|
+
duration,
|
|
206
|
+
frame(ctx, globe, t) {
|
|
207
|
+
const list = rows(globe, values, bars * 2);
|
|
208
|
+
if (!list.length) return;
|
|
209
|
+
const h = globe.canvas.clientHeight;
|
|
210
|
+
// Each entry breathes on its own phase, so the ranking genuinely changes.
|
|
211
|
+
const ranked = list
|
|
212
|
+
.map((r, i) => ({ ...r, now: r.value * (0.55 + 0.45 * Math.sin(t * TAU + i * 0.9)) }))
|
|
213
|
+
.sort((a, b) => b.now - a.now)
|
|
214
|
+
.slice(0, bars);
|
|
215
|
+
const max = Math.max(...ranked.map((r) => r.now), 1);
|
|
216
|
+
const rowH = 18, x = 16, y0 = h - 24 - ranked.length * rowH;
|
|
217
|
+
panel(ctx, x - 8, y0 - 22, 250, ranked.length * rowH + 30, { stroke: "rgba(232,236,245,.1)" });
|
|
218
|
+
if (caption) {
|
|
219
|
+
label(ctx, caption, x, y0 - 8, { size: 9, weight: 700, color: "rgba(232,236,245,.5)" });
|
|
220
|
+
}
|
|
221
|
+
ranked.forEach((r, i) => {
|
|
222
|
+
const y = y0 + i * rowH;
|
|
223
|
+
ctx.fillStyle = i === 0 ? accent : "rgba(52,211,153,.45)";
|
|
224
|
+
ctx.beginPath();
|
|
225
|
+
ctx.roundRect(x + 44, y + 3, (r.now / max) * 150, 10, 3);
|
|
226
|
+
ctx.fill();
|
|
227
|
+
label(ctx, r.code, x, y + 12, { size: 10, weight: 700 });
|
|
228
|
+
label(ctx, Math.round(r.now).toLocaleString(), x + 240, y + 12, {
|
|
229
|
+
size: 10, weight: 600, align: "right", color: "rgba(232,236,245,.7)",
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* One dot per unit of whatever you are counting, scattered inside each country.
|
|
237
|
+
* Ports catalogue effect AL.
|
|
238
|
+
*/
|
|
239
|
+
export const dotDensity = ({
|
|
240
|
+
values,
|
|
241
|
+
duration = 3000,
|
|
242
|
+
hold = 1200,
|
|
243
|
+
per = 40,
|
|
244
|
+
color = "#38bdf8",
|
|
245
|
+
seed = 31,
|
|
246
|
+
} = {}) => ({
|
|
247
|
+
name: "dotDensity",
|
|
248
|
+
stage: "above",
|
|
249
|
+
duration,
|
|
250
|
+
hold,
|
|
251
|
+
setup(globe) {
|
|
252
|
+
// Jittered once at setup so the cloud does not shimmer between frames.
|
|
253
|
+
let s = seed;
|
|
254
|
+
const next = () => ((s = (s * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff);
|
|
255
|
+
const cloud = [];
|
|
256
|
+
for (const r of rows(globe, values)) {
|
|
257
|
+
const n = Math.max(1, Math.round(r.value / per));
|
|
258
|
+
for (let i = 0; i < n; i++) {
|
|
259
|
+
cloud.push({ lon: r.lon + (next() - 0.5) * 9, lat: r.lat + (next() - 0.5) * 7 });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return { cloud };
|
|
263
|
+
},
|
|
264
|
+
frame(ctx, globe, t, state) {
|
|
265
|
+
const upto = Math.floor(easeOut(t) * state.cloud.length);
|
|
266
|
+
ctx.fillStyle = color;
|
|
267
|
+
for (let i = 0; i < upto; i++) {
|
|
268
|
+
const p = globe.project(state.cloud[i].lon, state.cloud[i].lat);
|
|
269
|
+
if (!p) continue;
|
|
270
|
+
ctx.beginPath();
|
|
271
|
+
ctx.arc(p.x, p.y, 1.3, 0, TAU);
|
|
272
|
+
ctx.fill();
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Countries inflate or shrink toward a value rather than their true area.
|
|
279
|
+
* Ports catalogue effect AI.
|
|
280
|
+
*/
|
|
281
|
+
export const cartogramMorph = ({ values, duration = 3800, hold = 900, strength = 0.9 } = {}) => ({
|
|
282
|
+
name: "cartogramMorph",
|
|
283
|
+
stage: "above",
|
|
284
|
+
duration,
|
|
285
|
+
hold,
|
|
286
|
+
frame(ctx, globe, t) {
|
|
287
|
+
const list = rows(globe, values);
|
|
288
|
+
if (!list.length) return;
|
|
289
|
+
const e = easeInOut(pingPong(t) * 2 > 1 ? 1 : pingPong(t) * 2);
|
|
290
|
+
const max = Math.max(...list.map((r) => r.value));
|
|
291
|
+
for (const r of list) {
|
|
292
|
+
const p = globe.project(r.lon, r.lat);
|
|
293
|
+
if (!p) continue;
|
|
294
|
+
const scale = 1 + (r.value / max) * strength * e;
|
|
295
|
+
ctx.save();
|
|
296
|
+
ctx.translate(p.x, p.y);
|
|
297
|
+
ctx.scale(scale, scale);
|
|
298
|
+
ctx.translate(-p.x, -p.y);
|
|
299
|
+
ctx.globalAlpha = 0.85;
|
|
300
|
+
ctx.fillStyle = `hsl(${258 - (r.value / max) * 60} 82% 62%)`;
|
|
301
|
+
if (r.shape) {
|
|
302
|
+
ctx.beginPath();
|
|
303
|
+
globe.tracePath(r.shape, ctx);
|
|
304
|
+
ctx.fill();
|
|
305
|
+
} else {
|
|
306
|
+
ctx.beginPath();
|
|
307
|
+
ctx.arc(p.x, p.y, 6, 0, TAU);
|
|
308
|
+
ctx.fill();
|
|
309
|
+
}
|
|
310
|
+
ctx.restore();
|
|
311
|
+
}
|
|
312
|
+
ctx.globalAlpha = 1;
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Twelve little maps, one per period.
|
|
318
|
+
* Ports catalogue effect BP.
|
|
319
|
+
*/
|
|
320
|
+
export const smallMultiples = ({
|
|
321
|
+
panels = 12,
|
|
322
|
+
columns = 4,
|
|
323
|
+
duration = 4800,
|
|
324
|
+
labels = (i) => `P${i + 1}`,
|
|
325
|
+
accent = "#34d399",
|
|
326
|
+
} = {}) => ({
|
|
327
|
+
name: "smallMultiples",
|
|
328
|
+
stage: "above",
|
|
329
|
+
duration,
|
|
330
|
+
frame(ctx, globe, t) {
|
|
331
|
+
const w = globe.canvas.clientWidth, h = globe.canvas.clientHeight;
|
|
332
|
+
const rowsN = Math.ceil(panels / columns);
|
|
333
|
+
const cw = w / columns, ch = h / rowsN;
|
|
334
|
+
const markers = globe.markers;
|
|
335
|
+
ctx.fillStyle = "rgba(7,8,13,.86)";
|
|
336
|
+
ctx.fillRect(0, 0, w, h);
|
|
337
|
+
for (let i = 0; i < panels; i++) {
|
|
338
|
+
const col = i % columns, row = Math.floor(i / columns);
|
|
339
|
+
const x = col * cw, y = row * ch;
|
|
340
|
+
const lit = clamp01(t * panels - i);
|
|
341
|
+
ctx.strokeStyle = "rgba(232,236,245,.08)";
|
|
342
|
+
ctx.strokeRect(x + 2, y + 2, cw - 4, ch - 4);
|
|
343
|
+
label(ctx, labels(i), x + 8, y + 15, {
|
|
344
|
+
size: 9, weight: 700, color: lit > 0 ? accent : "rgba(232,236,245,.3)",
|
|
345
|
+
});
|
|
346
|
+
if (lit <= 0) continue;
|
|
347
|
+
// A miniature of the marker layout, one panel per period.
|
|
348
|
+
const upto = Math.ceil(lit * markers.length);
|
|
349
|
+
for (let m = 0; m < upto; m++) {
|
|
350
|
+
const mk = markers[m];
|
|
351
|
+
const px = x + cw / 2 + (mk.lon / 180) * (cw / 2 - 8);
|
|
352
|
+
const py = y + ch / 2 - (mk.lat / 90) * (ch / 2 - 12);
|
|
353
|
+
ctx.fillStyle = accent;
|
|
354
|
+
ctx.globalAlpha = lit;
|
|
355
|
+
ctx.beginPath();
|
|
356
|
+
ctx.arc(px, py, 1.8, 0, TAU);
|
|
357
|
+
ctx.fill();
|
|
358
|
+
}
|
|
359
|
+
ctx.globalAlpha = 1;
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The spider chart: several axes, one closed outline per series.
|
|
366
|
+
* Ports catalogue effect BR.
|
|
367
|
+
*/
|
|
368
|
+
export const radarProfile = ({
|
|
369
|
+
axes = ["Reach", "Speed", "Cost", "Uptime", "Support"],
|
|
370
|
+
series,
|
|
371
|
+
duration = 3200,
|
|
372
|
+
hold = 1400,
|
|
373
|
+
colors = ["#34d399", "#f472b6"],
|
|
374
|
+
} = {}) => ({
|
|
375
|
+
name: "radarProfile",
|
|
376
|
+
stage: "above",
|
|
377
|
+
duration,
|
|
378
|
+
hold,
|
|
379
|
+
frame(ctx, globe, t) {
|
|
380
|
+
const w = globe.canvas.clientWidth, h = globe.canvas.clientHeight;
|
|
381
|
+
const cx = w / 2, cy = h / 2, R = Math.min(w, h) * 0.3;
|
|
382
|
+
const sets = series || [[0.9, 0.7, 0.5, 0.95, 0.6], [0.6, 0.85, 0.8, 0.7, 0.9]];
|
|
383
|
+
const step = TAU / axes.length;
|
|
384
|
+
ctx.strokeStyle = "rgba(232,236,245,.12)";
|
|
385
|
+
ctx.lineWidth = 1;
|
|
386
|
+
for (let r = 1; r <= 4; r++) {
|
|
387
|
+
ctx.beginPath();
|
|
388
|
+
for (let i = 0; i <= axes.length; i++) {
|
|
389
|
+
const a = i * step - Math.PI / 2;
|
|
390
|
+
const x = cx + Math.cos(a) * (R * r) / 4, y = cy + Math.sin(a) * (R * r) / 4;
|
|
391
|
+
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
|
|
392
|
+
}
|
|
393
|
+
ctx.stroke();
|
|
394
|
+
}
|
|
395
|
+
axes.forEach((name, i) => {
|
|
396
|
+
const a = i * step - Math.PI / 2;
|
|
397
|
+
label(ctx, name, cx + Math.cos(a) * (R + 16), cy + Math.sin(a) * (R + 16) + 3, {
|
|
398
|
+
size: 9, weight: 700, align: "center", color: "rgba(232,236,245,.6)",
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
sets.forEach((values, s) => {
|
|
402
|
+
const grow = easeOut(clamp01(t * 1.6 - s * 0.2));
|
|
403
|
+
if (grow <= 0) return;
|
|
404
|
+
ctx.strokeStyle = colors[s % colors.length];
|
|
405
|
+
ctx.fillStyle = colors[s % colors.length];
|
|
406
|
+
ctx.globalAlpha = 0.16;
|
|
407
|
+
ctx.beginPath();
|
|
408
|
+
values.forEach((v, i) => {
|
|
409
|
+
const a = i * step - Math.PI / 2;
|
|
410
|
+
const r = R * v * grow;
|
|
411
|
+
const x = cx + Math.cos(a) * r, y = cy + Math.sin(a) * r;
|
|
412
|
+
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
|
|
413
|
+
});
|
|
414
|
+
ctx.closePath();
|
|
415
|
+
ctx.fill();
|
|
416
|
+
ctx.globalAlpha = 1;
|
|
417
|
+
ctx.lineWidth = 2;
|
|
418
|
+
ctx.stroke();
|
|
419
|
+
});
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* One square per unit - "one square = one thousand".
|
|
425
|
+
* Ports catalogue effect BS.
|
|
426
|
+
*/
|
|
427
|
+
export const waffle = ({
|
|
428
|
+
total = 100,
|
|
429
|
+
filled = 68,
|
|
430
|
+
columns = 10,
|
|
431
|
+
cell = 13,
|
|
432
|
+
duration = 2800,
|
|
433
|
+
hold = 1500,
|
|
434
|
+
accent = "#34d399",
|
|
435
|
+
caption = "",
|
|
436
|
+
} = {}) => ({
|
|
437
|
+
name: "waffle",
|
|
438
|
+
stage: "above",
|
|
439
|
+
duration,
|
|
440
|
+
hold,
|
|
441
|
+
frame(ctx, globe, t) {
|
|
442
|
+
const w = globe.canvas.clientWidth, h = globe.canvas.clientHeight;
|
|
443
|
+
const rowsN = Math.ceil(total / columns);
|
|
444
|
+
const gridW = columns * cell, gridH = rowsN * cell;
|
|
445
|
+
const ox = w - gridW - 24, oy = h - gridH - 34;
|
|
446
|
+
const lit = Math.round(easeOut(t) * filled);
|
|
447
|
+
for (let i = 0; i < total; i++) {
|
|
448
|
+
const col = i % columns, row = Math.floor(i / columns);
|
|
449
|
+
ctx.fillStyle = i < lit ? accent : "rgba(232,236,245,.1)";
|
|
450
|
+
ctx.beginPath();
|
|
451
|
+
ctx.roundRect(ox + col * cell, oy + row * cell, cell - 3, cell - 3, 2);
|
|
452
|
+
ctx.fill();
|
|
453
|
+
}
|
|
454
|
+
label(ctx, caption || `${lit} of ${total}`, ox, oy + gridH + 14, {
|
|
455
|
+
size: 10, weight: 700, color: "rgba(232,236,245,.7)",
|
|
456
|
+
});
|
|
457
|
+
},
|
|
458
|
+
});
|
package/src/controls.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Controls: bindings between your own DOM and the globe.
|
|
3
|
+
*
|
|
4
|
+
* These are not effects. Each one wires an element you already have - an
|
|
5
|
+
* input, a slider, a list - to the globe, and returns a function that unbinds
|
|
6
|
+
* it. Nothing is injected into the page, so the markup and styling stay yours.
|
|
7
|
+
*
|
|
8
|
+
* import { searchAndFly } from "canvas-globe/controls";
|
|
9
|
+
*
|
|
10
|
+
* const off = searchAndFly(globe, document.querySelector("#city"));
|
|
11
|
+
*/
|
|
12
|
+
import { geocode } from "./csv.js";
|
|
13
|
+
|
|
14
|
+
const listen = (target, type, handler) => {
|
|
15
|
+
target.addEventListener(type, handler);
|
|
16
|
+
return () => target.removeEventListener(type, handler);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Types a place name and flies there.
|
|
21
|
+
*
|
|
22
|
+
* Resolution order: your markers, then the bundled country list, then the
|
|
23
|
+
* built-in gazetteer (`geocode`, roughly 300 major cities at no extra
|
|
24
|
+
* payload). Pass `gazetteer` for your own table, or `source` for an async
|
|
25
|
+
* resolver - a city index, Nominatim, your own API - when you need more
|
|
26
|
+
* places than the library is willing to bundle.
|
|
27
|
+
*
|
|
28
|
+
* Ports catalogue effect CG.
|
|
29
|
+
*/
|
|
30
|
+
export function searchAndFly(globe, input, {
|
|
31
|
+
zoom = 2.2,
|
|
32
|
+
gazetteer,
|
|
33
|
+
source,
|
|
34
|
+
minLength = 2,
|
|
35
|
+
onMatch,
|
|
36
|
+
onMiss,
|
|
37
|
+
} = {}) {
|
|
38
|
+
if (!globe || !input) return () => {};
|
|
39
|
+
|
|
40
|
+
const local = (query) => {
|
|
41
|
+
const q = query.trim().toLowerCase();
|
|
42
|
+
if (q.length < minLength) return null;
|
|
43
|
+
const marker = globe.markers.find((m) =>
|
|
44
|
+
(m.city || m.label || m.name || "").toLowerCase().startsWith(q)
|
|
45
|
+
);
|
|
46
|
+
if (marker) return { lon: marker.lon, lat: marker.lat, label: marker.city || marker.label };
|
|
47
|
+
const shape = globe.world.find(
|
|
48
|
+
(s) => s.name.toLowerCase().startsWith(q) || s.code?.toLowerCase() === q
|
|
49
|
+
);
|
|
50
|
+
if (shape) return { shape, label: shape.name };
|
|
51
|
+
const point = geocode(query.trim(), { gazetteer });
|
|
52
|
+
return point ? { ...point, label: query.trim() } : null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const land = (hit) => {
|
|
56
|
+
if (!hit) return false;
|
|
57
|
+
if (hit.shape) globe.focusOn(hit.shape.code || hit.shape.name);
|
|
58
|
+
else globe.flyTo(hit.lon, hit.lat, { zoom });
|
|
59
|
+
onMatch?.(hit);
|
|
60
|
+
return true;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
let token = 0;
|
|
64
|
+
const go = () => {
|
|
65
|
+
const query = input.value;
|
|
66
|
+
if (land(local(query))) return;
|
|
67
|
+
if (!source) {
|
|
68
|
+
onMiss?.(query);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
// Late responses from a stale keystroke must not move the camera.
|
|
72
|
+
const mine = ++token;
|
|
73
|
+
Promise.resolve(source(query)).then((found) => {
|
|
74
|
+
if (mine !== token) return;
|
|
75
|
+
const hit = Array.isArray(found) ? found[0] : found;
|
|
76
|
+
if (!land(hit)) onMiss?.(query);
|
|
77
|
+
}, () => onMiss?.(query));
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const onKey = (e) => {
|
|
81
|
+
if (e.key === "Enter") go();
|
|
82
|
+
};
|
|
83
|
+
return listen(input, "keydown", onKey);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Drags across a range input to filter markers by date.
|
|
88
|
+
* Ports catalogue effect CO.
|
|
89
|
+
*/
|
|
90
|
+
export function timelineBrush(globe, slider, { field = "date", onChange } = {}) {
|
|
91
|
+
if (!globe || !slider) return () => {};
|
|
92
|
+
const all = globe.markers.slice();
|
|
93
|
+
const stamps = all
|
|
94
|
+
.map((m) => new Date(m[field]).getTime())
|
|
95
|
+
.filter((n) => Number.isFinite(n));
|
|
96
|
+
if (!stamps.length) return () => {};
|
|
97
|
+
const min = Math.min(...stamps), max = Math.max(...stamps);
|
|
98
|
+
slider.min = "0";
|
|
99
|
+
slider.max = "100";
|
|
100
|
+
const apply = () => {
|
|
101
|
+
const cut = min + ((max - min) * Number(slider.value)) / 100;
|
|
102
|
+
const shown = all.filter((m) => {
|
|
103
|
+
const at = new Date(m[field]).getTime();
|
|
104
|
+
return !Number.isFinite(at) || at <= cut;
|
|
105
|
+
});
|
|
106
|
+
globe.setMarkers(shown);
|
|
107
|
+
onChange?.(shown, new Date(cut));
|
|
108
|
+
};
|
|
109
|
+
apply();
|
|
110
|
+
const off = listen(slider, "input", apply);
|
|
111
|
+
return () => {
|
|
112
|
+
off();
|
|
113
|
+
globe.setMarkers(all);
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Raises a cut-off and hides anything below it.
|
|
119
|
+
* Ports catalogue effect CQ.
|
|
120
|
+
*/
|
|
121
|
+
export function thresholdFilter(globe, slider, { field = "count", onChange } = {}) {
|
|
122
|
+
if (!globe || !slider) return () => {};
|
|
123
|
+
const all = globe.markers.slice();
|
|
124
|
+
const max = Math.max(1, ...all.map((m) => m[field] || 0));
|
|
125
|
+
slider.min = "0";
|
|
126
|
+
slider.max = String(max);
|
|
127
|
+
const apply = () => {
|
|
128
|
+
const cut = Number(slider.value);
|
|
129
|
+
const shown = all.filter((m) => (m[field] || 0) >= cut);
|
|
130
|
+
globe.setMarkers(shown);
|
|
131
|
+
onChange?.(shown, cut);
|
|
132
|
+
};
|
|
133
|
+
apply();
|
|
134
|
+
const off = listen(slider, "input", apply);
|
|
135
|
+
return () => {
|
|
136
|
+
off();
|
|
137
|
+
globe.setMarkers(all);
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Two-way highlight between a list of elements and the map.
|
|
143
|
+
*
|
|
144
|
+
* Each element needs a `data-code` matching a marker's code, city or label.
|
|
145
|
+
* Hovering either side highlights the other.
|
|
146
|
+
* Ports catalogue effect CR.
|
|
147
|
+
*/
|
|
148
|
+
export function crossfilter(globe, container, { attribute = "data-code", active = "is-active" } = {}) {
|
|
149
|
+
if (!globe || !container) return () => {};
|
|
150
|
+
const items = Array.from(container.querySelectorAll(`[${attribute}]`));
|
|
151
|
+
const keyOf = (m) => String(m.code || m.city || m.label || m.name || "");
|
|
152
|
+
const all = globe.markers.slice();
|
|
153
|
+
|
|
154
|
+
const highlight = (key) => {
|
|
155
|
+
for (const el of items) el.classList.toggle(active, el.getAttribute(attribute) === key);
|
|
156
|
+
globe.setMarkers(
|
|
157
|
+
all.map((m) => ({ ...m, live: key != null && keyOf(m) === key }))
|
|
158
|
+
);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const offs = items.map((el) => [
|
|
162
|
+
listen(el, "pointerenter", () => highlight(el.getAttribute(attribute))),
|
|
163
|
+
listen(el, "pointerleave", () => highlight(null)),
|
|
164
|
+
]).flat();
|
|
165
|
+
|
|
166
|
+
const onMove = (e) => {
|
|
167
|
+
const r = globe.canvas.getBoundingClientRect();
|
|
168
|
+
let best = null, bd = 26;
|
|
169
|
+
for (const m of all) {
|
|
170
|
+
const p = globe.project(m.lon, m.lat);
|
|
171
|
+
if (!p) continue;
|
|
172
|
+
const d = Math.hypot(p.x - (e.clientX - r.left), p.y - (e.clientY - r.top));
|
|
173
|
+
if (d < bd) {
|
|
174
|
+
bd = d;
|
|
175
|
+
best = m;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
highlight(best ? keyOf(best) : null);
|
|
179
|
+
};
|
|
180
|
+
offs.push(listen(globe.canvas, "pointermove", onMove));
|
|
181
|
+
|
|
182
|
+
return () => {
|
|
183
|
+
for (const off of offs) off();
|
|
184
|
+
globe.setMarkers(all);
|
|
185
|
+
for (const el of items) el.classList.remove(active);
|
|
186
|
+
};
|
|
187
|
+
}
|