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/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
- const textured = this._texture && this._texture.draw(ctx, cx, cy, r, this.lon, this.lat, this._textureOptions());
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 hits = this._paintMarkers(pts, t);
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 textured = this._texture && this._texture.drawFlat(ctx, fwd, w, h);
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 hits = this._paintMarkers(pts, t);
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] = 255;
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 whole texture into a flat-map viewport. */
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.0";
2
+ export const CANVAS_GLOBE_VERSION = "1.4.0";
@@ -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 }>;