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.
@@ -640,6 +640,76 @@ const withAlpha = (color, a) => {
640
640
  return color;
641
641
  };
642
642
 
643
+ /**
644
+ * Aggregates projected points into a pointy-top hexagonal grid.
645
+ *
646
+ * The input is deliberately renderer-shaped (`{ x, y, depth, m }`) so the
647
+ * same helper works after either globe or flat-map projection. Each returned
648
+ * bin preserves its source markers for tooltips, clicks, and custom details.
649
+ */
650
+ const hexBinPoints = (points = [], radius = 18) => {
651
+ const size = Math.max(1, Number(radius) || 18);
652
+ const sqrt3 = Math.sqrt(3);
653
+ const cells = new Map();
654
+
655
+ const roundAxial = (q, r) => {
656
+ let x = q;
657
+ let z = r;
658
+ let y = -x - z;
659
+ let rx = Math.round(x);
660
+ let ry = Math.round(y);
661
+ let rz = Math.round(z);
662
+ const dx = Math.abs(rx - x);
663
+ const dy = Math.abs(ry - y);
664
+ const dz = Math.abs(rz - z);
665
+ if (dx > dy && dx > dz) rx = -ry - rz;
666
+ else if (dy > dz) ry = -rx - rz;
667
+ else rz = -rx - ry;
668
+ return [rx, rz];
669
+ };
670
+
671
+ for (const point of points) {
672
+ if (!Number.isFinite(point?.x) || !Number.isFinite(point?.y)) continue;
673
+ const q = (sqrt3 / 3 * point.x - point.y / 3) / size;
674
+ const r = (2 * point.y / 3) / size;
675
+ const [hq, hr] = roundAxial(q, r);
676
+ const key = `${hq}:${hr}`;
677
+ let cell = cells.get(key);
678
+ if (!cell) {
679
+ cell = {
680
+ q: hq,
681
+ r: hr,
682
+ x: size * sqrt3 * (hq + hr / 2),
683
+ y: size * 1.5 * hr,
684
+ count: 0,
685
+ value: 0,
686
+ depth: 0,
687
+ lon: 0,
688
+ lat: 0,
689
+ weight: 0,
690
+ markers: [],
691
+ };
692
+ cells.set(key, cell);
693
+ }
694
+ const marker = point.m || {};
695
+ const value = Number(marker.count);
696
+ const weight = Number.isFinite(value) && value > 0 ? value : 1;
697
+ cell.count += 1;
698
+ cell.value += weight;
699
+ cell.depth = Math.max(cell.depth, Number(point.depth) || 0);
700
+ cell.lon += (Number(marker.lon) || 0) * weight;
701
+ cell.lat += (Number(marker.lat) || 0) * weight;
702
+ cell.weight += weight;
703
+ cell.markers.push(marker);
704
+ }
705
+
706
+ return [...cells.values()].map((cell) => ({
707
+ ...cell,
708
+ lon: cell.weight ? cell.lon / cell.weight : 0,
709
+ lat: cell.weight ? cell.lat / cell.weight : 0,
710
+ }));
711
+ };
712
+
643
713
  const parseRGB = (color) => {
644
714
  if (color.startsWith("#")) {
645
715
  const hex = color.length === 4 ? color.replace(/#(.)(.)(.)/, "#$1$1$2$2$3$3") : color;
@@ -1127,7 +1197,7 @@ class SphereTexture {
1127
1197
  out[o] = pixels[s] * k;
1128
1198
  out[o + 1] = pixels[s + 1] * k;
1129
1199
  out[o + 2] = pixels[s + 2] * k;
1130
- out[o + 3] = 255;
1200
+ out[o + 3] = pixels[s + 3];
1131
1201
  }
1132
1202
  }
1133
1203
  this._ctx.putImageData(this._image, 0, 0);
@@ -1135,10 +1205,45 @@ class SphereTexture {
1135
1205
  return true;
1136
1206
  }
1137
1207
 
1138
- /** Paints the whole texture into a flat-map viewport. */
1139
- drawFlat(ctx, fwd, w, h) {
1208
+ /** Paints the texture into a flat-map viewport, respecting its projection. */
1209
+ drawFlat(ctx, fwd, w, h, options = {}) {
1210
+ if (!this.ready) return false;
1211
+ const { inv, step = 2, key = "", latRange = [90, -90] } = options;
1212
+ if (inv) {
1213
+ const size = Math.max(1, Number(step) || 1);
1214
+ const width = Math.max(1, Math.ceil(w / size));
1215
+ const height = Math.max(1, Math.ceil(h / size));
1216
+ const cacheKey = `${width}:${height}:${key}:${latRange[0]}:${latRange[1]}`;
1217
+ if (!this._flatProjected || this._flatKey !== cacheKey) {
1218
+ this._flatProjected = makeSurface(width, height);
1219
+ if (!this._flatProjected) return false;
1220
+ this._flatCtx = this._flatProjected.getContext("2d");
1221
+ this._flatImage = this._flatCtx.createImageData(width, height);
1222
+ const out = this._flatImage.data;
1223
+ const [north, south] = latRange;
1224
+ for (let y = 0, offset = 0; y < height; y++) {
1225
+ for (let x = 0; x < width; x++, offset += 4) {
1226
+ const geo = inv((x + 0.5) * size, (y + 0.5) * size);
1227
+ if (!geo || !Number.isFinite(geo[0]) || !Number.isFinite(geo[1]) || geo[1] > north || geo[1] < south) {
1228
+ out[offset + 3] = 0;
1229
+ continue;
1230
+ }
1231
+ const u = Math.max(0, Math.min(this.tw - 1, Math.floor((((geo[0] + 180) % 360 + 360) % 360) * (this.tw / 360))));
1232
+ const v = Math.max(0, Math.min(this.th - 1, Math.floor(((90 - geo[1]) / 180) * this.th)));
1233
+ const source = (v * this.tw + u) * 4;
1234
+ out[offset] = this.pixels[source];
1235
+ out[offset + 1] = this.pixels[source + 1];
1236
+ out[offset + 2] = this.pixels[source + 2];
1237
+ out[offset + 3] = this.pixels[source + 3];
1238
+ }
1239
+ }
1240
+ this._flatCtx.putImageData(this._flatImage, 0, 0);
1241
+ this._flatKey = cacheKey;
1242
+ }
1243
+ ctx.drawImage(this._flatProjected, 0, 0, w, h);
1244
+ return true;
1245
+ }
1140
1246
  if (!this.ready || !this._surface2) {
1141
- if (!this.ready) return false;
1142
1247
  this._surface2 = makeSurface(this.tw, this.th);
1143
1248
  if (!this._surface2) return false;
1144
1249
  const c = this._surface2.getContext("2d");
@@ -1152,6 +1257,233 @@ class SphereTexture {
1152
1257
  }
1153
1258
  }
1154
1259
 
1260
+ /**
1261
+ * Optional XYZ raster tiles composed into an equirectangular texture.
1262
+ *
1263
+ * Nothing is requested unless a tile layer is configured. The deliberately
1264
+ * small default request ceiling keeps this suitable for globe and overview-map
1265
+ * backgrounds, rather than pretending to be a full slippy-map engine.
1266
+ */
1267
+
1268
+ const makeTileSurface = (width, height) => {
1269
+ if (typeof OffscreenCanvas !== "undefined") return new OffscreenCanvas(width, height);
1270
+ if (typeof document === "undefined") return null;
1271
+ const canvas = document.createElement("canvas");
1272
+ canvas.width = width;
1273
+ canvas.height = height;
1274
+ return canvas;
1275
+ };
1276
+
1277
+ const isTilePromise = (value) => value && typeof value.then === "function";
1278
+ const isTileDrawable = (value) => value && typeof value === "object" && (
1279
+ Number(value.naturalWidth || value.videoWidth || value.width) > 0
1280
+ );
1281
+
1282
+ const tileUrl = (template, { x, y, z }) => String(template)
1283
+ .replaceAll("{z}", String(z))
1284
+ .replaceAll("{x}", String(x))
1285
+ .replaceAll("{y}", String(y))
1286
+ .replaceAll("{-y}", String((2 ** z) - y - 1));
1287
+
1288
+ const tileSourceFrom = (spec) => {
1289
+ if (typeof spec === "string" || typeof spec === "function") return spec;
1290
+ return spec?.getTile || spec?.url || spec?.source || null;
1291
+ };
1292
+
1293
+ class TileLayer {
1294
+ constructor(input, { onLoad } = {}) {
1295
+ const spec = typeof input === "object" && input && !isTileDrawable(input) ? input : { source: input };
1296
+ this.spec = spec;
1297
+ this.zoom = Math.max(0, Math.floor(spec.zoom ?? 2));
1298
+ this.tileSize = Math.max(16, Math.floor(spec.tileSize ?? 256));
1299
+ this.opacity = Math.max(0, Math.min(1, Number(spec.opacity ?? 1)));
1300
+ this.attribution = spec.attribution || "";
1301
+ this.crossOrigin = "crossOrigin" in spec ? spec.crossOrigin : "anonymous";
1302
+ this.maxTiles = Math.max(1, Math.floor(spec.maxTiles ?? 64));
1303
+ this.total = (2 ** this.zoom) ** 2;
1304
+ this.loaded = 0;
1305
+ this.failed = 0;
1306
+ this.ready = false;
1307
+ this.error = null;
1308
+ this.cache = new Map();
1309
+ this._source = tileSourceFrom(input);
1310
+ this._onLoad = onLoad;
1311
+ this._destroyed = false;
1312
+ this._refreshQueued = false;
1313
+
1314
+ if (!this._source) {
1315
+ this.error = new TypeError("canvas-globe: tileLayer requires url, source, or getTile");
1316
+ return;
1317
+ }
1318
+ if (this.total > this.maxTiles) {
1319
+ this.error = new RangeError(`canvas-globe: tileLayer zoom ${this.zoom} needs ${this.total} tiles; raise maxTiles to allow it`);
1320
+ spec.onError?.(this.error, null);
1321
+ return;
1322
+ }
1323
+
1324
+ const side = this.tileSize * (2 ** this.zoom);
1325
+ const maxWidth = Math.max(this.tileSize, Math.floor(spec.maxWidth ?? 2048));
1326
+ const width = Math.min(side, maxWidth);
1327
+ this._scale = width / side;
1328
+ this._surface = makeTileSurface(width, width);
1329
+ this._ctx = this._surface?.getContext?.("2d") || null;
1330
+ if (!this._ctx) {
1331
+ this.error = new Error("canvas-globe: tileLayer needs a canvas-capable browser");
1332
+ return;
1333
+ }
1334
+ this._loadAll();
1335
+ }
1336
+
1337
+ _loadAll() {
1338
+ const side = 2 ** this.zoom;
1339
+ for (let y = 0; y < side; y++) {
1340
+ for (let x = 0; x < side; x++) this._load({ x, y, z: this.zoom });
1341
+ }
1342
+ }
1343
+
1344
+ _resolve(tile) {
1345
+ if (typeof this._source === "function") return this._source(tile);
1346
+ return tileUrl(this._source, tile);
1347
+ }
1348
+
1349
+ _load(tile) {
1350
+ const key = `${tile.z}/${tile.x}/${tile.y}`;
1351
+ if (this.cache.has(key)) return this.cache.get(key);
1352
+ const entry = { ...tile, key, status: "loading", source: null, error: null };
1353
+ this.cache.set(key, entry);
1354
+ let resolved;
1355
+ try {
1356
+ resolved = this._resolve(tile);
1357
+ } catch (error) {
1358
+ this._fail(entry, error);
1359
+ return entry;
1360
+ }
1361
+ const finish = (source) => this._loadSource(source, entry);
1362
+ if (isTilePromise(resolved)) resolved.then(finish, (error) => this._fail(entry, error));
1363
+ else finish(resolved);
1364
+ return entry;
1365
+ }
1366
+
1367
+ _loadSource(source, entry) {
1368
+ if (this._destroyed) return;
1369
+ if (isTileDrawable(source)) {
1370
+ this._draw(source, entry);
1371
+ return;
1372
+ }
1373
+ if (typeof source !== "string" || !source) {
1374
+ this._fail(entry, new TypeError(`canvas-globe: tile ${entry.key} did not resolve to an image or URL`));
1375
+ return;
1376
+ }
1377
+ if (typeof Image === "undefined") {
1378
+ this._fail(entry, new Error("canvas-globe: tile URLs need the browser Image API"));
1379
+ return;
1380
+ }
1381
+ const image = new Image();
1382
+ if (this.crossOrigin != null) image.crossOrigin = this.crossOrigin;
1383
+ image.onload = () => this._draw(image, entry);
1384
+ image.onerror = () => this._fail(entry, new Error(`canvas-globe: could not load tile ${entry.key}`));
1385
+ image.src = source;
1386
+ entry.source = source;
1387
+ }
1388
+
1389
+ _draw(source, entry) {
1390
+ if (this._destroyed || entry.status !== "loading") return;
1391
+ const size = this.tileSize * this._scale;
1392
+ try {
1393
+ this._ctx.drawImage(source, entry.x * size, entry.y * size, size, size);
1394
+ entry.status = "loaded";
1395
+ entry.source = source;
1396
+ this.loaded++;
1397
+ this._queueRefresh();
1398
+ } catch (error) {
1399
+ this._fail(entry, error);
1400
+ }
1401
+ }
1402
+
1403
+ _fail(entry, error) {
1404
+ if (this._destroyed || entry.status === "failed") return;
1405
+ entry.status = "failed";
1406
+ entry.error = error instanceof Error ? error : new Error(String(error));
1407
+ this.failed++;
1408
+ if (!this.error) this.error = entry.error;
1409
+ this.spec.onError?.(entry.error, { x: entry.x, y: entry.y, z: entry.z });
1410
+ if (this.loaded) this._queueRefresh();
1411
+ this._onLoad?.(this);
1412
+ }
1413
+
1414
+ _queueRefresh() {
1415
+ const complete = this.loaded + this.failed;
1416
+ const interval = Math.max(1, Math.ceil(this.total / 4));
1417
+ if (complete < this.total && this.loaded % interval !== 0) return;
1418
+ if (this._refreshQueued) return;
1419
+ this._refreshQueued = true;
1420
+ queueMicrotask(() => {
1421
+ this._refreshQueued = false;
1422
+ if (this._destroyed || !this.loaded) return;
1423
+ const surface = this._toEquirectangular();
1424
+ const texture = new SphereTexture(surface, {
1425
+ maxWidth: surface.width,
1426
+ onLoad: () => this._onLoad?.(this),
1427
+ });
1428
+ if (texture.ready) {
1429
+ this.texture = texture;
1430
+ this.ready = true;
1431
+ } else if (texture.error) {
1432
+ this.error = texture.error;
1433
+ this.spec.onError?.(texture.error, null);
1434
+ }
1435
+ this._onLoad?.(this);
1436
+ });
1437
+ }
1438
+
1439
+ /** XYZ rows use Web Mercator; the globe texture expects linear latitude. */
1440
+ _toEquirectangular() {
1441
+ const width = this._surface.width;
1442
+ const height = Math.max(1, Math.round(width / 2));
1443
+ const surface = makeTileSurface(width, height);
1444
+ const ctx = surface.getContext("2d");
1445
+ const sourceHeight = this._surface.height;
1446
+ for (let y = 0; y < height; y++) {
1447
+ const lat = 90 - ((y + 0.5) / height) * 180;
1448
+ const sin = Math.sin((Math.max(-85.05112878, Math.min(85.05112878, lat)) * Math.PI) / 180);
1449
+ const mercatorY = 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI);
1450
+ const sourceY = Math.max(0, Math.min(sourceHeight - 1, mercatorY * sourceHeight));
1451
+ ctx.drawImage(this._surface, 0, sourceY, width, 1, 0, y, width, 1);
1452
+ }
1453
+ return surface;
1454
+ }
1455
+
1456
+ draw(ctx, ...args) {
1457
+ if (!this.ready || !this.texture || this.opacity <= 0) return false;
1458
+ ctx.save();
1459
+ ctx.globalAlpha *= this.opacity;
1460
+ const drew = this.texture.draw(ctx, ...args);
1461
+ ctx.restore();
1462
+ return drew;
1463
+ }
1464
+
1465
+ drawFlat(ctx, ...args) {
1466
+ if (!this.ready || !this.texture || this.opacity <= 0) return false;
1467
+ ctx.save();
1468
+ ctx.globalAlpha *= this.opacity;
1469
+ const drew = this.texture.drawFlat(ctx, ...args);
1470
+ ctx.restore();
1471
+ return drew;
1472
+ }
1473
+
1474
+ get stats() {
1475
+ return { loaded: this.loaded, failed: this.failed, total: this.total, cached: this.cache.size };
1476
+ }
1477
+
1478
+ destroy() {
1479
+ this._destroyed = true;
1480
+ this.cache.clear();
1481
+ this.texture = null;
1482
+ this._surface = null;
1483
+ this._ctx = null;
1484
+ }
1485
+ }
1486
+
1155
1487
  /**
1156
1488
  * Media sources that can be painted inside a country's outline: a still, an
1157
1489
  * animated GIF, a video, another canvas, or a live MediaStream.
@@ -1314,7 +1646,7 @@ function drawFitted(ctx, media, box) {
1314
1646
  }
1315
1647
 
1316
1648
  // Keep in sync with package.json. Release checks enforce this value.
1317
- const CANVAS_GLOBE_VERSION = "1.2.0";
1649
+ const CANVAS_GLOBE_VERSION = "1.4.0";
1318
1650
 
1319
1651
  /** Local license-key checks and production-use presentation helpers. */
1320
1652
 
@@ -1441,7 +1773,7 @@ function reportLicenseStatus(value, mode = COMMERCIAL_LICENSE_MODE) {
1441
1773
 
1442
1774
  /**
1443
1775
  * canvas-globe: interactive globe & world map on a 2D canvas.
1444
- * No dependencies, no WebGL, no network calls, no API keys.
1776
+ * No dependencies, no WebGL, no required network calls, no API keys.
1445
1777
  */
1446
1778
 
1447
1779
 
@@ -1454,6 +1786,7 @@ function reportLicenseStatus(value, mode = COMMERCIAL_LICENSE_MODE) {
1454
1786
 
1455
1787
 
1456
1788
 
1789
+
1457
1790
  const DEFAULTS = {
1458
1791
  licenseKey: null,
1459
1792
  mode: "globe",
@@ -1468,6 +1801,7 @@ const DEFAULTS = {
1468
1801
  countryPalette: null,
1469
1802
  texture: null,
1470
1803
  textureQuality: "auto",
1804
+ tileLayer: null,
1471
1805
  focus: null,
1472
1806
  countryMedia: null,
1473
1807
  annotations: null,
@@ -1477,6 +1811,7 @@ const DEFAULTS = {
1477
1811
  timeline: null,
1478
1812
  transparentBackground: false,
1479
1813
  heatmap: false,
1814
+ hexBins: false,
1480
1815
  spikes: false,
1481
1816
  labels: false,
1482
1817
  legend: null,
@@ -1541,6 +1876,7 @@ const phaseOf = (fx, ms) => {
1541
1876
  const defaultTooltip = (target, kind) => {
1542
1877
  if (kind === "country") return target.name || String(target.id ?? "");
1543
1878
  if (kind === "cluster") return `${target.count} in this area`;
1879
+ if (kind === "hex-bin") return `${target.markerCount} markers, value ${target.value}`;
1544
1880
  const name = target.city || target.name || target.label;
1545
1881
  const count = target.count != null ? `: ${target.count}` : "";
1546
1882
  return name ? `${name}${count}` : `${target.lat.toFixed(2)}, ${target.lon.toFixed(2)}${count}`;
@@ -1586,6 +1922,7 @@ class GeoGlobe {
1586
1922
  this._story = null;
1587
1923
  this._viewer = null;
1588
1924
  this._texture = null;
1925
+ this._tileLayer = null;
1589
1926
  this._media = new Map();
1590
1927
  this._markerMedia = new Map();
1591
1928
  this._counterShown = null;
@@ -1601,6 +1938,7 @@ class GeoGlobe {
1601
1938
  this._applyWorld();
1602
1939
  this._applyMarkers(this.o.markers);
1603
1940
  this._applyTexture();
1941
+ this._applyTileLayer();
1604
1942
  this._applyMedia();
1605
1943
  this._watchMotion();
1606
1944
  this._bind();
@@ -1771,6 +2109,7 @@ class GeoGlobe {
1771
2109
  if ("ariaLabel" in patch) this.canvas.setAttribute("aria-label", this.o.ariaLabel);
1772
2110
  if ("projection" in patch || "latRange" in patch) this._bbox = null;
1773
2111
  if ("texture" in patch) this._applyTexture();
2112
+ if ("tileLayer" in patch) this._applyTileLayer();
1774
2113
  if ("countryMedia" in patch) this._applyMedia();
1775
2114
  if ("theme" in patch) this._cssCache = null;
1776
2115
  if ("focus" in patch) this._resolveFocus();
@@ -1838,6 +2177,11 @@ class GeoGlobe {
1838
2177
  return this.setOptions({ texture: source });
1839
2178
  }
1840
2179
 
2180
+ /** Optional XYZ overview tiles. Pass null to remove the layer. */
2181
+ setTileLayer(source) {
2182
+ return this.setOptions({ tileLayer: source });
2183
+ }
2184
+
1841
2185
  /* ---------------------------- country focus ---------------------------- */
1842
2186
 
1843
2187
  /**
@@ -2334,6 +2678,8 @@ class GeoGlobe {
2334
2678
  this._media.clear();
2335
2679
  for (const media of this._markerMedia.values()) media.destroy?.();
2336
2680
  this._markerMedia.clear();
2681
+ this._tileLayer?.destroy?.();
2682
+ this._tileLayer = null;
2337
2683
  this._tip = null;
2338
2684
  this._live = null;
2339
2685
  this._licenseHits = [];
@@ -2400,6 +2746,26 @@ class GeoGlobe {
2400
2746
  this._texture = new SphereTexture(source, { onLoad: () => this.invalidate() });
2401
2747
  }
2402
2748
 
2749
+ _applyTileLayer() {
2750
+ const source = this.o.tileLayer;
2751
+ if (!source) {
2752
+ this._tileLayer?.destroy?.();
2753
+ this._tileLayer = null;
2754
+ this._tileLayerFor = null;
2755
+ return;
2756
+ }
2757
+ if (this._tileLayerFor === source) return;
2758
+ this._tileLayer?.destroy?.();
2759
+ this._tileLayerFor = source;
2760
+ if (source instanceof TileLayer) {
2761
+ this._tileLayer = source;
2762
+ source._onLoad = () => this.invalidate();
2763
+ } else {
2764
+ this._tileLayer = new TileLayer(source, { onLoad: () => this.invalidate() });
2765
+ }
2766
+ this._dirty = true;
2767
+ }
2768
+
2403
2769
  /** Rebuilds the per-country media map, reusing sources that did not change. */
2404
2770
  _applyMedia() {
2405
2771
  const spec = this.o.countryMedia || {};
@@ -2836,7 +3202,7 @@ class GeoGlobe {
2836
3202
  this._cursor();
2837
3203
  this._dirty = true;
2838
3204
  const target = marker || this._hoveredCountry;
2839
- this._showTip(marker ? (marker.cluster ? "cluster" : "marker") : this._hoveredCountry ? "country" : null, target);
3205
+ this._showTip(marker ? (marker.hexBin ? "hex-bin" : marker.cluster ? "cluster" : "marker") : this._hoveredCountry ? "country" : null, target);
2840
3206
  } else if (this._tipVisible) {
2841
3207
  this._placeTip();
2842
3208
  }
@@ -3641,6 +4007,23 @@ class GeoGlobe {
3641
4007
  ctx.restore();
3642
4008
  }
3643
4009
 
4010
+ _paintTileAttribution(w, h) {
4011
+ const text = this._tileLayer?.attribution;
4012
+ if (!text || !this._tileLayer.ready) return;
4013
+ const { ctx } = this;
4014
+ ctx.save();
4015
+ ctx.font = "500 10px Inter,system-ui,sans-serif";
4016
+ ctx.textAlign = "right";
4017
+ ctx.textBaseline = "bottom";
4018
+ const width = Math.min(w - 16, ctx.measureText(text).width + 12);
4019
+ const x = w - 8, y = h - 8;
4020
+ ctx.fillStyle = "rgba(7, 12, 22, 0.72)";
4021
+ ctx.fillRect(x - width, y - 16, width, 18);
4022
+ ctx.fillStyle = "rgba(255,255,255,0.9)";
4023
+ ctx.fillText(text, x - 6, y - 3, width - 12);
4024
+ ctx.restore();
4025
+ }
4026
+
3644
4027
  _licenseRect(ctx, x, y, width, height, radius) {
3645
4028
  ctx.beginPath();
3646
4029
  if (typeof ctx.roundRect === "function") ctx.roundRect(x, y, width, height, radius);
@@ -4024,6 +4407,87 @@ class GeoGlobe {
4024
4407
  ctx.restore();
4025
4408
  }
4026
4409
 
4410
+ /** Aggregate projected markers into an interactive hexagonal density layer. */
4411
+ _paintHexBins(pts, t, cx, cy, globeRadius, w, h) {
4412
+ const o = this.o.hexBins === true ? {} : this.o.hexBins;
4413
+ const { ctx } = this;
4414
+ const radius = Math.max(4, o.radius ?? 18);
4415
+ const padding = clamp(o.padding ?? 1.5, 0, radius * 0.45);
4416
+ const drawRadius = radius - padding;
4417
+ const minValue = Math.max(0, o.minValue ?? 1);
4418
+ const metric = o.value === "count" ? "count" : "value";
4419
+ const bins = hexBinPoints(pts, radius).filter((bin) => {
4420
+ if (bin[metric] < minValue) return false;
4421
+ if (this.o.mode === "globe") return Math.hypot(bin.x - cx, bin.y - cy) <= globeRadius + drawRadius;
4422
+ return bin.x >= -drawRadius && bin.x <= w + drawRadius && bin.y >= -drawRadius && bin.y <= h + drawRadius;
4423
+ });
4424
+ const max = Math.max(1, ...bins.map((bin) => bin[metric]));
4425
+ const range = Array.isArray(o.colorRange) && o.colorRange.length > 1
4426
+ ? o.colorRange
4427
+ : [t.ocean[0], o.color || t.marker];
4428
+ const domain = range.map((_, index) => max * index / (range.length - 1));
4429
+ const scale = colorScale(domain, range);
4430
+ const hits = [];
4431
+
4432
+ ctx.save();
4433
+ if (this.o.mode === "globe") {
4434
+ ctx.beginPath();
4435
+ ctx.arc(cx, cy, globeRadius, 0, TAU);
4436
+ ctx.clip();
4437
+ } else {
4438
+ ctx.beginPath();
4439
+ ctx.rect(0, 0, w, h);
4440
+ ctx.clip();
4441
+ }
4442
+
4443
+ ctx.lineJoin = "round";
4444
+ for (const bin of bins) {
4445
+ const value = bin[metric];
4446
+ ctx.beginPath();
4447
+ for (let i = 0; i < 6; i++) {
4448
+ const angle = (60 * i - 30) * D2R;
4449
+ const x = bin.x + drawRadius * Math.cos(angle);
4450
+ const y = bin.y + drawRadius * Math.sin(angle);
4451
+ i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
4452
+ }
4453
+ ctx.closePath();
4454
+ ctx.globalAlpha = clamp(o.opacity ?? 0.82, 0, 1);
4455
+ ctx.fillStyle = scale(value) || o.color || t.marker;
4456
+ ctx.fill();
4457
+ if ((o.strokeWidth ?? 0.8) > 0) {
4458
+ ctx.globalAlpha = 1;
4459
+ ctx.strokeStyle = o.stroke || withAlpha(t.label, 0.3);
4460
+ ctx.lineWidth = o.strokeWidth ?? 0.8;
4461
+ ctx.stroke();
4462
+ }
4463
+ if (o.showCount && drawRadius >= 10) {
4464
+ ctx.globalAlpha = 1;
4465
+ ctx.fillStyle = o.labelColor || t.label;
4466
+ ctx.font = `600 ${Math.max(9, Math.min(13, drawRadius * 0.7))}px Inter,system-ui,sans-serif`;
4467
+ ctx.textAlign = "center";
4468
+ ctx.textBaseline = "middle";
4469
+ ctx.fillText(String(value), bin.x, bin.y);
4470
+ }
4471
+ hits.push({
4472
+ marker: {
4473
+ hexBin: true,
4474
+ count: bin.count,
4475
+ value: bin.value,
4476
+ markerCount: bin.count,
4477
+ markers: bin.markers,
4478
+ lon: bin.lon,
4479
+ lat: bin.lat,
4480
+ },
4481
+ x: bin.x,
4482
+ y: bin.y,
4483
+ r: drawRadius,
4484
+ });
4485
+ }
4486
+ ctx.restore();
4487
+ this._lastHexBins = bins;
4488
+ return hits;
4489
+ }
4490
+
4027
4491
  /** Spike height for a marker, as a fraction of the globe radius. */
4028
4492
  _spikeLift(m) {
4029
4493
  if (!this.o.spikes) return 0;
@@ -4336,7 +4800,8 @@ class GeoGlobe {
4336
4800
  ctx.fillRect(cx - r, cy - r, r * 2, r * 2);
4337
4801
  }
4338
4802
 
4339
- const textured = this._texture && this._texture.draw(ctx, cx, cy, r, this.lon, this.lat, this._textureOptions());
4803
+ let textured = !!(this._texture && this._texture.draw(ctx, cx, cy, r, this.lon, this.lat, this._textureOptions()));
4804
+ textured = !!(this._tileLayer?.draw(ctx, cx, cy, r, this.lon, this.lat, this._textureOptions()) || textured);
4340
4805
 
4341
4806
  if (this.o.graticule) {
4342
4807
  ctx.strokeStyle = t.graticule;
@@ -4404,9 +4869,11 @@ class GeoGlobe {
4404
4869
  pts.push({ m, x: cx + x, y: cy + y, depth: 0.65 + c * 0.35 });
4405
4870
  }
4406
4871
  if (this.o.heatmap) this._paintHeatmap(pts, t);
4872
+ const binHits = this.o.hexBins ? this._paintHexBins(pts, t, cx, cy, r, w, h) : [];
4407
4873
  if (this.o.spikes) this._paintSpikes(t, cx, cy, r, w, h, null);
4408
4874
  this._paintViewerAccuracy(t, cx, cy, r, null);
4409
- const hits = this._paintMarkers(pts, t);
4875
+ const showMarkers = !this.o.hexBins || (this.o.hexBins !== true && this.o.hexBins.hideMarkers === false);
4876
+ const hits = [...binHits, ...(showMarkers ? this._paintMarkers(pts, t) : [])];
4410
4877
  if (this.o.labels) this._paintLabels(pts, t, cx, cy, r, w, h);
4411
4878
  this._paintAnnotations(t);
4412
4879
  this._paintPings(t);
@@ -4414,6 +4881,7 @@ class GeoGlobe {
4414
4881
  this._paintCounter(t, w, h);
4415
4882
  this._paintTitle(t, w, h);
4416
4883
  this._paintWatermark(t, w, h);
4884
+ this._paintTileAttribution(w, h);
4417
4885
  return hits;
4418
4886
  }
4419
4887
 
@@ -4433,7 +4901,14 @@ class GeoGlobe {
4433
4901
  ctx.fillRect(0, 0, w, h);
4434
4902
  }
4435
4903
 
4436
- const textured = this._texture && this._texture.drawFlat(ctx, fwd, w, h);
4904
+ const flatTextureOptions = {
4905
+ ...this._textureOptions(),
4906
+ inv: v.inv,
4907
+ latRange: this.o.latRange,
4908
+ key: `${this.o.projection}:${this.lon.toFixed(5)}:${this.lat.toFixed(5)}:${this._zoom.toFixed(5)}`,
4909
+ };
4910
+ let textured = !!(this._texture && this._texture.drawFlat(ctx, fwd, w, h, flatTextureOptions));
4911
+ textured = !!(this._tileLayer?.drawFlat(ctx, fwd, w, h, flatTextureOptions) || textured);
4437
4912
 
4438
4913
  if (this.o.graticule) {
4439
4914
  ctx.strokeStyle = t.graticule;
@@ -4481,9 +4956,11 @@ class GeoGlobe {
4481
4956
  pts.push({ m, x, y, depth: 1 });
4482
4957
  }
4483
4958
  if (this.o.heatmap) this._paintHeatmap(pts, t);
4959
+ const binHits = this.o.hexBins ? this._paintHexBins(pts, t, 0, 0, 0, w, h) : [];
4484
4960
  if (this.o.spikes) this._paintSpikes(t, 0, 0, 0, w, h, fwd);
4485
4961
  this._paintViewerAccuracy(t, 0, 0, 0, fwd);
4486
- const hits = this._paintMarkers(pts, t);
4962
+ const showMarkers = !this.o.hexBins || (this.o.hexBins !== true && this.o.hexBins.hideMarkers === false);
4963
+ const hits = [...binHits, ...(showMarkers ? this._paintMarkers(pts, t) : [])];
4487
4964
  if (this.o.labels) this._paintLabels(pts, t, 0, 0, 0, w, h);
4488
4965
  this._paintAnnotations(t);
4489
4966
  this._paintPings(t);
@@ -4491,6 +4968,7 @@ class GeoGlobe {
4491
4968
  this._paintCounter(t, w, h);
4492
4969
  this._paintTitle(t, w, h);
4493
4970
  this._paintWatermark(t, w, h);
4971
+ this._paintTileAttribution(w, h);
4494
4972
  return hits;
4495
4973
  }
4496
4974
 
@@ -4822,5 +5300,5 @@ function defineGeoGlobe(tag = "geo-globe") {
4822
5300
  defineGeoGlobe();
4823
5301
 
4824
5302
  const CanvasGlobe = GeoGlobe; const createCanvasGlobe = createGlobe;
4825
- return { GeoGlobe, CanvasGlobe, createGlobe, createCanvasGlobe, GeoGlobeElement, defineGeoGlobe, themes, presets, scenes, countryPalette, exportPresets, exportSize, fromCSV, fromRows, parseCSV, geocode, countryPoint, locateViewer, locateViewerPrecise, timeZoneLocation, countryLocation, placeLocation, recordCanvas, downloadBlob, canRecord, supportedRecordingType, SphereTexture, Media, mapAspect, colorScale, subsolarPoint, greatCircle, angularDistance, pointInGeometry, geometryBounds, projections, world, DEFAULT_LICENSE_KEY, LICENSE_PAGE_URL, inspectRuntime, inspectLicenseKey, verifyLicenseKey, hasLicenseKey, default: createGlobe };
5303
+ return { GeoGlobe, CanvasGlobe, createGlobe, createCanvasGlobe, GeoGlobeElement, defineGeoGlobe, themes, presets, scenes, countryPalette, exportPresets, exportSize, fromCSV, fromRows, parseCSV, geocode, countryPoint, locateViewer, locateViewerPrecise, timeZoneLocation, countryLocation, placeLocation, recordCanvas, downloadBlob, canRecord, supportedRecordingType, SphereTexture, TileLayer, tileUrl, Media, mapAspect, colorScale, subsolarPoint, greatCircle, angularDistance, pointInGeometry, geometryBounds, projections, world, DEFAULT_LICENSE_KEY, LICENSE_PAGE_URL, inspectRuntime, inspectLicenseKey, verifyLicenseKey, hasLicenseKey, default: createGlobe };
4826
5304
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canvas-globe",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Build interactive 3D globes and flat world maps in JavaScript, React, Vue, Angular, or Svelte with Canvas 2D and no WebGL.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": {