canvas-globe 1.1.1 → 1.3.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 +44 -0
- package/README.md +113 -5
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/codemeta.json +1 -1
- package/custom-elements.json +2 -2
- package/dist/canvas-globe.umd.js +331 -8
- 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 +261 -8
- package/src/geo.js +70 -0
- 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/element.d.ts +3 -3
- package/types/fx-interaction.d.ts +16 -0
- package/types/fx.d.ts +313 -0
- package/types/index.d.ts +94 -7
- package/types/jsr-element.d.ts +3 -3
- package/types/places.d.ts +43 -0
- package/types/recipes.d.ts +54 -0
package/src/geo-globe.js
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
getLicensePresentation,
|
|
16
16
|
reportLicenseStatus,
|
|
17
17
|
} from "./license.js";
|
|
18
|
-
import { D2R, R2D, TAU, clamp, wrapLon, resolveProjection, projectionBounds, ortho, orthoInverse, greatCircle, circleAround, distanceMeters, subsolarPoint, pointInGeometry, geometryBounds, normalizeShapes, withAlpha } from "./geo.js";
|
|
18
|
+
import { D2R, R2D, TAU, clamp, wrapLon, resolveProjection, projectionBounds, ortho, orthoInverse, greatCircle, circleAround, distanceMeters, subsolarPoint, pointInGeometry, geometryBounds, normalizeShapes, withAlpha, colorScale, hexBinPoints } from "./geo.js";
|
|
19
19
|
|
|
20
20
|
const DEFAULTS = {
|
|
21
21
|
licenseKey: null,
|
|
@@ -40,6 +40,7 @@ const DEFAULTS = {
|
|
|
40
40
|
timeline: null,
|
|
41
41
|
transparentBackground: false,
|
|
42
42
|
heatmap: false,
|
|
43
|
+
hexBins: false,
|
|
43
44
|
spikes: false,
|
|
44
45
|
labels: false,
|
|
45
46
|
legend: null,
|
|
@@ -83,13 +84,28 @@ const DEFAULTS = {
|
|
|
83
84
|
onCountryHover: null,
|
|
84
85
|
onCountryClick: null,
|
|
85
86
|
onRender: null,
|
|
87
|
+
effects: null,
|
|
86
88
|
};
|
|
87
89
|
|
|
88
90
|
const coord = (v) => (Array.isArray(v) ? [v[0], v[1]] : [v.lon ?? v.lng ?? v.longitude, v.lat ?? v.latitude]);
|
|
89
91
|
|
|
92
|
+
const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* An effect's own 0→1 progress at a point on the shared clock. Derived purely
|
|
96
|
+
* from `ms`, so the same millisecond always renders the same frame.
|
|
97
|
+
*/
|
|
98
|
+
const phaseOf = (fx, ms) => {
|
|
99
|
+
const duration = Math.max(1, fx.duration ?? 1000);
|
|
100
|
+
const total = duration + (fx.hold ?? 0);
|
|
101
|
+
const elapsed = fx.loop === false ? Math.min(ms, total) : ((ms % total) + total) % total;
|
|
102
|
+
return Math.min(1, elapsed / duration);
|
|
103
|
+
};
|
|
104
|
+
|
|
90
105
|
const defaultTooltip = (target, kind) => {
|
|
91
106
|
if (kind === "country") return target.name || String(target.id ?? "");
|
|
92
107
|
if (kind === "cluster") return `${target.count} in this area`;
|
|
108
|
+
if (kind === "hex-bin") return `${target.markerCount} markers, value ${target.value}`;
|
|
93
109
|
const name = target.city || target.name || target.label;
|
|
94
110
|
const count = target.count != null ? `: ${target.count}` : "";
|
|
95
111
|
return name ? `${name}${count}` : `${target.lat.toFixed(2)}, ${target.lon.toFixed(2)}${count}`;
|
|
@@ -141,6 +157,11 @@ export class GeoGlobe {
|
|
|
141
157
|
this._licenseHits = [];
|
|
142
158
|
this._licenseDismissed = false;
|
|
143
159
|
this._licenseHovered = false;
|
|
160
|
+
this._fx = [];
|
|
161
|
+
this._clock = null;
|
|
162
|
+
this._t0 = now();
|
|
163
|
+
this._spinBase = this.lon;
|
|
164
|
+
this.pointer = { x: 0, y: 0, lon: 0, lat: 0, over: false };
|
|
144
165
|
|
|
145
166
|
this._applyWorld();
|
|
146
167
|
this._applyMarkers(this.o.markers);
|
|
@@ -152,12 +173,138 @@ export class GeoGlobe {
|
|
|
152
173
|
this.resize();
|
|
153
174
|
if (this.o.focus) this.focusOn(this.o.focus, { instant: true });
|
|
154
175
|
if (this.o.showViewer) this._initViewer();
|
|
176
|
+
for (const fx of this.o.effects || []) this.use(fx);
|
|
155
177
|
this._loop = this._loop.bind(this);
|
|
156
178
|
this._raf = requestAnimationFrame(this._loop);
|
|
157
179
|
}
|
|
158
180
|
|
|
159
181
|
/* ------------------------------ public API ------------------------------ */
|
|
160
182
|
|
|
183
|
+
/* --------------------------------- effects ------------------------------- */
|
|
184
|
+
|
|
185
|
+
/** Installs an effect. Returns `this`, so calls chain. */
|
|
186
|
+
use(effect) {
|
|
187
|
+
if (!effect || typeof effect.frame !== "function") return this;
|
|
188
|
+
this._fx.push({ fx: effect, state: effect.setup?.(this) ?? {} });
|
|
189
|
+
this._fx.sort((a, b) => (a.fx.z ?? 0) - (b.fx.z ?? 0));
|
|
190
|
+
return this.invalidate();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Removes an effect by reference or by name. */
|
|
194
|
+
remove(effect) {
|
|
195
|
+
const i = this._fx.findIndex((e) => e.fx === effect || e.fx.name === effect);
|
|
196
|
+
if (i < 0) return this;
|
|
197
|
+
const [entry] = this._fx.splice(i, 1);
|
|
198
|
+
entry.fx.dispose?.(entry.state, this);
|
|
199
|
+
return this.invalidate();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
clearEffects() {
|
|
203
|
+
while (this._fx.length) this.remove(this._fx[0].fx);
|
|
204
|
+
return this;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
get effects() {
|
|
208
|
+
return this._fx.map((e) => e.fx);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Pins the effect clock to `ms`. Rendering becomes reproducible, which is
|
|
213
|
+
* what frame-exact export and seamless loops need. `play()` releases it.
|
|
214
|
+
*/
|
|
215
|
+
seek(ms) {
|
|
216
|
+
// Capture where auto-rotation started from, so longitude can be derived
|
|
217
|
+
// from the clock instead of accumulated per frame. Only on the way in:
|
|
218
|
+
// seeking again while already seeked must not shift the origin.
|
|
219
|
+
if (this._clock == null) this._spinBase = wrapLon(this.lon - this._spinRate() * this._fxTime());
|
|
220
|
+
this._clock = Math.max(0, ms);
|
|
221
|
+
if (this._autoSpin()) this.lon = wrapLon(this._spinBase + this._spinRate() * this._clock);
|
|
222
|
+
return this.invalidate();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
play() {
|
|
226
|
+
// Resume the timeline where it was paused rather than snapping back to 0.
|
|
227
|
+
if (this._clock != null) this._t0 = now() - this._clock;
|
|
228
|
+
this._clock = null;
|
|
229
|
+
return this.invalidate();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Draws the single frame that belongs at `ms` on the effect clock. */
|
|
233
|
+
renderFrame(ms) {
|
|
234
|
+
return this.seek(ms).render();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
_fxTime() {
|
|
238
|
+
return this._clock != null ? this._clock : now() - this._t0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Wall clock for looping scene animation. Pinned while seeking, so arcs,
|
|
243
|
+
* orbits and pulses land on the same phase every time a frame is redrawn.
|
|
244
|
+
*/
|
|
245
|
+
_animTime() {
|
|
246
|
+
return this._clock != null ? this._clock : Date.now();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Degrees of auto-rotation per millisecond. */
|
|
250
|
+
_spinRate() {
|
|
251
|
+
return ((this.o.rotateSpeed || 0) * (this.o.fps || 30)) / 1000;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
_runPasses(stage) {
|
|
255
|
+
if (!this._fx.length) return;
|
|
256
|
+
const ms = this._fxTime();
|
|
257
|
+
const { ctx } = this;
|
|
258
|
+
for (const entry of this._fx) {
|
|
259
|
+
if ((entry.fx.stage || "above") !== stage) continue;
|
|
260
|
+
ctx.save();
|
|
261
|
+
try {
|
|
262
|
+
entry.fx.frame(ctx, this, phaseOf(entry.fx, ms), entry.state);
|
|
263
|
+
} catch (err) {
|
|
264
|
+
// One bad effect must not take the render loop down with it.
|
|
265
|
+
if (!entry.failed) {
|
|
266
|
+
entry.failed = true;
|
|
267
|
+
console.error(`canvas-globe: effect "${entry.fx.name || "anonymous"}" failed`, err);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
ctx.restore();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Land sample points as `[lon, lat]` pairs — what the dot matrix draws. */
|
|
275
|
+
landPoints(spacing) {
|
|
276
|
+
const prev = this.o.dotSpacing;
|
|
277
|
+
if (spacing != null) this.o.dotSpacing = spacing;
|
|
278
|
+
const flat = this._dotPoints();
|
|
279
|
+
this.o.dotSpacing = prev;
|
|
280
|
+
const out = [];
|
|
281
|
+
for (let i = 0; i < flat.length; i += 2) out.push([flat[i], flat[i + 1]]);
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Traces a country outline into a path using the current projection. */
|
|
286
|
+
tracePath(shape, ctx = this.ctx, transform) {
|
|
287
|
+
const geom = shape?.geometry || shape;
|
|
288
|
+
if (!geom?.coordinates) return this;
|
|
289
|
+
const polys = geom.type === "Polygon" ? [geom.coordinates] : geom.coordinates;
|
|
290
|
+
for (const poly of polys) {
|
|
291
|
+
for (const ring of poly) {
|
|
292
|
+
let open = false;
|
|
293
|
+
for (const c of ring) {
|
|
294
|
+
const src = transform ? transform(c) : c;
|
|
295
|
+
const p = this.project(src[0], src[1]);
|
|
296
|
+
if (!p) {
|
|
297
|
+
open = false;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
open ? ctx.lineTo(p.x, p.y) : (ctx.moveTo(p.x, p.y), (open = true));
|
|
301
|
+
}
|
|
302
|
+
if (open) ctx.closePath();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return this;
|
|
306
|
+
}
|
|
307
|
+
|
|
161
308
|
setMarkers(markers = []) {
|
|
162
309
|
this._applyMarkers(markers);
|
|
163
310
|
this._focus = -1;
|
|
@@ -714,9 +861,12 @@ export class GeoGlobe {
|
|
|
714
861
|
const { w, h } = this._size();
|
|
715
862
|
ctx.setTransform(this._dpr, 0, 0, this._dpr, 0, 0);
|
|
716
863
|
ctx.clearRect(0, 0, w, h);
|
|
864
|
+
this._runPasses("beneath");
|
|
717
865
|
this.hits = this.o.mode === "map" ? this._paintMap(w, h) : this._paintGlobe(w, h);
|
|
718
866
|
this._dirty = false;
|
|
867
|
+
this._runPasses("above");
|
|
719
868
|
this.o.onRender?.(this);
|
|
869
|
+
this._runPasses("post");
|
|
720
870
|
this._paintLicenseNotice(w, h);
|
|
721
871
|
return this;
|
|
722
872
|
}
|
|
@@ -734,6 +884,7 @@ export class GeoGlobe {
|
|
|
734
884
|
destroy() {
|
|
735
885
|
this._destroyed = true;
|
|
736
886
|
cancelAnimationFrame(this._raf);
|
|
887
|
+
this.clearEffects();
|
|
737
888
|
this.stopTour();
|
|
738
889
|
this.stopStory();
|
|
739
890
|
this.stopTimeline();
|
|
@@ -1188,6 +1339,15 @@ export class GeoGlobe {
|
|
|
1188
1339
|
this._onMove = (e) => {
|
|
1189
1340
|
const [x, y] = this._local(e);
|
|
1190
1341
|
this._pointer = { x: e.clientX, y: e.clientY };
|
|
1342
|
+
this.pointer.x = x;
|
|
1343
|
+
this.pointer.y = y;
|
|
1344
|
+
this.pointer.over = true;
|
|
1345
|
+
const at = this.unproject(x, y);
|
|
1346
|
+
if (at) {
|
|
1347
|
+
this.pointer.lon = at[0];
|
|
1348
|
+
this.pointer.lat = at[1];
|
|
1349
|
+
}
|
|
1350
|
+
if (this._fx.length) this._dirty = true;
|
|
1191
1351
|
const licenseHovered = this._licenseHitAt(x, y);
|
|
1192
1352
|
if (licenseHovered !== this._licenseHovered) {
|
|
1193
1353
|
this._licenseHovered = licenseHovered;
|
|
@@ -1241,7 +1401,7 @@ export class GeoGlobe {
|
|
|
1241
1401
|
this._cursor();
|
|
1242
1402
|
this._dirty = true;
|
|
1243
1403
|
const target = marker || this._hoveredCountry;
|
|
1244
|
-
this._showTip(marker ? (marker.cluster ? "cluster" : "marker") : this._hoveredCountry ? "country" : null, target);
|
|
1404
|
+
this._showTip(marker ? (marker.hexBin ? "hex-bin" : marker.cluster ? "cluster" : "marker") : this._hoveredCountry ? "country" : null, target);
|
|
1245
1405
|
} else if (this._tipVisible) {
|
|
1246
1406
|
this._placeTip();
|
|
1247
1407
|
}
|
|
@@ -1258,6 +1418,7 @@ export class GeoGlobe {
|
|
|
1258
1418
|
this._onLeave = (e) => {
|
|
1259
1419
|
this._onUp(e);
|
|
1260
1420
|
this._pointer = null;
|
|
1421
|
+
this.pointer.over = false;
|
|
1261
1422
|
this._licenseHovered = false;
|
|
1262
1423
|
if (this._hovered || this._hoveredCountry) {
|
|
1263
1424
|
if (this._hovered) this.o.onHover?.(null, null);
|
|
@@ -1438,13 +1599,20 @@ export class GeoGlobe {
|
|
|
1438
1599
|
|
|
1439
1600
|
/* --------------------------------- loop --------------------------------- */
|
|
1440
1601
|
|
|
1441
|
-
|
|
1602
|
+
/** Auto-rotation is configured and permitted, regardless of the clock. */
|
|
1603
|
+
_autoSpin() {
|
|
1442
1604
|
return !!this.o.autoRotate && !this._reducedMotion() && !this._drag && !this._vel && this.o.mode === "globe";
|
|
1443
1605
|
}
|
|
1444
1606
|
|
|
1607
|
+
/** Auto-rotation should advance on its own this frame. */
|
|
1608
|
+
_spinning() {
|
|
1609
|
+
return this._autoSpin() && this._clock == null;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1445
1612
|
/** True while something on screen still needs to move. */
|
|
1446
1613
|
_animating() {
|
|
1447
1614
|
if (this._target || this._drag || this._vel) return true;
|
|
1615
|
+
if (this._fx.length && this._clock == null) return true;
|
|
1448
1616
|
if (this._spinning()) return true;
|
|
1449
1617
|
if (this._reducedMotion()) return false;
|
|
1450
1618
|
if (this._hasLive || this._pings.length) return true;
|
|
@@ -1718,7 +1886,7 @@ export class GeoGlobe {
|
|
|
1718
1886
|
const list = this._orbitList();
|
|
1719
1887
|
if (!list.length) return;
|
|
1720
1888
|
const { ctx } = this;
|
|
1721
|
-
const spin = this._reducedMotion() ? 0 : (
|
|
1889
|
+
const spin = this._reducedMotion() ? 0 : (this._animTime() / 1000) % 3600;
|
|
1722
1890
|
ctx.lineCap = "round";
|
|
1723
1891
|
for (const orbit of list) {
|
|
1724
1892
|
const inc = (orbit.inclination ?? 30) * D2R;
|
|
@@ -2272,7 +2440,7 @@ export class GeoGlobe {
|
|
|
2272
2440
|
const arcs = this.o.arcs;
|
|
2273
2441
|
if (!arcs || !arcs.length) return;
|
|
2274
2442
|
const { ctx } = this;
|
|
2275
|
-
const now =
|
|
2443
|
+
const now = this._animTime();
|
|
2276
2444
|
const still = this._reducedMotion();
|
|
2277
2445
|
ctx.lineCap = "round";
|
|
2278
2446
|
ctx.lineJoin = "round";
|
|
@@ -2421,6 +2589,87 @@ export class GeoGlobe {
|
|
|
2421
2589
|
ctx.restore();
|
|
2422
2590
|
}
|
|
2423
2591
|
|
|
2592
|
+
/** Aggregate projected markers into an interactive hexagonal density layer. */
|
|
2593
|
+
_paintHexBins(pts, t, cx, cy, globeRadius, w, h) {
|
|
2594
|
+
const o = this.o.hexBins === true ? {} : this.o.hexBins;
|
|
2595
|
+
const { ctx } = this;
|
|
2596
|
+
const radius = Math.max(4, o.radius ?? 18);
|
|
2597
|
+
const padding = clamp(o.padding ?? 1.5, 0, radius * 0.45);
|
|
2598
|
+
const drawRadius = radius - padding;
|
|
2599
|
+
const minValue = Math.max(0, o.minValue ?? 1);
|
|
2600
|
+
const metric = o.value === "count" ? "count" : "value";
|
|
2601
|
+
const bins = hexBinPoints(pts, radius).filter((bin) => {
|
|
2602
|
+
if (bin[metric] < minValue) return false;
|
|
2603
|
+
if (this.o.mode === "globe") return Math.hypot(bin.x - cx, bin.y - cy) <= globeRadius + drawRadius;
|
|
2604
|
+
return bin.x >= -drawRadius && bin.x <= w + drawRadius && bin.y >= -drawRadius && bin.y <= h + drawRadius;
|
|
2605
|
+
});
|
|
2606
|
+
const max = Math.max(1, ...bins.map((bin) => bin[metric]));
|
|
2607
|
+
const range = Array.isArray(o.colorRange) && o.colorRange.length > 1
|
|
2608
|
+
? o.colorRange
|
|
2609
|
+
: [t.ocean[0], o.color || t.marker];
|
|
2610
|
+
const domain = range.map((_, index) => max * index / (range.length - 1));
|
|
2611
|
+
const scale = colorScale(domain, range);
|
|
2612
|
+
const hits = [];
|
|
2613
|
+
|
|
2614
|
+
ctx.save();
|
|
2615
|
+
if (this.o.mode === "globe") {
|
|
2616
|
+
ctx.beginPath();
|
|
2617
|
+
ctx.arc(cx, cy, globeRadius, 0, TAU);
|
|
2618
|
+
ctx.clip();
|
|
2619
|
+
} else {
|
|
2620
|
+
ctx.beginPath();
|
|
2621
|
+
ctx.rect(0, 0, w, h);
|
|
2622
|
+
ctx.clip();
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
ctx.lineJoin = "round";
|
|
2626
|
+
for (const bin of bins) {
|
|
2627
|
+
const value = bin[metric];
|
|
2628
|
+
ctx.beginPath();
|
|
2629
|
+
for (let i = 0; i < 6; i++) {
|
|
2630
|
+
const angle = (60 * i - 30) * D2R;
|
|
2631
|
+
const x = bin.x + drawRadius * Math.cos(angle);
|
|
2632
|
+
const y = bin.y + drawRadius * Math.sin(angle);
|
|
2633
|
+
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
|
|
2634
|
+
}
|
|
2635
|
+
ctx.closePath();
|
|
2636
|
+
ctx.globalAlpha = clamp(o.opacity ?? 0.82, 0, 1);
|
|
2637
|
+
ctx.fillStyle = scale(value) || o.color || t.marker;
|
|
2638
|
+
ctx.fill();
|
|
2639
|
+
if ((o.strokeWidth ?? 0.8) > 0) {
|
|
2640
|
+
ctx.globalAlpha = 1;
|
|
2641
|
+
ctx.strokeStyle = o.stroke || withAlpha(t.label, 0.3);
|
|
2642
|
+
ctx.lineWidth = o.strokeWidth ?? 0.8;
|
|
2643
|
+
ctx.stroke();
|
|
2644
|
+
}
|
|
2645
|
+
if (o.showCount && drawRadius >= 10) {
|
|
2646
|
+
ctx.globalAlpha = 1;
|
|
2647
|
+
ctx.fillStyle = o.labelColor || t.label;
|
|
2648
|
+
ctx.font = `600 ${Math.max(9, Math.min(13, drawRadius * 0.7))}px Inter,system-ui,sans-serif`;
|
|
2649
|
+
ctx.textAlign = "center";
|
|
2650
|
+
ctx.textBaseline = "middle";
|
|
2651
|
+
ctx.fillText(String(value), bin.x, bin.y);
|
|
2652
|
+
}
|
|
2653
|
+
hits.push({
|
|
2654
|
+
marker: {
|
|
2655
|
+
hexBin: true,
|
|
2656
|
+
count: bin.count,
|
|
2657
|
+
value: bin.value,
|
|
2658
|
+
markerCount: bin.count,
|
|
2659
|
+
markers: bin.markers,
|
|
2660
|
+
lon: bin.lon,
|
|
2661
|
+
lat: bin.lat,
|
|
2662
|
+
},
|
|
2663
|
+
x: bin.x,
|
|
2664
|
+
y: bin.y,
|
|
2665
|
+
r: drawRadius,
|
|
2666
|
+
});
|
|
2667
|
+
}
|
|
2668
|
+
ctx.restore();
|
|
2669
|
+
this._lastHexBins = bins;
|
|
2670
|
+
return hits;
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2424
2673
|
/** Spike height for a marker, as a fraction of the globe radius. */
|
|
2425
2674
|
_spikeLift(m) {
|
|
2426
2675
|
if (!this.o.spikes) return 0;
|
|
@@ -2801,9 +3050,11 @@ export class GeoGlobe {
|
|
|
2801
3050
|
pts.push({ m, x: cx + x, y: cy + y, depth: 0.65 + c * 0.35 });
|
|
2802
3051
|
}
|
|
2803
3052
|
if (this.o.heatmap) this._paintHeatmap(pts, t);
|
|
3053
|
+
const binHits = this.o.hexBins ? this._paintHexBins(pts, t, cx, cy, r, w, h) : [];
|
|
2804
3054
|
if (this.o.spikes) this._paintSpikes(t, cx, cy, r, w, h, null);
|
|
2805
3055
|
this._paintViewerAccuracy(t, cx, cy, r, null);
|
|
2806
|
-
const
|
|
3056
|
+
const showMarkers = !this.o.hexBins || (this.o.hexBins !== true && this.o.hexBins.hideMarkers === false);
|
|
3057
|
+
const hits = [...binHits, ...(showMarkers ? this._paintMarkers(pts, t) : [])];
|
|
2807
3058
|
if (this.o.labels) this._paintLabels(pts, t, cx, cy, r, w, h);
|
|
2808
3059
|
this._paintAnnotations(t);
|
|
2809
3060
|
this._paintPings(t);
|
|
@@ -2878,9 +3129,11 @@ export class GeoGlobe {
|
|
|
2878
3129
|
pts.push({ m, x, y, depth: 1 });
|
|
2879
3130
|
}
|
|
2880
3131
|
if (this.o.heatmap) this._paintHeatmap(pts, t);
|
|
3132
|
+
const binHits = this.o.hexBins ? this._paintHexBins(pts, t, 0, 0, 0, w, h) : [];
|
|
2881
3133
|
if (this.o.spikes) this._paintSpikes(t, 0, 0, 0, w, h, fwd);
|
|
2882
3134
|
this._paintViewerAccuracy(t, 0, 0, 0, fwd);
|
|
2883
|
-
const
|
|
3135
|
+
const showMarkers = !this.o.hexBins || (this.o.hexBins !== true && this.o.hexBins.hideMarkers === false);
|
|
3136
|
+
const hits = [...binHits, ...(showMarkers ? this._paintMarkers(pts, t) : [])];
|
|
2884
3137
|
if (this.o.labels) this._paintLabels(pts, t, 0, 0, 0, w, h);
|
|
2885
3138
|
this._paintAnnotations(t);
|
|
2886
3139
|
this._paintPings(t);
|
|
@@ -3001,7 +3254,7 @@ export class GeoGlobe {
|
|
|
3001
3254
|
}
|
|
3002
3255
|
|
|
3003
3256
|
if (m.live && !this._reducedMotion()) {
|
|
3004
|
-
const pulse = (
|
|
3257
|
+
const pulse = (this._animTime() % 2200) / 2200;
|
|
3005
3258
|
ctx.globalAlpha = 1 - pulse;
|
|
3006
3259
|
ctx.strokeStyle = t.live;
|
|
3007
3260
|
ctx.lineWidth = 1.4 * scale;
|
package/src/geo.js
CHANGED
|
@@ -254,6 +254,76 @@ export const withAlpha = (color, a) => {
|
|
|
254
254
|
return color;
|
|
255
255
|
};
|
|
256
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Aggregates projected points into a pointy-top hexagonal grid.
|
|
259
|
+
*
|
|
260
|
+
* The input is deliberately renderer-shaped (`{ x, y, depth, m }`) so the
|
|
261
|
+
* same helper works after either globe or flat-map projection. Each returned
|
|
262
|
+
* bin preserves its source markers for tooltips, clicks, and custom details.
|
|
263
|
+
*/
|
|
264
|
+
export const hexBinPoints = (points = [], radius = 18) => {
|
|
265
|
+
const size = Math.max(1, Number(radius) || 18);
|
|
266
|
+
const sqrt3 = Math.sqrt(3);
|
|
267
|
+
const cells = new Map();
|
|
268
|
+
|
|
269
|
+
const roundAxial = (q, r) => {
|
|
270
|
+
let x = q;
|
|
271
|
+
let z = r;
|
|
272
|
+
let y = -x - z;
|
|
273
|
+
let rx = Math.round(x);
|
|
274
|
+
let ry = Math.round(y);
|
|
275
|
+
let rz = Math.round(z);
|
|
276
|
+
const dx = Math.abs(rx - x);
|
|
277
|
+
const dy = Math.abs(ry - y);
|
|
278
|
+
const dz = Math.abs(rz - z);
|
|
279
|
+
if (dx > dy && dx > dz) rx = -ry - rz;
|
|
280
|
+
else if (dy > dz) ry = -rx - rz;
|
|
281
|
+
else rz = -rx - ry;
|
|
282
|
+
return [rx, rz];
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
for (const point of points) {
|
|
286
|
+
if (!Number.isFinite(point?.x) || !Number.isFinite(point?.y)) continue;
|
|
287
|
+
const q = (sqrt3 / 3 * point.x - point.y / 3) / size;
|
|
288
|
+
const r = (2 * point.y / 3) / size;
|
|
289
|
+
const [hq, hr] = roundAxial(q, r);
|
|
290
|
+
const key = `${hq}:${hr}`;
|
|
291
|
+
let cell = cells.get(key);
|
|
292
|
+
if (!cell) {
|
|
293
|
+
cell = {
|
|
294
|
+
q: hq,
|
|
295
|
+
r: hr,
|
|
296
|
+
x: size * sqrt3 * (hq + hr / 2),
|
|
297
|
+
y: size * 1.5 * hr,
|
|
298
|
+
count: 0,
|
|
299
|
+
value: 0,
|
|
300
|
+
depth: 0,
|
|
301
|
+
lon: 0,
|
|
302
|
+
lat: 0,
|
|
303
|
+
weight: 0,
|
|
304
|
+
markers: [],
|
|
305
|
+
};
|
|
306
|
+
cells.set(key, cell);
|
|
307
|
+
}
|
|
308
|
+
const marker = point.m || {};
|
|
309
|
+
const value = Number(marker.count);
|
|
310
|
+
const weight = Number.isFinite(value) && value > 0 ? value : 1;
|
|
311
|
+
cell.count += 1;
|
|
312
|
+
cell.value += weight;
|
|
313
|
+
cell.depth = Math.max(cell.depth, Number(point.depth) || 0);
|
|
314
|
+
cell.lon += (Number(marker.lon) || 0) * weight;
|
|
315
|
+
cell.lat += (Number(marker.lat) || 0) * weight;
|
|
316
|
+
cell.weight += weight;
|
|
317
|
+
cell.markers.push(marker);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return [...cells.values()].map((cell) => ({
|
|
321
|
+
...cell,
|
|
322
|
+
lon: cell.weight ? cell.lon / cell.weight : 0,
|
|
323
|
+
lat: cell.weight ? cell.lat / cell.weight : 0,
|
|
324
|
+
}));
|
|
325
|
+
};
|
|
326
|
+
|
|
257
327
|
const parseRGB = (color) => {
|
|
258
328
|
if (color.startsWith("#")) {
|
|
259
329
|
const hex = color.length === 4 ? color.replace(/#(.)(.)(.)/, "#$1$1$2$2$3$3") : color;
|
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;
|