canvas-globe 1.2.0 → 1.4.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 +33 -0
- package/README.md +82 -8
- package/codemeta.json +2 -2
- package/custom-elements.json +2 -2
- package/dist/canvas-globe.umd.js +490 -12
- package/package.json +1 -1
- package/src/geo-globe.js +153 -7
- package/src/geo.js +70 -0
- package/src/index.js +1 -0
- package/src/texture.js +39 -4
- package/src/tiles.js +227 -0
- package/src/version.js +1 -1
- package/types/element.d.ts +3 -3
- package/types/index.d.ts +95 -7
- package/types/jsr-element.d.ts +3 -3
package/src/geo-globe.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* canvas-globe: interactive globe & world map on a 2D canvas.
|
|
3
|
-
* No dependencies, no WebGL, no network calls, no API keys.
|
|
3
|
+
* No dependencies, no WebGL, no required network calls, no API keys.
|
|
4
4
|
*/
|
|
5
5
|
import { world as bundledWorld } from "./data/world.js";
|
|
6
6
|
import { themes, countryPalette } from "./themes.js";
|
|
@@ -8,6 +8,7 @@ import { presets, presetKeys } from "./presets.js";
|
|
|
8
8
|
import { locateViewer, locateViewerPrecise } from "./viewer.js";
|
|
9
9
|
import { recordCanvas, downloadBlob, canRecord } from "./recorder.js";
|
|
10
10
|
import { SphereTexture } from "./texture.js";
|
|
11
|
+
import { TileLayer } from "./tiles.js";
|
|
11
12
|
import { Media, drawFitted } from "./media.js";
|
|
12
13
|
import { scenes, sceneKeys } from "./scenes.js";
|
|
13
14
|
import { exportSize } from "./export.js";
|
|
@@ -15,7 +16,7 @@ import {
|
|
|
15
16
|
getLicensePresentation,
|
|
16
17
|
reportLicenseStatus,
|
|
17
18
|
} 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";
|
|
19
|
+
import { D2R, R2D, TAU, clamp, wrapLon, resolveProjection, projectionBounds, ortho, orthoInverse, greatCircle, circleAround, distanceMeters, subsolarPoint, pointInGeometry, geometryBounds, normalizeShapes, withAlpha, colorScale, hexBinPoints } from "./geo.js";
|
|
19
20
|
|
|
20
21
|
const DEFAULTS = {
|
|
21
22
|
licenseKey: null,
|
|
@@ -31,6 +32,7 @@ const DEFAULTS = {
|
|
|
31
32
|
countryPalette: null,
|
|
32
33
|
texture: null,
|
|
33
34
|
textureQuality: "auto",
|
|
35
|
+
tileLayer: null,
|
|
34
36
|
focus: null,
|
|
35
37
|
countryMedia: null,
|
|
36
38
|
annotations: null,
|
|
@@ -40,6 +42,7 @@ const DEFAULTS = {
|
|
|
40
42
|
timeline: null,
|
|
41
43
|
transparentBackground: false,
|
|
42
44
|
heatmap: false,
|
|
45
|
+
hexBins: false,
|
|
43
46
|
spikes: false,
|
|
44
47
|
labels: false,
|
|
45
48
|
legend: null,
|
|
@@ -104,6 +107,7 @@ const phaseOf = (fx, ms) => {
|
|
|
104
107
|
const defaultTooltip = (target, kind) => {
|
|
105
108
|
if (kind === "country") return target.name || String(target.id ?? "");
|
|
106
109
|
if (kind === "cluster") return `${target.count} in this area`;
|
|
110
|
+
if (kind === "hex-bin") return `${target.markerCount} markers, value ${target.value}`;
|
|
107
111
|
const name = target.city || target.name || target.label;
|
|
108
112
|
const count = target.count != null ? `: ${target.count}` : "";
|
|
109
113
|
return name ? `${name}${count}` : `${target.lat.toFixed(2)}, ${target.lon.toFixed(2)}${count}`;
|
|
@@ -149,6 +153,7 @@ export class GeoGlobe {
|
|
|
149
153
|
this._story = null;
|
|
150
154
|
this._viewer = null;
|
|
151
155
|
this._texture = null;
|
|
156
|
+
this._tileLayer = null;
|
|
152
157
|
this._media = new Map();
|
|
153
158
|
this._markerMedia = new Map();
|
|
154
159
|
this._counterShown = null;
|
|
@@ -164,6 +169,7 @@ export class GeoGlobe {
|
|
|
164
169
|
this._applyWorld();
|
|
165
170
|
this._applyMarkers(this.o.markers);
|
|
166
171
|
this._applyTexture();
|
|
172
|
+
this._applyTileLayer();
|
|
167
173
|
this._applyMedia();
|
|
168
174
|
this._watchMotion();
|
|
169
175
|
this._bind();
|
|
@@ -334,6 +340,7 @@ export class GeoGlobe {
|
|
|
334
340
|
if ("ariaLabel" in patch) this.canvas.setAttribute("aria-label", this.o.ariaLabel);
|
|
335
341
|
if ("projection" in patch || "latRange" in patch) this._bbox = null;
|
|
336
342
|
if ("texture" in patch) this._applyTexture();
|
|
343
|
+
if ("tileLayer" in patch) this._applyTileLayer();
|
|
337
344
|
if ("countryMedia" in patch) this._applyMedia();
|
|
338
345
|
if ("theme" in patch) this._cssCache = null;
|
|
339
346
|
if ("focus" in patch) this._resolveFocus();
|
|
@@ -401,6 +408,11 @@ export class GeoGlobe {
|
|
|
401
408
|
return this.setOptions({ texture: source });
|
|
402
409
|
}
|
|
403
410
|
|
|
411
|
+
/** Optional XYZ overview tiles. Pass null to remove the layer. */
|
|
412
|
+
setTileLayer(source) {
|
|
413
|
+
return this.setOptions({ tileLayer: source });
|
|
414
|
+
}
|
|
415
|
+
|
|
404
416
|
/* ---------------------------- country focus ---------------------------- */
|
|
405
417
|
|
|
406
418
|
/**
|
|
@@ -897,6 +909,8 @@ export class GeoGlobe {
|
|
|
897
909
|
this._media.clear();
|
|
898
910
|
for (const media of this._markerMedia.values()) media.destroy?.();
|
|
899
911
|
this._markerMedia.clear();
|
|
912
|
+
this._tileLayer?.destroy?.();
|
|
913
|
+
this._tileLayer = null;
|
|
900
914
|
this._tip = null;
|
|
901
915
|
this._live = null;
|
|
902
916
|
this._licenseHits = [];
|
|
@@ -963,6 +977,26 @@ export class GeoGlobe {
|
|
|
963
977
|
this._texture = new SphereTexture(source, { onLoad: () => this.invalidate() });
|
|
964
978
|
}
|
|
965
979
|
|
|
980
|
+
_applyTileLayer() {
|
|
981
|
+
const source = this.o.tileLayer;
|
|
982
|
+
if (!source) {
|
|
983
|
+
this._tileLayer?.destroy?.();
|
|
984
|
+
this._tileLayer = null;
|
|
985
|
+
this._tileLayerFor = null;
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
if (this._tileLayerFor === source) return;
|
|
989
|
+
this._tileLayer?.destroy?.();
|
|
990
|
+
this._tileLayerFor = source;
|
|
991
|
+
if (source instanceof TileLayer) {
|
|
992
|
+
this._tileLayer = source;
|
|
993
|
+
source._onLoad = () => this.invalidate();
|
|
994
|
+
} else {
|
|
995
|
+
this._tileLayer = new TileLayer(source, { onLoad: () => this.invalidate() });
|
|
996
|
+
}
|
|
997
|
+
this._dirty = true;
|
|
998
|
+
}
|
|
999
|
+
|
|
966
1000
|
/** Rebuilds the per-country media map, reusing sources that did not change. */
|
|
967
1001
|
_applyMedia() {
|
|
968
1002
|
const spec = this.o.countryMedia || {};
|
|
@@ -1399,7 +1433,7 @@ export class GeoGlobe {
|
|
|
1399
1433
|
this._cursor();
|
|
1400
1434
|
this._dirty = true;
|
|
1401
1435
|
const target = marker || this._hoveredCountry;
|
|
1402
|
-
this._showTip(marker ? (marker.cluster ? "cluster" : "marker") : this._hoveredCountry ? "country" : null, target);
|
|
1436
|
+
this._showTip(marker ? (marker.hexBin ? "hex-bin" : marker.cluster ? "cluster" : "marker") : this._hoveredCountry ? "country" : null, target);
|
|
1403
1437
|
} else if (this._tipVisible) {
|
|
1404
1438
|
this._placeTip();
|
|
1405
1439
|
}
|
|
@@ -2204,6 +2238,23 @@ export class GeoGlobe {
|
|
|
2204
2238
|
ctx.restore();
|
|
2205
2239
|
}
|
|
2206
2240
|
|
|
2241
|
+
_paintTileAttribution(w, h) {
|
|
2242
|
+
const text = this._tileLayer?.attribution;
|
|
2243
|
+
if (!text || !this._tileLayer.ready) return;
|
|
2244
|
+
const { ctx } = this;
|
|
2245
|
+
ctx.save();
|
|
2246
|
+
ctx.font = "500 10px Inter,system-ui,sans-serif";
|
|
2247
|
+
ctx.textAlign = "right";
|
|
2248
|
+
ctx.textBaseline = "bottom";
|
|
2249
|
+
const width = Math.min(w - 16, ctx.measureText(text).width + 12);
|
|
2250
|
+
const x = w - 8, y = h - 8;
|
|
2251
|
+
ctx.fillStyle = "rgba(7, 12, 22, 0.72)";
|
|
2252
|
+
ctx.fillRect(x - width, y - 16, width, 18);
|
|
2253
|
+
ctx.fillStyle = "rgba(255,255,255,0.9)";
|
|
2254
|
+
ctx.fillText(text, x - 6, y - 3, width - 12);
|
|
2255
|
+
ctx.restore();
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2207
2258
|
_licenseRect(ctx, x, y, width, height, radius) {
|
|
2208
2259
|
ctx.beginPath();
|
|
2209
2260
|
if (typeof ctx.roundRect === "function") ctx.roundRect(x, y, width, height, radius);
|
|
@@ -2587,6 +2638,87 @@ export class GeoGlobe {
|
|
|
2587
2638
|
ctx.restore();
|
|
2588
2639
|
}
|
|
2589
2640
|
|
|
2641
|
+
/** Aggregate projected markers into an interactive hexagonal density layer. */
|
|
2642
|
+
_paintHexBins(pts, t, cx, cy, globeRadius, w, h) {
|
|
2643
|
+
const o = this.o.hexBins === true ? {} : this.o.hexBins;
|
|
2644
|
+
const { ctx } = this;
|
|
2645
|
+
const radius = Math.max(4, o.radius ?? 18);
|
|
2646
|
+
const padding = clamp(o.padding ?? 1.5, 0, radius * 0.45);
|
|
2647
|
+
const drawRadius = radius - padding;
|
|
2648
|
+
const minValue = Math.max(0, o.minValue ?? 1);
|
|
2649
|
+
const metric = o.value === "count" ? "count" : "value";
|
|
2650
|
+
const bins = hexBinPoints(pts, radius).filter((bin) => {
|
|
2651
|
+
if (bin[metric] < minValue) return false;
|
|
2652
|
+
if (this.o.mode === "globe") return Math.hypot(bin.x - cx, bin.y - cy) <= globeRadius + drawRadius;
|
|
2653
|
+
return bin.x >= -drawRadius && bin.x <= w + drawRadius && bin.y >= -drawRadius && bin.y <= h + drawRadius;
|
|
2654
|
+
});
|
|
2655
|
+
const max = Math.max(1, ...bins.map((bin) => bin[metric]));
|
|
2656
|
+
const range = Array.isArray(o.colorRange) && o.colorRange.length > 1
|
|
2657
|
+
? o.colorRange
|
|
2658
|
+
: [t.ocean[0], o.color || t.marker];
|
|
2659
|
+
const domain = range.map((_, index) => max * index / (range.length - 1));
|
|
2660
|
+
const scale = colorScale(domain, range);
|
|
2661
|
+
const hits = [];
|
|
2662
|
+
|
|
2663
|
+
ctx.save();
|
|
2664
|
+
if (this.o.mode === "globe") {
|
|
2665
|
+
ctx.beginPath();
|
|
2666
|
+
ctx.arc(cx, cy, globeRadius, 0, TAU);
|
|
2667
|
+
ctx.clip();
|
|
2668
|
+
} else {
|
|
2669
|
+
ctx.beginPath();
|
|
2670
|
+
ctx.rect(0, 0, w, h);
|
|
2671
|
+
ctx.clip();
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
ctx.lineJoin = "round";
|
|
2675
|
+
for (const bin of bins) {
|
|
2676
|
+
const value = bin[metric];
|
|
2677
|
+
ctx.beginPath();
|
|
2678
|
+
for (let i = 0; i < 6; i++) {
|
|
2679
|
+
const angle = (60 * i - 30) * D2R;
|
|
2680
|
+
const x = bin.x + drawRadius * Math.cos(angle);
|
|
2681
|
+
const y = bin.y + drawRadius * Math.sin(angle);
|
|
2682
|
+
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
|
|
2683
|
+
}
|
|
2684
|
+
ctx.closePath();
|
|
2685
|
+
ctx.globalAlpha = clamp(o.opacity ?? 0.82, 0, 1);
|
|
2686
|
+
ctx.fillStyle = scale(value) || o.color || t.marker;
|
|
2687
|
+
ctx.fill();
|
|
2688
|
+
if ((o.strokeWidth ?? 0.8) > 0) {
|
|
2689
|
+
ctx.globalAlpha = 1;
|
|
2690
|
+
ctx.strokeStyle = o.stroke || withAlpha(t.label, 0.3);
|
|
2691
|
+
ctx.lineWidth = o.strokeWidth ?? 0.8;
|
|
2692
|
+
ctx.stroke();
|
|
2693
|
+
}
|
|
2694
|
+
if (o.showCount && drawRadius >= 10) {
|
|
2695
|
+
ctx.globalAlpha = 1;
|
|
2696
|
+
ctx.fillStyle = o.labelColor || t.label;
|
|
2697
|
+
ctx.font = `600 ${Math.max(9, Math.min(13, drawRadius * 0.7))}px Inter,system-ui,sans-serif`;
|
|
2698
|
+
ctx.textAlign = "center";
|
|
2699
|
+
ctx.textBaseline = "middle";
|
|
2700
|
+
ctx.fillText(String(value), bin.x, bin.y);
|
|
2701
|
+
}
|
|
2702
|
+
hits.push({
|
|
2703
|
+
marker: {
|
|
2704
|
+
hexBin: true,
|
|
2705
|
+
count: bin.count,
|
|
2706
|
+
value: bin.value,
|
|
2707
|
+
markerCount: bin.count,
|
|
2708
|
+
markers: bin.markers,
|
|
2709
|
+
lon: bin.lon,
|
|
2710
|
+
lat: bin.lat,
|
|
2711
|
+
},
|
|
2712
|
+
x: bin.x,
|
|
2713
|
+
y: bin.y,
|
|
2714
|
+
r: drawRadius,
|
|
2715
|
+
});
|
|
2716
|
+
}
|
|
2717
|
+
ctx.restore();
|
|
2718
|
+
this._lastHexBins = bins;
|
|
2719
|
+
return hits;
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2590
2722
|
/** Spike height for a marker, as a fraction of the globe radius. */
|
|
2591
2723
|
_spikeLift(m) {
|
|
2592
2724
|
if (!this.o.spikes) return 0;
|
|
@@ -2899,7 +3031,8 @@ export class GeoGlobe {
|
|
|
2899
3031
|
ctx.fillRect(cx - r, cy - r, r * 2, r * 2);
|
|
2900
3032
|
}
|
|
2901
3033
|
|
|
2902
|
-
|
|
3034
|
+
let textured = !!(this._texture && this._texture.draw(ctx, cx, cy, r, this.lon, this.lat, this._textureOptions()));
|
|
3035
|
+
textured = !!(this._tileLayer?.draw(ctx, cx, cy, r, this.lon, this.lat, this._textureOptions()) || textured);
|
|
2903
3036
|
|
|
2904
3037
|
if (this.o.graticule) {
|
|
2905
3038
|
ctx.strokeStyle = t.graticule;
|
|
@@ -2967,9 +3100,11 @@ export class GeoGlobe {
|
|
|
2967
3100
|
pts.push({ m, x: cx + x, y: cy + y, depth: 0.65 + c * 0.35 });
|
|
2968
3101
|
}
|
|
2969
3102
|
if (this.o.heatmap) this._paintHeatmap(pts, t);
|
|
3103
|
+
const binHits = this.o.hexBins ? this._paintHexBins(pts, t, cx, cy, r, w, h) : [];
|
|
2970
3104
|
if (this.o.spikes) this._paintSpikes(t, cx, cy, r, w, h, null);
|
|
2971
3105
|
this._paintViewerAccuracy(t, cx, cy, r, null);
|
|
2972
|
-
const
|
|
3106
|
+
const showMarkers = !this.o.hexBins || (this.o.hexBins !== true && this.o.hexBins.hideMarkers === false);
|
|
3107
|
+
const hits = [...binHits, ...(showMarkers ? this._paintMarkers(pts, t) : [])];
|
|
2973
3108
|
if (this.o.labels) this._paintLabels(pts, t, cx, cy, r, w, h);
|
|
2974
3109
|
this._paintAnnotations(t);
|
|
2975
3110
|
this._paintPings(t);
|
|
@@ -2977,6 +3112,7 @@ export class GeoGlobe {
|
|
|
2977
3112
|
this._paintCounter(t, w, h);
|
|
2978
3113
|
this._paintTitle(t, w, h);
|
|
2979
3114
|
this._paintWatermark(t, w, h);
|
|
3115
|
+
this._paintTileAttribution(w, h);
|
|
2980
3116
|
return hits;
|
|
2981
3117
|
}
|
|
2982
3118
|
|
|
@@ -2996,7 +3132,14 @@ export class GeoGlobe {
|
|
|
2996
3132
|
ctx.fillRect(0, 0, w, h);
|
|
2997
3133
|
}
|
|
2998
3134
|
|
|
2999
|
-
const
|
|
3135
|
+
const flatTextureOptions = {
|
|
3136
|
+
...this._textureOptions(),
|
|
3137
|
+
inv: v.inv,
|
|
3138
|
+
latRange: this.o.latRange,
|
|
3139
|
+
key: `${this.o.projection}:${this.lon.toFixed(5)}:${this.lat.toFixed(5)}:${this._zoom.toFixed(5)}`,
|
|
3140
|
+
};
|
|
3141
|
+
let textured = !!(this._texture && this._texture.drawFlat(ctx, fwd, w, h, flatTextureOptions));
|
|
3142
|
+
textured = !!(this._tileLayer?.drawFlat(ctx, fwd, w, h, flatTextureOptions) || textured);
|
|
3000
3143
|
|
|
3001
3144
|
if (this.o.graticule) {
|
|
3002
3145
|
ctx.strokeStyle = t.graticule;
|
|
@@ -3044,9 +3187,11 @@ export class GeoGlobe {
|
|
|
3044
3187
|
pts.push({ m, x, y, depth: 1 });
|
|
3045
3188
|
}
|
|
3046
3189
|
if (this.o.heatmap) this._paintHeatmap(pts, t);
|
|
3190
|
+
const binHits = this.o.hexBins ? this._paintHexBins(pts, t, 0, 0, 0, w, h) : [];
|
|
3047
3191
|
if (this.o.spikes) this._paintSpikes(t, 0, 0, 0, w, h, fwd);
|
|
3048
3192
|
this._paintViewerAccuracy(t, 0, 0, 0, fwd);
|
|
3049
|
-
const
|
|
3193
|
+
const showMarkers = !this.o.hexBins || (this.o.hexBins !== true && this.o.hexBins.hideMarkers === false);
|
|
3194
|
+
const hits = [...binHits, ...(showMarkers ? this._paintMarkers(pts, t) : [])];
|
|
3050
3195
|
if (this.o.labels) this._paintLabels(pts, t, 0, 0, 0, w, h);
|
|
3051
3196
|
this._paintAnnotations(t);
|
|
3052
3197
|
this._paintPings(t);
|
|
@@ -3054,6 +3199,7 @@ export class GeoGlobe {
|
|
|
3054
3199
|
this._paintCounter(t, w, h);
|
|
3055
3200
|
this._paintTitle(t, w, h);
|
|
3056
3201
|
this._paintWatermark(t, w, h);
|
|
3202
|
+
this._paintTileAttribution(w, h);
|
|
3057
3203
|
return hits;
|
|
3058
3204
|
}
|
|
3059
3205
|
|
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/index.js
CHANGED
|
@@ -15,6 +15,7 @@ export { fromCSV, fromRows, parseCSV, geocode, countryPoint } from "./csv.js";
|
|
|
15
15
|
export { locateViewer, locateViewerPrecise, timeZoneLocation, countryLocation, placeLocation } from "./viewer.js";
|
|
16
16
|
export { recordCanvas, downloadBlob, canRecord, supportedRecordingType } from "./recorder.js";
|
|
17
17
|
export { SphereTexture } from "./texture.js";
|
|
18
|
+
export { TileLayer, tileUrl } from "./tiles.js";
|
|
18
19
|
export { Media, drawFitted } from "./media.js";
|
|
19
20
|
export {
|
|
20
21
|
DEFAULT_LICENSE_KEY,
|
package/src/texture.js
CHANGED
|
@@ -103,7 +103,7 @@ export class SphereTexture {
|
|
|
103
103
|
out[o] = pixels[s] * k;
|
|
104
104
|
out[o + 1] = pixels[s + 1] * k;
|
|
105
105
|
out[o + 2] = pixels[s + 2] * k;
|
|
106
|
-
out[o + 3] =
|
|
106
|
+
out[o + 3] = pixels[s + 3];
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
this._ctx.putImageData(this._image, 0, 0);
|
|
@@ -111,10 +111,45 @@ export class SphereTexture {
|
|
|
111
111
|
return true;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
/** Paints the
|
|
115
|
-
drawFlat(ctx, fwd, w, h) {
|
|
114
|
+
/** Paints the texture into a flat-map viewport, respecting its projection. */
|
|
115
|
+
drawFlat(ctx, fwd, w, h, options = {}) {
|
|
116
|
+
if (!this.ready) return false;
|
|
117
|
+
const { inv, step = 2, key = "", latRange = [90, -90] } = options;
|
|
118
|
+
if (inv) {
|
|
119
|
+
const size = Math.max(1, Number(step) || 1);
|
|
120
|
+
const width = Math.max(1, Math.ceil(w / size));
|
|
121
|
+
const height = Math.max(1, Math.ceil(h / size));
|
|
122
|
+
const cacheKey = `${width}:${height}:${key}:${latRange[0]}:${latRange[1]}`;
|
|
123
|
+
if (!this._flatProjected || this._flatKey !== cacheKey) {
|
|
124
|
+
this._flatProjected = makeSurface(width, height);
|
|
125
|
+
if (!this._flatProjected) return false;
|
|
126
|
+
this._flatCtx = this._flatProjected.getContext("2d");
|
|
127
|
+
this._flatImage = this._flatCtx.createImageData(width, height);
|
|
128
|
+
const out = this._flatImage.data;
|
|
129
|
+
const [north, south] = latRange;
|
|
130
|
+
for (let y = 0, offset = 0; y < height; y++) {
|
|
131
|
+
for (let x = 0; x < width; x++, offset += 4) {
|
|
132
|
+
const geo = inv((x + 0.5) * size, (y + 0.5) * size);
|
|
133
|
+
if (!geo || !Number.isFinite(geo[0]) || !Number.isFinite(geo[1]) || geo[1] > north || geo[1] < south) {
|
|
134
|
+
out[offset + 3] = 0;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const u = Math.max(0, Math.min(this.tw - 1, Math.floor((((geo[0] + 180) % 360 + 360) % 360) * (this.tw / 360))));
|
|
138
|
+
const v = Math.max(0, Math.min(this.th - 1, Math.floor(((90 - geo[1]) / 180) * this.th)));
|
|
139
|
+
const source = (v * this.tw + u) * 4;
|
|
140
|
+
out[offset] = this.pixels[source];
|
|
141
|
+
out[offset + 1] = this.pixels[source + 1];
|
|
142
|
+
out[offset + 2] = this.pixels[source + 2];
|
|
143
|
+
out[offset + 3] = this.pixels[source + 3];
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
this._flatCtx.putImageData(this._flatImage, 0, 0);
|
|
147
|
+
this._flatKey = cacheKey;
|
|
148
|
+
}
|
|
149
|
+
ctx.drawImage(this._flatProjected, 0, 0, w, h);
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
116
152
|
if (!this.ready || !this._surface2) {
|
|
117
|
-
if (!this.ready) return false;
|
|
118
153
|
this._surface2 = makeSurface(this.tw, this.th);
|
|
119
154
|
if (!this._surface2) return false;
|
|
120
155
|
const c = this._surface2.getContext("2d");
|
package/src/tiles.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional XYZ raster tiles composed into an equirectangular texture.
|
|
3
|
+
*
|
|
4
|
+
* Nothing is requested unless a tile layer is configured. The deliberately
|
|
5
|
+
* small default request ceiling keeps this suitable for globe and overview-map
|
|
6
|
+
* backgrounds, rather than pretending to be a full slippy-map engine.
|
|
7
|
+
*/
|
|
8
|
+
import { SphereTexture } from "./texture.js";
|
|
9
|
+
|
|
10
|
+
const makeTileSurface = (width, height) => {
|
|
11
|
+
if (typeof OffscreenCanvas !== "undefined") return new OffscreenCanvas(width, height);
|
|
12
|
+
if (typeof document === "undefined") return null;
|
|
13
|
+
const canvas = document.createElement("canvas");
|
|
14
|
+
canvas.width = width;
|
|
15
|
+
canvas.height = height;
|
|
16
|
+
return canvas;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const isTilePromise = (value) => value && typeof value.then === "function";
|
|
20
|
+
const isTileDrawable = (value) => value && typeof value === "object" && (
|
|
21
|
+
Number(value.naturalWidth || value.videoWidth || value.width) > 0
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
export const tileUrl = (template, { x, y, z }) => String(template)
|
|
25
|
+
.replaceAll("{z}", String(z))
|
|
26
|
+
.replaceAll("{x}", String(x))
|
|
27
|
+
.replaceAll("{y}", String(y))
|
|
28
|
+
.replaceAll("{-y}", String((2 ** z) - y - 1));
|
|
29
|
+
|
|
30
|
+
const tileSourceFrom = (spec) => {
|
|
31
|
+
if (typeof spec === "string" || typeof spec === "function") return spec;
|
|
32
|
+
return spec?.getTile || spec?.url || spec?.source || null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export class TileLayer {
|
|
36
|
+
constructor(input, { onLoad } = {}) {
|
|
37
|
+
const spec = typeof input === "object" && input && !isTileDrawable(input) ? input : { source: input };
|
|
38
|
+
this.spec = spec;
|
|
39
|
+
this.zoom = Math.max(0, Math.floor(spec.zoom ?? 2));
|
|
40
|
+
this.tileSize = Math.max(16, Math.floor(spec.tileSize ?? 256));
|
|
41
|
+
this.opacity = Math.max(0, Math.min(1, Number(spec.opacity ?? 1)));
|
|
42
|
+
this.attribution = spec.attribution || "";
|
|
43
|
+
this.crossOrigin = "crossOrigin" in spec ? spec.crossOrigin : "anonymous";
|
|
44
|
+
this.maxTiles = Math.max(1, Math.floor(spec.maxTiles ?? 64));
|
|
45
|
+
this.total = (2 ** this.zoom) ** 2;
|
|
46
|
+
this.loaded = 0;
|
|
47
|
+
this.failed = 0;
|
|
48
|
+
this.ready = false;
|
|
49
|
+
this.error = null;
|
|
50
|
+
this.cache = new Map();
|
|
51
|
+
this._source = tileSourceFrom(input);
|
|
52
|
+
this._onLoad = onLoad;
|
|
53
|
+
this._destroyed = false;
|
|
54
|
+
this._refreshQueued = false;
|
|
55
|
+
|
|
56
|
+
if (!this._source) {
|
|
57
|
+
this.error = new TypeError("canvas-globe: tileLayer requires url, source, or getTile");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (this.total > this.maxTiles) {
|
|
61
|
+
this.error = new RangeError(`canvas-globe: tileLayer zoom ${this.zoom} needs ${this.total} tiles; raise maxTiles to allow it`);
|
|
62
|
+
spec.onError?.(this.error, null);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const side = this.tileSize * (2 ** this.zoom);
|
|
67
|
+
const maxWidth = Math.max(this.tileSize, Math.floor(spec.maxWidth ?? 2048));
|
|
68
|
+
const width = Math.min(side, maxWidth);
|
|
69
|
+
this._scale = width / side;
|
|
70
|
+
this._surface = makeTileSurface(width, width);
|
|
71
|
+
this._ctx = this._surface?.getContext?.("2d") || null;
|
|
72
|
+
if (!this._ctx) {
|
|
73
|
+
this.error = new Error("canvas-globe: tileLayer needs a canvas-capable browser");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
this._loadAll();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
_loadAll() {
|
|
80
|
+
const side = 2 ** this.zoom;
|
|
81
|
+
for (let y = 0; y < side; y++) {
|
|
82
|
+
for (let x = 0; x < side; x++) this._load({ x, y, z: this.zoom });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
_resolve(tile) {
|
|
87
|
+
if (typeof this._source === "function") return this._source(tile);
|
|
88
|
+
return tileUrl(this._source, tile);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
_load(tile) {
|
|
92
|
+
const key = `${tile.z}/${tile.x}/${tile.y}`;
|
|
93
|
+
if (this.cache.has(key)) return this.cache.get(key);
|
|
94
|
+
const entry = { ...tile, key, status: "loading", source: null, error: null };
|
|
95
|
+
this.cache.set(key, entry);
|
|
96
|
+
let resolved;
|
|
97
|
+
try {
|
|
98
|
+
resolved = this._resolve(tile);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
this._fail(entry, error);
|
|
101
|
+
return entry;
|
|
102
|
+
}
|
|
103
|
+
const finish = (source) => this._loadSource(source, entry);
|
|
104
|
+
if (isTilePromise(resolved)) resolved.then(finish, (error) => this._fail(entry, error));
|
|
105
|
+
else finish(resolved);
|
|
106
|
+
return entry;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
_loadSource(source, entry) {
|
|
110
|
+
if (this._destroyed) return;
|
|
111
|
+
if (isTileDrawable(source)) {
|
|
112
|
+
this._draw(source, entry);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (typeof source !== "string" || !source) {
|
|
116
|
+
this._fail(entry, new TypeError(`canvas-globe: tile ${entry.key} did not resolve to an image or URL`));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (typeof Image === "undefined") {
|
|
120
|
+
this._fail(entry, new Error("canvas-globe: tile URLs need the browser Image API"));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const image = new Image();
|
|
124
|
+
if (this.crossOrigin != null) image.crossOrigin = this.crossOrigin;
|
|
125
|
+
image.onload = () => this._draw(image, entry);
|
|
126
|
+
image.onerror = () => this._fail(entry, new Error(`canvas-globe: could not load tile ${entry.key}`));
|
|
127
|
+
image.src = source;
|
|
128
|
+
entry.source = source;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_draw(source, entry) {
|
|
132
|
+
if (this._destroyed || entry.status !== "loading") return;
|
|
133
|
+
const size = this.tileSize * this._scale;
|
|
134
|
+
try {
|
|
135
|
+
this._ctx.drawImage(source, entry.x * size, entry.y * size, size, size);
|
|
136
|
+
entry.status = "loaded";
|
|
137
|
+
entry.source = source;
|
|
138
|
+
this.loaded++;
|
|
139
|
+
this._queueRefresh();
|
|
140
|
+
} catch (error) {
|
|
141
|
+
this._fail(entry, error);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
_fail(entry, error) {
|
|
146
|
+
if (this._destroyed || entry.status === "failed") return;
|
|
147
|
+
entry.status = "failed";
|
|
148
|
+
entry.error = error instanceof Error ? error : new Error(String(error));
|
|
149
|
+
this.failed++;
|
|
150
|
+
if (!this.error) this.error = entry.error;
|
|
151
|
+
this.spec.onError?.(entry.error, { x: entry.x, y: entry.y, z: entry.z });
|
|
152
|
+
if (this.loaded) this._queueRefresh();
|
|
153
|
+
this._onLoad?.(this);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
_queueRefresh() {
|
|
157
|
+
const complete = this.loaded + this.failed;
|
|
158
|
+
const interval = Math.max(1, Math.ceil(this.total / 4));
|
|
159
|
+
if (complete < this.total && this.loaded % interval !== 0) return;
|
|
160
|
+
if (this._refreshQueued) return;
|
|
161
|
+
this._refreshQueued = true;
|
|
162
|
+
queueMicrotask(() => {
|
|
163
|
+
this._refreshQueued = false;
|
|
164
|
+
if (this._destroyed || !this.loaded) return;
|
|
165
|
+
const surface = this._toEquirectangular();
|
|
166
|
+
const texture = new SphereTexture(surface, {
|
|
167
|
+
maxWidth: surface.width,
|
|
168
|
+
onLoad: () => this._onLoad?.(this),
|
|
169
|
+
});
|
|
170
|
+
if (texture.ready) {
|
|
171
|
+
this.texture = texture;
|
|
172
|
+
this.ready = true;
|
|
173
|
+
} else if (texture.error) {
|
|
174
|
+
this.error = texture.error;
|
|
175
|
+
this.spec.onError?.(texture.error, null);
|
|
176
|
+
}
|
|
177
|
+
this._onLoad?.(this);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** XYZ rows use Web Mercator; the globe texture expects linear latitude. */
|
|
182
|
+
_toEquirectangular() {
|
|
183
|
+
const width = this._surface.width;
|
|
184
|
+
const height = Math.max(1, Math.round(width / 2));
|
|
185
|
+
const surface = makeTileSurface(width, height);
|
|
186
|
+
const ctx = surface.getContext("2d");
|
|
187
|
+
const sourceHeight = this._surface.height;
|
|
188
|
+
for (let y = 0; y < height; y++) {
|
|
189
|
+
const lat = 90 - ((y + 0.5) / height) * 180;
|
|
190
|
+
const sin = Math.sin((Math.max(-85.05112878, Math.min(85.05112878, lat)) * Math.PI) / 180);
|
|
191
|
+
const mercatorY = 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI);
|
|
192
|
+
const sourceY = Math.max(0, Math.min(sourceHeight - 1, mercatorY * sourceHeight));
|
|
193
|
+
ctx.drawImage(this._surface, 0, sourceY, width, 1, 0, y, width, 1);
|
|
194
|
+
}
|
|
195
|
+
return surface;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
draw(ctx, ...args) {
|
|
199
|
+
if (!this.ready || !this.texture || this.opacity <= 0) return false;
|
|
200
|
+
ctx.save();
|
|
201
|
+
ctx.globalAlpha *= this.opacity;
|
|
202
|
+
const drew = this.texture.draw(ctx, ...args);
|
|
203
|
+
ctx.restore();
|
|
204
|
+
return drew;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
drawFlat(ctx, ...args) {
|
|
208
|
+
if (!this.ready || !this.texture || this.opacity <= 0) return false;
|
|
209
|
+
ctx.save();
|
|
210
|
+
ctx.globalAlpha *= this.opacity;
|
|
211
|
+
const drew = this.texture.drawFlat(ctx, ...args);
|
|
212
|
+
ctx.restore();
|
|
213
|
+
return drew;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
get stats() {
|
|
217
|
+
return { loaded: this.loaded, failed: this.failed, total: this.total, cached: this.cache.size };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
destroy() {
|
|
221
|
+
this._destroyed = true;
|
|
222
|
+
this.cache.clear();
|
|
223
|
+
this.texture = null;
|
|
224
|
+
this._surface = null;
|
|
225
|
+
this._ctx = null;
|
|
226
|
+
}
|
|
227
|
+
}
|
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.4.0";
|
package/types/element.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { Arc, CountryShape, GeoGlobe, GeoGlobeOptions, Marker, ClusterMarker, FlyToOptions } from "./index.js";
|
|
1
|
+
import type { Arc, CountryShape, GeoGlobe, GeoGlobeOptions, Marker, ClusterMarker, HexBinMarker, FlyToOptions } from "./index.js";
|
|
2
2
|
|
|
3
3
|
export interface GeoGlobeEventMap {
|
|
4
|
-
"geo-hover": CustomEvent<{ marker: Marker | ClusterMarker | null; pos: { x: number; y: number } | null }>;
|
|
5
|
-
"geo-click": CustomEvent<{ marker: Marker | ClusterMarker; pos: { x: number; y: number } }>;
|
|
4
|
+
"geo-hover": CustomEvent<{ marker: Marker | ClusterMarker | HexBinMarker | null; pos: { x: number; y: number } | null }>;
|
|
5
|
+
"geo-click": CustomEvent<{ marker: Marker | ClusterMarker | HexBinMarker; pos: { x: number; y: number } }>;
|
|
6
6
|
"geo-country-hover": CustomEvent<{ country: CountryShape | null; pos: { x: number; y: number } | null }>;
|
|
7
7
|
"geo-country-click": CustomEvent<{ country: CountryShape; pos: { x: number; y: number } }>;
|
|
8
8
|
"geo-render": CustomEvent<{ globe: GeoGlobe }>;
|