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/dist/canvas-globe.umd.js
CHANGED
|
@@ -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] =
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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": {
|