canvas-globe 1.3.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 CHANGED
@@ -6,6 +6,19 @@ All notable changes to this package are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.4.0] - 2026-09-19
10
+
11
+ ### Added
12
+
13
+ - Added opt-in cached XYZ overview tiles with Web Mercator conversion, globe and flat-map
14
+ reprojection, provider callbacks, request ceilings, graceful failures, CORS-aware export, and
15
+ attribution painted into every frame.
16
+
17
+ ### Fixed
18
+
19
+ - Reprojected equirectangular textures correctly in Mercator and Natural Earth map views instead
20
+ of stretching them to the projected viewport.
21
+
9
22
  ## [1.3.0] - 2026-09-19
10
23
 
11
24
  ### Added
package/README.md CHANGED
@@ -42,10 +42,12 @@
42
42
  `canvas-globe` is a zero-dependency JavaScript library for an interactive
43
43
  **3D globe** and **flat world map** on Canvas 2D. It works with
44
44
  vanilla JavaScript, React, Vue, Angular, Svelte, or a Web Component and requires no WebGL, map API
45
- key, tile service, or runtime network request.
45
+ key, tile service, or runtime network request. Optional XYZ tiles are available when you explicitly
46
+ configure a provider.
46
47
 
47
- - **Zero dependencies:** no WebGL, D3, map tiles, or API keys
48
- - **Zero network calls:** country geometry ships inside the package
48
+ - **Zero dependencies:** no required WebGL, D3, map tiles, or API keys
49
+ - **Zero required network calls:** country geometry ships inside the package
50
+ - **Optional XYZ tiles:** opt into a cached raster provider for globe and overview-map backgrounds
49
51
  - **Interactive:** drag, zoom, pinch, hover, and click
50
52
  - **Marker support:** weighted markers, avatars, pulse rings, and clustering
51
53
  - **Great-circle arcs:** animated routes clipped at the horizon
@@ -139,19 +141,19 @@ your existing application rather than installing a second React copy.
139
141
  For a plain `<script>` installation, use the versioned UMD build:
140
142
 
141
143
  ```html
142
- <script src="https://cdn.jsdelivr.net/npm/canvas-globe@1.3.0/dist/canvas-globe.umd.js"></script>
144
+ <script src="https://cdn.jsdelivr.net/npm/canvas-globe@1.4.0/dist/canvas-globe.umd.js"></script>
143
145
  ```
144
146
 
145
147
  The same npm release is also available from UNPKG:
146
148
 
147
149
  ```html
148
- <script src="https://unpkg.com/canvas-globe@1.3.0/dist/canvas-globe.umd.js"></script>
150
+ <script src="https://unpkg.com/canvas-globe@1.4.0/dist/canvas-globe.umd.js"></script>
149
151
  ```
150
152
 
151
153
  Modern browsers can import the package through an ESM CDN:
152
154
 
153
155
  ```js
154
- import { createGlobe } from "https://esm.sh/canvas-globe@1.3.0";
156
+ import { createGlobe } from "https://esm.sh/canvas-globe@1.4.0";
155
157
  ```
156
158
 
157
159
  Pin an exact version in production so a future release cannot change a deployed page unexpectedly.
@@ -171,7 +173,7 @@ import { createGlobe } from "canvas-globe";
171
173
  Or drop the UMD build on a page with no build step at all:
172
174
 
173
175
  ```html
174
- <script src="https://cdn.jsdelivr.net/npm/canvas-globe@1.3.0/dist/canvas-globe.umd.js"></script>
176
+ <script src="https://cdn.jsdelivr.net/npm/canvas-globe@1.4.0/dist/canvas-globe.umd.js"></script>
175
177
  <canvas id="globe" style="width:520px;aspect-ratio:1"></canvas>
176
178
  <script>
177
179
  CanvasGlobe.createGlobe(document.getElementById("globe"), {
@@ -371,6 +373,7 @@ included effects. See `types/fx.d.ts` for every option and callback signature.
371
373
  | `orbits` | `0` | Decorative rings: a count (0-6) or explicit specs |
372
374
  | `texture` | Not set | Equirectangular image painted onto the sphere |
373
375
  | `textureQuality` | `"auto"` | Pixel step for the texture pass; higher is faster |
376
+ | `tileLayer` | Not set | Optional cached XYZ overview tiles; see [Tile layers](#tile-layers) |
374
377
  | `focus` | Not set | Frame one country: `"IN"` or `{ country, isolate, dim, outlineWidth }` |
375
378
  | `countryMedia` | Not set | Media clipped to each country, keyed by ISO, id or name |
376
379
  | `scene` | Not set | Whole composition: preset plus the layers a job needs |
@@ -479,6 +482,41 @@ Available options are `radius`, `minValue`, `value`, `color`, `colorRange`, `opa
479
482
  in globe and flat-map modes. Since binning happens after projection, `radius` is measured in screen
480
483
  pixels rather than geographic degrees.
481
484
 
485
+ ## Tile layers
486
+
487
+ CanvasGlobe does not contact a tile service by default. Set `tileLayer` only when a raster basemap
488
+ adds real value to the product. The same cached tile mosaic is reprojected for the globe,
489
+ equirectangular, Mercator, and Natural Earth views.
490
+
491
+ ```js
492
+ const globe = createGlobe(canvas, {
493
+ tileLayer: {
494
+ url: "https://tiles.example.com/{z}/{x}/{y}.png",
495
+ zoom: 2,
496
+ attribution: "Map data and imagery: Example Maps",
497
+ onError(error, tile) {
498
+ console.warn("Tile unavailable", tile, error);
499
+ },
500
+ },
501
+ });
502
+ ```
503
+
504
+ `url` accepts `{z}`, `{x}`, `{y}`, and `{-y}` placeholders. For private, signed, generated, or
505
+ offline imagery, use `getTile({ x, y, z })` and return a URL, canvas, image, bitmap, or a Promise of
506
+ one. Loaded XYZ coordinates are cached for the lifetime of the layer, and failed tiles leave the
507
+ built-in CanvasGlobe background visible instead of breaking the render loop.
508
+
509
+ This is an overview layer, not a slippy-map engine. `zoom` defaults to 2. The default `maxTiles: 64`
510
+ allows up to zoom 3 and prevents a configuration mistake from starting hundreds of requests. Raise
511
+ the ceiling explicitly only when the provider terms, page weight, and device budget allow it.
512
+
513
+ Always supply the attribution required by your provider. CanvasGlobe paints it into the canvas, so
514
+ it remains present in image and video exports. Remote images use anonymous CORS by default because
515
+ globe reprojection and export must read their pixels. The provider must return a compatible
516
+ `Access-Control-Allow-Origin` header. You may set `crossOrigin: null`, but a cross-origin response
517
+ without CORS cannot be reprojected or exported. You are responsible for the provider's licence,
518
+ usage policy, access token, and request limits.
519
+
482
520
  ### How accurate is marker placement?
483
521
 
484
522
  The projection maths is exact: a marker's pixel position matches the closed-form projection to
package/codemeta.json CHANGED
@@ -3,9 +3,9 @@
3
3
  "@type": "SoftwareSourceCode",
4
4
  "name": "CanvasGlobe",
5
5
  "identifier": "canvas-globe",
6
- "description": "CanvasGlobe helps developers add interactive 3D globes and flat world maps to JavaScript and React apps with Canvas 2D, without WebGL, map tiles, API keys, or runtime network calls.",
6
+ "description": "CanvasGlobe helps developers add interactive 3D globes and flat world maps to JavaScript and React apps with Canvas 2D, without WebGL, required map tiles, API keys, or required runtime network calls.",
7
7
  "url": "https://canvasglobe.swiftools.com/",
8
- "version": "1.3.0",
8
+ "version": "1.4.0",
9
9
  "codeRepository": "https://github.com/Shree-hari/canvas-globe",
10
10
  "issueTracker": "https://github.com/Shree-hari/canvas-globe/issues",
11
11
  "downloadUrl": "https://www.npmjs.com/package/canvas-globe",
@@ -1197,7 +1197,7 @@ class SphereTexture {
1197
1197
  out[o] = pixels[s] * k;
1198
1198
  out[o + 1] = pixels[s + 1] * k;
1199
1199
  out[o + 2] = pixels[s + 2] * k;
1200
- out[o + 3] = 255;
1200
+ out[o + 3] = pixels[s + 3];
1201
1201
  }
1202
1202
  }
1203
1203
  this._ctx.putImageData(this._image, 0, 0);
@@ -1205,10 +1205,45 @@ class SphereTexture {
1205
1205
  return true;
1206
1206
  }
1207
1207
 
1208
- /** Paints the whole texture into a flat-map viewport. */
1209
- 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
+ }
1210
1246
  if (!this.ready || !this._surface2) {
1211
- if (!this.ready) return false;
1212
1247
  this._surface2 = makeSurface(this.tw, this.th);
1213
1248
  if (!this._surface2) return false;
1214
1249
  const c = this._surface2.getContext("2d");
@@ -1222,6 +1257,233 @@ class SphereTexture {
1222
1257
  }
1223
1258
  }
1224
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
+
1225
1487
  /**
1226
1488
  * Media sources that can be painted inside a country's outline: a still, an
1227
1489
  * animated GIF, a video, another canvas, or a live MediaStream.
@@ -1384,7 +1646,7 @@ function drawFitted(ctx, media, box) {
1384
1646
  }
1385
1647
 
1386
1648
  // Keep in sync with package.json. Release checks enforce this value.
1387
- const CANVAS_GLOBE_VERSION = "1.3.0";
1649
+ const CANVAS_GLOBE_VERSION = "1.4.0";
1388
1650
 
1389
1651
  /** Local license-key checks and production-use presentation helpers. */
1390
1652
 
@@ -1511,7 +1773,7 @@ function reportLicenseStatus(value, mode = COMMERCIAL_LICENSE_MODE) {
1511
1773
 
1512
1774
  /**
1513
1775
  * canvas-globe: interactive globe & world map on a 2D canvas.
1514
- * No dependencies, no WebGL, no network calls, no API keys.
1776
+ * No dependencies, no WebGL, no required network calls, no API keys.
1515
1777
  */
1516
1778
 
1517
1779
 
@@ -1524,6 +1786,7 @@ function reportLicenseStatus(value, mode = COMMERCIAL_LICENSE_MODE) {
1524
1786
 
1525
1787
 
1526
1788
 
1789
+
1527
1790
  const DEFAULTS = {
1528
1791
  licenseKey: null,
1529
1792
  mode: "globe",
@@ -1538,6 +1801,7 @@ const DEFAULTS = {
1538
1801
  countryPalette: null,
1539
1802
  texture: null,
1540
1803
  textureQuality: "auto",
1804
+ tileLayer: null,
1541
1805
  focus: null,
1542
1806
  countryMedia: null,
1543
1807
  annotations: null,
@@ -1658,6 +1922,7 @@ class GeoGlobe {
1658
1922
  this._story = null;
1659
1923
  this._viewer = null;
1660
1924
  this._texture = null;
1925
+ this._tileLayer = null;
1661
1926
  this._media = new Map();
1662
1927
  this._markerMedia = new Map();
1663
1928
  this._counterShown = null;
@@ -1673,6 +1938,7 @@ class GeoGlobe {
1673
1938
  this._applyWorld();
1674
1939
  this._applyMarkers(this.o.markers);
1675
1940
  this._applyTexture();
1941
+ this._applyTileLayer();
1676
1942
  this._applyMedia();
1677
1943
  this._watchMotion();
1678
1944
  this._bind();
@@ -1843,6 +2109,7 @@ class GeoGlobe {
1843
2109
  if ("ariaLabel" in patch) this.canvas.setAttribute("aria-label", this.o.ariaLabel);
1844
2110
  if ("projection" in patch || "latRange" in patch) this._bbox = null;
1845
2111
  if ("texture" in patch) this._applyTexture();
2112
+ if ("tileLayer" in patch) this._applyTileLayer();
1846
2113
  if ("countryMedia" in patch) this._applyMedia();
1847
2114
  if ("theme" in patch) this._cssCache = null;
1848
2115
  if ("focus" in patch) this._resolveFocus();
@@ -1910,6 +2177,11 @@ class GeoGlobe {
1910
2177
  return this.setOptions({ texture: source });
1911
2178
  }
1912
2179
 
2180
+ /** Optional XYZ overview tiles. Pass null to remove the layer. */
2181
+ setTileLayer(source) {
2182
+ return this.setOptions({ tileLayer: source });
2183
+ }
2184
+
1913
2185
  /* ---------------------------- country focus ---------------------------- */
1914
2186
 
1915
2187
  /**
@@ -2406,6 +2678,8 @@ class GeoGlobe {
2406
2678
  this._media.clear();
2407
2679
  for (const media of this._markerMedia.values()) media.destroy?.();
2408
2680
  this._markerMedia.clear();
2681
+ this._tileLayer?.destroy?.();
2682
+ this._tileLayer = null;
2409
2683
  this._tip = null;
2410
2684
  this._live = null;
2411
2685
  this._licenseHits = [];
@@ -2472,6 +2746,26 @@ class GeoGlobe {
2472
2746
  this._texture = new SphereTexture(source, { onLoad: () => this.invalidate() });
2473
2747
  }
2474
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
+
2475
2769
  /** Rebuilds the per-country media map, reusing sources that did not change. */
2476
2770
  _applyMedia() {
2477
2771
  const spec = this.o.countryMedia || {};
@@ -3713,6 +4007,23 @@ class GeoGlobe {
3713
4007
  ctx.restore();
3714
4008
  }
3715
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
+
3716
4027
  _licenseRect(ctx, x, y, width, height, radius) {
3717
4028
  ctx.beginPath();
3718
4029
  if (typeof ctx.roundRect === "function") ctx.roundRect(x, y, width, height, radius);
@@ -4489,7 +4800,8 @@ class GeoGlobe {
4489
4800
  ctx.fillRect(cx - r, cy - r, r * 2, r * 2);
4490
4801
  }
4491
4802
 
4492
- 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);
4493
4805
 
4494
4806
  if (this.o.graticule) {
4495
4807
  ctx.strokeStyle = t.graticule;
@@ -4569,6 +4881,7 @@ class GeoGlobe {
4569
4881
  this._paintCounter(t, w, h);
4570
4882
  this._paintTitle(t, w, h);
4571
4883
  this._paintWatermark(t, w, h);
4884
+ this._paintTileAttribution(w, h);
4572
4885
  return hits;
4573
4886
  }
4574
4887
 
@@ -4588,7 +4901,14 @@ class GeoGlobe {
4588
4901
  ctx.fillRect(0, 0, w, h);
4589
4902
  }
4590
4903
 
4591
- 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);
4592
4912
 
4593
4913
  if (this.o.graticule) {
4594
4914
  ctx.strokeStyle = t.graticule;
@@ -4648,6 +4968,7 @@ class GeoGlobe {
4648
4968
  this._paintCounter(t, w, h);
4649
4969
  this._paintTitle(t, w, h);
4650
4970
  this._paintWatermark(t, w, h);
4971
+ this._paintTileAttribution(w, h);
4651
4972
  return hits;
4652
4973
  }
4653
4974
 
@@ -4979,5 +5300,5 @@ function defineGeoGlobe(tag = "geo-globe") {
4979
5300
  defineGeoGlobe();
4980
5301
 
4981
5302
  const CanvasGlobe = GeoGlobe; const createCanvasGlobe = createGlobe;
4982
- 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 };
4983
5304
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canvas-globe",
3
- "version": "1.3.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": {
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";
@@ -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,
@@ -151,6 +153,7 @@ export class GeoGlobe {
151
153
  this._story = null;
152
154
  this._viewer = null;
153
155
  this._texture = null;
156
+ this._tileLayer = null;
154
157
  this._media = new Map();
155
158
  this._markerMedia = new Map();
156
159
  this._counterShown = null;
@@ -166,6 +169,7 @@ export class GeoGlobe {
166
169
  this._applyWorld();
167
170
  this._applyMarkers(this.o.markers);
168
171
  this._applyTexture();
172
+ this._applyTileLayer();
169
173
  this._applyMedia();
170
174
  this._watchMotion();
171
175
  this._bind();
@@ -336,6 +340,7 @@ export class GeoGlobe {
336
340
  if ("ariaLabel" in patch) this.canvas.setAttribute("aria-label", this.o.ariaLabel);
337
341
  if ("projection" in patch || "latRange" in patch) this._bbox = null;
338
342
  if ("texture" in patch) this._applyTexture();
343
+ if ("tileLayer" in patch) this._applyTileLayer();
339
344
  if ("countryMedia" in patch) this._applyMedia();
340
345
  if ("theme" in patch) this._cssCache = null;
341
346
  if ("focus" in patch) this._resolveFocus();
@@ -403,6 +408,11 @@ export class GeoGlobe {
403
408
  return this.setOptions({ texture: source });
404
409
  }
405
410
 
411
+ /** Optional XYZ overview tiles. Pass null to remove the layer. */
412
+ setTileLayer(source) {
413
+ return this.setOptions({ tileLayer: source });
414
+ }
415
+
406
416
  /* ---------------------------- country focus ---------------------------- */
407
417
 
408
418
  /**
@@ -899,6 +909,8 @@ export class GeoGlobe {
899
909
  this._media.clear();
900
910
  for (const media of this._markerMedia.values()) media.destroy?.();
901
911
  this._markerMedia.clear();
912
+ this._tileLayer?.destroy?.();
913
+ this._tileLayer = null;
902
914
  this._tip = null;
903
915
  this._live = null;
904
916
  this._licenseHits = [];
@@ -965,6 +977,26 @@ export class GeoGlobe {
965
977
  this._texture = new SphereTexture(source, { onLoad: () => this.invalidate() });
966
978
  }
967
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
+
968
1000
  /** Rebuilds the per-country media map, reusing sources that did not change. */
969
1001
  _applyMedia() {
970
1002
  const spec = this.o.countryMedia || {};
@@ -2206,6 +2238,23 @@ export class GeoGlobe {
2206
2238
  ctx.restore();
2207
2239
  }
2208
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
+
2209
2258
  _licenseRect(ctx, x, y, width, height, radius) {
2210
2259
  ctx.beginPath();
2211
2260
  if (typeof ctx.roundRect === "function") ctx.roundRect(x, y, width, height, radius);
@@ -2982,7 +3031,8 @@ export class GeoGlobe {
2982
3031
  ctx.fillRect(cx - r, cy - r, r * 2, r * 2);
2983
3032
  }
2984
3033
 
2985
- 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);
2986
3036
 
2987
3037
  if (this.o.graticule) {
2988
3038
  ctx.strokeStyle = t.graticule;
@@ -3062,6 +3112,7 @@ export class GeoGlobe {
3062
3112
  this._paintCounter(t, w, h);
3063
3113
  this._paintTitle(t, w, h);
3064
3114
  this._paintWatermark(t, w, h);
3115
+ this._paintTileAttribution(w, h);
3065
3116
  return hits;
3066
3117
  }
3067
3118
 
@@ -3081,7 +3132,14 @@ export class GeoGlobe {
3081
3132
  ctx.fillRect(0, 0, w, h);
3082
3133
  }
3083
3134
 
3084
- 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);
3085
3143
 
3086
3144
  if (this.o.graticule) {
3087
3145
  ctx.strokeStyle = t.graticule;
@@ -3141,6 +3199,7 @@ export class GeoGlobe {
3141
3199
  this._paintCounter(t, w, h);
3142
3200
  this._paintTitle(t, w, h);
3143
3201
  this._paintWatermark(t, w, h);
3202
+ this._paintTileAttribution(w, h);
3144
3203
  return hits;
3145
3204
  }
3146
3205
 
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.3.0";
2
+ export const CANVAS_GLOBE_VERSION = "1.4.0";
package/types/index.d.ts CHANGED
@@ -256,6 +256,39 @@ export interface RecordingHandle {
256
256
  stop(): Promise<Blob>;
257
257
  }
258
258
 
259
+ export interface TileCoordinate {
260
+ x: number;
261
+ y: number;
262
+ z: number;
263
+ }
264
+
265
+ export type TileSource = string | CanvasImageSource;
266
+ export type TileProvider = (tile: TileCoordinate) => TileSource | Promise<TileSource>;
267
+
268
+ /** Optional XYZ overview tiles. No request is made unless this option is set. */
269
+ export interface TileLayerOptions {
270
+ /** URL template using {z}, {x}, {y}, and optional {-y}. */
271
+ url?: string;
272
+ /** Alias for a URL template or provider callback. */
273
+ source?: string | TileProvider;
274
+ /** Return a URL or drawable image for one XYZ tile. */
275
+ getTile?: TileProvider;
276
+ /** Fixed overview zoom. Default 2. */
277
+ zoom?: number;
278
+ /** Source tile width/height. Default 256. */
279
+ tileSize?: number;
280
+ /** Maximum composed texture width. Default 2048. */
281
+ maxWidth?: number;
282
+ /** Request ceiling. Default 64, which allows zoom 3. */
283
+ maxTiles?: number;
284
+ opacity?: number;
285
+ /** Painted into the canvas and every export. */
286
+ attribution?: string;
287
+ /** Image CORS mode. Default "anonymous"; null leaves it unset. */
288
+ crossOrigin?: string | null;
289
+ onError?: (error: Error, tile: TileCoordinate | null) => void;
290
+ }
291
+
259
292
  /** Anything `drawImage` accepts, plus a URL or a live stream. */
260
293
  export type MediaSource = string | CanvasImageSource | MediaStream;
261
294
 
@@ -445,6 +478,8 @@ export interface GeoGlobeOptions {
445
478
  texture?: string | CanvasImageSource | null;
446
479
  /** Pixel step for the texture pass; higher is faster. Default "auto". */
447
480
  textureQuality?: "auto" | number;
481
+ /** Optional XYZ overview tiles. The default configuration performs no requests. */
482
+ tileLayer?: string | TileProvider | TileLayerOptions | TileLayer | null;
448
483
  /** Frame a single country, optionally dropping the rest of the world. */
449
484
  focus?: string | FocusSpec | null;
450
485
  /** Media painted inside each country's outline, keyed by ISO, id or name. */
@@ -602,6 +637,8 @@ export declare class GeoGlobe {
602
637
  setLandStyle(style: LandStyle): this;
603
638
  /** Equirectangular image painted onto the sphere; null removes it. */
604
639
  setTexture(source: string | CanvasImageSource | null): this;
640
+ /** Optional XYZ overview tiles; null removes the layer. */
641
+ setTileLayer(source: GeoGlobeOptions["tileLayer"]): this;
605
642
  /** Frames a country and, with `isolate`, drops the rest of the world away. */
606
643
  focusOn(country: string | FocusSpec | null, opts?: Partial<FocusSpec> & FlyToOptions): this;
607
644
  clearFocus(): this;
@@ -781,6 +818,18 @@ export declare class SphereTexture {
781
818
  readonly error: Error | null;
782
819
  }
783
820
 
821
+ /** Cached XYZ tiles composed into the raster used by globe and map modes. */
822
+ export declare class TileLayer {
823
+ constructor(source: string | TileProvider | TileLayerOptions, options?: { onLoad?: (layer: TileLayer) => void });
824
+ readonly ready: boolean;
825
+ readonly error: Error | null;
826
+ readonly attribution: string;
827
+ readonly stats: { loaded: number; failed: number; total: number; cached: number };
828
+ destroy(): void;
829
+ }
830
+
831
+ export declare function tileUrl(template: string, tile: TileCoordinate): string;
832
+
784
833
  /** A drawable media source: image, GIF, video, canvas or live stream. */
785
834
  export declare class Media {
786
835
  constructor(spec: MediaSource | MediaSpec, onReady?: (media: Media) => void);