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 +13 -0
- package/README.md +45 -7
- package/codemeta.json +2 -2
- package/dist/canvas-globe.umd.js +330 -9
- package/package.json +1 -1
- package/src/geo-globe.js +62 -3
- 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/index.d.ts +49 -0
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/dist/canvas-globe.umd.js
CHANGED
|
@@ -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] =
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
+
"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
|
-
|
|
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
|
|
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] =
|
|
106
|
+
out[o + 3] = pixels[s + 3];
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
this._ctx.putImageData(this._image, 0, 0);
|
|
@@ -111,10 +111,45 @@ export class SphereTexture {
|
|
|
111
111
|
return true;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
/** Paints the
|
|
115
|
-
drawFlat(ctx, fwd, w, h) {
|
|
114
|
+
/** Paints the texture into a flat-map viewport, respecting its projection. */
|
|
115
|
+
drawFlat(ctx, fwd, w, h, options = {}) {
|
|
116
|
+
if (!this.ready) return false;
|
|
117
|
+
const { inv, step = 2, key = "", latRange = [90, -90] } = options;
|
|
118
|
+
if (inv) {
|
|
119
|
+
const size = Math.max(1, Number(step) || 1);
|
|
120
|
+
const width = Math.max(1, Math.ceil(w / size));
|
|
121
|
+
const height = Math.max(1, Math.ceil(h / size));
|
|
122
|
+
const cacheKey = `${width}:${height}:${key}:${latRange[0]}:${latRange[1]}`;
|
|
123
|
+
if (!this._flatProjected || this._flatKey !== cacheKey) {
|
|
124
|
+
this._flatProjected = makeSurface(width, height);
|
|
125
|
+
if (!this._flatProjected) return false;
|
|
126
|
+
this._flatCtx = this._flatProjected.getContext("2d");
|
|
127
|
+
this._flatImage = this._flatCtx.createImageData(width, height);
|
|
128
|
+
const out = this._flatImage.data;
|
|
129
|
+
const [north, south] = latRange;
|
|
130
|
+
for (let y = 0, offset = 0; y < height; y++) {
|
|
131
|
+
for (let x = 0; x < width; x++, offset += 4) {
|
|
132
|
+
const geo = inv((x + 0.5) * size, (y + 0.5) * size);
|
|
133
|
+
if (!geo || !Number.isFinite(geo[0]) || !Number.isFinite(geo[1]) || geo[1] > north || geo[1] < south) {
|
|
134
|
+
out[offset + 3] = 0;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const u = Math.max(0, Math.min(this.tw - 1, Math.floor((((geo[0] + 180) % 360 + 360) % 360) * (this.tw / 360))));
|
|
138
|
+
const v = Math.max(0, Math.min(this.th - 1, Math.floor(((90 - geo[1]) / 180) * this.th)));
|
|
139
|
+
const source = (v * this.tw + u) * 4;
|
|
140
|
+
out[offset] = this.pixels[source];
|
|
141
|
+
out[offset + 1] = this.pixels[source + 1];
|
|
142
|
+
out[offset + 2] = this.pixels[source + 2];
|
|
143
|
+
out[offset + 3] = this.pixels[source + 3];
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
this._flatCtx.putImageData(this._flatImage, 0, 0);
|
|
147
|
+
this._flatKey = cacheKey;
|
|
148
|
+
}
|
|
149
|
+
ctx.drawImage(this._flatProjected, 0, 0, w, h);
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
116
152
|
if (!this.ready || !this._surface2) {
|
|
117
|
-
if (!this.ready) return false;
|
|
118
153
|
this._surface2 = makeSurface(this.tw, this.th);
|
|
119
154
|
if (!this._surface2) return false;
|
|
120
155
|
const c = this._surface2.getContext("2d");
|
package/src/tiles.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional XYZ raster tiles composed into an equirectangular texture.
|
|
3
|
+
*
|
|
4
|
+
* Nothing is requested unless a tile layer is configured. The deliberately
|
|
5
|
+
* small default request ceiling keeps this suitable for globe and overview-map
|
|
6
|
+
* backgrounds, rather than pretending to be a full slippy-map engine.
|
|
7
|
+
*/
|
|
8
|
+
import { SphereTexture } from "./texture.js";
|
|
9
|
+
|
|
10
|
+
const makeTileSurface = (width, height) => {
|
|
11
|
+
if (typeof OffscreenCanvas !== "undefined") return new OffscreenCanvas(width, height);
|
|
12
|
+
if (typeof document === "undefined") return null;
|
|
13
|
+
const canvas = document.createElement("canvas");
|
|
14
|
+
canvas.width = width;
|
|
15
|
+
canvas.height = height;
|
|
16
|
+
return canvas;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const isTilePromise = (value) => value && typeof value.then === "function";
|
|
20
|
+
const isTileDrawable = (value) => value && typeof value === "object" && (
|
|
21
|
+
Number(value.naturalWidth || value.videoWidth || value.width) > 0
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
export const tileUrl = (template, { x, y, z }) => String(template)
|
|
25
|
+
.replaceAll("{z}", String(z))
|
|
26
|
+
.replaceAll("{x}", String(x))
|
|
27
|
+
.replaceAll("{y}", String(y))
|
|
28
|
+
.replaceAll("{-y}", String((2 ** z) - y - 1));
|
|
29
|
+
|
|
30
|
+
const tileSourceFrom = (spec) => {
|
|
31
|
+
if (typeof spec === "string" || typeof spec === "function") return spec;
|
|
32
|
+
return spec?.getTile || spec?.url || spec?.source || null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export class TileLayer {
|
|
36
|
+
constructor(input, { onLoad } = {}) {
|
|
37
|
+
const spec = typeof input === "object" && input && !isTileDrawable(input) ? input : { source: input };
|
|
38
|
+
this.spec = spec;
|
|
39
|
+
this.zoom = Math.max(0, Math.floor(spec.zoom ?? 2));
|
|
40
|
+
this.tileSize = Math.max(16, Math.floor(spec.tileSize ?? 256));
|
|
41
|
+
this.opacity = Math.max(0, Math.min(1, Number(spec.opacity ?? 1)));
|
|
42
|
+
this.attribution = spec.attribution || "";
|
|
43
|
+
this.crossOrigin = "crossOrigin" in spec ? spec.crossOrigin : "anonymous";
|
|
44
|
+
this.maxTiles = Math.max(1, Math.floor(spec.maxTiles ?? 64));
|
|
45
|
+
this.total = (2 ** this.zoom) ** 2;
|
|
46
|
+
this.loaded = 0;
|
|
47
|
+
this.failed = 0;
|
|
48
|
+
this.ready = false;
|
|
49
|
+
this.error = null;
|
|
50
|
+
this.cache = new Map();
|
|
51
|
+
this._source = tileSourceFrom(input);
|
|
52
|
+
this._onLoad = onLoad;
|
|
53
|
+
this._destroyed = false;
|
|
54
|
+
this._refreshQueued = false;
|
|
55
|
+
|
|
56
|
+
if (!this._source) {
|
|
57
|
+
this.error = new TypeError("canvas-globe: tileLayer requires url, source, or getTile");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (this.total > this.maxTiles) {
|
|
61
|
+
this.error = new RangeError(`canvas-globe: tileLayer zoom ${this.zoom} needs ${this.total} tiles; raise maxTiles to allow it`);
|
|
62
|
+
spec.onError?.(this.error, null);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const side = this.tileSize * (2 ** this.zoom);
|
|
67
|
+
const maxWidth = Math.max(this.tileSize, Math.floor(spec.maxWidth ?? 2048));
|
|
68
|
+
const width = Math.min(side, maxWidth);
|
|
69
|
+
this._scale = width / side;
|
|
70
|
+
this._surface = makeTileSurface(width, width);
|
|
71
|
+
this._ctx = this._surface?.getContext?.("2d") || null;
|
|
72
|
+
if (!this._ctx) {
|
|
73
|
+
this.error = new Error("canvas-globe: tileLayer needs a canvas-capable browser");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
this._loadAll();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
_loadAll() {
|
|
80
|
+
const side = 2 ** this.zoom;
|
|
81
|
+
for (let y = 0; y < side; y++) {
|
|
82
|
+
for (let x = 0; x < side; x++) this._load({ x, y, z: this.zoom });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
_resolve(tile) {
|
|
87
|
+
if (typeof this._source === "function") return this._source(tile);
|
|
88
|
+
return tileUrl(this._source, tile);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
_load(tile) {
|
|
92
|
+
const key = `${tile.z}/${tile.x}/${tile.y}`;
|
|
93
|
+
if (this.cache.has(key)) return this.cache.get(key);
|
|
94
|
+
const entry = { ...tile, key, status: "loading", source: null, error: null };
|
|
95
|
+
this.cache.set(key, entry);
|
|
96
|
+
let resolved;
|
|
97
|
+
try {
|
|
98
|
+
resolved = this._resolve(tile);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
this._fail(entry, error);
|
|
101
|
+
return entry;
|
|
102
|
+
}
|
|
103
|
+
const finish = (source) => this._loadSource(source, entry);
|
|
104
|
+
if (isTilePromise(resolved)) resolved.then(finish, (error) => this._fail(entry, error));
|
|
105
|
+
else finish(resolved);
|
|
106
|
+
return entry;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
_loadSource(source, entry) {
|
|
110
|
+
if (this._destroyed) return;
|
|
111
|
+
if (isTileDrawable(source)) {
|
|
112
|
+
this._draw(source, entry);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (typeof source !== "string" || !source) {
|
|
116
|
+
this._fail(entry, new TypeError(`canvas-globe: tile ${entry.key} did not resolve to an image or URL`));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (typeof Image === "undefined") {
|
|
120
|
+
this._fail(entry, new Error("canvas-globe: tile URLs need the browser Image API"));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const image = new Image();
|
|
124
|
+
if (this.crossOrigin != null) image.crossOrigin = this.crossOrigin;
|
|
125
|
+
image.onload = () => this._draw(image, entry);
|
|
126
|
+
image.onerror = () => this._fail(entry, new Error(`canvas-globe: could not load tile ${entry.key}`));
|
|
127
|
+
image.src = source;
|
|
128
|
+
entry.source = source;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_draw(source, entry) {
|
|
132
|
+
if (this._destroyed || entry.status !== "loading") return;
|
|
133
|
+
const size = this.tileSize * this._scale;
|
|
134
|
+
try {
|
|
135
|
+
this._ctx.drawImage(source, entry.x * size, entry.y * size, size, size);
|
|
136
|
+
entry.status = "loaded";
|
|
137
|
+
entry.source = source;
|
|
138
|
+
this.loaded++;
|
|
139
|
+
this._queueRefresh();
|
|
140
|
+
} catch (error) {
|
|
141
|
+
this._fail(entry, error);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
_fail(entry, error) {
|
|
146
|
+
if (this._destroyed || entry.status === "failed") return;
|
|
147
|
+
entry.status = "failed";
|
|
148
|
+
entry.error = error instanceof Error ? error : new Error(String(error));
|
|
149
|
+
this.failed++;
|
|
150
|
+
if (!this.error) this.error = entry.error;
|
|
151
|
+
this.spec.onError?.(entry.error, { x: entry.x, y: entry.y, z: entry.z });
|
|
152
|
+
if (this.loaded) this._queueRefresh();
|
|
153
|
+
this._onLoad?.(this);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
_queueRefresh() {
|
|
157
|
+
const complete = this.loaded + this.failed;
|
|
158
|
+
const interval = Math.max(1, Math.ceil(this.total / 4));
|
|
159
|
+
if (complete < this.total && this.loaded % interval !== 0) return;
|
|
160
|
+
if (this._refreshQueued) return;
|
|
161
|
+
this._refreshQueued = true;
|
|
162
|
+
queueMicrotask(() => {
|
|
163
|
+
this._refreshQueued = false;
|
|
164
|
+
if (this._destroyed || !this.loaded) return;
|
|
165
|
+
const surface = this._toEquirectangular();
|
|
166
|
+
const texture = new SphereTexture(surface, {
|
|
167
|
+
maxWidth: surface.width,
|
|
168
|
+
onLoad: () => this._onLoad?.(this),
|
|
169
|
+
});
|
|
170
|
+
if (texture.ready) {
|
|
171
|
+
this.texture = texture;
|
|
172
|
+
this.ready = true;
|
|
173
|
+
} else if (texture.error) {
|
|
174
|
+
this.error = texture.error;
|
|
175
|
+
this.spec.onError?.(texture.error, null);
|
|
176
|
+
}
|
|
177
|
+
this._onLoad?.(this);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** XYZ rows use Web Mercator; the globe texture expects linear latitude. */
|
|
182
|
+
_toEquirectangular() {
|
|
183
|
+
const width = this._surface.width;
|
|
184
|
+
const height = Math.max(1, Math.round(width / 2));
|
|
185
|
+
const surface = makeTileSurface(width, height);
|
|
186
|
+
const ctx = surface.getContext("2d");
|
|
187
|
+
const sourceHeight = this._surface.height;
|
|
188
|
+
for (let y = 0; y < height; y++) {
|
|
189
|
+
const lat = 90 - ((y + 0.5) / height) * 180;
|
|
190
|
+
const sin = Math.sin((Math.max(-85.05112878, Math.min(85.05112878, lat)) * Math.PI) / 180);
|
|
191
|
+
const mercatorY = 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI);
|
|
192
|
+
const sourceY = Math.max(0, Math.min(sourceHeight - 1, mercatorY * sourceHeight));
|
|
193
|
+
ctx.drawImage(this._surface, 0, sourceY, width, 1, 0, y, width, 1);
|
|
194
|
+
}
|
|
195
|
+
return surface;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
draw(ctx, ...args) {
|
|
199
|
+
if (!this.ready || !this.texture || this.opacity <= 0) return false;
|
|
200
|
+
ctx.save();
|
|
201
|
+
ctx.globalAlpha *= this.opacity;
|
|
202
|
+
const drew = this.texture.draw(ctx, ...args);
|
|
203
|
+
ctx.restore();
|
|
204
|
+
return drew;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
drawFlat(ctx, ...args) {
|
|
208
|
+
if (!this.ready || !this.texture || this.opacity <= 0) return false;
|
|
209
|
+
ctx.save();
|
|
210
|
+
ctx.globalAlpha *= this.opacity;
|
|
211
|
+
const drew = this.texture.drawFlat(ctx, ...args);
|
|
212
|
+
ctx.restore();
|
|
213
|
+
return drew;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
get stats() {
|
|
217
|
+
return { loaded: this.loaded, failed: this.failed, total: this.total, cached: this.cache.size };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
destroy() {
|
|
221
|
+
this._destroyed = true;
|
|
222
|
+
this.cache.clear();
|
|
223
|
+
this.texture = null;
|
|
224
|
+
this._surface = null;
|
|
225
|
+
this._ctx = null;
|
|
226
|
+
}
|
|
227
|
+
}
|
package/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Keep in sync with package.json. Release checks enforce this value.
|
|
2
|
-
export const CANVAS_GLOBE_VERSION = "1.
|
|
2
|
+
export const CANVAS_GLOBE_VERSION = "1.4.0";
|
package/types/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);
|