diff --git a/CHANGELOG.md b/CHANGELOG.md
index d753ae06c4..22bd3e628a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,9 +1,17 @@
## main
### ✨ Features and improvements
+
+- Changed `ImageRequest` to be `Promise` based ([#3233](https://github.com/maplibre/maplibre-gl-js/pull/3233))
+- ⚠️ Changed the undeling worker communication from callbacks to promises. This has a breaking effect on the implementation of custom `WorkerSource` and how it behaves ([#3233](https://github.com/maplibre/maplibre-gl-js/pull/3233))
+- ⚠️ Changed the `Source` interface to return promises instead of callbacks ([#3233](https://github.com/maplibre/maplibre-gl-js/pull/3233))
+- ⚠️ Changed all the sources to be promises based. ([#3233](https://github.com/maplibre/maplibre-gl-js/pull/3233))
+- ⚠️ Changed the `map.loadImage` method to return a `Promise` ([#3233](https://github.com/maplibre/maplibre-gl-js/pull/3233))
- _...Add new stuff here..._
### 🐞 Bug fixes
+
+- Fixes a security issue in `Actor` against XSS attacks in postMessage / onmessage ([#3239](https://github.com/maplibre/maplibre-gl-js/pull/3239))
- _...Add new stuff here..._
## 3.6.2
diff --git a/build/generate-doc-images.ts b/build/generate-doc-images.ts
index 50ce85bb19..cf44d80269 100644
--- a/build/generate-doc-images.ts
+++ b/build/generate-doc-images.ts
@@ -2,6 +2,7 @@ import path from 'path';
import fs from 'fs';
import puppeteer from 'puppeteer';
import packageJson from '../package.json' assert { type: 'json' };
+import {sleep} from '../src/util/test/util';
const exampleName = process.argv[2];
const examplePath = path.resolve('test', 'examples');
@@ -28,10 +29,8 @@ async function createImage(exampleName) {
.then(async () => {
// Wait for 5 seconds on 3d model examples, since this takes longer to load.
const waitTime = exampleName.includes('3d-model') ? 5000 : 1500;
- await new Promise((resolve) => {
- console.log(`waiting for ${waitTime} ms`);
- setTimeout(resolve, waitTime);
- });
+ console.log(`waiting for ${waitTime} ms`);
+ await sleep(waitTime);
})
// map.loaded() does not evaluate to true within 3 seconds, it's probably an animated example.
// In this case we take the screenshot immediately.
diff --git a/developer-guides/life-of-a-tile.md b/developer-guides/life-of-a-tile.md
index 4ebe28631e..8e980c4d04 100644
--- a/developer-guides/life-of-a-tile.md
+++ b/developer-guides/life-of-a-tile.md
@@ -93,7 +93,7 @@ sequenceDiagram
worker_tile->glyph manager: getImages
glyph manager->>ajax: Fetch icon
images
glyph manager-->>worker_tile: glyph/Image dependencies
- worker_tile->>worker_tile: maybePrepare()
+ worker_tile->>worker_tile: wait for all requests to finish
worker_tile->>worker_tile: create GlyphAtlas
worker_tile->>worker_tile: create ImageAtlas
worker_tile->>bucket: addFeatures
@@ -169,19 +169,19 @@ sequenceDiagram
map->>painter: render(style)
painter->>source_cache: prepare(context)
loop for each tile
- source_cache->>GPU: upload vertices
- source_cache->>GPU: upload image textures
+ source_cache->>GPU: upload vertices
+ source_cache->>GPU: upload image textures
end
loop for each layer
- painter->>layer: renderLayer(pass=offscreen)
- painter->>layer: renderLayer(pass=opaque)
- painter->>layer: renderLayer(pass=translucent)
- painter->>layer: renderLayer(pass=debug)
- loop renderLayer() call for each tile
- layer->>GPU: load program
- layer->>GPU: drawElements()
- GPU->>user: display pixels
- end
+ painter->>layer: renderLayer(pass=offscreen)
+ painter->>layer: renderLayer(pass=opaque)
+ painter->>layer: renderLayer(pass=translucent)
+ painter->>layer: renderLayer(pass=debug)
+ loop renderLayer() call for each tile
+ layer->>GPU: load program
+ layer->>GPU: drawElements()
+ GPU->>user: display pixels
+ end
end
map->>map: triggerRepaint()
```
diff --git a/src/data/dem_data.ts b/src/data/dem_data.ts
index b95be92b7a..90ae832c2a 100644
--- a/src/data/dem_data.ts
+++ b/src/data/dem_data.ts
@@ -3,20 +3,24 @@ import {RGBAImage} from '../util/image';
import {warnOnce} from '../util/util';
import {register} from '../util/web_worker_transfer';
-// DEMData is a data structure for decoding, backfilling, and storing elevation data for processing in the hillshade shaders
-// data can be populated either from a pngraw image tile or from serliazed data sent back from a worker. When data is initially
-// loaded from a image tile, we decode the pixel values using the appropriate decoding formula, but we store the
-// elevation data as an Int32 value. we add 65536 (2^16) to eliminate negative values and enable the use of
-// integer overflow when creating the texture used in the hillshadePrepare step.
-
-// DEMData also handles the backfilling of data from a tile's neighboring tiles. This is necessary because we use a pixel's 8
-// surrounding pixel values to compute the slope at that pixel, and we cannot accurately calculate the slope at pixels on a
-// tile's edge without backfilling from neighboring tiles.
-
+/**
+ * The possible DEM encoding types
+ */
export type DEMEncoding = 'mapbox' | 'terrarium' | 'custom'
+/**
+ * DEMData is a data structure for decoding, backfilling, and storing elevation data for processing in the hillshade shaders
+ * data can be populated either from a pngraw image tile or from serliazed data sent back from a worker. When data is initially
+ * loaded from a image tile, we decode the pixel values using the appropriate decoding formula, but we store the
+ * elevation data as an Int32 value. we add 65536 (2^16) to eliminate negative values and enable the use of
+ * integer overflow when creating the texture used in the hillshadePrepare step.
+ *
+ * DEMData also handles the backfilling of data from a tile's neighboring tiles. This is necessary because we use a pixel's 8
+ * surrounding pixel values to compute the slope at that pixel, and we cannot accurately calculate the slope at pixels on a
+ * tile's edge without backfilling from neighboring tiles.
+ */
export class DEMData {
- uid: string;
+ uid: string | number;
data: Uint32Array;
stride: number;
dim: number;
@@ -27,9 +31,18 @@ export class DEMData {
blueFactor: number;
baseShift: number;
- // RGBAImage data has uniform 1px padding on all sides: square tile edge size defines stride
+ /**
+ * Constructs a `DEMData` object
+ * @param uid - the tile's unique id
+ * @param data - RGBAImage data has uniform 1px padding on all sides: square tile edge size defines stride
// and dim is calculated as stride - 2.
- constructor(uid: string, data: RGBAImage, encoding: DEMEncoding, redFactor = 1.0, greenFactor = 1.0, blueFactor = 1.0, baseShift = 0.0) {
+ * @param encoding - the encoding type of the data
+ * @param redFactor - the red channel factor used to unpack the data, used for `custom` encoding only
+ * @param greenFactor - the green channel factor used to unpack the data, used for `custom` encoding only
+ * @param blueFactor - the blue channel factor used to unpack the data, used for `custom` encoding only
+ * @param baseShift - the base shift used to unpack the data, used for `custom` encoding only
+ */
+ constructor(uid: string | number, data: RGBAImage | ImageData, encoding: DEMEncoding, redFactor = 1.0, greenFactor = 1.0, blueFactor = 1.0, baseShift = 0.0) {
this.uid = uid;
if (data.height !== data.width) throw new RangeError('DEM tiles must be square');
if (encoding && !['mapbox', 'terrarium', 'custom'].includes(encoding)) {
diff --git a/src/data/feature_index.ts b/src/data/feature_index.ts
index 1d8ea9df41..4869eca60c 100644
--- a/src/data/feature_index.ts
+++ b/src/data/feature_index.ts
@@ -40,7 +40,6 @@ type QueryParameters = {
};
/**
- * @internal
* An in memory index class to allow fast interaction with features
*/
export class FeatureIndex {
diff --git a/src/index.test.ts b/src/index.test.ts
index 628bbe2d86..16536806b4 100644
--- a/src/index.test.ts
+++ b/src/index.test.ts
@@ -2,6 +2,7 @@ import {config} from './util/config';
import maplibre from './index';
import {getJSON, getArrayBuffer} from './util/ajax';
import {ImageRequest} from './util/image_request';
+import {isAbortError} from './util/abort_error';
describe('maplibre', () => {
beforeEach(() => {
@@ -34,65 +35,55 @@ describe('maplibre', () => {
expect(Object.keys(config.REGISTERED_PROTOCOLS)).toHaveLength(0);
});
- test('#addProtocol - getJSON', done => {
+ test('#addProtocol - getJSON', async () => {
let protocolCallbackCalled = false;
maplibre.addProtocol('custom', (reqParam, callback) => {
protocolCallbackCalled = true;
callback(null, {'foo': 'bar'});
return {cancel: () => {}};
});
- getJSON({url: 'custom://test/url/json'}, (error, data) => {
- expect(error).toBeFalsy();
- expect(data).toEqual({foo: 'bar'});
- expect(protocolCallbackCalled).toBeTruthy();
- done();
- });
+ const response = await getJSON({url: 'custom://test/url/json'}, new AbortController());
+ expect(response.data).toEqual({foo: 'bar'});
+ expect(protocolCallbackCalled).toBeTruthy();
});
- test('#addProtocol - getArrayBuffer', done => {
+ test('#addProtocol - getArrayBuffer', async () => {
let protocolCallbackCalled = false;
- maplibre.addProtocol('custom', (reqParam, callback) => {
+ maplibre.addProtocol('custom', (_reqParam, callback) => {
protocolCallbackCalled = true;
- callback(null, new ArrayBuffer(1));
+ callback(null, new ArrayBuffer(1), 'cache-control', 'expires');
return {cancel: () => {}};
});
- getArrayBuffer({url: 'custom://test/url/getArrayBuffer'}, async (error, data) => {
- expect(error).toBeFalsy();
- expect(data).toBeInstanceOf(ArrayBuffer);
- expect(protocolCallbackCalled).toBeTruthy();
- done();
- });
+ const response = await getArrayBuffer({url: 'custom://test/url/getArrayBuffer'}, new AbortController());
+ expect(response.data).toBeInstanceOf(ArrayBuffer);
+ expect(response.cacheControl).toBe('cache-control');
+ expect(response.expires).toBe('expires');
+ expect(protocolCallbackCalled).toBeTruthy();
});
- test('#addProtocol - returning ImageBitmap for getImage', done => {
+ test('#addProtocol - returning ImageBitmap for getImage', async () => {
let protocolCallbackCalled = false;
- maplibre.addProtocol('custom', (reqParam, callback) => {
+ maplibre.addProtocol('custom', (_reqParam, callback) => {
protocolCallbackCalled = true;
callback(null, new ImageBitmap());
return {cancel: () => {}};
});
- ImageRequest.getImage({url: 'custom://test/url/getImage'}, async (error, img) => {
- expect(error).toBeFalsy();
- expect(img).toBeInstanceOf(ImageBitmap);
- expect(protocolCallbackCalled).toBeTruthy();
- done();
- });
+ const img = await ImageRequest.getImage({url: 'custom://test/url/getImage'}, new AbortController());
+ expect(img.data).toBeInstanceOf(ImageBitmap);
+ expect(protocolCallbackCalled).toBeTruthy();
});
- test('#addProtocol - returning HTMLImageElement for getImage', done => {
+ test('#addProtocol - returning HTMLImageElement for getImage', async () => {
let protocolCallbackCalled = false;
maplibre.addProtocol('custom', (reqParam, callback) => {
protocolCallbackCalled = true;
callback(null, new Image());
return {cancel: () => {}};
});
- ImageRequest.getImage({url: 'custom://test/url/getImage'}, async (error, img) => {
- expect(error).toBeFalsy();
- expect(img).toBeInstanceOf(HTMLImageElement);
- expect(protocolCallbackCalled).toBeTruthy();
- done();
- });
+ const img = await ImageRequest.getImage({url: 'custom://test/url/getImage'}, new AbortController());
+ expect(img.data).toBeInstanceOf(HTMLImageElement);
+ expect(protocolCallbackCalled).toBeTruthy();
});
test('#addProtocol - error', () => {
@@ -101,20 +92,27 @@ describe('maplibre', () => {
return {cancel: () => { }};
});
- getJSON({url: 'custom://test/url/json'}, (error) => {
+ getJSON({url: 'custom://test/url/json'}, new AbortController()).catch((error) => {
expect(error).toBeTruthy();
});
});
- test('#addProtocol - Cancel request', () => {
+ test('#addProtocol - Cancel request', async () => {
let cancelCalled = false;
maplibre.addProtocol('custom', () => {
return {cancel: () => {
cancelCalled = true;
}};
});
- const request = getJSON({url: 'custom://test/url/json'}, () => { });
- request.cancel();
+ const abortController = new AbortController();
+ const promise = getJSON({url: 'custom://test/url/json'}, abortController);
+ abortController.abort();
+ try {
+ await promise;
+ } catch (err) {
+ expect(isAbortError(err)).toBeTruthy();
+ }
+
expect(cancelCalled).toBeTruthy();
});
diff --git a/src/render/glyph_atlas.ts b/src/render/glyph_atlas.ts
index 0f80b336e8..6ce83f7e4c 100644
--- a/src/render/glyph_atlas.ts
+++ b/src/render/glyph_atlas.ts
@@ -2,7 +2,8 @@ import {AlphaImage} from '../util/image';
import {register} from '../util/web_worker_transfer';
import potpack from 'potpack';
-import type {GlyphMetrics, StyleGlyph} from '../style/style_glyph';
+import type {GlyphMetrics} from '../style/style_glyph';
+import type {GetGlyphsResponse} from '../util/actor_messages';
const padding = 1;
@@ -37,11 +38,7 @@ export class GlyphAtlas {
image: AlphaImage;
positions: GlyphPositions;
- constructor(stacks: {
- [_: string]: {
- [_: number]: StyleGlyph;
- };
- }) {
+ constructor(stacks: GetGlyphsResponse) {
const positions = {};
const bins = [];
diff --git a/src/render/glyph_manager.test.ts b/src/render/glyph_manager.test.ts
index 65c0ff72ff..60eb97f61e 100644
--- a/src/render/glyph_manager.test.ts
+++ b/src/render/glyph_manager.test.ts
@@ -3,144 +3,118 @@ import {GlyphManager} from './glyph_manager';
import fs from 'fs';
import {RequestManager} from '../util/request_manager';
-const glyphs = {};
-for (const glyph of parseGlyphPbf(fs.readFileSync('./test/unit/assets/0-255.pbf'))) {
- glyphs[glyph.id] = glyph;
-}
-
-const identityTransform = ((url) => ({url})) as any as RequestManager;
-
-const createLoadGlyphRangeStub = () => {
- return jest.spyOn(GlyphManager, 'loadGlyphRange').mockImplementation((stack, range, urlTemplate, transform, callback) => {
- expect(stack).toBe('Arial Unicode MS');
- expect(range).toBe(0);
- expect(urlTemplate).toBe('https://localhost/fonts/v1/{fontstack}/{range}.pbf');
- expect(transform).toBe(identityTransform);
- setTimeout(() => callback(null, glyphs), 0);
- });
-};
-
-const createGlyphManager = (font?) => {
- const manager = new GlyphManager(identityTransform, font);
- manager.setURL('https://localhost/fonts/v1/{fontstack}/{range}.pbf');
- return manager;
-};
+describe('GlyphManager', () => {
+ const GLYPHS = {};
+ for (const glyph of parseGlyphPbf(fs.readFileSync('./test/unit/assets/0-255.pbf'))) {
+ GLYPHS[glyph.id] = glyph;
+ }
+
+ const identityTransform = ((url) => ({url})) as any as RequestManager;
+
+ const createLoadGlyphRangeStub = () => {
+ return jest.spyOn(GlyphManager, 'loadGlyphRange').mockImplementation((stack, range, urlTemplate, transform) => {
+ expect(stack).toBe('Arial Unicode MS');
+ expect(range).toBe(0);
+ expect(urlTemplate).toBe('https://localhost/fonts/v1/{fontstack}/{range}.pbf');
+ expect(transform).toBe(identityTransform);
+ return Promise.resolve(GLYPHS);
+ });
+ };
-afterEach(() => {
- jest.clearAllMocks();
-});
+ const createGlyphManager = (font?) => {
+ const manager = new GlyphManager(identityTransform, font);
+ manager.setURL('https://localhost/fonts/v1/{fontstack}/{range}.pbf');
+ return manager;
+ };
-describe('GlyphManager', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
- test('GlyphManager requests 0-255 PBF', done => {
+ test('GlyphManager requests 0-255 PBF', async () => {
createLoadGlyphRangeStub();
const manager = createGlyphManager();
- manager.getGlyphs({'Arial Unicode MS': [55]}, (err, glyphs) => {
- expect(err).toBeFalsy();
- expect(glyphs['Arial Unicode MS']['55'].metrics.advance).toBe(12);
- done();
- });
+ const returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [55]});
+ expect(returnedGlyphs['Arial Unicode MS']['55'].metrics.advance).toBe(12);
});
- test('GlyphManager doesn\'t request twice 0-255 PBF if a glyph is missing', done => {
+ test('GlyphManager doesn\'t request twice 0-255 PBF if a glyph is missing', async () => {
const stub = createLoadGlyphRangeStub();
const manager = createGlyphManager();
- manager.getGlyphs({'Arial Unicode MS': [0.5]}, (err) => {
- expect(err).toBeFalsy();
- expect(manager.entries['Arial Unicode MS'].ranges[0]).toBe(true);
- expect(stub).toHaveBeenCalledTimes(1);
+ await manager.getGlyphs({'Arial Unicode MS': [0.5]});
+ expect(manager.entries['Arial Unicode MS'].ranges[0]).toBe(true);
+ expect(stub).toHaveBeenCalledTimes(1);
- // We remove all requests as in getGlyphs code.
- delete manager.entries['Arial Unicode MS'].requests[0];
+ // We remove all requests as in getGlyphs code.
+ delete manager.entries['Arial Unicode MS'].requests[0];
- manager.getGlyphs({'Arial Unicode MS': [0.5]}, (err) => {
- expect(err).toBeFalsy();
- expect(manager.entries['Arial Unicode MS'].ranges[0]).toBe(true);
- expect(stub).toHaveBeenCalledTimes(1);
- done();
- });
- });
+ await manager.getGlyphs({'Arial Unicode MS': [0.5]});
+ expect(manager.entries['Arial Unicode MS'].ranges[0]).toBe(true);
+ expect(stub).toHaveBeenCalledTimes(1);
});
- test('GlyphManager requests remote CJK PBF', done => {
- jest.spyOn(GlyphManager, 'loadGlyphRange').mockImplementation((stack, range, urlTemplate, transform, callback) => {
- setTimeout(() => callback(null, glyphs), 0);
+ test('GlyphManager requests remote CJK PBF', async () => {
+ jest.spyOn(GlyphManager, 'loadGlyphRange').mockImplementation((_stack, _range, _urlTemplate, _transform) => {
+ return Promise.resolve(GLYPHS);
});
const manager = createGlyphManager();
- manager.getGlyphs({'Arial Unicode MS': [0x5e73]}, (err, glyphs) => {
- expect(err).toBeFalsy();
- expect(glyphs['Arial Unicode MS'][0x5e73]).toBeNull(); // The fixture returns a PBF without the glyph we requested
- done();
- });
+ const returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [0x5e73]});
+ expect(returnedGlyphs['Arial Unicode MS'][0x5e73]).toBeNull(); // The fixture returns a PBF without the glyph we requested
});
- test('GlyphManager does not cache CJK chars that should be rendered locally', done => {
- jest.spyOn(GlyphManager, 'loadGlyphRange').mockImplementation((stack, range, urlTemplate, transform, callback) => {
+ test('GlyphManager does not cache CJK chars that should be rendered locally', async () => {
+ jest.spyOn(GlyphManager, 'loadGlyphRange').mockImplementation((_stack, range, _urlTemplate, _transform) => {
const overlappingGlyphs = {};
const start = range * 256;
const end = start + 256;
for (let i = start, j = 0; i < end; i++, j++) {
- overlappingGlyphs[i] = glyphs[j];
+ overlappingGlyphs[i] = GLYPHS[j];
}
- setTimeout(() => callback(null, overlappingGlyphs), 0);
+ return Promise.resolve(overlappingGlyphs);
});
const manager = createGlyphManager('sans-serif');
//Request char that overlaps Katakana range
- manager.getGlyphs({'Arial Unicode MS': [0x3005]}, (err, glyphs) => {
- expect(err).toBeFalsy();
- expect(glyphs['Arial Unicode MS'][0x3005]).not.toBeNull();
- //Request char from Katakana range (te テ)
- manager.getGlyphs({'Arial Unicode MS': [0x30C6]}, (err, glyphs) => {
- expect(err).toBeFalsy();
- const glyph = glyphs['Arial Unicode MS'][0x30c6];
- //Ensure that te is locally generated.
- expect(glyph.bitmap.height).toBe(12);
- expect(glyph.bitmap.width).toBe(12);
- done();
- });
- });
+ let returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [0x3005]});
+ expect(returnedGlyphs['Arial Unicode MS'][0x3005]).not.toBeNull();
+ //Request char from Katakana range (te テ)
+ returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [0x30C6]});
+ const glyph = returnedGlyphs['Arial Unicode MS'][0x30c6];
+ //Ensure that te is locally generated.
+ expect(glyph.bitmap.height).toBe(12);
+ expect(glyph.bitmap.width).toBe(12);
});
- test('GlyphManager generates CJK PBF locally', done => {
+ test('GlyphManager generates CJK PBF locally', async () => {
const manager = createGlyphManager('sans-serif');
// character 平
- manager.getGlyphs({'Arial Unicode MS': [0x5e73]}, (err, glyphs) => {
- expect(err).toBeFalsy();
- expect(glyphs['Arial Unicode MS'][0x5e73].metrics.advance).toBe(0.5);
- done();
- });
+ const returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [0x5e73]});
+ expect(returnedGlyphs['Arial Unicode MS'][0x5e73].metrics.advance).toBe(0.5);
});
- test('GlyphManager generates Katakana PBF locally', done => {
+ test('GlyphManager generates Katakana PBF locally', async () => {
const manager = createGlyphManager('sans-serif');
// Katakana letter te テ
- manager.getGlyphs({'Arial Unicode MS': [0x30c6]}, (err, glyphs) => {
- expect(err).toBeFalsy();
- expect(glyphs['Arial Unicode MS'][0x30c6].metrics.advance).toBe(0.5);
- done();
- });
+ const returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [0x30c6]});
+ expect(returnedGlyphs['Arial Unicode MS'][0x30c6].metrics.advance).toBe(0.5);
});
- test('GlyphManager generates Hiragana PBF locally', done => {
+ test('GlyphManager generates Hiragana PBF locally', async () => {
const manager = createGlyphManager('sans-serif');
//Hiragana letter te て
- manager.getGlyphs({'Arial Unicode MS': [0x3066]}, (err, glyphs) => {
- expect(err).toBeFalsy();
- expect(glyphs['Arial Unicode MS'][0x3066].metrics.advance).toBe(0.5);
- done();
- });
+ const returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [0x3066]});
+ expect(returnedGlyphs['Arial Unicode MS'][0x3066].metrics.advance).toBe(0.5);
});
- test('GlyphManager caches locally generated glyphs', done => {
+ test('GlyphManager caches locally generated glyphs', async () => {
const manager = createGlyphManager('sans-serif');
const drawSpy = GlyphManager.TinySDF.prototype.draw = jest.fn().mockImplementation(() => {
@@ -148,13 +122,9 @@ describe('GlyphManager', () => {
});
// Katakana letter te
- manager.getGlyphs({'Arial Unicode MS': [0x30c6]}, (err, glyphs) => {
- expect(err).toBeFalsy();
- expect(glyphs['Arial Unicode MS'][0x30c6].metrics.advance).toBe(24);
- manager.getGlyphs({'Arial Unicode MS': [0x30c6]}, () => {
- expect(drawSpy).toHaveBeenCalledTimes(1);
- done();
- });
- });
+ const returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [0x30c6]});
+ expect(returnedGlyphs['Arial Unicode MS'][0x30c6].metrics.advance).toBe(24);
+ await manager.getGlyphs({'Arial Unicode MS': [0x30c6]});
+ expect(drawSpy).toHaveBeenCalledTimes(1);
});
});
diff --git a/src/render/glyph_manager.ts b/src/render/glyph_manager.ts
index acffee4d2d..e4ffda9406 100644
--- a/src/render/glyph_manager.ts
+++ b/src/render/glyph_manager.ts
@@ -2,12 +2,11 @@ import {loadGlyphRange} from '../style/load_glyph_range';
import TinySDF from '@mapbox/tiny-sdf';
import {unicodeBlockLookup} from '../util/is_char_in_unicode_block';
-import {asyncAll} from '../util/util';
import {AlphaImage} from '../util/image';
import type {StyleGlyph} from '../style/style_glyph';
import type {RequestManager} from '../util/request_manager';
-import type {Callback} from '../types/callback';
+import type {GetGlyphsResponse} from '../util/actor_messages';
type Entry = {
// null means we've requested the range, but the glyph wasn't included in the result.
@@ -15,9 +14,7 @@ type Entry = {
[id: number]: StyleGlyph | null;
};
requests: {
- [range: number]: Array>;
+ [range: number]: Promise<{[_: number]: StyleGlyph | null}>;
};
ranges: {
[range: number]: boolean | null;
@@ -28,9 +25,7 @@ type Entry = {
export class GlyphManager {
requestManager: RequestManager;
localIdeographFontFamily: string;
- entries: {
- [_: string]: Entry;
- };
+ entries: {[stack: string]: Entry};
url: string;
// exposed as statics to enable stubbing in unit tests
@@ -47,117 +42,81 @@ export class GlyphManager {
this.url = url;
}
- getGlyphs(glyphs: {
- [stack: string]: Array;
- }, callback: Callback<{
- [stack: string]: {
- [id: number]: StyleGlyph;
- };
- }>) {
- const all = [];
+ async getGlyphs(glyphs: {[stack: string]: Array}): Promise {
+ const glyphsPromises: Promise<{stack: string; id: number; glyph: StyleGlyph}>[] = [];
for (const stack in glyphs) {
for (const id of glyphs[stack]) {
- all.push({stack, id});
+ glyphsPromises.push(this._getAndCacheGlyphsPromise(stack, id));
}
}
- asyncAll(all, ({stack, id}, callback: Callback<{
- stack: string;
- id: number;
- glyph: StyleGlyph;
- }>) => {
- let entry = this.entries[stack];
- if (!entry) {
- entry = this.entries[stack] = {
- glyphs: {},
- requests: {},
- ranges: {}
- };
- }
+ const updatedGlyphs = await Promise.all(glyphsPromises);
- let glyph = entry.glyphs[id];
- if (glyph !== undefined) {
- callback(null, {stack, id, glyph});
- return;
- }
+ const result: GetGlyphsResponse = {};
- glyph = this._tinySDF(entry, stack, id);
- if (glyph) {
- entry.glyphs[id] = glyph;
- callback(null, {stack, id, glyph});
- return;
+ for (const {stack, id, glyph} of updatedGlyphs) {
+ if (!result[stack]) {
+ result[stack] = {};
}
+ // Clone the glyph so that our own copy of its ArrayBuffer doesn't get transferred.
+ result[stack][id] = glyph && {
+ id: glyph.id,
+ bitmap: glyph.bitmap.clone(),
+ metrics: glyph.metrics
+ };
+ }
- const range = Math.floor(id / 256);
- if (range * 256 > 65535) {
- callback(new Error('glyphs > 65535 not supported'));
- return;
- }
+ return result;
+ }
- if (entry.ranges[range]) {
- callback(null, {stack, id, glyph});
- return;
- }
+ async _getAndCacheGlyphsPromise(stack: string, id: number): Promise<{stack: string; id: number; glyph: StyleGlyph}> {
+ let entry = this.entries[stack];
+ if (!entry) {
+ entry = this.entries[stack] = {
+ glyphs: {},
+ requests: {},
+ ranges: {}
+ };
+ }
- if (!this.url) {
- callback(new Error('glyphsUrl is not set'));
- return;
- }
+ let glyph = entry.glyphs[id];
+ if (glyph !== undefined) {
+ return {stack, id, glyph};
+ }
- let requests = entry.requests[range];
- if (!requests) {
- requests = entry.requests[range] = [];
- GlyphManager.loadGlyphRange(stack, range, this.url, this.requestManager,
- (err, response?: {
- [_: number]: StyleGlyph | null;
- } | null) => {
- if (response) {
- for (const id in response) {
- if (!this._doesCharSupportLocalGlyph(+id)) {
- entry.glyphs[+id] = response[+id];
- }
- }
- entry.ranges[range] = true;
- }
- for (const cb of requests) {
- cb(err, response);
- }
- delete entry.requests[range];
- });
- }
+ glyph = this._tinySDF(entry, stack, id);
+ if (glyph) {
+ entry.glyphs[id] = glyph;
+ return {stack, id, glyph};
+ }
- requests.push((err, result?: {
- [_: number]: StyleGlyph | null;
- } | null) => {
- if (err) {
- callback(err);
- } else if (result) {
- callback(null, {stack, id, glyph: result[id] || null});
- }
- });
- }, (err, glyphs?: Array<{
- stack: string;
- id: number;
- glyph: StyleGlyph;
- }> | null) => {
- if (err) {
- callback(err);
- } else if (glyphs) {
- const result = {};
-
- for (const {stack, id, glyph} of glyphs) {
- // Clone the glyph so that our own copy of its ArrayBuffer doesn't get transferred.
- (result[stack] || (result[stack] = {}))[id] = glyph && {
- id: glyph.id,
- bitmap: glyph.bitmap.clone(),
- metrics: glyph.metrics
- };
- }
-
- callback(null, result);
+ const range = Math.floor(id / 256);
+ if (range * 256 > 65535) {
+ throw new Error('glyphs > 65535 not supported');
+ }
+
+ if (entry.ranges[range]) {
+ return {stack, id, glyph};
+ }
+
+ if (!this.url) {
+ throw new Error('glyphsUrl is not set');
+ }
+
+ if (!entry.requests[range]) {
+ const promise = GlyphManager.loadGlyphRange(stack, range, this.url, this.requestManager);
+ entry.requests[range] = promise;
+ }
+
+ const response = await entry.requests[range];
+ for (const id in response) {
+ if (!this._doesCharSupportLocalGlyph(+id)) {
+ entry.glyphs[+id] = response[+id];
}
- });
+ }
+ entry.ranges[range] = true;
+ return {stack, id, glyph: response[id] || null};
}
_doesCharSupportLocalGlyph(id: number): boolean {
diff --git a/src/render/image_atlas.ts b/src/render/image_atlas.ts
index a86d2e69ff..492dab944d 100644
--- a/src/render/image_atlas.ts
+++ b/src/render/image_atlas.ts
@@ -7,6 +7,7 @@ import type {StyleImage} from '../style/style_image';
import type {ImageManager} from './image_manager';
import type {Texture} from './texture';
import type {Rect} from './glyph_atlas';
+import type {GetImagesResponse} from '../util/actor_messages';
const IMAGE_PADDING: number = 1;
export {IMAGE_PADDING};
@@ -61,7 +62,6 @@ export class ImagePosition {
}
/**
- * @internal
* A class holding all the images
*/
export class ImageAtlas {
@@ -71,7 +71,7 @@ export class ImageAtlas {
haveRenderCallbacks: Array;
uploaded: boolean;
- constructor(icons: {[_: string]: StyleImage}, patterns: {[_: string]: StyleImage}) {
+ constructor(icons: GetImagesResponse, patterns: GetImagesResponse) {
const iconPositions = {}, patternPositions = {};
this.haveRenderCallbacks = [];
diff --git a/src/render/image_manager.ts b/src/render/image_manager.ts
index 33a0d1aaf1..0e50eef309 100644
--- a/src/render/image_manager.ts
+++ b/src/render/image_manager.ts
@@ -11,38 +11,44 @@ import {warnOnce} from '../util/util';
import type {StyleImage} from '../style/style_image';
import type {Context} from '../gl/context';
import type {PotpackBox} from 'potpack';
-import type {Callback} from '../types/callback';
+import type {GetImagesResponse} from '../util/actor_messages';
type Pattern = {
bin: PotpackBox;
position: ImagePosition;
};
-// When copied into the atlas texture, image data is padded by one pixel on each side. Icon
-// images are padded with fully transparent pixels, while pattern images are padded with a
-// copy of the image data wrapped from the opposite side. In both cases, this ensures the
-// correct behavior of GL_LINEAR texture sampling mode.
+/**
+ * When copied into the atlas texture, image data is padded by one pixel on each side. Icon
+ * images are padded with fully transparent pixels, while pattern images are padded with a
+ * copy of the image data wrapped from the opposite side. In both cases, this ensures the
+ * correct behavior of GL_LINEAR texture sampling mode.
+ */
const padding = 1;
-/*
- ImageManager does three things:
-
- 1. Tracks requests for icon images from tile workers and sends responses when the requests are fulfilled.
- 2. Builds a texture atlas for pattern images.
- 3. Rerenders renderable images once per frame
-
- These are disparate responsibilities and should eventually be handled by different classes. When we implement
- data-driven support for `*-pattern`, we'll likely use per-bucket pattern atlases, and that would be a good time
- to refactor this.
+/**
+ * ImageManager does three things:
+ *
+ * 1. Tracks requests for icon images from tile workers and sends responses when the requests are fulfilled.
+ * 2. Builds a texture atlas for pattern images.
+ * 3. Rerenders renderable images once per frame
+ *
+ * These are disparate responsibilities and should eventually be handled by different classes. When we implement
+ * data-driven support for `*-pattern`, we'll likely use per-bucket pattern atlases, and that would be a good time
+ * to refactor this.
*/
export class ImageManager extends Evented {
images: {[_: string]: StyleImage};
updatedImages: {[_: string]: boolean};
callbackDispatchedThisFrame: {[_: string]: boolean};
loaded: boolean;
+ /**
+ * This is used to track requests for images that are not yet available. When the image is loaded,
+ * the requestors will be notified.
+ */
requestors: Array<{
ids: Array;
- callback: Callback<{[_: string]: StyleImage}>;
+ promiseResolve: (value: GetImagesResponse) => void;
}>;
patterns: {[_: string]: Pattern};
@@ -75,8 +81,8 @@ export class ImageManager extends Evented {
this.loaded = loaded;
if (loaded) {
- for (const {ids, callback} of this.requestors) {
- this._notify(ids, callback);
+ for (const {ids, promiseResolve} of this.requestors) {
+ promiseResolve(this._getImagesForIds(ids));
}
this.requestors = [];
}
@@ -176,28 +182,30 @@ export class ImageManager extends Evented {
return Object.keys(this.images);
}
- getImages(ids: Array, callback: Callback<{[_: string]: StyleImage}>) {
- // If the sprite has been loaded, or if all the icon dependencies are already present
- // (i.e. if they've been added via runtime styling), then notify the requestor immediately.
- // Otherwise, delay notification until the sprite is loaded. At that point, if any of the
- // dependencies are still unavailable, we'll just assume they are permanently missing.
- let hasAllDependencies = true;
- if (!this.isLoaded()) {
- for (const id of ids) {
- if (!this.images[id]) {
- hasAllDependencies = false;
+ getImages(ids: Array): Promise {
+ return new Promise((resolve, _reject) => {
+ // If the sprite has been loaded, or if all the icon dependencies are already present
+ // (i.e. if they've been added via runtime styling), then notify the requestor immediately.
+ // Otherwise, delay notification until the sprite is loaded. At that point, if any of the
+ // dependencies are still unavailable, we'll just assume they are permanently missing.
+ let hasAllDependencies = true;
+ if (!this.isLoaded()) {
+ for (const id of ids) {
+ if (!this.images[id]) {
+ hasAllDependencies = false;
+ }
}
}
- }
- if (this.isLoaded() || hasAllDependencies) {
- this._notify(ids, callback);
- } else {
- this.requestors.push({ids, callback});
- }
+ if (this.isLoaded() || hasAllDependencies) {
+ resolve(this._getImagesForIds(ids));
+ } else {
+ this.requestors.push({ids, promiseResolve: resolve});
+ }
+ });
}
- _notify(ids: Array, callback: Callback<{[_: string]: StyleImage}>) {
- const response = {};
+ _getImagesForIds(ids: Array): GetImagesResponse {
+ const response: GetImagesResponse = {};
for (const id of ids) {
let image = this.getImage(id);
@@ -224,8 +232,7 @@ export class ImageManager extends Evented {
warnOnce(`Image "${id}" could not be loaded. Please make sure you have added the image with map.addImage() or a "sprite" property in your style. You can provide missing images by listening for the "styleimagemissing" map event.`);
}
}
-
- callback(null, response);
+ return response;
}
// Pattern stuff
diff --git a/src/source/canvas_source.ts b/src/source/canvas_source.ts
index 72994df239..911c87416c 100644
--- a/src/source/canvas_source.ts
+++ b/src/source/canvas_source.ts
@@ -107,7 +107,7 @@ export class CanvasSource extends ImageSource {
this.animate = options.animate !== undefined ? options.animate : true;
}
- load = () => {
+ async load() {
this._loaded = true;
if (!this.canvas) {
this.canvas = (this.options.canvas instanceof HTMLCanvasElement) ?
@@ -137,7 +137,7 @@ export class CanvasSource extends ImageSource {
};
this._finishLoading();
- };
+ }
/**
* Returns the HTML `canvas` element.
@@ -160,7 +160,7 @@ export class CanvasSource extends ImageSource {
this.pause();
}
- prepare = () => {
+ prepare() {
let resize = false;
if (this.canvas.width !== this.width) {
this.width = this.canvas.width;
@@ -205,14 +205,14 @@ export class CanvasSource extends ImageSource {
if (newTilesLoaded) {
this.fire(new Event('data', {dataType: 'source', sourceDataType: 'idle', sourceId: this.id}));
}
- };
+ }
- serialize = (): CanvasSourceSpecification => {
+ serialize(): CanvasSourceSpecification {
return {
type: 'canvas',
coordinates: this.coordinates
};
- };
+ }
hasTransition() {
return this._playing;
diff --git a/src/source/geojson_source.test.ts b/src/source/geojson_source.test.ts
index a5eeebbf9b..19e2718951 100644
--- a/src/source/geojson_source.test.ts
+++ b/src/source/geojson_source.test.ts
@@ -16,7 +16,7 @@ const wrapDispatcher = (dispatcher) => {
};
const mockDispatcher = wrapDispatcher({
- send() {}
+ sendAsync() { return Promise.resolve({}); }
});
const hawkHill = {
@@ -58,10 +58,10 @@ describe('GeoJSONSource#setData', () => {
opts = opts || {};
opts = extend(opts, {data: {}});
return new GeoJSONSource('id', opts, wrapDispatcher({
- send (type, data, callback) {
- if (callback) {
- return setTimeout(callback, 0);
- }
+ sendAsync(_message) {
+ return new Promise((resolve) => {
+ setTimeout(() => resolve({}), 0);
+ });
}
}), undefined);
}
@@ -91,8 +91,10 @@ describe('GeoJSONSource#setData', () => {
test('fires "dataabort" event', done => {
const source = new GeoJSONSource('id', {} as any, wrapDispatcher({
- send(type, data, callback) {
- setTimeout(() => callback(null, {abandoned: true}));
+ sendAsync(_message) {
+ return new Promise((resolve) => {
+ setTimeout(() => resolve({abandoned: true}), 0);
+ });
}
}), undefined);
source.on('dataabort', () => {
@@ -108,13 +110,15 @@ describe('GeoJSONSource#setData', () => {
transformRequest: (url) => { return {url}; }
} as any as RequestManager
} as any;
- source.actor.send = function(type, params: any, cb) {
- if (type === 'geojson.loadData') {
- expect(params.request.collectResourceTiming).toBeTruthy();
- setTimeout(cb, 0);
- done();
- }
- } as any;
+ source.actor.sendAsync = (message) => {
+ return new Promise((resolve) => {
+ if (message.type === 'loadData') {
+ expect((message.data as any).request.collectResourceTiming).toBeTruthy();
+ setTimeout(() => resolve({} as any), 0);
+ done();
+ }
+ });
+ };
source.setData('http://localhost/nonexistent');
});
@@ -148,8 +152,10 @@ describe('GeoJSONSource#setData', () => {
test('marks source as loaded before firing "dataabort" event', done => {
const source = new GeoJSONSource('id', {} as any, wrapDispatcher({
- send(type, data, callback) {
- setTimeout(() => callback(null, {abandoned: true}));
+ sendAsync(_message) {
+ return new Promise((resolve) => {
+ setTimeout(() => resolve({abandoned: true}), 0);
+ });
}
}), undefined);
source.on('dataabort', () => {
@@ -163,11 +169,11 @@ describe('GeoJSONSource#setData', () => {
describe('GeoJSONSource#onRemove', () => {
test('broadcasts "removeSource" event', done => {
const source = new GeoJSONSource('id', {data: {}} as GeoJSONSourceOptions, wrapDispatcher({
- send(type, data, callback) {
- expect(callback).toBeFalsy();
- expect(type).toBe('removeSource');
- expect(data).toEqual({type: 'geojson', source: 'id'});
+ sendAsync(message) {
+ expect(message.type).toBe('removeSource');
+ expect(message.data).toEqual({type: 'geojson', source: 'id'});
done();
+ return Promise.resolve({});
},
broadcast() {
// Ignore
@@ -187,9 +193,10 @@ describe('GeoJSONSource#update', () => {
test('sends initial loadData request to dispatcher', done => {
const mockDispatcher = wrapDispatcher({
- send(message) {
- expect(message).toBe('geojson.loadData');
+ sendAsync(message) {
+ expect(message.type).toBe('loadData');
done();
+ return Promise.resolve({});
}
});
@@ -198,9 +205,9 @@ describe('GeoJSONSource#update', () => {
test('forwards geojson-vt options with worker request', done => {
const mockDispatcher = wrapDispatcher({
- send(message, params) {
- expect(message).toBe('geojson.loadData');
- expect(params.geojsonVtOptions).toEqual({
+ sendAsync(message) {
+ expect(message.type).toBe('loadData');
+ expect(message.data.geojsonVtOptions).toEqual({
extent: 8192,
maxZoom: 10,
tolerance: 4,
@@ -209,6 +216,7 @@ describe('GeoJSONSource#update', () => {
generateId: true
});
done();
+ return Promise.resolve({});
}
});
@@ -223,9 +231,9 @@ describe('GeoJSONSource#update', () => {
test('forwards Supercluster options with worker request', done => {
const mockDispatcher = wrapDispatcher({
- send(message, params) {
- expect(message).toBe('geojson.loadData');
- expect(params.superclusterOptions).toEqual({
+ sendAsync(message) {
+ expect(message.type).toBe('loadData');
+ expect(message.data.superclusterOptions).toEqual({
maxZoom: 12,
minPoints: 3,
extent: 8192,
@@ -234,6 +242,7 @@ describe('GeoJSONSource#update', () => {
generateId: true
});
done();
+ return Promise.resolve({});
}
});
@@ -250,12 +259,13 @@ describe('GeoJSONSource#update', () => {
test('modifying cluster properties after adding a source', done => {
// test setCluster function on GeoJSONSource
const mockDispatcher = wrapDispatcher({
- send(message, params) {
- expect(message).toBe('geojson.loadData');
- expect(params.cluster).toBe(true);
- expect(params.superclusterOptions.radius).toBe(80);
- expect(params.superclusterOptions.maxZoom).toBe(16);
+ sendAsync(message) {
+ expect(message.type).toBe('loadData');
+ expect(message.data.cluster).toBe(true);
+ expect(message.data.superclusterOptions.radius).toBe(80);
+ expect(message.data.superclusterOptions.maxZoom).toBe(16);
done();
+ return Promise.resolve({});
}
});
new GeoJSONSource('id', {
@@ -270,9 +280,9 @@ describe('GeoJSONSource#update', () => {
test('forwards Supercluster options with worker request, ignore max zoom of source', done => {
const mockDispatcher = wrapDispatcher({
- send(message, params) {
- expect(message).toBe('geojson.loadData');
- expect(params.superclusterOptions).toEqual({
+ sendAsync(message) {
+ expect(message.type).toBe('loadData');
+ expect(message.data.superclusterOptions).toEqual({
maxZoom: 12,
minPoints: 3,
extent: 8192,
@@ -281,6 +291,7 @@ describe('GeoJSONSource#update', () => {
generateId: true
});
done();
+ return Promise.resolve({});
}
});
@@ -309,10 +320,10 @@ describe('GeoJSONSource#update', () => {
});
test('fires event when metadata loads', done => {
const mockDispatcher = wrapDispatcher({
- send(message, args, callback) {
- if (callback) {
- setTimeout(callback, 0);
- }
+ sendAsync(_message) {
+ return new Promise((resolve) => {
+ setTimeout(() => resolve({}), 0);
+ });
}
});
@@ -328,8 +339,10 @@ describe('GeoJSONSource#update', () => {
test('fires metadata data event even when initial request is aborted', done => {
let requestCount = 0;
const mockDispatcher = wrapDispatcher({
- send(message, args, callback) {
- setTimeout(() => callback(null, {abandoned: requestCount++ === 0}));
+ sendAsync(_message) {
+ return new Promise((resolve) => {
+ setTimeout(() => resolve({abandoned: requestCount++ === 0}));
+ });
}
});
@@ -345,10 +358,8 @@ describe('GeoJSONSource#update', () => {
test('fires "error"', done => {
const mockDispatcher = wrapDispatcher({
- send(message, args, callback) {
- if (callback) {
- setTimeout(callback.bind(null, 'error'), 0);
- }
+ sendAsync(_message) {
+ return Promise.reject('error'); // eslint-disable-line prefer-promise-reject-errors
}
});
@@ -365,13 +376,11 @@ describe('GeoJSONSource#update', () => {
test('sends loadData request to dispatcher after data update', done => {
let expectedLoadDataCalls = 2;
const mockDispatcher = wrapDispatcher({
- send(message, args, callback) {
- if (message === 'geojson.loadData' && --expectedLoadDataCalls <= 0) {
+ sendAsync(message) {
+ if (message.type === 'loadData' && --expectedLoadDataCalls <= 0) {
done();
}
- if (callback) {
- setTimeout(callback, 0);
- }
+ return new Promise((resolve) => setTimeout(() => resolve({}), 0));
}
});
@@ -384,7 +393,7 @@ describe('GeoJSONSource#update', () => {
source.on('data', (e) => {
if (e.sourceDataType === 'metadata') {
source.setData({} as GeoJSON.GeoJSON);
- source.loadTile(new Tile(new OverscaledTileID(0, 0, 0, 0, 0), 512), () => {});
+ source.loadTile(new Tile(new OverscaledTileID(0, 0, 0, 0, 0), 512));
}
});
diff --git a/src/source/geojson_source.ts b/src/source/geojson_source.ts
index ada698d39d..4799e1891d 100644
--- a/src/source/geojson_source.ts
+++ b/src/source/geojson_source.ts
@@ -13,10 +13,10 @@ import type {Actor} from '../util/actor';
import type {Callback} from '../types/callback';
import type {GeoJSONSourceSpecification, PromoteIdSpecification} from '@maplibre/maplibre-gl-style-spec';
import type {GeoJSONSourceDiff} from './geojson_source_diff';
-import type {Options, ClusterProperties} from 'supercluster';
+import type {GeoJSONWorkerOptions, LoadGeoJSONParameters} from './geojson_worker_source';
export type GeoJSONSourceOptions = GeoJSONSourceSpecification & {
- workerOptions?: WorkerOptions;
+ workerOptions?: GeoJSONWorkerOptions;
collectResourceTiming?: boolean;
}
@@ -28,23 +28,6 @@ export type GeoJsonSourceOptions = {
clusterMinPoints?: number;
generateId?: boolean;
}
-export type WorkerOptions = {
- source?: string;
- cluster?: boolean;
- geojsonVtOptions?: {
- buffer?: number;
- tolerance?: number;
- extent?: number;
- maxZoom?: number;
- linemetrics?: boolean;
- generateId?: boolean;
- };
- superclusterOptions?: Options;
- clusterProperties?: ClusterProperties;
- fliter?: any;
- promoteId?: any;
- collectResourceTiming?: boolean;
-}
/**
* The cluster options to set
@@ -131,7 +114,7 @@ export class GeoJSONSource extends Evented implements Source {
reparseOverscaled: boolean;
_data: GeoJSON.GeoJSON | string | undefined;
_options: GeoJsonSourceOptions;
- workerOptions: WorkerOptions;
+ workerOptions: GeoJSONWorkerOptions;
map: Map;
actor: Actor;
_pendingLoads: number;
@@ -204,9 +187,9 @@ export class GeoJSONSource extends Evented implements Source {
}
}
- load = () => {
- this._updateWorkerData();
- };
+ async load() {
+ await this._updateWorkerData();
+ }
onAdd(map: Map) {
this.map = map;
@@ -275,7 +258,9 @@ export class GeoJSONSource extends Evented implements Source {
* @returns `this`
*/
getClusterExpansionZoom(clusterId: number, callback: Callback): this {
- this.actor.send('geojson.getClusterExpansionZoom', {clusterId, source: this.id}, callback);
+ this.actor.sendAsync({type: 'getClusterExpansionZoom', data: {type: this.type, clusterId, source: this.id}})
+ .then((v) => callback(null, v))
+ .catch((e) => callback(e));
return this;
}
@@ -287,7 +272,9 @@ export class GeoJSONSource extends Evented implements Source {
* @returns `this`
*/
getClusterChildren(clusterId: number, callback: Callback>): this {
- this.actor.send('geojson.getClusterChildren', {clusterId, source: this.id}, callback);
+ this.actor.sendAsync({type: 'getClusterChildren', data: {type: this.type, clusterId, source: this.id}})
+ .then((v) => callback(null, v))
+ .catch((e) => callback(e));
return this;
}
@@ -319,12 +306,14 @@ export class GeoJSONSource extends Evented implements Source {
* ```
*/
getClusterLeaves(clusterId: number, limit: number, offset: number, callback: Callback>): this {
- this.actor.send('geojson.getClusterLeaves', {
+ this.actor.sendAsync({type: 'getClusterLeaves', data: {
+ type: this.type,
source: this.id,
clusterId,
limit,
offset
- }, callback);
+ }}).then((l) => callback(null, l))
+ .catch((e) => callback(e));
return this;
}
@@ -334,8 +323,8 @@ export class GeoJSONSource extends Evented implements Source {
* using geojson-vt or supercluster as appropriate.
* @param diff - the diff object
*/
- _updateWorkerData(diff?: GeoJSONSourceDiff) {
- const options = extend({}, this.workerOptions);
+ async _updateWorkerData(diff?: GeoJSONSourceDiff) {
+ const options: LoadGeoJSONParameters = extend({}, this.workerOptions);
if (diff) {
options.dataDiff = diff;
} else if (typeof this._data === 'string') {
@@ -344,46 +333,46 @@ export class GeoJSONSource extends Evented implements Source {
} else {
options.data = JSON.stringify(this._data);
}
-
+ options.type = this.type;
this._pendingLoads++;
this.fire(new Event('dataloading', {dataType: 'source'}));
-
- // target {this.type}.loadData rather than literally geojson.loadData,
- // so that other geojson-like source types can easily reuse this
- // implementation
- this.actor.send(`${this.type}.loadData`, options, (err, result) => {
+ try {
+ const result = await this.actor.sendAsync({type: 'loadData', data: options});
this._pendingLoads--;
-
- if (this._removed || (result && result.abandoned)) {
+ if (this._removed || result.abandoned) {
this.fire(new Event('dataabort', {dataType: 'source'}));
return;
}
- let resourceTiming = null;
- if (result && result.resourceTiming && result.resourceTiming[this.id])
+ let resourceTiming: PerformanceResourceTiming[] = null;
+ if (result.resourceTiming && result.resourceTiming[this.id]) {
resourceTiming = result.resourceTiming[this.id].slice(0);
-
- if (err) {
- this.fire(new ErrorEvent(err));
- return;
}
const data: any = {dataType: 'source'};
- if (this._collectResourceTiming && resourceTiming && resourceTiming.length > 0)
+ if (this._collectResourceTiming && resourceTiming && resourceTiming.length > 0) {
extend(data, {resourceTiming});
+ }
// although GeoJSON sources contain no metadata, we fire this event to let the SourceCache
// know its ok to start requesting tiles.
this.fire(new Event('data', {...data, sourceDataType: 'metadata'}));
this.fire(new Event('data', {...data, sourceDataType: 'content'}));
- });
+ } catch (err) {
+ this._pendingLoads--;
+ if (this._removed) {
+ this.fire(new Event('dataabort', {dataType: 'source'}));
+ return;
+ }
+ this.fire(new ErrorEvent(err));
+ }
}
loaded(): boolean {
return this._pendingLoads === 0;
}
- loadTile(tile: Tile, callback: Callback) {
+ async loadTile(tile: Tile): Promise {
const message = !tile.actor ? 'loadTile' : 'reloadTile';
tile.actor = this.actor;
const params = {
@@ -399,48 +388,40 @@ export class GeoJSONSource extends Evented implements Source {
promoteId: this.promoteId
};
- tile.request = this.actor.send(message, params, (err, data) => {
- delete tile.request;
- tile.unloadVectorData();
-
- if (tile.aborted) {
- return callback(null);
- }
-
- if (err) {
- return callback(err);
- }
+ tile.abortController = new AbortController();
+ const data = await this.actor.sendAsync({type: message, data: params}, tile.abortController);
+ delete tile.abortController;
+ tile.unloadVectorData();
+ if (!tile.aborted) {
tile.loadVectorData(data, this.map.painter, message === 'reloadTile');
-
- return callback(null);
- });
+ }
}
- abortTile(tile: Tile) {
- if (tile.request) {
- tile.request.cancel();
- delete tile.request;
+ async abortTile(tile: Tile) {
+ if (tile.abortController) {
+ tile.abortController.abort();
+ delete tile.abortController;
}
tile.aborted = true;
}
- unloadTile(tile: Tile) {
+ async unloadTile(tile: Tile) {
tile.unloadVectorData();
- this.actor.send('removeTile', {uid: tile.uid, type: this.type, source: this.id});
+ await this.actor.sendAsync({type: 'removeTile', data: {uid: tile.uid, type: this.type, source: this.id}});
}
onRemove() {
this._removed = true;
- this.actor.send('removeSource', {type: this.type, source: this.id});
+ this.actor.sendAsync({type: 'removeSource', data: {type: this.type, source: this.id}});
}
- serialize = (): GeoJSONSourceSpecification => {
+ serialize(): GeoJSONSourceSpecification {
return extend({}, this._options, {
type: this.type,
data: this._data
});
- };
+ }
hasTransition() {
return false;
diff --git a/src/source/geojson_worker_source.test.ts b/src/source/geojson_worker_source.test.ts
index c4e1643973..c540aaa4de 100644
--- a/src/source/geojson_worker_source.test.ts
+++ b/src/source/geojson_worker_source.test.ts
@@ -5,7 +5,7 @@ import perf from '../util/performance';
import {LayerSpecification} from '@maplibre/maplibre-gl-style-spec';
import {Actor} from '../util/actor';
import {WorkerTileParameters} from './worker_source';
-import {setPerformance} from '../util/test/util';
+import {setPerformance, sleep} from '../util/test/util';
import {type FakeServer, fakeServer} from 'nise';
const actor = {send: () => {}} as any as Actor;
@@ -15,7 +15,7 @@ beforeEach(() => {
});
describe('reloadTile', () => {
- test('does not rebuild vector data unless data has changed', done => {
+ test('does not rebuild vector data unless data has changed', async () => {
const layers = [
{
id: 'mylayer',
@@ -25,12 +25,7 @@ describe('reloadTile', () => {
] as LayerSpecification[];
const layerIndex = new StyleLayerIndex(layers);
const source = new GeoJSONWorkerSource(actor, layerIndex, []);
- const originalLoadVectorData = source.loadVectorData;
- let loadVectorCallCount = 0;
- source.loadVectorData = function(params, callback) {
- loadVectorCallCount++;
- return originalLoadVectorData.call(this, params, callback);
- };
+ const spy = jest.spyOn(source, 'loadVectorTile');
const geoJson = {
'type': 'Feature',
'geometry': {
@@ -45,49 +40,29 @@ describe('reloadTile', () => {
maxZoom: 10
};
- function addData(callback) {
- source.loadData({source: 'sourceId', data: JSON.stringify(geoJson)} as LoadGeoJSONParameters, (err) => {
- expect(err).toBeNull();
- callback();
- });
- }
+ await source.loadData({source: 'sourceId', data: JSON.stringify(geoJson)} as LoadGeoJSONParameters);
- function reloadTile(callback) {
- source.reloadTile(tileParams as any as WorkerTileParameters, (err, data) => {
- expect(err).toBeNull();
- return callback(data);
- });
- }
+ // first call should load vector data from geojson
+ const firstData = await source.reloadTile(tileParams as any as WorkerTileParameters);
+ expect(spy).toHaveBeenCalledTimes(1);
- addData(() => {
- // first call should load vector data from geojson
- let firstData;
- reloadTile(data => {
- firstData = data;
- });
- expect(loadVectorCallCount).toBe(1);
+ // second call won't give us new rawTileData
+ let data = await source.reloadTile(tileParams as any as WorkerTileParameters);
+ expect('rawTileData' in data).toBeFalsy();
+ data.rawTileData = firstData.rawTileData;
+ expect(data).toEqual(firstData);
- // second call won't give us new rawTileData
- reloadTile(data => {
- expect('rawTileData' in data).toBeFalsy();
- data.rawTileData = firstData.rawTileData;
- expect(data).toEqual(firstData);
- });
+ // also shouldn't call loadVectorData again
+ expect(spy).toHaveBeenCalledTimes(1);
- // also shouldn't call loadVectorData again
- expect(loadVectorCallCount).toBe(1);
-
- // replace geojson data
- addData(() => {
- // should call loadVectorData again after changing geojson data
- reloadTile(data => {
- expect('rawTileData' in data).toBeTruthy();
- expect(data).toEqual(firstData);
- });
- expect(loadVectorCallCount).toBe(2);
- done();
- });
- });
+ // replace geojson data
+ await source.loadData({source: 'sourceId', data: JSON.stringify(geoJson)} as LoadGeoJSONParameters);
+
+ // should call loadVectorData again after changing geojson data
+ data = await source.reloadTile(tileParams as any as WorkerTileParameters);
+ expect('rawTileData' in data).toBeTruthy();
+ expect(data).toEqual(firstData);
+ expect(spy).toHaveBeenCalledTimes(2);
});
});
@@ -109,7 +84,7 @@ describe('resourceTiming', () => {
}
} as GeoJSON.GeoJSON;
- test('loadData - url', done => {
+ test('loadData - url', async () => {
const exampleResourceTiming = {
connectEnd: 473,
connectStart: 473,
@@ -134,19 +109,15 @@ describe('resourceTiming', () => {
window.performance.getEntriesByName = jest.fn().mockReturnValue([exampleResourceTiming]);
const layerIndex = new StyleLayerIndex(layers);
- const source = new GeoJSONWorkerSource(actor, layerIndex, [], (params, callback) => {
- callback(null, geoJson);
- return {cancel: () => {}};
- });
+ const source = new GeoJSONWorkerSource(actor, layerIndex, []);
+ source.loadGeoJSON = () => Promise.resolve(geoJson);
- source.loadData({source: 'testSource', request: {url: 'http://localhost/nonexistent', collectResourceTiming: true}} as LoadGeoJSONParameters, (err, result) => {
- expect(err).toBeNull();
- expect(result.resourceTiming.testSource).toEqual([exampleResourceTiming]);
- done();
- });
+ const result = await source.loadData({source: 'testSource', request: {url: 'http://localhost/nonexistent', collectResourceTiming: true}} as LoadGeoJSONParameters);
+
+ expect(result.resourceTiming.testSource).toEqual([exampleResourceTiming]);
});
- test('loadData - url (resourceTiming fallback method)', done => {
+ test('loadData - url (resourceTiming fallback method)', async () => {
const sampleMarks = [100, 350];
const marks = {};
const measures = {};
@@ -169,29 +140,22 @@ describe('resourceTiming', () => {
jest.spyOn(perf, 'clearMeasures').mockImplementation(() => { return null; });
const layerIndex = new StyleLayerIndex(layers);
- const source = new GeoJSONWorkerSource(actor, layerIndex, [], (params, callback) => {
- callback(null, geoJson);
- return {cancel: () => {}};
- });
+ const source = new GeoJSONWorkerSource(actor, layerIndex, []);
+ source.loadGeoJSON = () => Promise.resolve(geoJson);
- source.loadData({source: 'testSource', request: {url: 'http://localhost/nonexistent', collectResourceTiming: true}} as LoadGeoJSONParameters, (err, result) => {
- expect(err).toBeNull();
- expect(result.resourceTiming.testSource).toEqual(
- [{'duration': 250, 'entryType': 'measure', 'name': 'http://localhost/nonexistent', 'startTime': 100}]
- );
- done();
- });
+ const result = await source.loadData({source: 'testSource', request: {url: 'http://localhost/nonexistent', collectResourceTiming: true}} as LoadGeoJSONParameters);
+
+ expect(result.resourceTiming.testSource).toEqual(
+ [{'duration': 250, 'entryType': 'measure', 'name': 'http://localhost/nonexistent', 'startTime': 100}]
+ );
});
- test('loadData - data', done => {
+ test('loadData - data', async () => {
const layerIndex = new StyleLayerIndex(layers);
const source = new GeoJSONWorkerSource(actor, layerIndex, []);
- source.loadData({source: 'testSource', data: JSON.stringify(geoJson)} as LoadGeoJSONParameters, (err, result) => {
- expect(err).toBeNull();
- expect(result.resourceTiming).toBeUndefined();
- done();
- });
+ const result = await source.loadData({source: 'testSource', data: JSON.stringify(geoJson)} as LoadGeoJSONParameters);
+ expect(result.resourceTiming).toBeUndefined();
});
});
@@ -239,131 +203,105 @@ describe('loadData', () => {
const layerIndex = new StyleLayerIndex(layers);
function createWorker() {
- const worker = new GeoJSONWorkerSource(actor, layerIndex, []);
-
- // Making the call to loadGeoJSON asynchronous
- // allows these tests to mimic a message queue building up
- // (regardless of timing)
- const originalLoadGeoJSON = worker.loadGeoJSON;
- worker.loadGeoJSON = function(params, callback) {
- const timeout = setTimeout(() => {
- originalLoadGeoJSON(params, callback);
- }, 0);
-
- return {cancel: () => clearTimeout(timeout)};
- };
- return worker;
+ return new GeoJSONWorkerSource(actor, layerIndex, []);
}
- test('abandons previous callbacks', done => {
+ test('abandons previous requests', async () => {
const worker = createWorker();
- let firstCallbackHasRun = false;
- worker.loadData({source: 'source1', data: JSON.stringify(geoJson)} as LoadGeoJSONParameters, (err, result) => {
- expect(err).toBeNull();
- expect(result && result.abandoned).toBeTruthy();
- firstCallbackHasRun = true;
+ server.respondWith(request => {
+ request.respond(200, {'Content-Type': 'application/json'}, JSON.stringify(geoJson));
});
- worker.loadData({source: 'source1', data: JSON.stringify(geoJson)} as LoadGeoJSONParameters, (err, result) => {
- expect(err).toBeNull();
- expect(result && result.abandoned).toBeFalsy();
- expect(firstCallbackHasRun).toBeTruthy();
- done();
- });
+ const p1 = worker.loadData({source: 'source1', request: {url: ''}} as LoadGeoJSONParameters);
+ await sleep(0);
+
+ const p2 = worker.loadData({source: 'source1', request: {url: ''}} as LoadGeoJSONParameters);
+
+ await sleep(0);
+
+ server.respond();
+
+ const firstCallResult = await p1;
+ expect(firstCallResult && firstCallResult.abandoned).toBeTruthy();
+ const result = await p2;
+ expect(result && result.abandoned).toBeFalsy();
});
- test('removeSource aborts callbacks', done => {
+ test('removeSource aborts requests', async () => {
const worker = createWorker();
- let loadDataCallbackHasRun = false;
- worker.loadData({source: 'source1', data: JSON.stringify(geoJson)} as LoadGeoJSONParameters, (err, result) => {
- expect(err).toBeNull();
- expect(result && result.abandoned).toBeTruthy();
- loadDataCallbackHasRun = true;
- });
- worker.removeSource({source: 'source1'}, (err) => {
- expect(err).toBeFalsy();
- expect(loadDataCallbackHasRun).toBeTruthy();
- done();
+ server.respondWith(request => {
+ request.respond(200, {'Content-Type': 'application/json'}, JSON.stringify(geoJson));
});
+
+ const loadPromise = worker.loadData({source: 'source1', request: {url: ''}} as LoadGeoJSONParameters);
+ await sleep(0);
+ const removePromise = worker.removeSource({source: 'source1', type: 'type'});
+ await sleep(0);
+
+ server.respond();
+
+ const result = await loadPromise;
+ expect(result && result.abandoned).toBeTruthy();
+ await removePromise;
});
- test('loadData with geojson creates an non-updateable source', done => {
+ test('loadData with geojson creates an non-updateable source', async () => {
const worker = new GeoJSONWorkerSource(actor, layerIndex, []);
- worker.loadData({source: 'source1', data: JSON.stringify(geoJson)} as LoadGeoJSONParameters, (err, _result) => {
- expect(err).toBeNull();
- worker.loadData({source: 'source1', dataDiff: {removeAll: true}} as LoadGeoJSONParameters, (err, _result) => {
- expect(err).toBeDefined();
- done();
- });
- });
+ await worker.loadData({source: 'source1', data: JSON.stringify(geoJson)} as LoadGeoJSONParameters);
+ await expect(worker.loadData({source: 'source1', dataDiff: {removeAll: true}} as LoadGeoJSONParameters)).rejects.toBeDefined();
});
- test('loadData with geojson creates an updateable source', done => {
+ test('loadData with geojson creates an updateable source', async () => {
const worker = new GeoJSONWorkerSource(actor, layerIndex, []);
- worker.loadData({source: 'source1', data: JSON.stringify(updateableGeoJson)} as LoadGeoJSONParameters, (err, _result) => {
- expect(err).toBeNull();
- worker.loadData({source: 'source1', dataDiff: {removeAll: true}} as LoadGeoJSONParameters, (err, _result) => {
- expect(err).toBeNull();
- done();
- });
- });
+ await worker.loadData({source: 'source1', data: JSON.stringify(updateableGeoJson)} as LoadGeoJSONParameters);
+ await expect(worker.loadData({source: 'source1', dataDiff: {removeAll: true}} as LoadGeoJSONParameters)).resolves.toBeDefined();
});
- test('loadData with geojson network call creates an updateable source', done => {
+ test('loadData with geojson network call creates an updateable source', async () => {
const worker = new GeoJSONWorkerSource(actor, layerIndex, []);
server.respondWith(request => {
request.respond(200, {'Content-Type': 'application/json'}, JSON.stringify(updateableGeoJson));
});
- worker.loadData({source: 'source1', request: {url: ''}} as LoadGeoJSONParameters, (err, _result) => {
- expect(err).toBeNull();
- worker.loadData({source: 'source1', dataDiff: {removeAll: true}} as LoadGeoJSONParameters, (err, _result) => {
- expect(err).toBeNull();
- done();
- });
- });
-
+ const load1Promise = worker.loadData({source: 'source1', request: {url: ''}} as LoadGeoJSONParameters);
server.respond();
+
+ await load1Promise;
+ await expect(worker.loadData({source: 'source1', dataDiff: {removeAll: true}} as LoadGeoJSONParameters)).resolves.toBeDefined();
});
- test('loadData with geojson network call creates a non-updateable source', done => {
+ test('loadData with geojson network call creates a non-updateable source', async () => {
const worker = new GeoJSONWorkerSource(actor, layerIndex, []);
server.respondWith(request => {
request.respond(200, {'Content-Type': 'application/json'}, JSON.stringify(geoJson));
});
- worker.loadData({source: 'source1', request: {url: ''}} as LoadGeoJSONParameters, (err, _result) => {
- expect(err).toBeNull();
- worker.loadData({source: 'source1', dataDiff: {removeAll: true}} as LoadGeoJSONParameters, (err, _result) => {
- expect(err).toBeDefined();
- done();
- });
- });
+ const promise = worker.loadData({source: 'source1', request: {url: ''}} as LoadGeoJSONParameters);
server.respond();
+
+ await promise;
+
+ await expect(worker.loadData({source: 'source1', dataDiff: {removeAll: true}} as LoadGeoJSONParameters)).rejects.toBeDefined();
});
- test('loadData with diff updates', done => {
+ test('loadData with diff updates', async () => {
const worker = new GeoJSONWorkerSource(actor, layerIndex, []);
- worker.loadData({source: 'source1', data: JSON.stringify(updateableGeoJson)} as LoadGeoJSONParameters, (err, _result) => {
- expect(err).toBeNull();
- worker.loadData({source: 'source1', dataDiff: {
- add: [{
- type: 'Feature',
- id: 'update_point',
- geometry: {type: 'Point', coordinates: [0, 0]},
- properties: {}
- }]}} as LoadGeoJSONParameters, (err, _result) => {
- expect(err).toBeNull();
- done();
- });
- });
+ await worker.loadData({source: 'source1', data: JSON.stringify(updateableGeoJson)} as LoadGeoJSONParameters);
+ await expect(worker.loadData({source: 'source1', dataDiff: {
+ add: [{
+ type: 'Feature',
+ id: 'update_point',
+ geometry: {type: 'Point', coordinates: [0, 0]},
+ properties: {}
+ }]
+ }} as LoadGeoJSONParameters)).resolves.toBeDefined();
});
});
diff --git a/src/source/geojson_worker_source.ts b/src/source/geojson_worker_source.ts
index a0fc131c70..1634d81522 100644
--- a/src/source/geojson_worker_source.ts
+++ b/src/source/geojson_worker_source.ts
@@ -1,5 +1,4 @@
import {getJSON} from '../util/ajax';
-
import {RequestPerformance} from '../util/performance';
import rewind from '@mapbox/geojson-rewind';
import {GeoJSONWrapper} from './geojson_wrapper';
@@ -8,38 +7,46 @@ import Supercluster, {type Options as SuperclusterOptions, type ClusterPropertie
import geojsonvt, {type Options as GeoJSONVTOptions} from 'geojson-vt';
import {VectorTileWorkerSource} from './vector_tile_worker_source';
import {createExpression} from '@maplibre/maplibre-gl-style-spec';
+import {isAbortError} from '../util/abort_error';
import type {
WorkerTileParameters,
- WorkerTileCallback,
+ WorkerTileResult,
} from '../source/worker_source';
-import type {Actor} from '../util/actor';
-import type {StyleLayerIndex} from '../style/style_layer_index';
-
-import type {LoadVectorDataCallback} from './vector_tile_worker_source';
-import type {RequestParameters, ResponseCallback} from '../util/ajax';
-import type {Callback} from '../types/callback';
-import type {Cancelable} from '../types/cancelable';
+import type {LoadVectorTileResult} from './vector_tile_worker_source';
+import type {RequestParameters} from '../util/ajax';
import {isUpdateableGeoJSON, type GeoJSONSourceDiff, applySourceDiff, toUpdateable, GeoJSONFeatureId} from './geojson_source_diff';
+import type {ClusterIDAndSource, GeoJSONWorkerSourceLoadDataResult, RemoveSourceParams} from '../util/actor_messages';
-export type LoadGeoJSONParameters = {
+/**
+ * The geojson worker options that can be passed to the worker
+ */
+export type GeoJSONWorkerOptions = {
+ source?: string;
+ cluster?: boolean;
+ geojsonVtOptions?: GeoJSONVTOptions;
+ superclusterOptions?: SuperclusterOptions;
+ clusterProperties?: ClusterProperties;
+ filter?: Array;
+ promoteId?: string;
+ collectResourceTiming?: boolean;
+}
+
+/**
+ * Parameters needed to load a geojson to the wokrer
+ */
+export type LoadGeoJSONParameters = GeoJSONWorkerOptions & {
+ type: 'geojson';
request?: RequestParameters;
/**
* Literal GeoJSON data. Must be provided if `request.url` is not.
*/
data?: string;
dataDiff?: GeoJSONSourceDiff;
- source: string;
- cluster: boolean;
- superclusterOptions?: SuperclusterOptions;
- geojsonVtOptions?: GeoJSONVTOptions;
- clusterProperties?: ClusterProperties;
- filter?: Array;
- promoteId?: string;
};
-export type LoadGeoJSON = (params: LoadGeoJSONParameters, callback: ResponseCallback) => Cancelable;
+export type LoadGeoJSON = (params: LoadGeoJSONParameters, abortController: AbortController) => Promise;
type GeoJSONIndex = ReturnType | Supercluster;
@@ -52,37 +59,20 @@ type GeoJSONIndex = ReturnType | Supercluster;
* For a full example, see [mapbox-gl-topojson](https://github.com/developmentseed/mapbox-gl-topojson).
*/
export class GeoJSONWorkerSource extends VectorTileWorkerSource {
- _pendingCallback: Callback<{
- resourceTiming?: {[_: string]: Array};
- abandoned?: boolean;
- }>;
- _pendingRequest: Cancelable;
+ _pendingRequest: AbortController;
_geoJSONIndex: GeoJSONIndex;
_dataUpdateable = new Map();
- /**
- * @param loadGeoJSON - Optional method for custom loading/parsing of
- * GeoJSON based on parameters passed from the main-thread Source.
- * See {@link GeoJSONWorkerSource#loadGeoJSON}.
- */
- constructor(actor: Actor, layerIndex: StyleLayerIndex, availableImages: Array, loadGeoJSON?: LoadGeoJSON | null) {
- super(actor, layerIndex, availableImages);
- this.loadVectorData = this.loadGeoJSONTile;
- if (loadGeoJSON) {
- this.loadGeoJSON = loadGeoJSON;
- }
- }
-
- loadGeoJSONTile(params: WorkerTileParameters, callback: LoadVectorDataCallback): (() => void) | void {
+ override async loadVectorTile(params: WorkerTileParameters, _abortController: AbortController): Promise {
const canonical = params.tileID.canonical;
if (!this._geoJSONIndex) {
- return callback(null, null); // we couldn't load the file
+ throw new Error('Unable to parse the data into a cluster or geojson');
}
const geoJSONTile = this._geoJSONIndex.getTile(canonical.z, canonical.x, canonical.y);
if (!geoJSONTile) {
- return callback(null, null); // nothing in the given tile
+ return null;
}
const geojsonWrapper = new GeoJSONWrapper(geoJSONTile.features);
@@ -95,10 +85,10 @@ export class GeoJSONWorkerSource extends VectorTileWorkerSource {
pbf = new Uint8Array(pbf);
}
- callback(null, {
+ return {
vectorTile: geojsonWrapper,
rawData: pbf.buffer
- });
+ };
}
/**
@@ -107,72 +97,60 @@ export class GeoJSONWorkerSource extends VectorTileWorkerSource {
* can correctly serve up tiles.
*
* Defers to {@link GeoJSONWorkerSource#loadGeoJSON} for the fetching/parsing,
- * expecting `callback(error, data)` to be called with either an error or a
- * parsed GeoJSON object.
*
* When a `loadData` request comes in while a previous one is being processed,
* the previous one is aborted.
*
* @param params - the parameters
- * @param callback - the callback for completion or error
+ * @returns a promise that resolves when the data is loaded and parsed into a GeoJSON object
*/
- loadData(params: LoadGeoJSONParameters, callback: Callback<{
- resourceTiming?: {[_: string]: Array};
- abandoned?: boolean;
- }>) {
- this._pendingRequest?.cancel();
- if (this._pendingCallback) {
- // Tell the foreground the previous call has been abandoned
- this._pendingCallback(null, {abandoned: true});
- }
-
+ async loadData(params: LoadGeoJSONParameters): Promise {
+ this._pendingRequest?.abort();
const perf = (params && params.request && params.request.collectResourceTiming) ?
new RequestPerformance(params.request) : false;
- this._pendingCallback = callback;
- this._pendingRequest = this.loadGeoJSON(params, (err?: Error | null, data?: any | null) => {
- delete this._pendingCallback;
+ this._pendingRequest = new AbortController();
+ try {
+ let data = await this.loadGeoJSON(params, this._pendingRequest);
delete this._pendingRequest;
+ if (typeof data !== 'object') {
+ throw new Error(`Input data given to '${params.source}' is not a valid GeoJSON object.`);
+ }
+ rewind(data, true);
- if (err || !data) {
- return callback(err);
- } else if (typeof data !== 'object') {
- return callback(new Error(`Input data given to '${params.source}' is not a valid GeoJSON object.`));
- } else {
- rewind(data, true);
-
- try {
- if (params.filter) {
- const compiled = createExpression(params.filter, {type: 'boolean', 'property-type': 'data-driven', overridable: false, transition: false} as any);
- if (compiled.result === 'error')
- throw new Error(compiled.value.map(err => `${err.key}: ${err.message}`).join(', '));
-
- const features = data.features.filter(feature => compiled.value.evaluate({zoom: 0}, feature));
- data = {type: 'FeatureCollection', features};
- }
-
- this._geoJSONIndex = params.cluster ?
- new Supercluster(getSuperclusterOptions(params)).load(data.features) :
- geojsonvt(data, params.geojsonVtOptions);
- } catch (err) {
- return callback(err);
- }
+ if (params.filter) {
+ const compiled = createExpression(params.filter, {type: 'boolean', 'property-type': 'data-driven', overridable: false, transition: false} as any);
+ if (compiled.result === 'error')
+ throw new Error(compiled.value.map(err => `${err.key}: ${err.message}`).join(', '));
+
+ const features = (data as any).features.filter(feature => compiled.value.evaluate({zoom: 0}, feature));
+ data = {type: 'FeatureCollection', features};
+ }
- this.loaded = {};
-
- const result = {} as { resourceTiming: any };
- if (perf) {
- const resourceTimingData = perf.finish();
- // it's necessary to eval the result of getEntriesByName() here via parse/stringify
- // late evaluation in the main thread causes TypeError: illegal invocation
- if (resourceTimingData) {
- result.resourceTiming = {};
- result.resourceTiming[params.source] = JSON.parse(JSON.stringify(resourceTimingData));
- }
+ this._geoJSONIndex = params.cluster ?
+ new Supercluster(getSuperclusterOptions(params)).load((data as any).features) :
+ geojsonvt(data, params.geojsonVtOptions);
+
+ this.loaded = {};
+
+ const result = {} as GeoJSONWorkerSourceLoadDataResult;
+ if (perf) {
+ const resourceTimingData = perf.finish();
+ // it's necessary to eval the result of getEntriesByName() here via parse/stringify
+ // late evaluation in the main thread causes TypeError: illegal invocation
+ if (resourceTimingData) {
+ result.resourceTiming = {};
+ result.resourceTiming[params.source] = JSON.parse(JSON.stringify(resourceTimingData));
}
- callback(null, result);
}
- });
+ return result;
+ } catch (err) {
+ delete this._pendingRequest;
+ if (isAbortError(err)) {
+ return {abandoned: true};
+ }
+ throw err;
+ }
}
/**
@@ -182,108 +160,75 @@ export class GeoJSONWorkerSource extends VectorTileWorkerSource {
* Otherwise, such as after a setData() call, we load the tile fresh.
*
* @param params - the parameters
- * @param callback - the callback for completion or error
+ * @returns A promise that resolves when the tile is reloaded
*/
- reloadTile(params: WorkerTileParameters, callback: WorkerTileCallback) {
+ reloadTile(params: WorkerTileParameters): Promise {
const loaded = this.loaded,
uid = params.uid;
if (loaded && loaded[uid]) {
- return super.reloadTile(params, callback);
+ return super.reloadTile(params);
} else {
- return this.loadTile(params, callback);
+ return this.loadTile(params);
}
}
/**
- * Fetch and parse GeoJSON according to the given params. Calls `callback`
- * with `(err, data)`, where `data` is a parsed GeoJSON object.
+ * Fetch and parse GeoJSON according to the given params.
*
* GeoJSON is loaded and parsed from `params.url` if it exists, or else
* expected as a literal (string or object) `params.data`.
*
* @param params - the parameters
- * @param callback - the callback for completion or error
- * @returns A Cancelable object.
+ * @param abortController - the abort controller that allows aborting this operation
+ * @returns a promise that resolves when the data is loaded
*/
- loadGeoJSON = (params: LoadGeoJSONParameters, callback: ResponseCallback): Cancelable => {
+ async loadGeoJSON(params: LoadGeoJSONParameters, abortController: AbortController): Promise {
const {promoteId} = params;
- // Because of same origin issues, urls must either include an explicit
- // origin or absolute path.
- // ie: /foo/bar.json or http://example.com/bar.json
- // but not ../foo/bar.json
if (params.request) {
- return getJSON(params.request, (
- error?: Error,
- data?: any,
- cacheControl?: string,
- expires?: string
- ) => {
- this._dataUpdateable = isUpdateableGeoJSON(data, promoteId) ? toUpdateable(data, promoteId) : undefined;
- callback(error, data, cacheControl, expires);
- });
- } else if (typeof params.data === 'string') {
+ const response = await getJSON(params.request, abortController);
+ this._dataUpdateable = isUpdateableGeoJSON(response.data, promoteId) ? toUpdateable(response.data, promoteId) : undefined;
+ return response.data;
+ }
+ if (typeof params.data === 'string') {
try {
const parsed = JSON.parse(params.data);
this._dataUpdateable = isUpdateableGeoJSON(parsed, promoteId) ? toUpdateable(parsed, promoteId) : undefined;
- callback(null, parsed);
+ return parsed;
} catch (e) {
- callback(new Error(`Input data given to '${params.source}' is not a valid GeoJSON object.`));
+ throw new Error(`Input data given to '${params.source}' is not a valid GeoJSON object.`);
}
- } else if (params.dataDiff) {
- if (this._dataUpdateable) {
- applySourceDiff(this._dataUpdateable, params.dataDiff, promoteId);
- callback(null, {type: 'FeatureCollection', features: Array.from(this._dataUpdateable.values())});
- } else {
- callback(new Error(`Cannot update existing geojson data in ${params.source}`));
- }
- } else {
- callback(new Error(`Input data given to '${params.source}' is not a valid GeoJSON object.`));
}
-
- return {cancel: () => {}};
- };
-
- removeSource(params: {
- source: string;
- }, callback: WorkerTileCallback) {
- if (this._pendingCallback) {
- // Don't leak callbacks
- this._pendingCallback(null, {abandoned: true});
+ if (!params.dataDiff) {
+ throw new Error(`Input data given to '${params.source}' is not a valid GeoJSON object.`);
+ }
+ if (!this._dataUpdateable) {
+ throw new Error(`Cannot update existing geojson data in ${params.source}`);
}
- callback();
+ applySourceDiff(this._dataUpdateable, params.dataDiff, promoteId);
+ return {type: 'FeatureCollection', features: Array.from(this._dataUpdateable.values())};
}
- getClusterExpansionZoom(params: {
- clusterId: number;
- }, callback: Callback) {
- try {
- callback(null, (this._geoJSONIndex as Supercluster).getClusterExpansionZoom(params.clusterId));
- } catch (e) {
- callback(e);
+ async removeSource(_params: RemoveSourceParams): Promise {
+ if (this._pendingRequest) {
+ this._pendingRequest.abort();
}
}
- getClusterChildren(params: {
- clusterId: number;
- }, callback: Callback>) {
- try {
- callback(null, (this._geoJSONIndex as Supercluster).getChildren(params.clusterId));
- } catch (e) {
- callback(e);
- }
+ getClusterExpansionZoom(params: ClusterIDAndSource): number {
+ return (this._geoJSONIndex as Supercluster).getClusterExpansionZoom(params.clusterId);
+ }
+
+ getClusterChildren(params: ClusterIDAndSource): Array {
+ return (this._geoJSONIndex as Supercluster).getChildren(params.clusterId);
}
getClusterLeaves(params: {
clusterId: number;
limit: number;
offset: number;
- }, callback: Callback>) {
- try {
- callback(null, (this._geoJSONIndex as Supercluster).getLeaves(params.clusterId, params.limit, params.offset));
- } catch (e) {
- callback(e);
- }
+ }): Array {
+ return (this._geoJSONIndex as Supercluster).getLeaves(params.clusterId, params.limit, params.offset);
}
}
diff --git a/src/source/image_source.test.ts b/src/source/image_source.test.ts
index 124b2a86cf..cbb9fab461 100644
--- a/src/source/image_source.test.ts
+++ b/src/source/image_source.test.ts
@@ -4,8 +4,7 @@ import {Transform} from '../geo/transform';
import {extend} from '../util/util';
import {type FakeServer, fakeServer} from 'nise';
import {RequestManager} from '../util/request_manager';
-import {Dispatcher} from '../util/dispatcher';
-import {stubAjaxGetImage} from '../util/test/util';
+import {sleep, stubAjaxGetImage} from '../util/test/util';
import {Tile} from './tile';
import {OverscaledTileID} from './tile_id';
import {VertexBuffer} from '../gl/vertex_buffer';
@@ -18,7 +17,7 @@ function createSource(options) {
coordinates: [[0, 0], [1, 0], [1, 1], [0, 1]]
}, options);
- const source = new ImageSource('id', options, {send() {}} as any as Dispatcher, options.eventedParent);
+ const source = new ImageSource('id', options, {} as any, options.eventedParent);
return source;
}
@@ -61,13 +60,14 @@ describe('ImageSource', () => {
expect(source.tileSize).toBe(512);
});
- test('fires dataloading event', () => {
+ test('fires dataloading event', async () => {
const source = createSource({url: '/image.png'});
source.on('dataloading', (e) => {
expect(e.dataType).toBe('source');
});
source.onAdd(new StubMap() as any);
server.respond();
+ await sleep(0);
expect(source.image).toBeTruthy();
});
@@ -110,7 +110,7 @@ describe('ImageSource', () => {
expect(afterSerialized.coordinates).toEqual([[0, 0], [-1, 0], [-1, -1], [0, -1]]);
});
- test('sets coordinates via updateImage', () => {
+ test('sets coordinates via updateImage', async () => {
const source = createSource({url: '/image.png'});
const map = new StubMap() as any;
source.onAdd(map);
@@ -122,6 +122,7 @@ describe('ImageSource', () => {
coordinates: [[0, 0], [-1, 0], [-1, -1], [0, -1]]
});
server.respond();
+ await sleep(0);
const afterSerialized = source.serialize();
expect(afterSerialized.coordinates).toEqual([[0, 0], [-1, 0], [-1, -1], [0, -1]]);
});
@@ -164,7 +165,7 @@ describe('ImageSource', () => {
source.tiles[String(tile.tileID.wrap)] = tile;
source.image = new ImageBitmap();
// assign dummies directly so we don't need to stub the gl things
- source.boundsBuffer = {} as VertexBuffer;
+ source.boundsBuffer = {destroy: () => {}} as VertexBuffer;
source.boundsSegments = {} as SegmentVector;
source.texture = {} as Texture;
source.prepare();
@@ -179,22 +180,27 @@ describe('ImageSource', () => {
expect(serialized.coordinates).toEqual([[0, 0], [1, 0], [1, 1], [0, 1]]);
});
- test('allows using updateImage before initial image is loaded', () => {
- const source = createSource({url: '/image.png'});
+ test('allows using updateImage before initial image is loaded', async () => {
const map = new StubMap() as any;
+ const source = createSource({url: '/image.png', eventedParent: map});
+ // Suppress errors because we're aborting when updating.
+ map.on('error', () => {});
source.onAdd(map);
-
expect(source.image).toBeUndefined();
source.updateImage({url: '/image2.png'});
server.respond();
+ await sleep(10);
+
expect(source.image).toBeTruthy();
});
test('cancels request if updateImage is used', () => {
- const source = createSource({url: '/image.png'});
const map = new StubMap() as any;
+ const source = createSource({url: '/image.png', eventedParent: map});
+ // Suppress errors because we're aborting.
+ map.on('error', () => {});
source.onAdd(map);
const spy = jest.spyOn(server.requests[0] as any, 'abort');
diff --git a/src/source/image_source.ts b/src/source/image_source.ts
index 8be422aa54..a27fdbd322 100644
--- a/src/source/image_source.ts
+++ b/src/source/image_source.ts
@@ -20,7 +20,6 @@ import type {
ImageSourceSpecification,
VideoSourceSpecification
} from '@maplibre/maplibre-gl-style-spec';
-import {Cancelable} from '../types/cancelable';
/**
* Four geographical coordinates,
@@ -107,7 +106,7 @@ export class ImageSource extends Evented implements Source {
boundsBuffer: VertexBuffer;
boundsSegments: SegmentVector;
_loaded: boolean;
- _request: Cancelable;
+ _request: AbortController;
/** @internal */
constructor(id: string, options: ImageSourceSpecification | VideoSourceSpecification | CanvasSourceSpecification, dispatcher: Dispatcher, eventedParent: Evented) {
@@ -128,30 +127,30 @@ export class ImageSource extends Evented implements Source {
this.options = options;
}
- load = (newCoordinates?: Coordinates, successCallback?: () => void) => {
+ async load(newCoordinates?: Coordinates): Promise {
this._loaded = false;
this.fire(new Event('dataloading', {dataType: 'source'}));
this.url = this.options.url;
- this._request = ImageRequest.getImage(this.map._requestManager.transformRequest(this.url, ResourceType.Image), (err, image) => {
+ this._request = new AbortController();
+ try {
+ const image = await ImageRequest.getImage(this.map._requestManager.transformRequest(this.url, ResourceType.Image), this._request);
this._request = null;
this._loaded = true;
- if (err) {
- this.fire(new ErrorEvent(err));
- } else if (image) {
- this.image = image;
+ if (image && image.data) {
+ this.image = image.data;
if (newCoordinates) {
this.coordinates = newCoordinates;
}
- if (successCallback) {
- successCallback();
- }
this._finishLoading();
}
- });
- };
+ } catch (err) {
+ this._request = null;
+ this.fire(new ErrorEvent(err));
+ }
+ }
loaded(): boolean {
return this._loaded;
@@ -170,12 +169,12 @@ export class ImageSource extends Evented implements Source {
}
if (this._request) {
- this._request.cancel();
+ this._request.abort();
this._request = null;
}
this.options.url = options.url;
- this.load(options.coordinates, () => { this.texture = null; });
+ this.load(options.coordinates).finally(() => { this.texture = null; });
return this;
}
@@ -193,7 +192,7 @@ export class ImageSource extends Evented implements Source {
onRemove() {
if (this._request) {
- this._request.cancel();
+ this._request.abort();
this._request = null;
}
}
@@ -245,7 +244,7 @@ export class ImageSource extends Evented implements Source {
return this;
}
- prepare = () => {
+ prepare() {
if (Object.keys(this.tiles).length === 0 || !this.image) {
return;
}
@@ -279,9 +278,9 @@ export class ImageSource extends Evented implements Source {
if (newTilesLoaded) {
this.fire(new Event('data', {dataType: 'source', sourceDataType: 'idle', sourceId: this.id}));
}
- };
+ }
- loadTile(tile: Tile, callback: Callback) {
+ async loadTile(tile: Tile, callback?: Callback): Promise {
// We have a single tile -- whose coordinates are this.tileID -- that
// covers the image we want to render. If that's the one being
// requested, set it up with the image; otherwise, mark the tile as
@@ -291,20 +290,21 @@ export class ImageSource extends Evented implements Source {
if (this.tileID && this.tileID.equals(tile.tileID.canonical)) {
this.tiles[String(tile.tileID.wrap)] = tile;
tile.buckets = {};
- callback(null);
} else {
tile.state = 'errored';
- callback(null);
+ }
+ if (callback) {
+ callback();
}
}
- serialize = (): ImageSourceSpecification | VideoSourceSpecification | CanvasSourceSpecification => {
+ serialize(): ImageSourceSpecification | VideoSourceSpecification | CanvasSourceSpecification {
return {
type: 'image',
url: this.options.url,
coordinates: this.coordinates
};
- };
+ }
hasTransition() {
return false;
diff --git a/src/source/load_tilejson.ts b/src/source/load_tilejson.ts
index 9fc6e61fc8..af38b6afc7 100644
--- a/src/source/load_tilejson.ts
+++ b/src/source/load_tilejson.ts
@@ -1,42 +1,36 @@
import {pick, extend} from '../util/util';
-
import {getJSON} from '../util/ajax';
import {ResourceType} from '../util/request_manager';
import {browser} from '../util/browser';
import type {RequestManager} from '../util/request_manager';
-import type {Callback} from '../types/callback';
import type {TileJSON} from '../types/tilejson';
-import type {Cancelable} from '../types/cancelable';
import type {RasterDEMSourceSpecification, RasterSourceSpecification, VectorSourceSpecification} from '@maplibre/maplibre-gl-style-spec';
-export function loadTileJson(
+export async function loadTileJson(
options: RasterSourceSpecification | RasterDEMSourceSpecification | VectorSourceSpecification,
requestManager: RequestManager,
- callback: Callback
-): Cancelable {
- const loaded = function(err: Error, tileJSON: any) {
- if (err) {
- return callback(err);
- } else if (tileJSON) {
- const result: any = pick(
- // explicit source options take precedence over TileJSON
- extend(tileJSON, options),
- ['tiles', 'minzoom', 'maxzoom', 'attribution', 'bounds', 'scheme', 'tileSize', 'encoding']
- );
-
- if (tileJSON.vector_layers) {
- result.vectorLayers = tileJSON.vector_layers;
- result.vectorLayerIds = result.vectorLayers.map((layer) => { return layer.id; });
- }
+ abortController: AbortController,
+): Promise {
+ let tileJSON: any = options;
+ if (options.url) {
+ const response = await getJSON(requestManager.transformRequest(options.url, ResourceType.Source), abortController);
+ tileJSON = response.data;
+ } else {
+ await browser.frameAsync(abortController);
+ }
+ if (tileJSON) {
+ const result: TileJSON = pick(
+ // explicit source options take precedence over TileJSON
+ extend(tileJSON, options),
+ ['tiles', 'minzoom', 'maxzoom', 'attribution', 'bounds', 'scheme', 'tileSize', 'encoding']
+ );
- callback(null, result);
+ if (tileJSON.vector_layers) {
+ result.vectorLayers = tileJSON.vector_layers;
+ result.vectorLayerIds = result.vectorLayers.map((layer) => { return layer.id; });
}
- };
- if (options.url) {
- return getJSON(requestManager.transformRequest(options.url, ResourceType.Source), loaded);
- } else {
- return browser.frame(() => loaded(null, options));
+ return result;
}
}
diff --git a/src/source/query_features.test.ts b/src/source/query_features.test.ts
index b9ff7f8610..29fa78e44f 100644
--- a/src/source/query_features.test.ts
+++ b/src/source/query_features.test.ts
@@ -5,7 +5,6 @@ import {
import {SourceCache} from './source_cache';
import {Transform} from '../geo/transform';
import Point from '@mapbox/point-geometry';
-import {Dispatcher} from '../util/dispatcher';
describe('QueryFeatures#rendered', () => {
test('returns empty object if source returns no tiles', () => {
@@ -23,12 +22,8 @@ describe('QueryFeatures#source', () => {
type: 'geojson',
data: {type: 'FeatureCollection', features: []}
}, {
- getActor() {
- return {
- send(type, params, callback) { return callback(); }
- };
- }
- } as any as Dispatcher);
+ getActor() {}
+ } as any);
const result = querySourceFeatures(sourceCache, {});
expect(result).toEqual([]);
});
diff --git a/src/source/raster_dem_tile_source.test.ts b/src/source/raster_dem_tile_source.test.ts
index 43bf16bbef..b913e9afa1 100644
--- a/src/source/raster_dem_tile_source.test.ts
+++ b/src/source/raster_dem_tile_source.test.ts
@@ -2,11 +2,11 @@ import {fakeServer, FakeServer} from 'nise';
import {RasterDEMTileSource} from './raster_dem_tile_source';
import {OverscaledTileID} from './tile_id';
import {RequestManager} from '../util/request_manager';
-import {Dispatcher} from '../util/dispatcher';
import {Tile} from './tile';
+import {waitForMetadataEvent} from '../util/test/util';
function createSource(options, transformCallback?) {
- const source = new RasterDEMTileSource('id', options, {send() {}} as any as Dispatcher, options.eventedParent);
+ const source = new RasterDEMTileSource('id', options, {} as any, options.eventedParent);
source.onAdd({
transform: {angle: 0, pitch: 0, showCollisionBoxes: false},
_getMapId: () => 1,
@@ -21,7 +21,7 @@ function createSource(options, transformCallback?) {
return source;
}
-describe('RasterTileSource', () => {
+describe('RasterDEMTileSource', () => {
let server: FakeServer;
beforeEach(() => {
global.fetch = null;
@@ -32,7 +32,7 @@ describe('RasterTileSource', () => {
server.restore();
});
- test('transforms request for TileJSON URL', done => {
+ test('transforms request for TileJSON URL', () => {
server.respondWith('/source.json', JSON.stringify({
minzoom: 0,
maxzoom: 22,
@@ -49,10 +49,9 @@ describe('RasterTileSource', () => {
expect(transformSpy.mock.calls[0][0]).toBe('/source.json');
expect(transformSpy.mock.calls[0][1]).toBe('Source');
- done();
});
- test('transforms tile urls before requesting', done => {
+ test('transforms tile urls before requesting', async () => {
server.respondWith('/source.json', JSON.stringify({
minzoom: 0,
maxzoom: 22,
@@ -62,26 +61,23 @@ describe('RasterTileSource', () => {
}));
const source = createSource({url: '/source.json'});
const transformSpy = jest.spyOn(source.map._requestManager, 'transformRequest');
- source.on('data', (e) => {
- if (e.sourceDataType === 'metadata') {
- const tile = {
- tileID: new OverscaledTileID(10, 0, 10, 5, 5),
- state: 'loading',
- loadVectorData () {},
- setExpiryData() {}
- } as any as Tile;
- source.loadTile(tile, () => {});
-
- expect(transformSpy).toHaveBeenCalledTimes(1);
- expect(transformSpy.mock.calls[0][0]).toBe('http://example.com/10/5/5.png');
- expect(transformSpy.mock.calls[0][1]).toBe('Tile');
- done();
-
- }
- });
+ const promise = waitForMetadataEvent(source);
server.respond();
+ await promise;
+ const tile = {
+ tileID: new OverscaledTileID(10, 0, 10, 5, 5),
+ state: 'loading',
+ loadVectorData () {},
+ setExpiryData() {}
+ } as any as Tile;
+ source.loadTile(tile);
+
+ expect(transformSpy).toHaveBeenCalledTimes(1);
+ expect(transformSpy.mock.calls[0][0]).toBe('http://example.com/10/5/5.png');
+ expect(transformSpy.mock.calls[0][1]).toBe('Tile');
});
- test('populates neighboringTiles', done => {
+
+ test('populates neighboringTiles', async () => {
server.respondWith('/source.json', JSON.stringify({
minzoom: 0,
maxzoom: 22,
@@ -89,66 +85,61 @@ describe('RasterTileSource', () => {
tiles: ['http://example.com/{z}/{x}/{y}.png']
}));
const source = createSource({url: '/source.json'});
- source.on('data', (e) => {
- if (e.sourceDataType === 'metadata') {
- const tile = {
- tileID: new OverscaledTileID(10, 0, 10, 5, 5),
- state: 'loading',
- loadVectorData () {},
- setExpiryData() {}
- } as any as Tile;
- source.loadTile(tile, () => {});
-
- expect(Object.keys(tile.neighboringTiles)).toEqual([
- new OverscaledTileID(10, 0, 10, 4, 5).key,
- new OverscaledTileID(10, 0, 10, 6, 5).key,
- new OverscaledTileID(10, 0, 10, 4, 4).key,
- new OverscaledTileID(10, 0, 10, 5, 4).key,
- new OverscaledTileID(10, 0, 10, 6, 4).key,
- new OverscaledTileID(10, 0, 10, 4, 6).key,
- new OverscaledTileID(10, 0, 10, 5, 6).key,
- new OverscaledTileID(10, 0, 10, 6, 6).key
- ]);
-
- done();
-
- }
- });
+ const promise = waitForMetadataEvent(source);
server.respond();
+ await promise;
+ const tile = {
+ tileID: new OverscaledTileID(10, 0, 10, 5, 5),
+ state: 'loading',
+ loadVectorData () {},
+ setExpiryData() {}
+ } as any as Tile;
+ source.loadTile(tile);
+
+ expect(Object.keys(tile.neighboringTiles)).toEqual([
+ new OverscaledTileID(10, 0, 10, 4, 5).key,
+ new OverscaledTileID(10, 0, 10, 6, 5).key,
+ new OverscaledTileID(10, 0, 10, 4, 4).key,
+ new OverscaledTileID(10, 0, 10, 5, 4).key,
+ new OverscaledTileID(10, 0, 10, 6, 4).key,
+ new OverscaledTileID(10, 0, 10, 4, 6).key,
+ new OverscaledTileID(10, 0, 10, 5, 6).key,
+ new OverscaledTileID(10, 0, 10, 6, 6).key
+ ]);
});
- test('populates neighboringTiles with wrapped tiles', done => {
+ test('populates neighboringTiles with wrapped tiles', async () => {
server.respondWith('/source.json', JSON.stringify({
minzoom: 0,
maxzoom: 22,
attribution: 'MapLibre',
tiles: ['http://example.com/{z}/{x}/{y}.png']
}));
+
const source = createSource({url: '/source.json'});
- source.on('data', (e) => {
- if (e.sourceDataType === 'metadata') {
- const tile = {
- tileID: new OverscaledTileID(5, 0, 5, 31, 5),
- state: 'loading',
- loadVectorData () {},
- setExpiryData() {}
- } as any as Tile;
- source.loadTile(tile, () => {});
-
- expect(Object.keys(tile.neighboringTiles)).toEqual([
- new OverscaledTileID(5, 0, 5, 30, 6).key,
- new OverscaledTileID(5, 0, 5, 31, 6).key,
- new OverscaledTileID(5, 0, 5, 30, 5).key,
- new OverscaledTileID(5, 1, 5, 0, 5).key,
- new OverscaledTileID(5, 0, 5, 30, 4).key,
- new OverscaledTileID(5, 0, 5, 31, 4).key,
- new OverscaledTileID(5, 1, 5, 0, 4).key,
- new OverscaledTileID(5, 1, 5, 0, 6).key
- ]);
- done();
- }
- });
+ const promise = waitForMetadataEvent(source);
+
server.respond();
+ await promise;
+
+ const tile = {
+ tileID: new OverscaledTileID(5, 0, 5, 31, 5),
+ state: 'loading',
+ loadVectorData() {},
+ setExpiryData() {}
+ } as any as Tile;
+ source.loadTile(tile);
+
+ expect(Object.keys(tile.neighboringTiles)).toEqual([
+ new OverscaledTileID(5, 0, 5, 30, 6).key,
+ new OverscaledTileID(5, 0, 5, 31, 6).key,
+ new OverscaledTileID(5, 0, 5, 30, 5).key,
+ new OverscaledTileID(5, 1, 5, 0, 5).key,
+ new OverscaledTileID(5, 0, 5, 30, 4).key,
+ new OverscaledTileID(5, 0, 5, 31, 4).key,
+ new OverscaledTileID(5, 1, 5, 0, 4).key,
+ new OverscaledTileID(5, 1, 5, 0, 6).key
+ ]);
});
it('serializes options', () => {
diff --git a/src/source/raster_dem_tile_source.ts b/src/source/raster_dem_tile_source.ts
index 19d052245e..dbb11342c5 100644
--- a/src/source/raster_dem_tile_source.ts
+++ b/src/source/raster_dem_tile_source.ts
@@ -13,9 +13,7 @@ import type {DEMEncoding} from '../data/dem_data';
import type {Source} from './source';
import type {Dispatcher} from '../util/dispatcher';
import type {Tile} from './tile';
-import type {Callback} from '../types/callback';
import type {RasterDEMSourceSpecification} from '@maplibre/maplibre-gl-style-spec';
-import type {ExpiryData} from '../util/ajax';
import {isOffscreenCanvasDistorted} from '../util/offscreen_canvas_distorted';
import {RGBAImage} from '../util/image';
@@ -54,25 +52,28 @@ export class RasterDEMTileSource extends RasterTileSource implements Source {
this.baseShift = options.baseShift;
}
- loadTile(tile: Tile, callback: Callback) {
+ override async loadTile(tile: Tile): Promise {
const url = tile.tileID.canonical.url(this.tiles, this.map.getPixelRatio(), this.scheme);
const request = this.map._requestManager.transformRequest(url, ResourceType.Tile);
tile.neighboringTiles = this._getNeighboringTiles(tile.tileID);
- tile.request = ImageRequest.getImage(request, async (err: Error, img: (HTMLImageElement | ImageBitmap), expiry: ExpiryData) => {
- delete tile.request;
+ tile.abortController = new AbortController();
+ try {
+ const response = await ImageRequest.getImage(request, tile.abortController, this.map._refreshExpiredTiles);
+ delete tile.abortController;
if (tile.aborted) {
tile.state = 'unloaded';
- callback(null);
- } else if (err) {
- tile.state = 'errored';
- callback(err);
- } else if (img) {
- if (this.map._refreshExpiredTiles) tile.setExpiryData(expiry);
+ return;
+ }
+ if (response && response.data) {
+ const img = response.data;
+ if (this.map._refreshExpiredTiles && response.cacheControl && response.expires) {
+ tile.setExpiryData({cacheControl: response.cacheControl, expires: response.expires});
+ }
const transfer = isImageBitmap(img) && offscreenCanvasSupported();
- const rawImageData = transfer ? img : await readImageNow(img);
+ const rawImageData = transfer ? img : await this.readImageNow(img);
const params = {
+ type: this.type,
uid: tile.uid,
- coord: tile.tileID,
source: this.id,
rawImageData,
encoding: this.encoding,
@@ -84,38 +85,37 @@ export class RasterDEMTileSource extends RasterTileSource implements Source {
if (!tile.actor || tile.state === 'expired') {
tile.actor = this.dispatcher.getActor();
- tile.actor.send('loadDEMTile', params, done);
- }
- }
- }, this.map._refreshExpiredTiles);
-
- async function readImageNow(img: ImageBitmap | HTMLImageElement): Promise {
- if (typeof VideoFrame !== 'undefined' && isOffscreenCanvasDistorted()) {
- const width = img.width + 2;
- const height = img.height + 2;
- try {
- return new RGBAImage({width, height}, await readImageUsingVideoFrame(img, -1, -1, width, height));
- } catch (e) {
- // fall-back to browser canvas decoding
+ /* eslint-disable require-atomic-updates */
+ const data = await tile.actor.sendAsync({type: 'loadDEMTile', data: params});
+ tile.dem = data;
+ tile.needsHillshadePrepare = true;
+ tile.needsTerrainPrepare = true;
+ tile.state = 'loaded';
+ /* eslint-enable require-atomic-updates */
}
}
- return browser.getImageData(img, 1);
- }
-
- function done(err, data) {
- if (err) {
+ } catch (err) {
+ delete tile.abortController;
+ if (tile.aborted) {
+ tile.state = 'unloaded';
+ } else if (err) {
tile.state = 'errored';
- callback(err);
+ throw err;
}
+ }
+ }
- if (data) {
- tile.dem = data;
- tile.needsHillshadePrepare = true;
- tile.needsTerrainPrepare = true;
- tile.state = 'loaded';
- callback(null);
+ async readImageNow(img: ImageBitmap | HTMLImageElement): Promise {
+ if (typeof VideoFrame !== 'undefined' && isOffscreenCanvasDistorted()) {
+ const width = img.width + 2;
+ const height = img.height + 2;
+ try {
+ return new RGBAImage({width, height}, await readImageUsingVideoFrame(img, -1, -1, width, height));
+ } catch (e) {
+ // fall-back to browser canvas decoding
}
}
+ return browser.getImageData(img, 1);
}
_getNeighboringTiles(tileID: OverscaledTileID) {
@@ -148,7 +148,7 @@ export class RasterDEMTileSource extends RasterTileSource implements Source {
return neighboringTiles;
}
- unloadTile(tile: Tile) {
+ async unloadTile(tile: Tile) {
if (tile.demTexture) this.map.painter.saveTileTexture(tile.demTexture);
if (tile.fbo) {
tile.fbo.destroy();
@@ -159,7 +159,7 @@ export class RasterDEMTileSource extends RasterTileSource implements Source {
tile.state = 'unloaded';
if (tile.actor) {
- tile.actor.send('removeDEMTile', {uid: tile.uid, source: this.id});
+ await tile.actor.sendAsync({type: 'removeDEMTile', data: {type: this.type, uid: tile.uid, source: this.id}});
}
}
}
diff --git a/src/source/raster_dem_tile_worker_source.test.ts b/src/source/raster_dem_tile_worker_source.test.ts
index 0641912f57..f76e2e68a5 100644
--- a/src/source/raster_dem_tile_worker_source.test.ts
+++ b/src/source/raster_dem_tile_worker_source.test.ts
@@ -3,20 +3,17 @@ import {DEMData} from '../data/dem_data';
import {WorkerDEMTileParameters} from './worker_source';
describe('loadTile', () => {
- test('loads DEM tile', done => {
+ test('loads DEM tile', async () => {
const source = new RasterDEMTileWorkerSource();
- source.loadTile({
+ const data = await source.loadTile({
source: 'source',
uid: '0',
rawImageData: {data: new Uint8ClampedArray(256), height: 8, width: 8},
dim: 256
- } as any as WorkerDEMTileParameters, (err, data) => {
- if (err) done(err);
- expect(Object.keys(source.loaded)).toEqual(['0']);
- expect(data instanceof DEMData).toBeTruthy();
- done();
- });
+ } as any as WorkerDEMTileParameters);
+ expect(Object.keys(source.loaded)).toEqual(['0']);
+ expect(data instanceof DEMData).toBeTruthy();
});
});
@@ -30,7 +27,8 @@ describe('removeTile', () => {
source.removeTile({
source: 'source',
- uid: '0'
+ uid: '0',
+ type: 'raster-dem',
});
expect(source.loaded).toEqual({});
diff --git a/src/source/raster_dem_tile_worker_source.ts b/src/source/raster_dem_tile_worker_source.ts
index d283804633..f654ca1b8d 100644
--- a/src/source/raster_dem_tile_worker_source.ts
+++ b/src/source/raster_dem_tile_worker_source.ts
@@ -3,7 +3,6 @@ import {RGBAImage} from '../util/image';
import type {Actor} from '../util/actor';
import type {
WorkerDEMTileParameters,
- WorkerDEMTileCallback,
TileParameters
} from './worker_source';
import {getImageData, isImageBitmap} from '../util/util';
@@ -16,17 +15,17 @@ export class RasterDEMTileWorkerSource {
this.loaded = {};
}
- async loadTile(params: WorkerDEMTileParameters, callback: WorkerDEMTileCallback) {
+ async loadTile(params: WorkerDEMTileParameters): Promise {
const {uid, encoding, rawImageData, redFactor, greenFactor, blueFactor, baseShift} = params;
const width = rawImageData.width + 2;
const height = rawImageData.height + 2;
- const imagePixels: RGBAImage = isImageBitmap(rawImageData) ?
+ const imagePixels: RGBAImage | ImageData = isImageBitmap(rawImageData) ?
new RGBAImage({width, height}, await getImageData(rawImageData, -1, -1, width, height)) :
rawImageData;
const dem = new DEMData(uid, imagePixels, encoding, redFactor, greenFactor, blueFactor, baseShift);
this.loaded = this.loaded || {};
this.loaded[uid] = dem;
- callback(null, dem);
+ return dem;
}
removeTile(params: TileParameters) {
diff --git a/src/source/raster_tile_source.test.ts b/src/source/raster_tile_source.test.ts
index 74f589fd7e..76fcdfb3a1 100644
--- a/src/source/raster_tile_source.test.ts
+++ b/src/source/raster_tile_source.test.ts
@@ -15,9 +15,7 @@ function createSource(options, transformCallback?) {
getPixelRatio() { return 1; }
} as any);
- source.on('error', (e) => {
- throw e.error;
- });
+ source.on('error', () => { }); // to prevent console log of errors
return source;
}
@@ -124,7 +122,7 @@ describe('RasterTileSource', () => {
loadVectorData () {},
setExpiryData() {}
} as any as Tile;
- source.loadTile(tile, () => {});
+ source.loadTile(tile);
expect(transformSpy).toHaveBeenCalledTimes(1);
expect(transformSpy.mock.calls[0][0]).toBe('http://example.com/10/5/5.png');
expect(transformSpy.mock.calls[0][1]).toBe('Tile');
@@ -154,7 +152,7 @@ describe('RasterTileSource', () => {
tileID: new OverscaledTileID(10, 0, 10, 5, 5),
state: 'loading'
} as any as Tile;
- source.loadTile(tile, () => {
+ source.loadTile(tile).then(() => {
expect(imageConstructorSpy).toHaveBeenCalledTimes(1);
expect(tile.state).toBe('loaded');
done();
@@ -178,7 +176,7 @@ describe('RasterTileSource', () => {
test('cancels TileJSON request if removed', () => {
const source = createSource({url: '/source.json'});
source.onRemove();
- expect((server.requests.pop() as any).aborted).toBe(true);
+ expect((server.lastRequest as any).aborted).toBe(true);
});
it('serializes options', () => {
diff --git a/src/source/raster_tile_source.ts b/src/source/raster_tile_source.ts
index 66630c5099..74e6491aaa 100644
--- a/src/source/raster_tile_source.ts
+++ b/src/source/raster_tile_source.ts
@@ -13,8 +13,6 @@ import type {OverscaledTileID} from './tile_id';
import type {Map} from '../ui/map';
import type {Dispatcher} from '../util/dispatcher';
import type {Tile} from './tile';
-import type {Callback} from '../types/callback';
-import type {Cancelable} from '../types/cancelable';
import type {
RasterSourceSpecification,
RasterDEMSourceSpecification
@@ -67,7 +65,7 @@ export class RasterTileSource extends Evented implements Source {
_loaded: boolean;
_options: RasterSourceSpecification | RasterDEMSourceSpecification;
- _tileJSONRequest: Cancelable;
+ _tileJSONRequest: AbortController;
constructor(id: string, options: RasterSourceSpecification | RasterDEMSourceSpecification, dispatcher: Dispatcher, eventedParent: Evented) {
super();
@@ -87,15 +85,15 @@ export class RasterTileSource extends Evented implements Source {
extend(this, pick(options, ['url', 'scheme', 'tileSize']));
}
- load() {
+ async load() {
this._loaded = false;
this.fire(new Event('dataloading', {dataType: 'source'}));
- this._tileJSONRequest = loadTileJson(this._options, this.map._requestManager, (err, tileJSON) => {
+ this._tileJSONRequest = new AbortController();
+ try {
+ const tileJSON = await loadTileJson(this._options, this.map._requestManager, this._tileJSONRequest);
this._tileJSONRequest = null;
this._loaded = true;
- if (err) {
- this.fire(new ErrorEvent(err));
- } else if (tileJSON) {
+ if (tileJSON) {
extend(this, tileJSON);
if (tileJSON.bounds) this.tileBounds = new TileBounds(tileJSON.bounds, this.minzoom, this.maxzoom);
@@ -105,7 +103,10 @@ export class RasterTileSource extends Evented implements Source {
this.fire(new Event('data', {dataType: 'source', sourceDataType: 'metadata'}));
this.fire(new Event('data', {dataType: 'source', sourceDataType: 'content'}));
}
- });
+ } catch (err) {
+ this._tileJSONRequest = null;
+ this.fire(new ErrorEvent(err));
+ }
}
loaded(): boolean {
@@ -119,14 +120,15 @@ export class RasterTileSource extends Evented implements Source {
onRemove() {
if (this._tileJSONRequest) {
- this._tileJSONRequest.cancel();
+ this._tileJSONRequest.abort();
this._tileJSONRequest = null;
}
}
setSourceProperty(callback: Function) {
if (this._tileJSONRequest) {
- this._tileJSONRequest.cancel();
+ this._tileJSONRequest.abort();
+ this._tileJSONRequest = null;
}
callback();
@@ -156,22 +158,23 @@ export class RasterTileSource extends Evented implements Source {
return !this.tileBounds || this.tileBounds.contains(tileID.canonical);
}
- loadTile(tile: Tile, callback: Callback) {
+ async loadTile(tile: Tile): Promise {
const url = tile.tileID.canonical.url(this.tiles, this.map.getPixelRatio(), this.scheme);
- tile.request = ImageRequest.getImage(this.map._requestManager.transformRequest(url, ResourceType.Tile), (err, img, expiry) => {
- delete tile.request;
-
+ tile.abortController = new AbortController();
+ try {
+ const response = await ImageRequest.getImage(this.map._requestManager.transformRequest(url, ResourceType.Tile), tile.abortController, this.map._refreshExpiredTiles);
+ delete tile.abortController;
if (tile.aborted) {
tile.state = 'unloaded';
- callback(null);
- } else if (err) {
- tile.state = 'errored';
- callback(err);
- } else if (img) {
- if (this.map._refreshExpiredTiles && expiry) tile.setExpiryData(expiry);
-
+ return;
+ }
+ if (response && response.data) {
+ if (this.map._refreshExpiredTiles && response.cacheControl && response.expires) {
+ tile.setExpiryData({cacheControl: response.cacheControl, expires: response.expires});
+ }
const context = this.map.painter.context;
const gl = context.gl;
+ const img = response.data;
tile.texture = this.map.painter.getTileTexture(img.width);
if (tile.texture) {
tile.texture.update(img, {useMipmap: true});
@@ -183,25 +186,30 @@ export class RasterTileSource extends Evented implements Source {
gl.texParameterf(gl.TEXTURE_2D, context.extTextureFilterAnisotropic.TEXTURE_MAX_ANISOTROPY_EXT, context.extTextureFilterAnisotropicMax);
}
}
-
tile.state = 'loaded';
-
- callback(null);
}
- }, this.map._refreshExpiredTiles);
+ } catch (err) {
+ delete tile.abortController;
+ if (tile.aborted) {
+ tile.state = 'unloaded';
+ } else if (err) {
+ tile.state = 'errored';
+ throw err;
+ }
+ }
}
- abortTile(tile: Tile, callback: Callback) {
- if (tile.request) {
- tile.request.cancel();
- delete tile.request;
+ async abortTile(tile: Tile) {
+ if (tile.abortController) {
+ tile.abortController.abort();
+ delete tile.abortController;
}
- callback();
}
- unloadTile(tile: Tile, callback: Callback) {
- if (tile.texture) this.map.painter.saveTileTexture(tile.texture);
- callback();
+ async unloadTile(tile: Tile) {
+ if (tile.texture) {
+ this.map.painter.saveTileTexture(tile.texture);
+ }
}
hasTransition() {
diff --git a/src/source/rtl_text_plugin.ts b/src/source/rtl_text_plugin.ts
index 249919cad6..6e00b675ee 100644
--- a/src/source/rtl_text_plugin.ts
+++ b/src/source/rtl_text_plugin.ts
@@ -3,34 +3,44 @@ import {browser} from '../util/browser';
import {Event, Evented} from '../util/evented';
import {isWorker} from '../util/util';
-const status = {
- unavailable: 'unavailable', // Not loaded
- deferred: 'deferred', // The plugin URL has been specified, but loading has been deferred
- loading: 'loading', // request in-flight
- loaded: 'loaded',
- error: 'error'
-};
+/**
+ * The possible option of the plugin's status
+ *
+ * `unavailable`: Not loaded.
+ *
+ * `deferred`: The plugin URL has been specified, but loading has been deferred.
+ *
+ * `loading`: request in-flight.
+ *
+ * `loaded`: The plugin is now loaded
+ *
+ * `error`: The plugin failed to load
+ */
+type RTLPlginStatus = 'unavailable' | 'deferred' | 'loading' | 'loaded' | 'error';
+/**
+ * The RTL plugin state
+ */
export type PluginState = {
- pluginStatus: typeof status[keyof typeof status];
+ pluginStatus: RTLPlginStatus;
pluginURL: string;
};
/**
* An error callback
*/
-type ErrorCallback = (error?: Error | null) => void;
+type ErrorCallback = (error?: Error | string | null) => void;
type PluginStateSyncCallback = (state: PluginState) => void;
-let _completionCallback = null;
+let _completionCallback: ErrorCallback = null;
//Variables defining the current state of the plugin
-let pluginStatus = status.unavailable;
+let pluginStatus: RTLPlginStatus = 'unavailable';
let pluginURL = null;
-export const triggerPluginCompletionEvent = function(error: Error | string) {
+export const triggerPluginCompletionEvent = (error: string | Error) => {
// NetworkError's are not correctly reflected by the plugin status which prevents reloading plugin
if (error && typeof error === 'string' && error.indexOf('NetworkError') > -1) {
- pluginStatus = status.error;
+ pluginStatus = 'error';
}
if (_completionCallback) {
@@ -44,11 +54,11 @@ function sendPluginStateToWorker() {
export const evented = new Evented();
-export const getRTLTextPluginStatus = function () {
+export const getRTLTextPluginStatus = () => {
return pluginStatus;
};
-export const registerForPluginStateChange = function(callback: PluginStateSyncCallback) {
+export const registerForPluginStateChange = (callback: PluginStateSyncCallback) => {
// Do an initial sync of the state
callback({pluginStatus, pluginURL});
// Listen for all future state changes
@@ -56,18 +66,18 @@ export const registerForPluginStateChange = function(callback: PluginStateSyncCa
return callback;
};
-export const clearRTLTextPlugin = function() {
- pluginStatus = status.unavailable;
+export const clearRTLTextPlugin = () => {
+ pluginStatus = 'unavailable';
pluginURL = null;
_completionCallback = null;
};
-export const setRTLTextPlugin = function(url: string, callback: ErrorCallback, deferred: boolean = false) {
- if (pluginStatus === status.deferred || pluginStatus === status.loading || pluginStatus === status.loaded) {
+export const setRTLTextPlugin = (url: string, callback?: ErrorCallback, deferred: boolean = false) => {
+ if (pluginStatus === 'deferred' || pluginStatus === 'loading' || pluginStatus === 'loaded') {
throw new Error('setRTLTextPlugin cannot be called multiple times.');
}
pluginURL = browser.resolveURL(url);
- pluginStatus = status.deferred;
+ pluginStatus = 'deferred';
_completionCallback = callback;
sendPluginStateToWorker();
@@ -77,26 +87,24 @@ export const setRTLTextPlugin = function(url: string, callback: ErrorCallback, d
}
};
-export const downloadRTLTextPlugin = function() {
- if (pluginStatus !== status.deferred || !pluginURL) {
+export const downloadRTLTextPlugin = () => {
+ if (pluginStatus !== 'deferred' || !pluginURL) {
throw new Error('rtl-text-plugin cannot be downloaded unless a pluginURL is specified');
}
- pluginStatus = status.loading;
+ pluginStatus = 'loading';
sendPluginStateToWorker();
- if (pluginURL) {
- getArrayBuffer({url: pluginURL}, (error) => {
- if (error) {
- triggerPluginCompletionEvent(error);
- } else {
- pluginStatus = status.loaded;
- sendPluginStateToWorker();
- }
- });
- }
+ getArrayBuffer({url: pluginURL}, new AbortController()).then(() => {
+ pluginStatus = 'loaded';
+ sendPluginStateToWorker();
+ }).catch((error) => {
+ if (error) {
+ triggerPluginCompletionEvent(error);
+ }
+ });
};
export const plugin: {
- applyArabicShaping: Function;
+ applyArabicShaping: (text: string) => string;
processBidirectionalText: ((b: string, a: Array) => Array);
processStyledBidirectionalText: ((c: string, b: Array, a: Array) => Array<[string, Array]>);
isLoaded: () => boolean;
@@ -109,32 +117,32 @@ export const plugin: {
processBidirectionalText: null,
processStyledBidirectionalText: null,
isLoaded() {
- return pluginStatus === status.loaded || // Main Thread: loaded if the completion callback returned successfully
+ return pluginStatus === 'loaded' || // Main Thread: loaded if the completion callback returned successfully
plugin.applyArabicShaping != null; // Web-worker: loaded if the plugin functions have been compiled
},
isLoading() { // Main Thread Only: query the loading status, this function does not return the correct value in the worker context.
- return pluginStatus === status.loading;
+ return pluginStatus === 'loading';
},
setState(state: PluginState) { // Worker thread only: this tells the worker threads that the plugin is available on the Main thread
- if (!isWorker()) throw new Error('Cannot set the state of the rtl-text-plugin when not in the web-worker context');
+ if (!isWorker(self)) throw new Error('Cannot set the state of the rtl-text-plugin when not in the web-worker context');
pluginStatus = state.pluginStatus;
pluginURL = state.pluginURL;
},
isParsed(): boolean {
- if (!isWorker()) throw new Error('rtl-text-plugin is only parsed on the worker-threads');
+ if (!isWorker(self)) throw new Error('rtl-text-plugin is only parsed on the worker-threads');
return plugin.applyArabicShaping != null &&
plugin.processBidirectionalText != null &&
plugin.processStyledBidirectionalText != null;
},
getPluginURL(): string {
- if (!isWorker()) throw new Error('rtl-text-plugin url can only be queried from the worker threads');
+ if (!isWorker(self)) throw new Error('rtl-text-plugin url can only be queried from the worker threads');
return pluginURL;
}
};
-export const lazyLoadRTLTextPlugin = function() {
+export const lazyLoadRTLTextPlugin = () => {
if (!plugin.isLoading() &&
!plugin.isLoaded() &&
getRTLTextPluginStatus() === 'deferred'
diff --git a/src/source/source.ts b/src/source/source.ts
index 34c5ce8876..622dddc5ac 100644
--- a/src/source/source.ts
+++ b/src/source/source.ts
@@ -12,7 +12,6 @@ import type {Event, Evented} from '../util/evented';
import type {Map} from '../ui/map';
import type {Tile} from './tile';
import type {OverscaledTileID, CanonicalTileID} from './tile_id';
-import type {Callback} from '../types/callback';
import type {CanvasSourceSpecification} from '../source/canvas_source';
const registeredSources = {} as {[key:string]: SourceClass};
@@ -33,9 +32,21 @@ export interface Source {
* The id for the source. Must not be used by any existing source.
*/
id: string;
+ /**
+ * The minimum zoom level for the source.
+ */
minzoom: number;
+ /**
+ * The maximum zoom level for the source.
+ */
maxzoom: number;
+ /**
+ * The tile size for the source.
+ */
tileSize: number;
+ /**
+ * The attribution for the source.
+ */
attribution?: string;
/**
* `true` if zoom levels are rounded to the nearest integer in the source data, `false` if they are floor-ed to the nearest integer.
@@ -51,22 +62,60 @@ export interface Source {
*/
reparseOverscaled?: boolean;
vectorLayerIds?: Array;
+ /**
+ * True if the source has transiotion, false otherwise.
+ */
hasTransition(): boolean;
+ /**
+ * True if the source is loaded, false otherwise.
+ */
loaded(): boolean;
+ /**
+ * An ability to fire an event to all the listeners, see {@link Evented}
+ * @param event - The event to fire
+ */
fire(event: Event): unknown;
- readonly onAdd?: (map: Map) => void;
- readonly onRemove?: (map: Map) => void;
- loadTile(tile: Tile, callback: Callback): void;
- readonly hasTile?: (tileID: OverscaledTileID) => boolean;
- readonly abortTile?: (tile: Tile, callback: Callback) => void;
- readonly unloadTile?: (tile: Tile, callback: Callback) => void;
+ /**
+ * This method is called when the source is added to the map.
+ * @param map - The map instance
+ */
+ onAdd?(map: Map): void;
+ /**
+ * This method is called when the source is removed from the map.
+ * @param map - The map instance
+ */
+ onRemove?(map: Map): void;
+ /**
+ * This method does the heavy lifting of loading a tile.
+ * In most cases it will defer the work to the relevant worker source.
+ * @param tile - The tile to load
+ */
+ loadTile(tile: Tile): Promise;
+ /**
+ * True is the tile is part of the source, false otherwise.
+ * @param tileID - The tile ID
+ */
+ hasTile?(tileID: OverscaledTileID): boolean;
+ /**
+ * Allows to abort a tile loading.
+ * @param tile - The tile to abort
+ */
+ abortTile?(tile: Tile): Promise;
+ /**
+ * Allows to unload a tile.
+ * @param tile - The tile to unload
+ */
+ unloadTile?(tile: Tile): Promise;
/**
* @returns A plain (stringifiable) JS object representing the current state of the source.
* Creating a source using the returned object as the `options` should result in a Source that is
* equivalent to this one.
*/
serialize(): any;
- readonly prepare?: () => void;
+ /**
+ * Allows to execute a prepare step before the source is used.
+ */
+ prepare?(): void;
}
/**
@@ -133,7 +182,3 @@ export const getSourceType = (name: string): SourceClass => {
export const setSourceType = (name: string, type: SourceClass) => {
registeredSources[name] = type;
};
-
-export interface Actor {
- send(type: string, data: any, callback: Callback): void;
-}
diff --git a/src/source/source_cache.test.ts b/src/source/source_cache.test.ts
index 0bf9e8faae..804dee9e2c 100644
--- a/src/source/source_cache.test.ts
+++ b/src/source/source_cache.test.ts
@@ -1,5 +1,5 @@
import {SourceCache} from './source_cache';
-import {setSourceType} from './source';
+import {Source, setSourceType} from './source';
import {Tile} from './tile';
import {OverscaledTileID} from './tile_id';
import {Transform} from '../geo/transform';
@@ -11,13 +11,16 @@ import {browser} from '../util/browser';
import {Dispatcher} from '../util/dispatcher';
import {Callback} from '../types/callback';
import {TileBounds} from './tile_bounds';
+import {sleep} from '../util/test/util';
-class SourceMock extends Evented {
+class SourceMock extends Evented implements Source {
id: string;
minzoom: number;
maxzoom: number;
hasTile: (tileID: OverscaledTileID) => boolean;
sourceOptions: any;
+ type: string;
+ tileSize: number;
constructor(id: string, sourceOptions: any, _dispatcher, eventedParent: Evented) {
super();
@@ -31,13 +34,18 @@ class SourceMock extends Evented {
this.hasTile = sourceOptions.hasTile;
}
}
- loadTile(tile: Tile, callback: Callback) {
+ loadTile(tile: Tile, callback?: Callback): Promise {
if (this.sourceOptions.expires) {
tile.setExpiryData({
expires: this.sourceOptions.expires
});
}
- setTimeout(callback, 0);
+ if (callback) {
+ setTimeout(callback, 0);
+ } else {
+ return new Promise(resolve => setTimeout(resolve, 0));
+ }
+
}
loaded() {
return true;
@@ -50,9 +58,12 @@ class SourceMock extends Evented {
this.fire(new Event('data', {dataType: 'source', sourceDataType: 'metadata'}));
}
}
- abortTile() {}
- unloadTile() {}
+ async abortTile() {}
+ async unloadTile() {}
serialize() {}
+ hasTransition(): boolean {
+ return false;
+ }
}
// Add a mocked source type for use in these tests
@@ -84,13 +95,12 @@ afterEach(() => {
describe('SourceCache#addTile', () => {
test('loads tile when uncached', done => {
const tileID = new OverscaledTileID(0, 0, 0, 0, 0);
- const sourceCache = createSourceCache({
- loadTile(tile) {
- expect(tile.tileID).toEqual(tileID);
- expect(tile.uses).toBe(0);
- done();
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ expect(tile.tileID).toEqual(tileID);
+ expect(tile.uses).toBe(0);
+ done();
+ };
sourceCache.onAdd(undefined);
sourceCache._addTile(tileID);
});
@@ -109,17 +119,15 @@ describe('SourceCache#addTile', () => {
test('updates feature state on added uncached tile', done => {
const tileID = new OverscaledTileID(0, 0, 0, 0, 0);
let updateFeaturesSpy;
- const sourceCache = createSourceCache({
- loadTile(tile, callback) {
- sourceCache.on('data', () => {
- expect(updateFeaturesSpy).toHaveBeenCalledTimes(1);
- done();
- });
- updateFeaturesSpy = jest.spyOn(tile, 'setFeatureState');
- tile.state = 'loaded';
- callback();
- }
- });
+ const sourceCache = createSourceCache({});
+ sourceCache._source.loadTile = async (tile) => {
+ sourceCache.on('data', () => {
+ expect(updateFeaturesSpy).toHaveBeenCalledTimes(1);
+ done();
+ });
+ updateFeaturesSpy = jest.spyOn(tile, 'setFeatureState');
+ tile.state = 'loaded';
+ };
sourceCache.onAdd(undefined);
sourceCache._addTile(tileID);
});
@@ -129,13 +137,12 @@ describe('SourceCache#addTile', () => {
let load = 0,
add = 0;
- const sourceCache = createSourceCache({
- loadTile(tile, callback) {
- tile.state = 'loaded';
- load++;
- callback();
- }
- }).on('dataloading', () => { add++; });
+ const sourceCache = createSourceCache({});
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = 'loaded';
+ load++;
+ };
+ sourceCache.on('dataloading', () => { add++; });
const tr = new Transform();
tr.width = 512;
@@ -153,12 +160,10 @@ describe('SourceCache#addTile', () => {
test('updates feature state on cached tile', () => {
const tileID = new OverscaledTileID(0, 0, 0, 0, 0);
- const sourceCache = createSourceCache({
- loadTile(tile, callback) {
- tile.state = 'loaded';
- callback();
- }
- });
+ const sourceCache = createSourceCache({});
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = 'loaded';
+ };
const tr = new Transform();
tr.width = 512;
@@ -184,11 +189,10 @@ describe('SourceCache#addTile', () => {
sourceCache._setTileReloadTimer = (id) => {
sourceCache._timers[id] = setTimeout(() => {}, 0);
};
- sourceCache._loadTile = (tile, callback) => {
+ sourceCache._source.loadTile = async (tile) => {
tile.state = 'loaded';
tile.getExpiryTimeout = () => 1000 * 60;
sourceCache._setTileReloadTimer(tileID.key, tile);
- callback();
};
const tr = new Transform();
@@ -222,13 +226,12 @@ describe('SourceCache#addTile', () => {
let load = 0,
add = 0;
- const sourceCache = createSourceCache({
- loadTile(tile, callback) {
- tile.state = 'loaded';
- load++;
- callback();
- }
- }).on('dataloading', () => { add++; });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = 'loaded';
+ load++;
+ };
+ sourceCache.on('dataloading', () => { add++; });
const t1 = sourceCache._addTile(tileID);
const t2 = sourceCache._addTile(new OverscaledTileID(0, 1, 0, 0, 0));
@@ -276,16 +279,13 @@ describe('SourceCache#removeTile', () => {
});
});
- test('caches (does not unload) loaded tile', done => {
+ test('caches (does not unload) loaded tile', () => {
const tileID = new OverscaledTileID(0, 0, 0, 0, 0);
- const sourceCache = createSourceCache({
- loadTile(tile) {
- tile.state = 'loaded';
- },
- unloadTile() {
- done('test failed: unloadTile has been called');
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = 'loaded';
+ };
+ sourceCache._source.unloadTile = jest.fn();
const tr = new Transform();
tr.width = 512;
@@ -295,7 +295,7 @@ describe('SourceCache#removeTile', () => {
sourceCache._addTile(tileID);
sourceCache._removeTile(tileID.key);
- done();
+ expect(sourceCache._source.unloadTile).not.toHaveBeenCalled();
});
test('aborts and unloads unfinished tile', () => {
@@ -303,16 +303,15 @@ describe('SourceCache#removeTile', () => {
let abort = 0,
unload = 0;
- const sourceCache = createSourceCache({
- abortTile(tile) {
- expect(tile.tileID).toEqual(tileID);
- abort++;
- },
- unloadTile(tile) {
- expect(tile.tileID).toEqual(tileID);
- unload++;
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.abortTile = async (tile) => {
+ expect(tile.tileID).toEqual(tileID);
+ abort++;
+ };
+ sourceCache._source.unloadTile = async (tile) => {
+ expect(tile.tileID).toEqual(tileID);
+ unload++;
+ };
sourceCache._addTile(tileID);
sourceCache._removeTile(tileID.key);
@@ -325,24 +324,21 @@ describe('SourceCache#removeTile', () => {
test('_tileLoaded after _removeTile skips tile.added', () => {
const tileID = new OverscaledTileID(0, 0, 0, 0, 0);
- const sourceCache = createSourceCache({
- loadTile(tile, callback) {
- tile.added = undefined;
- sourceCache._removeTile(tileID.key);
- callback();
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async () => {
+ sourceCache._removeTile(tileID.key);
+ };
sourceCache.map = {painter: {crossTileSymbolIndex: '', tileExtentVAO: {}}} as any;
sourceCache._addTile(tileID);
});
test('fires dataabort event', async () => {
- const sourceCache = createSourceCache({
- loadTile() {
- // Do not call back in order to make sure the tile is removed before it is loaded.
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = () => {
+ // Do not call back in order to make sure the tile is removed before it is loaded.
+ return new Promise(() => {});
+ };
const tileID = new OverscaledTileID(0, 0, 0, 0, 0);
const tile = sourceCache._addTile(tileID);
const abortPromise = sourceCache.once('dataabort');
@@ -354,12 +350,10 @@ describe('SourceCache#removeTile', () => {
});
test('does not fire dataabort event when the tile has already been loaded', () => {
- const sourceCache = createSourceCache({
- loadTile(tile, callback) {
- tile.state = 'loaded';
- callback();
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = 'loaded';
+ };
const tileID = new OverscaledTileID(0, 0, 0, 0, 0);
sourceCache._addTile(tileID);
const onAbort = jest.fn();
@@ -370,15 +364,13 @@ describe('SourceCache#removeTile', () => {
test('does not fire data event when the tile has already been aborted', () => {
const onData = jest.fn();
- const sourceCache = createSourceCache({
- loadTile(tile, callback) {
- sourceCache.once('dataabort', () => {
- tile.state = 'loaded';
- callback();
- expect(onData).toHaveBeenCalledTimes(0);
- });
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ sourceCache.once('dataabort', () => {
+ tile.state = 'loaded';
+ expect(onData).toHaveBeenCalledTimes(0);
+ });
+ };
sourceCache.once('data', onData);
const tileID = new OverscaledTileID(0, 0, 0, 0, 0);
sourceCache._addTile(tileID);
@@ -437,11 +429,11 @@ describe('SourceCache / Source lifecycle', () => {
const transform = new Transform();
transform.resize(511, 511);
transform.zoom = 0;
- const sourceCache = createSourceCache({
- loadTile (tile, callback) {
- callback('error');
- }
- }).on('data', (e) => {
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async () => {
+ throw new Error('Error loading tile');
+ };
+ sourceCache.on('data', (e) => {
if (e.dataType === 'source' && e.sourceDataType === 'metadata') {
sourceCache.update(transform);
}
@@ -488,13 +480,11 @@ describe('SourceCache / Source lifecycle', () => {
const expected = [new OverscaledTileID(0, 0, 0, 0, 0).key, new OverscaledTileID(0, 0, 0, 0, 0).key];
expect.assertions(expected.length);
- const sourceCache = createSourceCache({
- loadTile (tile, callback) {
- expect(tile.tileID.key).toBe(expected.shift());
- tile.loaded = true;
- callback();
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ expect(tile.tileID.key).toBe(expected.shift());
+ tile.state = 'loaded';
+ };
sourceCache.on('data', (e) => {
if (e.dataType === 'source' && e.sourceDataType === 'metadata') {
@@ -511,14 +501,12 @@ describe('SourceCache / Source lifecycle', () => {
transform.resize(511, 511);
transform.zoom = 1;
- const sourceCache = createSourceCache({
- loadTile (tile, callback) {
- // this transform will try to load the four tiles at z1 and a single z0 tile
- // we only expect _reloadTile to be called with the 'loaded' z0 tile
- tile.state = tile.tileID.canonical.z === 1 ? 'errored' : 'loaded';
- callback();
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ // this transform will try to load the four tiles at z1 and a single z0 tile
+ // we only expect _reloadTile to be called with the 'loaded' z0 tile
+ tile.state = tile.tileID.canonical.z === 1 ? 'errored' : 'loaded';
+ };
const reloadTileSpy = jest.spyOn(sourceCache, '_reloadTile');
sourceCache.on('data', (e) => {
@@ -595,12 +583,10 @@ describe('SourceCache#update', () => {
transform.resize(511, 511);
transform.zoom = 0;
- const sourceCache = createSourceCache({
- loadTile: (tile, callback) => {
- tile.state = 'loaded';
- callback(null);
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = 'loaded';
+ };
sourceCache.on('data', (e) => {
if (e.sourceDataType === 'metadata') {
@@ -629,12 +615,10 @@ describe('SourceCache#update', () => {
transform.resize(511, 511);
transform.zoom = 0;
- const sourceCache = createSourceCache({
- loadTile(tile, callback) {
- tile.state = (tile.tileID.key === new OverscaledTileID(0, 0, 0, 0, 0).key) ? 'loaded' : 'loading';
- callback();
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = (tile.tileID.key === new OverscaledTileID(0, 0, 0, 0, 0).key) ? 'loaded' : 'loading';
+ };
sourceCache.on('data', (e) => {
if (e.sourceDataType === 'metadata') {
@@ -663,12 +647,10 @@ describe('SourceCache#update', () => {
transform.zoom = 0;
transform.center = new LngLat(360, 0);
- const sourceCache = createSourceCache({
- loadTile(tile, callback) {
- tile.state = (tile.tileID.key === new OverscaledTileID(0, 1, 0, 0, 0).key) ? 'loaded' : 'loading';
- callback();
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = (tile.tileID.key === new OverscaledTileID(0, 1, 0, 0, 0).key) ? 'loaded' : 'loading';
+ };
sourceCache.on('data', (e) => {
if (e.sourceDataType === 'metadata') {
@@ -696,14 +678,12 @@ describe('SourceCache#update', () => {
transform.resize(511, 511);
transform.zoom = 2;
- const sourceCache = createSourceCache({
- loadTile(tile, callback) {
- tile.timeAdded = Infinity;
- tile.state = 'loaded';
- tile.registerFadeDuration(100);
- callback();
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ tile.timeAdded = Infinity;
+ tile.state = 'loaded';
+ tile.registerFadeDuration(100);
+ };
(sourceCache._source as any).type = 'raster';
@@ -732,14 +712,12 @@ describe('SourceCache#update', () => {
transform.resize(511, 511);
transform.zoom = 0;
- const sourceCache = createSourceCache({
- loadTile(tile, callback) {
- tile.timeAdded = Infinity;
- tile.state = 'loaded';
- tile.registerFadeDuration(100);
- callback();
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ tile.timeAdded = Infinity;
+ tile.state = 'loaded';
+ tile.registerFadeDuration(100);
+ };
(sourceCache._source as any).type = 'raster';
@@ -765,15 +743,12 @@ describe('SourceCache#update', () => {
transform.resize(511, 511);
transform.zoom = 1;
- const sourceCache = createSourceCache({
-
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
// not setting fadeEndTime because class Tile default is 0, and need to be tested
- loadTile(tile, callback) {
- tile.timeAdded = Date.now();
- tile.state = 'loaded';
- callback();
- }
- });
+ tile.timeAdded = Date.now();
+ tile.state = 'loaded';
+ };
(sourceCache._source as any).type = 'raster';
@@ -802,14 +777,12 @@ describe('SourceCache#update', () => {
let time = start;
jest.spyOn(browser, 'now').mockImplementation(() => time);
- const sourceCache = createSourceCache({
- loadTile(tile, callback) {
- tile.timeAdded = browser.now();
- tile.state = 'loaded';
- tile.fadeEndTime = browser.now() + fadeTime;
- callback();
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ tile.timeAdded = browser.now();
+ tile.state = 'loaded';
+ tile.fadeEndTime = browser.now() + fadeTime;
+ };
(sourceCache._source as any).type = 'raster';
@@ -845,13 +818,10 @@ describe('SourceCache#update', () => {
// use slightly offset center so that sort order is better defined
transform.center = new LngLat(-0.001, 0.001);
- const sourceCache = createSourceCache({
- reparseOverscaled: true,
- loadTile(tile, callback) {
- tile.state = tile.tileID.overscaledZ === 16 ? 'loaded' : 'loading';
- callback();
- }
- });
+ const sourceCache = createSourceCache({reparseOverscaled: true});
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = tile.tileID.overscaledZ === 16 ? 'loaded' : 'loading';
+ };
sourceCache.on('data', (e) => {
if (e.sourceDataType === 'metadata') {
@@ -910,12 +880,10 @@ describe('SourceCache#_updateRetainedTiles', () => {
test('loads ideal tiles if they exist', () => {
const stateCache = {};
- const sourceCache = createSourceCache({
- loadTile(tile, callback) {
- tile.state = stateCache[tile.tileID.key] || 'errored';
- callback();
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = stateCache[tile.tileID.key] || 'errored';
+ };
const getTileSpy = jest.spyOn(sourceCache, 'getTile');
const idealTile = new OverscaledTileID(1, 0, 1, 1, 1);
@@ -926,12 +894,10 @@ describe('SourceCache#_updateRetainedTiles', () => {
});
test('retains all loaded children ', () => {
- const sourceCache = createSourceCache({
- loadTile(tile, callback) {
- tile.state = 'errored';
- callback();
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = 'errored';
+ };
const idealTile = new OverscaledTileID(3, 0, 3, 1, 2);
sourceCache._tiles[idealTile.key] = new Tile(idealTile, undefined);
@@ -966,12 +932,10 @@ describe('SourceCache#_updateRetainedTiles', () => {
test('adds parent tile if ideal tile errors and no child tiles are loaded', () => {
const stateCache = {};
- const sourceCache = createSourceCache({
- loadTile(tile, callback) {
- tile.state = stateCache[tile.tileID.key] || 'errored';
- callback();
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = stateCache[tile.tileID.key] || 'errored';
+ };
jest.spyOn(sourceCache, '_addTile');
const getTileSpy = jest.spyOn(sourceCache, 'getTile');
@@ -997,12 +961,10 @@ describe('SourceCache#_updateRetainedTiles', () => {
});
test('don\'t use wrong parent tile', () => {
- const sourceCache = createSourceCache({
- loadTile(tile, callback) {
- tile.state = 'errored';
- callback();
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = 'errored';
+ };
const idealTile = new OverscaledTileID(2, 0, 2, 0, 0);
sourceCache._tiles[idealTile.key] = new Tile(idealTile, undefined);
@@ -1031,12 +993,10 @@ describe('SourceCache#_updateRetainedTiles', () => {
});
test('use parent tile when ideal tile is not loaded', () => {
- const sourceCache = createSourceCache({
- loadTile(tile, callback) {
- tile.state = 'loading';
- callback();
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = 'loading';
+ };
const idealTile = new OverscaledTileID(1, 0, 1, 0, 1);
const parentTile = new OverscaledTileID(0, 0, 0, 0, 0);
sourceCache._tiles[idealTile.key] = new Tile(idealTile, undefined);
@@ -1076,12 +1036,10 @@ describe('SourceCache#_updateRetainedTiles', () => {
});
test('don\'t load parent if all immediate children are loaded', () => {
- const sourceCache = createSourceCache({
- loadTile(tile, callback) {
- tile.state = 'loading';
- callback();
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = 'loading';
+ };
const idealTile = new OverscaledTileID(2, 0, 2, 1, 1);
const loadedTiles = [new OverscaledTileID(3, 0, 3, 2, 2), new OverscaledTileID(3, 0, 3, 3, 2), new OverscaledTileID(3, 0, 3, 2, 3), new OverscaledTileID(3, 0, 3, 3, 3)];
@@ -1098,12 +1056,10 @@ describe('SourceCache#_updateRetainedTiles', () => {
});
test('prefer loaded child tiles to parent tiles', () => {
- const sourceCache = createSourceCache({
- loadTile(tile, callback) {
- tile.state = 'loading';
- callback();
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = 'loading';
+ };
const idealTile = new OverscaledTileID(1, 0, 1, 0, 0);
const loadedTiles = [new OverscaledTileID(0, 0, 0, 0, 0), new OverscaledTileID(2, 0, 2, 0, 0)];
loadedTiles.forEach(t => {
@@ -1144,13 +1100,10 @@ describe('SourceCache#_updateRetainedTiles', () => {
});
test('don\'t use tiles below minzoom', () => {
- const sourceCache = createSourceCache({
- loadTile(tile, callback) {
- tile.state = 'loading';
- callback();
- },
- minzoom: 2
- });
+ const sourceCache = createSourceCache({minzoom: 2});
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = 'loading';
+ };
const idealTile = new OverscaledTileID(2, 0, 2, 0, 0);
const loadedTiles = [new OverscaledTileID(1, 0, 1, 0, 0)];
loadedTiles.forEach(t => {
@@ -1161,6 +1114,8 @@ describe('SourceCache#_updateRetainedTiles', () => {
const getTileSpy = jest.spyOn(sourceCache, 'getTile');
const retained = sourceCache._updateRetainedTiles([idealTile], 2);
+ sleep(10);
+
expect(getTileSpy.mock.calls.map((c) => { return c[0]; })).toEqual([]);
expect(retained).toEqual({
@@ -1171,13 +1126,10 @@ describe('SourceCache#_updateRetainedTiles', () => {
});
test('use overzoomed tile above maxzoom', () => {
- const sourceCache = createSourceCache({
- loadTile(tile, callback) {
- tile.state = 'loading';
- callback();
- },
- maxzoom: 2
- });
+ const sourceCache = createSourceCache({maxzoom: 2});
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = 'loading';
+ };
const idealTile = new OverscaledTileID(2, 0, 2, 0, 0);
const getTileSpy = jest.spyOn(sourceCache, 'getTile');
@@ -1199,12 +1151,10 @@ describe('SourceCache#_updateRetainedTiles', () => {
});
test('dont\'t ascend multiple times if a tile is not found', () => {
- const sourceCache = createSourceCache({
- loadTile(tile, callback) {
- tile.state = 'loading';
- callback();
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = 'loading';
+ };
const idealTiles = [new OverscaledTileID(8, 0, 8, 0, 0), new OverscaledTileID(8, 0, 8, 1, 0)];
const getTileSpy = jest.spyOn(sourceCache, 'getTile');
@@ -1241,12 +1191,10 @@ describe('SourceCache#_updateRetainedTiles', () => {
});
test('Only retain loaded parent tile when zooming in', () => {
- const sourceCache = createSourceCache({
- loadTile(tile, callback) {
- tile.state = 'loading';
- callback();
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = 'loading';
+ };
let idealTiles = [new OverscaledTileID(9, 0, 9, 0, 0), new OverscaledTileID(9, 0, 9, 1, 0)];
let retained = sourceCache._updateRetainedTiles(idealTiles, 9);
@@ -1280,12 +1228,10 @@ describe('SourceCache#_updateRetainedTiles', () => {
});
test('Only retain loaded child tile when zooming out', () => {
- const sourceCache = createSourceCache({
- loadTile(tile, callback) {
- tile.state = 'loading';
- callback();
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = 'loading';
+ };
let idealTiles = [new OverscaledTileID(7, 0, 7, 0, 0), new OverscaledTileID(7, 0, 7, 1, 0)];
let retained = sourceCache._updateRetainedTiles(idealTiles, 7);
@@ -1319,13 +1265,10 @@ describe('SourceCache#_updateRetainedTiles', () => {
});
test('adds correct loaded parent tiles for overzoomed tiles', () => {
- const sourceCache = createSourceCache({
- loadTile(tile, callback) {
- tile.state = 'loading';
- callback();
- },
- maxzoom: 7
- });
+ const sourceCache = createSourceCache({maxzoom: 7});
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = 'loading';
+ };
const loadedTiles = [new OverscaledTileID(7, 0, 7, 0, 0), new OverscaledTileID(7, 0, 7, 1, 0)];
loadedTiles.forEach(t => {
sourceCache._tiles[t.key] = new Tile(t, undefined);
@@ -1352,16 +1295,15 @@ describe('SourceCache#clearTiles', () => {
let abort = 0,
unload = 0;
- const sourceCache = createSourceCache({
- abortTile(tile) {
- expect(tile.tileID).toEqual(coord);
- abort++;
- },
- unloadTile(tile) {
- expect(tile.tileID).toEqual(coord);
- unload++;
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.abortTile = async (tile) => {
+ expect(tile.tileID).toEqual(coord);
+ abort++;
+ };
+ sourceCache._source.unloadTile = async (tile) => {
+ expect(tile.tileID).toEqual(coord);
+ unload++;
+ };
sourceCache.onAdd(undefined);
sourceCache._addTile(coord);
@@ -1401,13 +1343,10 @@ describe('SourceCache#tilesIn', () => {
transform.zoom = 1;
transform.center = new LngLat(0, 1);
- const sourceCache = createSourceCache({
- loadTile(tile, callback) {
- tile.state = 'loaded';
- tile.additionalRadius = 0;
- callback();
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = 'loaded';
+ };
sourceCache.on('data', (e) => {
if (e.sourceDataType === 'metadata') {
@@ -1447,16 +1386,14 @@ describe('SourceCache#tilesIn', () => {
test('reparsed overscaled tiles', () => {
const sourceCache = createSourceCache({
- loadTile(tile, callback) {
- tile.state = 'loaded';
- tile.additionalRadius = 0;
- callback();
- },
reparseOverscaled: true,
minzoom: 1,
maxzoom: 1,
tileSize: 512
});
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = 'loaded';
+ };
sourceCache.on('data', (e) => {
if (e.sourceDataType === 'metadata') {
@@ -1498,12 +1435,14 @@ describe('SourceCache#tilesIn', () => {
test('overscaled tiles', done => {
const sourceCache = createSourceCache({
- loadTile(tile, callback) { tile.state = 'loaded'; callback(); },
reparseOverscaled: false,
minzoom: 1,
maxzoom: 1,
tileSize: 512
});
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = 'loaded';
+ };
sourceCache.on('data', (e) => {
if (e.sourceDataType === 'metadata') {
@@ -1521,12 +1460,10 @@ describe('SourceCache#tilesIn', () => {
describe('source cache loaded', () => {
test('SourceCache#loaded (no errors)', done => {
- const sourceCache = createSourceCache({
- loadTile(tile, callback) {
- tile.state = 'loaded';
- callback();
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = 'loaded';
+ };
sourceCache.on('data', (e) => {
if (e.sourceDataType === 'metadata') {
@@ -1545,11 +1482,11 @@ describe('source cache loaded', () => {
});
test('SourceCache#loaded (with errors)', done => {
- const sourceCache = createSourceCache({
- loadTile(tile) {
- tile.state = 'errored';
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = 'errored';
+ throw new Error('Error');
+ };
sourceCache.on('data', (e) => {
if (e.sourceDataType === 'metadata') {
@@ -1568,11 +1505,11 @@ describe('source cache loaded', () => {
});
test('SourceCache#loaded (unused)', done => {
- const sourceCache = createSourceCache({
- loadTile(tile) {
- tile.state = 'errored';
- }
- }, false);
+ const sourceCache = createSourceCache(undefined, false);
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = 'errored';
+ throw new Error('Error');
+ };
sourceCache.on('data', (e) => {
if (e.sourceDataType === 'metadata') {
@@ -1584,11 +1521,11 @@ describe('source cache loaded', () => {
});
test('SourceCache#loaded (unusedForTerrain)', done => {
- const sourceCache = createSourceCache({
- loadTile(tile) {
- tile.state = 'errored';
- }
- }, false);
+ const sourceCache = createSourceCache(undefined, false);
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = 'errored';
+ throw new Error('Error');
+ };
sourceCache.usedForTerrain = false;
sourceCache.on('data', (e) => {
@@ -1601,11 +1538,11 @@ describe('source cache loaded', () => {
});
test('SourceCache#loaded (not loaded when no update)', done => {
- const sourceCache = createSourceCache({
- loadTile(tile) {
- tile.state = 'errored';
- }
- });
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = 'errored';
+ throw new Error('Error');
+ };
sourceCache.on('data', (e) => {
if (e.sourceDataType === 'metadata') {
@@ -1617,18 +1554,19 @@ describe('source cache loaded', () => {
});
test('SourceCache#loaded (on last tile load)', done => {
- const sourceCache = createSourceCache({
- hasTile(tileID: OverscaledTileID) {
- return !this.tileBounds || this.tileBounds.contains(tileID.canonical);
- },
- loadTile(tile, callback) {
- tile.state = 'loading';
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = 'loading';
+ return new Promise((resolve) => {
setTimeout(() => {
tile.state = 'loaded';
- callback();
+ resolve();
});
- }
- });
+ });
+ };
+ sourceCache._source.hasTile = function (tileID: OverscaledTileID) {
+ return !this.tileBounds || this.tileBounds.contains(tileID.canonical);
+ };
const tr = new Transform();
tr.zoom = 10;
@@ -1654,27 +1592,28 @@ describe('source cache loaded', () => {
test('SourceCache#loaded (tiles outside bounds, idle)', done => {
const japan = new TileBounds([122.74, 19.33, 149.0, 45.67]);
- const sourceCache = createSourceCache({
- onAdd() {
- if (this.sourceOptions.noLoad) return;
- if (this.sourceOptions.error) {
- this.fire(new ErrorEvent(this.sourceOptions.error));
- } else {
- this.fire(new Event('data', {dataType: 'source', sourceDataType: 'metadata'}));
- this.fire(new Event('data', {dataType: 'source', sourceDataType: 'content'}));
- }
- },
- hasTile(tileID: OverscaledTileID) {
- return japan.contains(tileID.canonical);
- },
- loadTile(tile, callback) {
- tile.state = 'loading';
+ const sourceCache = createSourceCache();
+ sourceCache._source.loadTile = async (tile) => {
+ tile.state = 'loading';
+ return new Promise((resolve) => {
setTimeout(() => {
tile.state = 'loaded';
- callback();
+ resolve();
});
+ });
+ };
+ sourceCache._source.onAdd = function() {
+ if (this.sourceOptions.noLoad) return;
+ if (this.sourceOptions.error) {
+ this.fire(new ErrorEvent(this.sourceOptions.error));
+ } else {
+ this.fire(new Event('data', {dataType: 'source', sourceDataType: 'metadata'}));
+ this.fire(new Event('data', {dataType: 'source', sourceDataType: 'content'}));
}
- });
+ };
+ sourceCache._source.hasTile = (tileID: OverscaledTileID) => {
+ return japan.contains(tileID.canonical);
+ };
sourceCache.on('data', (e) => {
if (e.sourceDataType !== 'idle') {
@@ -1842,7 +1781,7 @@ describe('SourceCache reloads expiring tiles', () => {
expiryDate.setMilliseconds(expiryDate.getMilliseconds() + 50);
const sourceCache = createSourceCache({expires: expiryDate});
- sourceCache._reloadTile = (id, state) => {
+ sourceCache._reloadTile = async (id, state) => {
expect(state).toBe('expired');
done();
};
diff --git a/src/source/source_cache.ts b/src/source/source_cache.ts
index 8a6d997fb4..d10a1ee640 100644
--- a/src/source/source_cache.ts
+++ b/src/source/source_cache.ts
@@ -18,7 +18,6 @@ import type {Style} from '../style/style';
import type {Dispatcher} from '../util/dispatcher';
import type {Transform} from '../geo/transform';
import type {TileState} from './tile';
-import type {Callback} from '../types/callback';
import type {SourceSpecification} from '@maplibre/maplibre-gl-style-spec';
import type {MapSourceDataEvent} from '../ui/events';
import {Terrain} from '../render/terrain';
@@ -105,7 +104,7 @@ export class SourceCache extends Evented {
this._source = createSource(id, options, dispatcher, this);
this._tiles = {};
- this._cache = new TileCache(0, this._unloadTile.bind(this));
+ this._cache = new TileCache(0, (tile) => this._unloadTile(tile));
this._timers = {};
this._cacheTimers = {};
this._maxTileCacheSize = null;
@@ -171,18 +170,29 @@ export class SourceCache extends Evented {
if (this.transform) this.update(this.transform, this.terrain);
}
- _loadTile(tile: Tile, callback: Callback) {
- return this._source.loadTile(tile, callback);
+ async _loadTile(tile: Tile, id: string, state: TileState): Promise {
+ try {
+ await this._source.loadTile(tile);
+ this._tileLoaded(tile, id, state);
+ } catch (err) {
+ tile.state = 'errored';
+ if ((err as any).status !== 404) {
+ this._source.fire(new ErrorEvent(err, {tile}));
+ } else {
+ // continue to try loading parent/children tiles if a tile doesn't exist (404)
+ this.update(this.transform, this.terrain);
+ }
+ }
}
_unloadTile(tile: Tile) {
if (this._source.unloadTile)
- return this._source.unloadTile(tile, () => {});
+ this._source.unloadTile(tile);
}
_abortTile(tile: Tile) {
if (this._source.abortTile)
- this._source.abortTile(tile, () => {});
+ this._source.abortTile(tile);
this._source.fire(new Event('dataabort', {tile, coord: tile.tileID, dataType: 'source'}));
}
@@ -254,7 +264,7 @@ export class SourceCache extends Evented {
}
}
- _reloadTile(id: string, state: TileState) {
+ async _reloadTile(id: string, state: TileState) {
const tile = this._tiles[id];
// this potentially does not address all underlying
@@ -269,19 +279,10 @@ export class SourceCache extends Evented {
if (tile.state !== 'loading') {
tile.state = state;
}
-
- this._loadTile(tile, this._tileLoaded.bind(this, tile, id, state));
+ await this._loadTile(tile, id, state);
}
- _tileLoaded(tile: Tile, id: string, previousState: TileState, err?: Error | null) {
- if (err) {
- tile.state = 'errored';
- if ((err as any).status !== 404) this._source.fire(new ErrorEvent(err, {tile}));
- // continue to try loading parent/children tiles if a tile doesn't exist (404)
- else this.update(this.transform, this.terrain);
- return;
- }
-
+ _tileLoaded(tile: Tile, id: string, previousState: TileState) {
tile.timeAdded = browser.now();
if (previousState === 'expired') tile.refreshedUponExpiration = true;
this._setTileReloadTimer(id, tile);
@@ -814,7 +815,7 @@ export class SourceCache extends Evented {
if (!tile) {
tile = new Tile(tileID, this._source.tileSize * tileID.overscaleFactor());
- this._loadTile(tile, this._tileLoaded.bind(this, tile, tileID.key, tile.state));
+ this._loadTile(tile, tileID.key, tile.state);
}
tile.uses++;
diff --git a/src/source/tile.ts b/src/source/tile.ts
index cc180ec561..32c0a6b8df 100644
--- a/src/source/tile.ts
+++ b/src/source/tile.ts
@@ -28,7 +28,6 @@ import type {OverscaledTileID} from './tile_id';
import type {Framebuffer} from '../gl/framebuffer';
import type {Transform} from '../geo/transform';
import type {LayerFeatureStates} from './source_state';
-import type {Cancelable} from '../types/cancelable';
import type {FilterSpecification} from '@maplibre/maplibre-gl-style-spec';
import type Point from '@mapbox/point-geometry';
import {mat4} from 'gl-matrix';
@@ -47,7 +46,6 @@ import {ExpiryData} from '../util/ajax';
export type TileState = 'loading' | 'loaded' | 'reloading' | 'unloaded' | 'errored' | 'expired';
/**
- * @internal
* A tile object is the combination of a Coordinate, which defines
* its place, as well as a unique ID and data tracking for its content
*/
@@ -81,12 +79,12 @@ export class Tile {
aborted: boolean;
needsHillshadePrepare: boolean;
needsTerrainPrepare: boolean;
- request: Cancelable;
+ abortController: AbortController;
texture: any;
fbo: Framebuffer;
demTexture: Texture;
refreshedUponExpiration: boolean;
- reloadCallback: any;
+ reloadPromise: {resolve: () => void; reject: () => void};
resourceTiming: Array;
queryPadding: number;
diff --git a/src/source/vector_tile_source.test.ts b/src/source/vector_tile_source.test.ts
index d2ff756f8f..f955ba1831 100644
--- a/src/source/vector_tile_source.test.ts
+++ b/src/source/vector_tile_source.test.ts
@@ -6,8 +6,9 @@ import {OverscaledTileID} from './tile_id';
import {Evented} from '../util/evented';
import {RequestManager} from '../util/request_manager';
import fixturesSource from '../../test/unit/assets/source.json' assert {type: 'json'};
-import {getMockDispatcher, getWrapDispatcher} from '../util/test/util';
+import {getMockDispatcher, getWrapDispatcher, sleep, waitForMetadataEvent} from '../util/test/util';
import {Map} from '../ui/map';
+import {WorkerTileParameters} from './worker_source';
function createSource(options, transformCallback?, clearTiles = () => {}) {
const source = new VectorTileSource('id', options, getMockDispatcher(), options.eventedParent);
@@ -19,9 +20,7 @@ function createSource(options, transformCallback?, clearTiles = () => {}) {
getPixelRatio() { return 1; }
} as any as Map);
- source.on('error', (e) => {
- throw e.error;
- });
+ source.on('error', () => { }); // to prevent console log of errors
return source;
}
@@ -37,7 +36,7 @@ describe('VectorTileSource', () => {
server.restore();
});
- test('can be constructed from TileJSON', done => {
+ test('can be constructed from TileJSON', async () => {
const source = createSource({
minzoom: 1,
maxzoom: 10,
@@ -45,33 +44,26 @@ describe('VectorTileSource', () => {
tiles: ['http://example.com/{z}/{x}/{y}.png']
});
- source.on('data', (e) => {
- if (e.sourceDataType === 'metadata') {
- expect(source.tiles).toEqual(['http://example.com/{z}/{x}/{y}.png']);
- expect(source.minzoom).toBe(1);
- expect(source.maxzoom).toBe(10);
- expect((source as Source).attribution).toBe('MapLibre');
- done();
- }
- });
+ await waitForMetadataEvent(source);
+ expect(source.tiles).toEqual(['http://example.com/{z}/{x}/{y}.png']);
+ expect(source.minzoom).toBe(1);
+ expect(source.maxzoom).toBe(10);
+ expect((source as Source).attribution).toBe('MapLibre');
});
- test('can be constructed from a TileJSON URL', done => {
+ test('can be constructed from a TileJSON URL', async () => {
server.respondWith('/source.json', JSON.stringify(fixturesSource));
const source = createSource({url: '/source.json'});
- source.on('data', (e) => {
- if (e.sourceDataType === 'metadata') {
- expect(source.tiles).toEqual(['http://example.com/{z}/{x}/{y}.png']);
- expect(source.minzoom).toBe(1);
- expect(source.maxzoom).toBe(10);
- expect((source as Source).attribution).toBe('MapLibre');
- done();
- }
- });
-
+ const promise = waitForMetadataEvent(source);
server.respond();
+
+ await promise;
+ expect(source.tiles).toEqual(['http://example.com/{z}/{x}/{y}.png']);
+ expect(source.minzoom).toBe(1);
+ expect(source.maxzoom).toBe(10);
+ expect((source as Source).attribution).toBe('MapLibre');
});
test('transforms the request for TileJSON URL', () => {
@@ -94,7 +86,7 @@ describe('VectorTileSource', () => {
server.respond();
});
- test('fires "dataloading" event', done => {
+ test('fires "dataloading" event', async () => {
server.respondWith('/source.json', JSON.stringify(fixturesSource));
const evented = new Evented();
let dataloadingFired = false;
@@ -102,13 +94,11 @@ describe('VectorTileSource', () => {
dataloadingFired = true;
});
const source = createSource({url: '/source.json', eventedParent: evented});
- source.on('data', (e) => {
- if (e.sourceDataType === 'metadata') {
- if (!dataloadingFired) done('test failed: dataloading not fired');
- done();
- }
- });
+ const promise = waitForMetadataEvent(source);
server.respond();
+
+ await promise;
+ expect(dataloadingFired).toBeTruthy();
});
test('serialize URL', () => {
@@ -138,7 +128,7 @@ describe('VectorTileSource', () => {
});
function testScheme(scheme, expectedURL) {
- test(`scheme "${scheme}"`, done => {
+ test(`scheme "${scheme}"`, async () => {
const source = createSource({
minzoom: 1,
maxzoom: 10,
@@ -147,85 +137,150 @@ describe('VectorTileSource', () => {
scheme
});
+ let receivedMessage = null;
+
source.dispatcher = getWrapDispatcher()({
- send(type, params) {
- expect(type).toBe('loadTile');
- expect(expectedURL).toBe(params.request.url);
- done();
+ sendAsync(message) {
+ receivedMessage = message;
+ return Promise.resolve({});
}
});
- source.on('data', (e) => {
- if (e.sourceDataType === 'metadata') source.loadTile({
- tileID: new OverscaledTileID(10, 0, 10, 5, 5)
- } as any as Tile, () => {});
- });
+ await waitForMetadataEvent(source);
+ await source.loadTile({
+ loadVectorData() {},
+ tileID: new OverscaledTileID(10, 0, 10, 5, 5)
+ } as any as Tile);
+
+ expect(receivedMessage.type).toBe('loadTile');
+ expect(expectedURL).toBe((receivedMessage.data as WorkerTileParameters).request.url);
});
}
testScheme('xyz', 'http://example.com/10/5/5.png');
testScheme('tms', 'http://example.com/10/5/1018.png');
- test('transforms tile urls before requesting', done => {
+ test('transforms tile urls before requesting', async () => {
server.respondWith('/source.json', JSON.stringify(fixturesSource));
const source = createSource({url: '/source.json'});
const transformSpy = jest.spyOn(source.map._requestManager, 'transformRequest');
- source.on('data', (e) => {
- if (e.sourceDataType === 'metadata') {
- const tile = {
- tileID: new OverscaledTileID(10, 0, 10, 5, 5),
- state: 'loading',
- loadVectorData () {},
- setExpiryData() {}
- } as any as Tile;
- source.loadTile(tile, () => {});
- expect(transformSpy).toHaveBeenCalledTimes(1);
- expect(transformSpy).toHaveBeenCalledWith('http://example.com/10/5/5.png', 'Tile');
- done();
+ const promise = waitForMetadataEvent(source);
+ server.respond();
+ await promise;
+
+ const tile = {
+ tileID: new OverscaledTileID(10, 0, 10, 5, 5),
+ state: 'loading',
+ loadVectorData() {},
+ setExpiryData() {}
+ } as any as Tile;
+ source.loadTile(tile);
+ expect(transformSpy).toHaveBeenCalledTimes(1);
+ expect(transformSpy).toHaveBeenCalledWith('http://example.com/10/5/5.png', 'Tile');
+ });
+
+ test('loads a tile even in case of 404', async () => {
+ server.respondWith('/source.json', JSON.stringify(fixturesSource));
+
+ const source = createSource({url: '/source.json'});
+ source.dispatcher = getWrapDispatcher()({
+ sendAsync(_message) {
+ const error = new Error();
+ (error as any).status = 404;
+ return Promise.reject(error);
+ }
+ });
+ const promise = waitForMetadataEvent(source);
+ server.respond();
+ await promise;
+ const tile = {
+ tileID: new OverscaledTileID(10, 0, 10, 5, 5),
+ state: 'loading',
+ loadVectorData: jest.fn(),
+ setExpiryData() {}
+ } as any as Tile;
+ await source.loadTile(tile);
+ expect(tile.loadVectorData).toHaveBeenCalledTimes(1);
+ });
+
+ test('does not load a tile in case of error', async () => {
+ server.respondWith('/source.json', JSON.stringify(fixturesSource));
+
+ const source = createSource({url: '/source.json'});
+ source.dispatcher = getWrapDispatcher()({
+ async sendAsync(_message) {
+ throw new Error('Error');
}
});
+ const promise = waitForMetadataEvent(source);
+ server.respond();
+ await promise;
+ const tile = {
+ tileID: new OverscaledTileID(10, 0, 10, 5, 5),
+ state: 'loading',
+ loadVectorData: jest.fn(),
+ setExpiryData() {}
+ } as any as Tile;
+ await expect(source.loadTile(tile)).rejects.toThrow('Error');
+ expect(tile.loadVectorData).toHaveBeenCalledTimes(0);
+ });
+
+ test('loads an empty tile received from worker', async () => {
+ server.respondWith('/source.json', JSON.stringify(fixturesSource));
+ const source = createSource({url: '/source.json'});
+ source.dispatcher = getWrapDispatcher()({
+ sendAsync(_message) {
+ return Promise.resolve(null);
+ }
+ });
+
+ const promise = waitForMetadataEvent(source);
server.respond();
+ await promise;
+
+ const tile = {
+ tileID: new OverscaledTileID(10, 0, 10, 5, 5),
+ state: 'loading',
+ loadVectorData: jest.fn(),
+ setExpiryData() {}
+ } as any as Tile;
+ await source.loadTile(tile);
+ expect(tile.loadVectorData).toHaveBeenCalledTimes(1);
});
- test('reloads a loading tile properly', done => {
+ test('reloads a loading tile properly', async () => {
const source = createSource({
tiles: ['http://example.com/{z}/{x}/{y}.png']
});
const events = [];
source.dispatcher = getWrapDispatcher()({
- send(type, params, cb) {
- events.push(type);
- if (cb) setTimeout(cb, 0);
- return 1;
+ sendAsync(message) {
+ events.push(message.type);
+ return Promise.resolve({});
}
});
- source.on('data', (e) => {
- if (e.sourceDataType === 'metadata') {
- const tile = {
- tileID: new OverscaledTileID(10, 0, 10, 5, 5),
- state: 'loading',
- loadVectorData () {
- this.state = 'loaded';
- events.push('tileLoaded');
- },
- setExpiryData() {}
- } as any as Tile;
- source.loadTile(tile, () => {});
- expect(tile.state).toBe('loading');
- source.loadTile(tile, () => {
- expect(events).toEqual(
- ['loadTile', 'tileLoaded', 'reloadTile', 'tileLoaded']
- );
- done();
- });
- }
- });
+ await waitForMetadataEvent(source);
+ const tile = {
+ tileID: new OverscaledTileID(10, 0, 10, 5, 5),
+ state: 'loading',
+ loadVectorData () {
+ this.state = 'loaded';
+ events.push('tileLoaded');
+ },
+ setExpiryData() {}
+ } as any as Tile;
+ const initialLoadPromise = source.loadTile(tile);
+
+ expect(tile.state).toBe('loading');
+ await source.loadTile(tile);
+ expect(events).toEqual(['loadTile', 'tileLoaded', 'reloadTile', 'tileLoaded']);
+ await expect(initialLoadPromise).resolves.toBeUndefined();
});
- test('respects TileJSON.bounds', done => {
+ test('respects TileJSON.bounds', async () => {
const source = createSource({
minzoom: 0,
maxzoom: 22,
@@ -233,16 +288,13 @@ describe('VectorTileSource', () => {
tiles: ['http://example.com/{z}/{x}/{y}.png'],
bounds: [-47, -7, -45, -5]
});
- source.on('data', (e) => {
- if (e.sourceDataType === 'metadata') {
- expect(source.hasTile(new OverscaledTileID(8, 0, 8, 96, 132))).toBeFalsy();
- expect(source.hasTile(new OverscaledTileID(8, 0, 8, 95, 132))).toBeTruthy();
- done();
- }
- });
+ await waitForMetadataEvent(source);
+
+ expect(source.hasTile(new OverscaledTileID(8, 0, 8, 96, 132))).toBeFalsy();
+ expect(source.hasTile(new OverscaledTileID(8, 0, 8, 95, 132))).toBeTruthy();
});
- test('does not error on invalid bounds', done => {
+ test('does not error on invalid bounds', async () => {
const source = createSource({
minzoom: 0,
maxzoom: 22,
@@ -251,15 +303,11 @@ describe('VectorTileSource', () => {
bounds: [-47, -7, -45, 91]
});
- source.on('data', (e) => {
- if (e.sourceDataType === 'metadata') {
- expect(source.tileBounds.bounds).toEqual({_sw: {lng: -47, lat: -7}, _ne: {lng: -45, lat: 90}});
- done();
- }
- });
+ await waitForMetadataEvent(source);
+ expect(source.tileBounds.bounds).toEqual({_sw: {lng: -47, lat: -7}, _ne: {lng: -45, lat: 90}});
});
- test('respects TileJSON.bounds when loaded from TileJSON', done => {
+ test('respects TileJSON.bounds when loaded from TileJSON', async () => {
server.respondWith('/source.json', JSON.stringify({
minzoom: 0,
maxzoom: 22,
@@ -269,51 +317,47 @@ describe('VectorTileSource', () => {
}));
const source = createSource({url: '/source.json'});
- source.on('data', (e) => {
- if (e.sourceDataType === 'metadata') {
- expect(source.hasTile(new OverscaledTileID(8, 0, 8, 96, 132))).toBeFalsy();
- expect(source.hasTile(new OverscaledTileID(8, 0, 8, 95, 132))).toBeTruthy();
- done();
- }
- });
+ const promise = waitForMetadataEvent(source);
server.respond();
+
+ await promise;
+ expect(source.hasTile(new OverscaledTileID(8, 0, 8, 96, 132))).toBeFalsy();
+ expect(source.hasTile(new OverscaledTileID(8, 0, 8, 95, 132))).toBeTruthy();
});
- test('respects collectResourceTiming parameter on source', done => {
+ test('respects collectResourceTiming parameter on source', async () => {
const source = createSource({
tiles: ['http://example.com/{z}/{x}/{y}.png'],
collectResourceTiming: true
});
+ let receivedMessage = null;
source.dispatcher = getWrapDispatcher()({
- send(type, params, cb) {
- expect(params.request.collectResourceTiming).toBeTruthy();
- setTimeout(cb, 0);
- done();
+ sendAsync(message) {
+ receivedMessage = message;
// do nothing for cache size check dispatch
source.dispatcher = getMockDispatcher();
- return 1;
+ return Promise.resolve({});
}
});
- source.on('data', (e) => {
- if (e.sourceDataType === 'metadata') {
- const tile = {
- tileID: new OverscaledTileID(10, 0, 10, 5, 5),
- state: 'loading',
- loadVectorData () {},
- setExpiryData() {}
- } as any as Tile;
- source.loadTile(tile, () => {});
- }
- });
+ await waitForMetadataEvent(source);
+ const tile = {
+ tileID: new OverscaledTileID(10, 0, 10, 5, 5),
+ state: 'loading',
+ loadVectorData() {},
+ setExpiryData() {}
+ } as any as Tile;
+ await source.loadTile(tile);
+
+ expect((receivedMessage.data as WorkerTileParameters).request.collectResourceTiming).toBeTruthy();
});
test('cancels TileJSON request if removed', () => {
const source = createSource({url: '/source.json'});
source.onRemove();
- expect((server as any).lastRequest.aborted).toBe(true);
+ expect((server.lastRequest as any).aborted).toBe(true);
});
test('supports url property updates', () => {
@@ -349,6 +393,7 @@ describe('VectorTileSource', () => {
const source = createSource({tiles: ['http://example.com/{z}/{x}/{y}.pbf']}, undefined, clearTiles);
source.setTiles(['http://example2.com/{z}/{x}/{y}.pbf']);
expect(clearTiles.mock.calls).toHaveLength(0);
+ await sleep(0);
await source.once('data');
expect(clearTiles.mock.calls).toHaveLength(1);
});
diff --git a/src/source/vector_tile_source.ts b/src/source/vector_tile_source.ts
index 8cdb19c349..326f1a7256 100644
--- a/src/source/vector_tile_source.ts
+++ b/src/source/vector_tile_source.ts
@@ -10,9 +10,8 @@ import type {OverscaledTileID} from './tile_id';
import type {Map} from '../ui/map';
import type {Dispatcher} from '../util/dispatcher';
import type {Tile} from './tile';
-import type {Callback} from '../types/callback';
-import type {Cancelable} from '../types/cancelable';
import type {VectorSourceSpecification, PromoteIdSpecification} from '@maplibre/maplibre-gl-style-spec';
+import type {WorkerTileResult} from './worker_source';
export type VectorTileSourceOptions = VectorSourceSpecification & {
collectResourceTiming?: boolean;
@@ -72,7 +71,7 @@ export class VectorTileSource extends Evented implements Source {
tileBounds: TileBounds;
reparseOverscaled: boolean;
isTileClipped: boolean;
- _tileJSONRequest: Cancelable;
+ _tileJSONRequest: AbortController;
_loaded: boolean;
constructor(id: string, options: VectorTileSourceOptions, dispatcher: Dispatcher, eventedParent: Evented) {
@@ -101,16 +100,16 @@ export class VectorTileSource extends Evented implements Source {
this.setEventedParent(eventedParent);
}
- load = () => {
+ async load() {
this._loaded = false;
this.fire(new Event('dataloading', {dataType: 'source'}));
- this._tileJSONRequest = loadTileJson(this._options, this.map._requestManager, (err, tileJSON) => {
+ this._tileJSONRequest = new AbortController();
+ try {
+ const tileJSON = await loadTileJson(this._options, this.map._requestManager, this._tileJSONRequest);
this._tileJSONRequest = null;
this._loaded = true;
this.map.style.sourceCaches[this.id].clearTiles();
- if (err) {
- this.fire(new ErrorEvent(err));
- } else if (tileJSON) {
+ if (tileJSON) {
extend(this, tileJSON);
if (tileJSON.bounds) this.tileBounds = new TileBounds(tileJSON.bounds, this.minzoom, this.maxzoom);
@@ -120,8 +119,11 @@ export class VectorTileSource extends Evented implements Source {
this.fire(new Event('data', {dataType: 'source', sourceDataType: 'metadata'}));
this.fire(new Event('data', {dataType: 'source', sourceDataType: 'content'}));
}
- });
- };
+ } catch (err) {
+ this._tileJSONRequest = null;
+ this.fire(new ErrorEvent(err));
+ }
+ }
loaded(): boolean {
return this._loaded;
@@ -138,7 +140,7 @@ export class VectorTileSource extends Evented implements Source {
setSourceProperty(callback: Function) {
if (this._tileJSONRequest) {
- this._tileJSONRequest.cancel();
+ this._tileJSONRequest.abort();
}
callback();
@@ -177,16 +179,16 @@ export class VectorTileSource extends Evented implements Source {
onRemove() {
if (this._tileJSONRequest) {
- this._tileJSONRequest.cancel();
+ this._tileJSONRequest.abort();
this._tileJSONRequest = null;
}
}
- serialize = (): VectorSourceSpecification => {
+ serialize(): VectorSourceSpecification {
return extend({}, this._options);
- };
+ }
- loadTile(tile: Tile, callback: Callback) {
+ async loadTile(tile: Tile): Promise {
const url = tile.tileID.canonical.url(this.tiles, this.map.getPixelRatio(), this.scheme);
const params = {
request: this.map._requestManager.transformRequest(url, ResourceType.Tile),
@@ -201,56 +203,68 @@ export class VectorTileSource extends Evented implements Source {
promoteId: this.promoteId
};
params.request.collectResourceTiming = this._collectResourceTiming;
-
+ let messageType: 'loadTile' | 'reloadTile' = 'reloadTile';
if (!tile.actor || tile.state === 'expired') {
tile.actor = this.dispatcher.getActor();
- tile.request = tile.actor.send('loadTile', params, done.bind(this));
+ messageType = 'loadTile';
} else if (tile.state === 'loading') {
- // schedule tile reloading after it has been loaded
- tile.reloadCallback = callback;
- } else {
- tile.request = tile.actor.send('reloadTile', params, done.bind(this));
+ return new Promise((resolve, reject) => {
+ tile.reloadPromise = {resolve, reject};
+ });
}
+ tile.abortController = new AbortController();
+ try {
+ const data = await tile.actor.sendAsync({type: messageType, data: params}, tile.abortController);
+ delete tile.abortController;
- function done(err, data) {
- delete tile.request;
-
- if (tile.aborted)
- return callback(null);
+ if (tile.aborted) {
+ return;
+ }
+ this._afterTileLoadWorkerResponse(tile, data);
+ } catch (err) {
+ delete tile.abortController;
+ if (tile.aborted) {
+ return;
+ }
if (err && err.status !== 404) {
- return callback(err);
+ throw err;
}
+ this._afterTileLoadWorkerResponse(tile, null);
+ }
+ }
- if (data && data.resourceTiming)
- tile.resourceTiming = data.resourceTiming;
-
- if (this.map._refreshExpiredTiles && data) tile.setExpiryData(data);
- tile.loadVectorData(data, this.map.painter);
+ private _afterTileLoadWorkerResponse(tile: Tile, data: WorkerTileResult) {
+ if (data && data.resourceTiming) {
+ tile.resourceTiming = data.resourceTiming;
+ }
- callback(null);
+ if (data && this.map._refreshExpiredTiles) {
+ tile.setExpiryData(data);
+ }
+ tile.loadVectorData(data, this.map.painter);
- if (tile.reloadCallback) {
- this.loadTile(tile, tile.reloadCallback);
- tile.reloadCallback = null;
- }
+ if (tile.reloadPromise) {
+ const reloadPromise = tile.reloadPromise;
+ tile.reloadPromise = null;
+ this.loadTile(tile).then(reloadPromise.resolve).catch(reloadPromise.reject);
}
}
- abortTile(tile: Tile) {
- if (tile.request) {
- tile.request.cancel();
- delete tile.request;
+ async abortTile(tile: Tile): Promise {
+ if (tile.abortController) {
+ tile.abortController.abort();
+ delete tile.abortController;
}
if (tile.actor) {
- tile.actor.send('abortTile', {uid: tile.uid, type: this.type, source: this.id}, undefined);
+ await tile.actor.sendAsync({type: 'abortTile', data: {uid: tile.uid, type: this.type, source: this.id}});
}
}
- unloadTile(tile: Tile) {
+ async unloadTile(tile: Tile): Promise {
tile.unloadVectorData();
if (tile.actor) {
- tile.actor.send('removeTile', {uid: tile.uid, type: this.type, source: this.id}, undefined);
+ await tile.actor.sendAsync({type: 'removeTile', data: {uid: tile.uid, type: this.type, source: this.id}});
}
}
diff --git a/src/source/vector_tile_worker_source.test.ts b/src/source/vector_tile_worker_source.test.ts
index 73c82201f4..60f4aefb90 100644
--- a/src/source/vector_tile_worker_source.test.ts
+++ b/src/source/vector_tile_worker_source.test.ts
@@ -2,16 +2,17 @@ import fs from 'fs';
import path from 'path';
import vt from '@mapbox/vector-tile';
import Protobuf from 'pbf';
-import {VectorTileWorkerSource} from '../source/vector_tile_worker_source';
+import {LoadVectorData, VectorTileWorkerSource} from '../source/vector_tile_worker_source';
import {StyleLayerIndex} from '../style/style_layer_index';
import {fakeServer, type FakeServer} from 'nise';
-import {Actor} from '../util/actor';
+import {IActor} from '../util/actor';
import {TileParameters, WorkerTileParameters, WorkerTileResult} from './worker_source';
import {WorkerTile} from './worker_tile';
-import {setPerformance} from '../util/test/util';
+import {setPerformance, sleep} from '../util/test/util';
+import {ABORT_ERROR} from '../util/abort_error';
describe('vector tile worker source', () => {
- const actor = {send: () => {}} as any as Actor;
+ const actor = {sendAsync: () => Promise.resolve({})} as IActor;
let server: FakeServer;
beforeEach(() => {
@@ -24,51 +25,45 @@ describe('vector tile worker source', () => {
server.restore();
jest.clearAllMocks();
});
- test('VectorTileWorkerSource#abortTile aborts pending request', () => {
+ test('VectorTileWorkerSource#abortTile aborts pending request', async () => {
const source = new VectorTileWorkerSource(actor, new StyleLayerIndex(), []);
- source.loadTile({
+ const loadPromise = source.loadTile({
source: 'source',
uid: 0,
tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}},
request: {url: 'http://localhost:2900/abort'}
- } as any as WorkerTileParameters, (err, res) => {
- expect(err).toBeFalsy();
- expect(res).toBeFalsy();
- });
+ } as any as WorkerTileParameters);
- source.abortTile({
+ const abortPromise = source.abortTile({
source: 'source',
uid: 0
- } as any as TileParameters, (err, res) => {
- expect(err).toBeFalsy();
- expect(res).toBeFalsy();
- });
+ } as any as TileParameters);
expect(source.loading).toEqual({});
+ await expect(abortPromise).resolves.toBeFalsy();
+ await expect(loadPromise).rejects.toThrow(ABORT_ERROR);
});
- test('VectorTileWorkerSource#removeTile removes loaded tile', () => {
+ test('VectorTileWorkerSource#removeTile removes loaded tile', async () => {
const source = new VectorTileWorkerSource(actor, new StyleLayerIndex(), []);
source.loaded = {
'0': {} as WorkerTile
};
- source.removeTile({
+ const res = await source.removeTile({
source: 'source',
uid: 0
- } as any as TileParameters, (err, res) => {
- expect(err).toBeFalsy();
- expect(res).toBeFalsy();
- });
+ } as any as TileParameters);
+ expect(res).toBeUndefined();
expect(source.loaded).toEqual({});
});
- test('VectorTileWorkerSource#reloadTile reloads a previously-loaded tile', () => {
+ test('VectorTileWorkerSource#reloadTile reloads a previously-loaded tile', async () => {
const source = new VectorTileWorkerSource(actor, new StyleLayerIndex(), []);
- const parse = jest.fn();
+ const parse = jest.fn().mockReturnValue(Promise.resolve({} as WorkerTileResult));
source.loaded = {
'0': {
@@ -78,18 +73,15 @@ describe('vector tile worker source', () => {
} as any as WorkerTile
};
- const callback = jest.fn();
- source.reloadTile({uid: 0} as any as WorkerTileParameters, callback);
+ const reloadPromise = source.reloadTile({uid: 0} as any as WorkerTileParameters);
expect(parse).toHaveBeenCalledTimes(1);
-
- parse.mock.calls[0][4]();
- expect(callback).toHaveBeenCalledTimes(1);
+ await expect(reloadPromise).resolves.toBeTruthy();
});
- test('VectorTileWorkerSource#loadTile reparses tile if the reloadTile has been called during parsing', (done) => {
+ test('VectorTileWorkerSource#loadTile reparses tile if the reloadTile has been called during parsing', async () => {
const rawTileData = new Uint8Array([]);
- function loadVectorData(params, callback) {
- return callback(null, {
+ const loadVectorData: LoadVectorData = async (_params, _abortController) => {
+ return {
vectorTile: {
layers: {
test: {
@@ -112,8 +104,8 @@ describe('vector tile worker source', () => {
}
} as any as vt.VectorTile,
rawData: rawTileData
- });
- }
+ };
+ };
const layerIndex = new StyleLayerIndex([{
id: 'test',
@@ -127,52 +119,51 @@ describe('vector tile worker source', () => {
}
}]);
- const send = jest.fn().mockImplementation((type: string, data: unknown, callback: Function) => {
- const res = setTimeout(() => callback(null,
- type === 'getImages' ?
- {'hello': {width: 1, height: 1, data: new Uint8Array([0])}} :
- {'StandardFont-Bold': {width: 1, height: 1, data: new Uint8Array([0])}}
- ));
-
- return {
- cancel: () => clearTimeout(res)
- };
- });
-
const actor = {
- send
- } as unknown as Actor;
- const source = new VectorTileWorkerSource(actor, layerIndex, ['hello'], loadVectorData);
+ sendAsync: (message: {type: string; data: unknown}, abortController: AbortController) => {
+ return new Promise((resolve, _reject) => {
+ const res = setTimeout(() => {
+ const response = message.type === 'getImages' ?
+ {'hello': {width: 1, height: 1, data: new Uint8Array([0])}} :
+ {'StandardFont-Bold': {width: 1, height: 1, data: new Uint8Array([0])}};
+ resolve(response);
+ }, 100);
+ abortController.signal.addEventListener('abort', () => {
+ clearTimeout(res);
+ });
+ });
+ }
+ };
+ const source = new VectorTileWorkerSource(actor, layerIndex, ['hello']);
+ source.loadVectorTile = loadVectorData;
source.loadTile({
source: 'source',
uid: 0,
tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}},
request: {url: 'http://localhost:2900/faketile.pbf'}
- } as any as WorkerTileParameters, () => {
- done.fail('should not be called');
- });
+ } as any as WorkerTileParameters).then(() => expect(false).toBeTruthy());
- source.reloadTile({
+ // allow promise to run
+ await sleep(0);
+
+ const res = await source.reloadTile({
source: 'source',
- uid: '0',
+ uid: 0,
tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}},
- } as any as WorkerTileParameters, (err, res) => {
- expect(err).toBeFalsy();
- expect(res).toBeDefined();
- expect(res.rawTileData).toBeDefined();
- expect(res.rawTileData).toStrictEqual(rawTileData);
- done();
- });
+ } as any as WorkerTileParameters);
+ expect(res).toBeDefined();
+ expect(res.rawTileData).toBeDefined();
+ expect(res.rawTileData).toStrictEqual(rawTileData);
});
- test('VectorTileWorkerSource#loadTile reparses tile if reloadTile is called during reparsing', (done) => {
+ test('VectorTileWorkerSource#loadTile reparses tile if reloadTile is called during reparsing', async () => {
const rawTileData = new Uint8Array([]);
- function loadVectorData(params, callback) {
- return callback(null, {
+ const loadVectorData: LoadVectorData = async (_params, _abortController) => {
+ return {
vectorTile: new vt.VectorTile(new Protobuf(rawTileData)),
rawData: rawTileData
- });
- }
+ };
+ };
const layerIndex = new StyleLayerIndex([{
id: 'test',
@@ -181,41 +172,39 @@ describe('vector tile worker source', () => {
type: 'fill'
}]);
- const source = new VectorTileWorkerSource(actor, layerIndex, [], loadVectorData);
+ const source = new VectorTileWorkerSource(actor, layerIndex, []);
+ source.loadVectorTile = loadVectorData;
const parseWorkerTileMock = jest
.spyOn(WorkerTile.prototype, 'parse')
- .mockImplementation(function(data, layerIndex, availableImages, actor, callback) {
+ .mockImplementation(function(_data, _layerIndex, _availableImages, _actor) {
this.status = 'parsing';
- window.setTimeout(() => callback(null, {} as WorkerTileResult), 10);
+ return new Promise((resolve) => {
+ setTimeout(() => resolve({} as WorkerTileResult), 20);
+ });
});
- let loadCallbackCalled = false;
- source.loadTile({
+ const loadPromise = source.loadTile({
source: 'source',
uid: 0,
tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}},
request: {url: 'http://localhost:2900/faketile.pbf'}
- } as any as WorkerTileParameters, (err, res) => {
- expect(err).toBeFalsy();
- expect(res).toBeDefined();
- loadCallbackCalled = true;
- });
+ } as any as WorkerTileParameters);
- source.reloadTile({
+ // let the promise start
+ await sleep(0);
+
+ const res = await source.reloadTile({
source: 'source',
uid: '0',
tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}},
- } as any as WorkerTileParameters, (err, res) => {
- expect(err).toBeFalsy();
- expect(res).toBeDefined();
- expect(parseWorkerTileMock).toHaveBeenCalledTimes(2);
- expect(loadCallbackCalled).toBeTruthy();
- done();
- });
+ } as any as WorkerTileParameters);
+ expect(res).toBeDefined();
+ expect(parseWorkerTileMock).toHaveBeenCalledTimes(2);
+ await expect(loadPromise).resolves.toBeTruthy();
});
- test('VectorTileWorkerSource#reloadTile does not reparse tiles with no vectorTile data but does call callback', () => {
+ test('VectorTileWorkerSource#reloadTile does not reparse tiles with no vectorTile data but does call callback', async () => {
const source = new VectorTileWorkerSource(actor, new StyleLayerIndex(), []);
const parse = jest.fn();
@@ -226,18 +215,35 @@ describe('vector tile worker source', () => {
} as any as WorkerTile
};
- const callback = jest.fn();
-
- source.reloadTile({uid: 0} as any as WorkerTileParameters, callback);
+ await source.reloadTile({uid: 0} as any as WorkerTileParameters);
expect(parse).not.toHaveBeenCalled();
- expect(callback).toHaveBeenCalledTimes(1);
+ });
+
+ test('VectorTileWorkerSource#loadTile returns null for an empty tile', async () => {
+ const source = new VectorTileWorkerSource(actor, new StyleLayerIndex(), []);
+ source.loadVectorTile = (_params, _abortController) => Promise.resolve(null);
+ const parse = jest.fn();
+
+ server.respondWith(request => {
+ request.respond(200, {'Content-Type': 'application/pbf'}, 'something...');
+ });
+
+ const promise = source.loadTile({
+ source: 'source',
+ uid: 0,
+ tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}},
+ request: {url: 'http://localhost:2900/faketile.pbf'}
+ } as any as WorkerTileParameters);
+ server.respond();
+
+ expect(parse).not.toHaveBeenCalled();
+ expect(await promise).toBeNull();
});
- test('VectorTileWorkerSource#returns a good error message when failing to parse a tile', () => {
+ test('VectorTileWorkerSource#returns a good error message when failing to parse a tile', done => {
const source = new VectorTileWorkerSource(actor, new StyleLayerIndex(), []);
const parse = jest.fn();
- const callback = jest.fn();
server.respondWith(request => {
request.respond(200, {'Content-Type': 'application/pbf'}, 'something...');
@@ -248,19 +254,19 @@ describe('vector tile worker source', () => {
uid: 0,
tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}},
request: {url: 'http://localhost:2900/faketile.pbf'}
- } as any as WorkerTileParameters, callback);
+ } as any as WorkerTileParameters).catch((err) => {
+ expect(err.message).toContain('Unable to parse the tile at');
+ done();
+ });
server.respond();
expect(parse).not.toHaveBeenCalled();
- expect(callback).toHaveBeenCalledTimes(1);
- expect(callback.mock.calls[0][0].message).toContain('Unable to parse the tile at');
});
- test('VectorTileWorkerSource#returns a good error message when failing to parse a gzipped tile', () => {
+ test('VectorTileWorkerSource#returns a good error message when failing to parse a gzipped tile', done => {
const source = new VectorTileWorkerSource(actor, new StyleLayerIndex(), []);
const parse = jest.fn();
- const callback = jest.fn();
server.respondWith(new Uint8Array([0x1f, 0x8b]).buffer);
@@ -269,26 +275,27 @@ describe('vector tile worker source', () => {
uid: 0,
tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}},
request: {url: 'http://localhost:2900/faketile.pbf'}
- } as any as WorkerTileParameters, callback);
+ } as any as WorkerTileParameters).catch((err) => {
+ expect(err.message).toContain('gzipped');
+ done();
+ });
server.respond();
expect(parse).not.toHaveBeenCalled();
- expect(callback).toHaveBeenCalledTimes(1);
- expect(callback.mock.calls[0][0].message).toContain('gzipped');
});
- test('VectorTileWorkerSource provides resource timing information', done => {
+ test('VectorTileWorkerSource provides resource timing information', async () => {
const rawTileData = fs.readFileSync(path.join(__dirname, '/../../test/unit/assets/mbsv5-6-18-23.vector.pbf'));
- function loadVectorData(params, callback) {
- return callback(null, {
+ const loadVectorData: LoadVectorData = async (_params, _abortController) => {
+ return {
vectorTile: new vt.VectorTile(new Protobuf(rawTileData)),
rawData: rawTileData,
cacheControl: null,
expires: null
- });
- }
+ };
+ };
const exampleResourceTiming = {
connectEnd: 473,
@@ -318,33 +325,33 @@ describe('vector tile worker source', () => {
type: 'fill'
}]);
- const source = new VectorTileWorkerSource(actor, layerIndex, [], loadVectorData);
+ const source = new VectorTileWorkerSource(actor, layerIndex, []);
+ source.loadVectorTile = loadVectorData;
window.performance.getEntriesByName = jest.fn().mockReturnValue([exampleResourceTiming]);
- source.loadTile({
+ const res = await source.loadTile({
source: 'source',
uid: 0,
tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}},
request: {url: 'http://localhost:2900/faketile.pbf', collectResourceTiming: true}
- } as any as WorkerTileParameters, (err, res) => {
- expect(err).toBeFalsy();
- expect(res.resourceTiming[0]).toEqual(exampleResourceTiming);
- done();
- });
+ } as any as WorkerTileParameters);
+
+ expect(res.resourceTiming[0]).toEqual(exampleResourceTiming);
+
});
- test('VectorTileWorkerSource provides resource timing information (fallback method)', done => {
+ test('VectorTileWorkerSource provides resource timing information (fallback method)', async () => {
const rawTileData = fs.readFileSync(path.join(__dirname, '/../../test/unit/assets/mbsv5-6-18-23.vector.pbf'));
- function loadVectorData(params, callback) {
- return callback(null, {
+ const loadVectorData: LoadVectorData = async (_params, _abortController) => {
+ return {
vectorTile: new vt.VectorTile(new Protobuf(rawTileData)),
rawData: rawTileData,
cacheControl: null,
expires: null
- });
- }
+ };
+ };
const layerIndex = new StyleLayerIndex([{
id: 'test',
@@ -353,7 +360,8 @@ describe('vector tile worker source', () => {
type: 'fill'
}]);
- const source = new VectorTileWorkerSource(actor, layerIndex, [], loadVectorData);
+ const source = new VectorTileWorkerSource(actor, layerIndex, []);
+ source.loadVectorTile = loadVectorData;
const sampleMarks = [100, 350];
const marks = {};
@@ -374,17 +382,15 @@ describe('vector tile worker source', () => {
return null;
});
- source.loadTile({
+ const res = await source.loadTile({
source: 'source',
uid: 0,
tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}},
request: {url: 'http://localhost:2900/faketile.pbf', collectResourceTiming: true}
- } as any as WorkerTileParameters, (err, res) => {
- expect(err).toBeFalsy();
- expect(res.resourceTiming[0]).toEqual(
- {'duration': 250, 'entryType': 'measure', 'name': 'http://localhost:2900/faketile.pbf', 'startTime': 100}
- );
- done();
- });
+ } as any as WorkerTileParameters);
+
+ expect(res.resourceTiming[0]).toEqual(
+ {'duration': 250, 'entryType': 'measure', 'name': 'http://localhost:2900/faketile.pbf', 'startTime': 100}
+ );
});
});
diff --git a/src/source/vector_tile_worker_source.ts b/src/source/vector_tile_worker_source.ts
index 7c7fca9def..9828305bdb 100644
--- a/src/source/vector_tile_worker_source.ts
+++ b/src/source/vector_tile_worker_source.ts
@@ -9,13 +9,12 @@ import {RequestPerformance} from '../util/performance';
import type {
WorkerSource,
WorkerTileParameters,
- WorkerTileCallback,
- TileParameters
+ TileParameters,
+ WorkerTileResult
} from '../source/worker_source';
-import type {Actor} from '../util/actor';
+import type {IActor} from '../util/actor';
import type {StyleLayerIndex} from '../style/style_layer_index';
-import type {Callback} from '../types/callback';
import type {VectorTile} from '@mapbox/vector-tile';
export type LoadVectorTileResult = {
@@ -30,61 +29,19 @@ type FetchingState = {
resourceTiming: any;
}
-/**
- * The callback when finished loading vector data
- */
-export type LoadVectorDataCallback = Callback;
-
export type AbortVectorData = () => void;
-export type LoadVectorData = (params: WorkerTileParameters, callback: LoadVectorDataCallback) => AbortVectorData | void;
-
-/**
- * Loads a vector tile
- */
-function loadVectorTile(params: WorkerTileParameters, callback: LoadVectorDataCallback) {
- const request = getArrayBuffer(params.request, (err?: Error | null, data?: ArrayBuffer | null, cacheControl?: string | null, expires?: string | null) => {
- if (err) {
- callback(err);
- } else if (data) {
- try {
- const vectorTile = new vt.VectorTile(new Protobuf(data));
- callback(null, {
- vectorTile,
- rawData: data,
- cacheControl,
- expires
- });
- } catch (ex) {
- const bytes = new Uint8Array(data);
- const isGzipped = bytes[0] === 0x1f && bytes[1] === 0x8b;
- let errorMessage = `Unable to parse the tile at ${params.request.url}, `;
- if (isGzipped) {
- errorMessage += 'please make sure the data is not gzipped and that you have configured the relevant header in the server';
- } else {
- errorMessage += `got error: ${ex.messge}`;
- }
- callback(new Error(errorMessage));
- }
- }
- });
- return () => {
- request.cancel();
- callback();
- };
-}
+export type LoadVectorData = (params: WorkerTileParameters, abortController: AbortController) => Promise;
/**
* The {@link WorkerSource} implementation that supports {@link VectorTileSource}.
* This class is designed to be easily reused to support custom source types
* for data formats that can be parsed/converted into an in-memory VectorTile
- * representation. To do so, create it with
- * `new VectorTileWorkerSource(actor, styleLayers, customLoadVectorDataFunction)`.
+ * representation. To do so, override its `loadVectorTile` method.
*/
export class VectorTileWorkerSource implements WorkerSource {
- actor: Actor;
+ actor: IActor;
layerIndex: StyleLayerIndex;
availableImages: Array;
- loadVectorData: LoadVectorData;
fetching: {[_: string]: FetchingState };
loading: {[_: string]: WorkerTile};
loaded: {[_: string]: WorkerTile};
@@ -95,38 +52,62 @@ export class VectorTileWorkerSource implements WorkerSource {
* {@link VectorTileWorkerSource#loadTile}. The default implementation simply
* loads the pbf at `params.url`.
*/
- constructor(actor: Actor, layerIndex: StyleLayerIndex, availableImages: Array, loadVectorData?: LoadVectorData | null) {
+ constructor(actor: IActor, layerIndex: StyleLayerIndex, availableImages: Array) {
this.actor = actor;
this.layerIndex = layerIndex;
this.availableImages = availableImages;
- this.loadVectorData = loadVectorData || loadVectorTile;
this.fetching = {};
this.loading = {};
this.loaded = {};
}
+ /**
+ * Loads a vector tile
+ */
+ async loadVectorTile(params: WorkerTileParameters, abortController: AbortController): Promise {
+ const response = await getArrayBuffer(params.request, abortController);
+ try {
+ const vectorTile = new vt.VectorTile(new Protobuf(response.data));
+ return {
+ vectorTile,
+ rawData: response.data,
+ cacheControl: response.cacheControl,
+ expires: response.expires
+ };
+ } catch (ex) {
+ const bytes = new Uint8Array(response.data);
+ const isGzipped = bytes[0] === 0x1f && bytes[1] === 0x8b;
+ let errorMessage = `Unable to parse the tile at ${params.request.url}, `;
+ if (isGzipped) {
+ errorMessage += 'please make sure the data is not gzipped and that you have configured the relevant header in the server';
+ } else {
+ errorMessage += `got error: ${ex.messge}`;
+ }
+ throw new Error(errorMessage);
+ }
+ }
+
/**
* Implements {@link WorkerSource#loadTile}. Delegates to
* {@link VectorTileWorkerSource#loadVectorData} (which by default expects
* a `params.url` property) for fetching and producing a VectorTile object.
*/
- loadTile(params: WorkerTileParameters, callback: WorkerTileCallback) {
- const uid = params.uid;
-
- if (!this.loading)
- this.loading = {};
+ async loadTile(params: WorkerTileParameters): Promise {
+ const tileUid = params.uid;
const perf = (params && params.request && params.request.collectResourceTiming) ?
new RequestPerformance(params.request) : false;
- const workerTile = this.loading[uid] = new WorkerTile(params);
- workerTile.abort = this.loadVectorData(params, (err, response) => {
- delete this.loading[uid];
+ const workerTile = new WorkerTile(params);
+ this.loading[tileUid] = workerTile;
- if (err || !response) {
- workerTile.status = 'done';
- this.loaded[uid] = workerTile;
- return callback(err);
+ const abortController = new AbortController();
+ workerTile.abort = abortController;
+ try {
+ const response = await this.loadVectorTile(params, abortController);
+ delete this.loading[tileUid];
+ if (!response) {
+ return null;
}
const rawTileData = response.rawData;
@@ -144,85 +125,75 @@ export class VectorTileWorkerSource implements WorkerSource {
}
workerTile.vectorTile = response.vectorTile;
- workerTile.parse(response.vectorTile, this.layerIndex, this.availableImages, this.actor, (err, result) => {
- delete this.fetching[uid];
- if (err || !result) return callback(err);
+ const parsePromise = workerTile.parse(response.vectorTile, this.layerIndex, this.availableImages, this.actor);
+ this.loaded[tileUid] = workerTile;
+ // keep the original fetching state so that reload tile can pick it up if the original parse is cancelled by reloads' parse
+ this.fetching[tileUid] = {rawTileData, cacheControl, resourceTiming};
+ try {
+ const result = await parsePromise;
// Transferring a copy of rawTileData because the worker needs to retain its copy.
- callback(null, extend({rawTileData: rawTileData.slice(0)}, result, cacheControl, resourceTiming));
- });
-
- this.loaded = this.loaded || {};
- this.loaded[uid] = workerTile;
- // keep the original fetching state so that reload tile can pick it up if the original parse is cancelled by reloads' parse
- this.fetching[uid] = {rawTileData, cacheControl, resourceTiming};
- }) as AbortVectorData;
+ return extend({rawTileData: rawTileData.slice(0)}, result, cacheControl, resourceTiming);
+ } finally {
+ delete this.fetching[tileUid];
+ }
+ } catch (err) {
+ delete this.loading[tileUid];
+ workerTile.status = 'done';
+ this.loaded[tileUid] = workerTile;
+ throw err;
+ }
}
/**
* Implements {@link WorkerSource#reloadTile}.
*/
- reloadTile(params: WorkerTileParameters, callback: WorkerTileCallback) {
- const loaded = this.loaded;
+ async reloadTile(params: WorkerTileParameters): Promise {
const uid = params.uid;
- if (loaded && loaded[uid]) {
- const workerTile = loaded[uid];
- workerTile.showCollisionBoxes = params.showCollisionBoxes;
- if (workerTile.status === 'parsing') {
- workerTile.parse(workerTile.vectorTile, this.layerIndex, this.availableImages, this.actor, (err, result) => {
- if (err || !result) return callback(err, result);
-
- // if we have cancelled the original parse, make sure to pass the rawTileData from the original fetch
- let parseResult;
- if (this.fetching[uid]) {
- const {rawTileData, cacheControl, resourceTiming} = this.fetching[uid];
- delete this.fetching[uid];
- parseResult = extend({rawTileData: rawTileData.slice(0)}, result, cacheControl, resourceTiming);
- } else {
- parseResult = result;
- }
-
- callback(null, parseResult);
- });
- } else if (workerTile.status === 'done') {
- // if there was no vector tile data on the initial load, don't try and re-parse tile
- if (workerTile.vectorTile) {
- workerTile.parse(workerTile.vectorTile, this.layerIndex, this.availableImages, this.actor, callback);
- } else {
- callback();
- }
+ if (!this.loaded || !this.loaded[uid]) {
+ throw new Error('Should not be trying to reload a tile that was never loaded or has been removed');
+ }
+ const workerTile = this.loaded[uid];
+ workerTile.showCollisionBoxes = params.showCollisionBoxes;
+ if (workerTile.status === 'parsing') {
+ const result = await workerTile.parse(workerTile.vectorTile, this.layerIndex, this.availableImages, this.actor);
+ // if we have cancelled the original parse, make sure to pass the rawTileData from the original fetch
+ let parseResult: WorkerTileResult;
+ if (this.fetching[uid]) {
+ const {rawTileData, cacheControl, resourceTiming} = this.fetching[uid];
+ delete this.fetching[uid];
+ parseResult = extend({rawTileData: rawTileData.slice(0)}, result, cacheControl, resourceTiming);
+ } else {
+ parseResult = result;
}
+ return parseResult;
+
+ }
+ // if there was no vector tile data on the initial load, don't try and re-parse tile
+ if (workerTile.status === 'done' && workerTile.vectorTile) {
+ // this seems like a missing case where cache control is lost? see #3309
+ return workerTile.parse(workerTile.vectorTile, this.layerIndex, this.availableImages, this.actor);
}
}
/**
* Implements {@link WorkerSource#abortTile}.
- *
- * @param params - The tile parameters
- * @param callback - The callback
*/
- abortTile(params: TileParameters, callback: WorkerTileCallback) {
- const loading = this.loading,
- uid = params.uid;
+ async abortTile(params: TileParameters): Promise {
+ const loading = this.loading;
+ const uid = params.uid;
if (loading && loading[uid] && loading[uid].abort) {
- loading[uid].abort();
+ loading[uid].abort.abort();
delete loading[uid];
}
- callback();
}
/**
* Implements {@link WorkerSource#removeTile}.
- *
- * @param params - The tile parameters
- * @param callback - The callback
*/
- removeTile(params: TileParameters, callback: WorkerTileCallback) {
- const loaded = this.loaded,
- uid = params.uid;
- if (loaded && loaded[uid]) {
- delete loaded[uid];
+ async removeTile(params: TileParameters): Promise {
+ if (this.loaded && this.loaded[params.uid]) {
+ delete this.loaded[params.uid];
}
- callback();
}
}
diff --git a/src/source/video_source.ts b/src/source/video_source.ts
index 5aa966dfc5..b14f436a50 100644
--- a/src/source/video_source.ts
+++ b/src/source/video_source.ts
@@ -66,7 +66,7 @@ export class VideoSource extends ImageSource {
this.options = options;
}
- load = () => {
+ async load() {
this._loaded = false;
const options = this.options;
@@ -74,29 +74,30 @@ export class VideoSource extends ImageSource {
for (const url of options.urls) {
this.urls.push(this.map._requestManager.transformRequest(url, ResourceType.Source).url);
}
-
- getVideo(this.urls, (err, video) => {
+ try {
+ const video = await getVideo(this.urls);
this._loaded = true;
- if (err) {
- this.fire(new ErrorEvent(err));
- } else if (video) {
- this.video = video;
- this.video.loop = true;
-
- // Start repainting when video starts playing. hasTransition() will then return
- // true to trigger additional frames as long as the videos continues playing.
- this.video.addEventListener('playing', () => {
- this.map.triggerRepaint();
- });
-
- if (this.map) {
- this.video.play();
- }
-
- this._finishLoading();
+ if (!video) {
+ return;
}
- });
- };
+ this.video = video;
+ this.video.loop = true;
+
+ // Start repainting when video starts playing. hasTransition() will then return
+ // true to trigger additional frames as long as the videos continues playing.
+ this.video.addEventListener('playing', () => {
+ this.map.triggerRepaint();
+ });
+
+ if (this.map) {
+ this.video.play();
+ }
+
+ this._finishLoading();
+ } catch (err) {
+ this.fire(new ErrorEvent(err));
+ }
+ }
/**
* Pauses the video.
@@ -152,7 +153,7 @@ export class VideoSource extends ImageSource {
*
* @returns `this`
*/
- prepare = (): this => {
+ prepare(): this {
if (Object.keys(this.tiles).length === 0 || this.video.readyState < 2) {
return; // not enough data for current position
}
@@ -189,15 +190,15 @@ export class VideoSource extends ImageSource {
if (newTilesLoaded) {
this.fire(new Event('data', {dataType: 'source', sourceDataType: 'idle', sourceId: this.id}));
}
- };
+ }
- serialize = (): VideoSourceSpecification => {
+ serialize(): VideoSourceSpecification {
return {
type: 'video',
urls: this.urls,
coordinates: this.coordinates
};
- };
+ }
hasTransition() {
return this.video && !this.video.paused;
diff --git a/src/source/worker.test.ts b/src/source/worker.test.ts
index 26d320972c..2b5189075e 100644
--- a/src/source/worker.test.ts
+++ b/src/source/worker.test.ts
@@ -1,46 +1,50 @@
import {fakeServer} from 'nise';
import Worker from './worker';
import {LayerSpecification} from '@maplibre/maplibre-gl-style-spec';
-import {Cancelable} from '../types/cancelable';
import {WorkerGlobalScopeInterface} from '../util/web_worker';
import {CanonicalTileID, OverscaledTileID} from './tile_id';
-import {TileParameters, WorkerSource, WorkerTileCallback, WorkerTileParameters} from './worker_source';
+import {WorkerSource, WorkerTileParameters, WorkerTileResult} from './worker_source';
import {plugin as globalRTLTextPlugin} from './rtl_text_plugin';
-import {ActorTarget} from '../util/actor';
-
-const _self = {
- addEventListener() {}
-} as any as WorkerGlobalScopeInterface & ActorTarget;
+import {ActorTarget, IActor} from '../util/actor';
class WorkerSourceMock implements WorkerSource {
availableImages: string[];
- constructor(private actor: any) {}
- loadTile(_: WorkerTileParameters, __: WorkerTileCallback): void {
- this.actor.send('main thread task', {}, () => {}, null);
+ constructor(private actor: IActor) {}
+ loadTile(_: WorkerTileParameters): Promise {
+ return this.actor.sendAsync({type: 'loadTile', data: {} as any}, new AbortController());
}
- reloadTile(_: WorkerTileParameters, __: WorkerTileCallback): void {
+ reloadTile(_: WorkerTileParameters): Promise {
throw new Error('Method not implemented.');
}
- abortTile(_: TileParameters, __: WorkerTileCallback): void {
+ abortTile(_: WorkerTileParameters): Promise {
throw new Error('Method not implemented.');
}
- removeTile(_: TileParameters, __: WorkerTileCallback): void {
+ removeTile(_: WorkerTileParameters): Promise {
throw new Error('Method not implemented.');
}
}
-describe('load tile', () => {
- test('calls callback on error', done => {
- const server = fakeServer.create();
+describe('Worker register RTLTextPlugin', () => {
+ let worker: Worker;
+ let _self: WorkerGlobalScopeInterface & ActorTarget;
+
+ beforeEach(() => {
+ _self = {
+ addEventListener() {}
+ } as any;
+ worker = new Worker(_self);
global.fetch = null;
- const worker = new Worker(_self);
- worker.loadTile('0', {
+ });
+
+ test('should validate handlers execution in worker for load tile', done => {
+ const server = fakeServer.create();
+ worker.actor.messageHandlers['loadTile']('0', {
type: 'vector',
source: 'source',
uid: '0',
tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0} as CanonicalTileID} as any as OverscaledTileID,
request: {url: '/error'}// Sinon fake server gives 404 responses by default
- } as WorkerTileParameters & { type: string }, (err) => {
+ } as WorkerTileParameters).catch((err) => {
expect(err).toBeTruthy();
server.restore();
done();
@@ -49,42 +53,38 @@ describe('load tile', () => {
});
test('isolates different instances\' data', () => {
- const worker = new Worker(_self);
-
- worker.setLayers('0', [
+ worker.actor.messageHandlers['setLayers']('0', [
{id: 'one', type: 'circle'} as LayerSpecification
- ], () => {});
+ ]);
- worker.setLayers('1', [
+ worker.actor.messageHandlers['setLayers']('1', [
{id: 'one', type: 'circle'} as LayerSpecification,
{id: 'two', type: 'circle'} as LayerSpecification,
- ], () => {});
+ ]);
expect(worker.layerIndexes[0]).not.toBe(worker.layerIndexes[1]);
});
test('worker source messages dispatched to the correct map instance', done => {
- const worker = new Worker(_self);
- const workerName = 'test';
+ const extenalSourceName = 'test';
- worker.actor.send = (type, data, callback, mapId): Cancelable => {
- expect(type).toBe('main thread task');
- expect(mapId).toBe('999');
+ worker.actor.sendAsync = (message, abortController) => {
+ expect(message.type).toBe('loadTile');
+ expect(message.targetMapId).toBe('999');
+ expect(abortController).toBeDefined();
done();
- return {cancel: () => {}};
+ return Promise.resolve({} as any);
};
- _self.registerWorkerSource(workerName, WorkerSourceMock);
+ _self.registerWorkerSource(extenalSourceName, WorkerSourceMock);
expect(() => {
- _self.registerWorkerSource(workerName, WorkerSourceMock);
- }).toThrow(`Worker source with name "${workerName}" already registered.`);
+ _self.registerWorkerSource(extenalSourceName, WorkerSourceMock);
+ }).toThrow(`Worker source with name "${extenalSourceName}" already registered.`);
- worker.loadTile('999', {type: 'test'} as WorkerTileParameters & { type: string }, () => {});
+ worker.actor.messageHandlers['loadTile']('999', {type: extenalSourceName} as WorkerTileParameters);
});
-});
-describe('register RTLTextPlugin', () => {
test('should not throw and set values in plugin', () => {
jest.spyOn(globalRTLTextPlugin, 'isParsed').mockImplementation(() => {
return false;
@@ -97,9 +97,9 @@ describe('register RTLTextPlugin', () => {
};
_self.registerRTLTextPlugin(rtlTextPlugin);
- expect(globalRTLTextPlugin['applyArabicShaping']).toBe('test');
- expect(globalRTLTextPlugin['processBidirectionalText']).toBe('test');
- expect(globalRTLTextPlugin['processStyledBidirectionalText']).toBe('test');
+ expect(globalRTLTextPlugin.applyArabicShaping).toBe('test');
+ expect(globalRTLTextPlugin.processBidirectionalText).toBe('test');
+ expect(globalRTLTextPlugin.processStyledBidirectionalText).toBe('test');
});
test('should throw if already parsed', () => {
@@ -117,37 +117,25 @@ describe('register RTLTextPlugin', () => {
_self.registerRTLTextPlugin(rtlTextPlugin);
}).toThrow('RTL text plugin already registered.');
});
-});
-describe('set Referrer', () => {
test('Referrer is set', () => {
- const worker = new Worker(_self);
- worker.setReferrer('fakeId', 'myMap');
+ worker.actor.messageHandlers['setReferrer']('fakeId', 'myMap');
expect(worker.referrer).toBe('myMap');
});
-});
-describe('load worker source', () => {
test('calls callback on error', done => {
const server = fakeServer.create();
- global.fetch = null;
- const worker = new Worker(_self);
- worker.loadWorkerSource('0', {
- url: '/error',
- }, (err) => {
+ worker.actor.messageHandlers['loadWorkerSource']('0', '/error').catch((err) => {
expect(err).toBeTruthy();
server.restore();
done();
});
server.respond();
});
-});
-describe('set images', () => {
test('set images', () => {
- const worker = new Worker(_self);
expect(worker.availableImages['0']).toBeUndefined();
- worker.setImages('0', ['availableImages'], () => {});
+ worker.actor.messageHandlers['setImages']('0', ['availableImages']);
expect(worker.availableImages['0']).toEqual(['availableImages']);
});
});
diff --git a/src/source/worker.ts b/src/source/worker.ts
index 74ae806d46..2c7d1f7b53 100644
--- a/src/source/worker.ts
+++ b/src/source/worker.ts
@@ -2,23 +2,22 @@ import {Actor, ActorTarget} from '../util/actor';
import {StyleLayerIndex} from '../style/style_layer_index';
import {VectorTileWorkerSource} from './vector_tile_worker_source';
import {RasterDEMTileWorkerSource} from './raster_dem_tile_worker_source';
-import {GeoJSONWorkerSource} from './geojson_worker_source';
+import {GeoJSONWorkerSource, LoadGeoJSONParameters} from './geojson_worker_source';
import {plugin as globalRTLTextPlugin} from './rtl_text_plugin';
import {isWorker} from '../util/util';
import type {
WorkerSource,
+ WorkerSourceConstructor,
WorkerTileParameters,
WorkerDEMTileParameters,
- WorkerTileCallback,
- WorkerDEMTileCallback,
TileParameters
} from '../source/worker_source';
import type {WorkerGlobalScopeInterface} from '../util/web_worker';
-import type {Callback} from '../types/callback';
import type {LayerSpecification} from '@maplibre/maplibre-gl-style-spec';
import type {PluginState} from './rtl_text_plugin';
+import type {ClusterIDAndSource, GetClusterLeavesParams, RemoveSourceParams, UpdateLayersParamaeters} from '../util/actor_messages';
/**
* The Worker class responsidble for background thread related execution
@@ -28,11 +27,13 @@ export default class Worker {
actor: Actor;
layerIndexes: {[_: string]: StyleLayerIndex};
availableImages: {[_: string]: Array};
- workerSourceTypes: {
- [_: string]: {
- new (...args: any): WorkerSource;
- };
- };
+ externalWorkerSourceTypes: { [_: string]: WorkerSourceConstructor };
+ /**
+ * This holds a cache for the already created worker source instances.
+ * The cache is build with the following hierarchy:
+ * [mapId][sourceType][sourceName]: worker source instance
+ * sourceType can be 'vector' for example
+ */
workerSources: {
[_: string]: {
[_: string]: {
@@ -40,6 +41,12 @@ export default class Worker {
};
};
};
+ /**
+ * This holds a cache for the already created DEM worker source instances.
+ * The cache is build with the following hierarchy:
+ * [mapId][sourceType]: DEM worker source instance
+ * sourceType can be 'raster-dem' for example
+ */
demWorkerSources: {
[_: string]: {
[_: string]: RasterDEMTileWorkerSource;
@@ -49,162 +56,156 @@ export default class Worker {
constructor(self: WorkerGlobalScopeInterface & ActorTarget) {
this.self = self;
- this.actor = new Actor(self, this);
+ this.actor = new Actor(self);
this.layerIndexes = {};
this.availableImages = {};
- this.workerSourceTypes = {
- vector: VectorTileWorkerSource,
- geojson: GeoJSONWorkerSource
- };
-
- // [mapId][sourceType][sourceName] => worker source instance
this.workerSources = {};
this.demWorkerSources = {};
+ this.externalWorkerSourceTypes = {};
- this.self.registerWorkerSource = (name: string, WorkerSource: {
- new (...args: any): WorkerSource;
- }) => {
- if (this.workerSourceTypes[name]) {
+ this.self.registerWorkerSource = (name: string, WorkerSource: WorkerSourceConstructor) => {
+ if (this.externalWorkerSourceTypes[name]) {
throw new Error(`Worker source with name "${name}" already registered.`);
}
- this.workerSourceTypes[name] = WorkerSource;
+ this.externalWorkerSourceTypes[name] = WorkerSource;
};
// This is invoked by the RTL text plugin when the download via the `importScripts` call has finished, and the code has been parsed.
this.self.registerRTLTextPlugin = (rtlTextPlugin: {
- applyArabicShaping: Function;
- processBidirectionalText: ((b: string, a: Array) => Array);
- processStyledBidirectionalText?: ((c: string, b: Array, a: Array) => Array<[string, Array]>);
+ applyArabicShaping: typeof globalRTLTextPlugin.applyArabicShaping;
+ processBidirectionalText: typeof globalRTLTextPlugin.processBidirectionalText;
+ processStyledBidirectionalText?: typeof globalRTLTextPlugin.processStyledBidirectionalText;
}) => {
if (globalRTLTextPlugin.isParsed()) {
throw new Error('RTL text plugin already registered.');
}
- globalRTLTextPlugin['applyArabicShaping'] = rtlTextPlugin.applyArabicShaping;
- globalRTLTextPlugin['processBidirectionalText'] = rtlTextPlugin.processBidirectionalText;
- globalRTLTextPlugin['processStyledBidirectionalText'] = rtlTextPlugin.processStyledBidirectionalText;
+ globalRTLTextPlugin.applyArabicShaping = rtlTextPlugin.applyArabicShaping;
+ globalRTLTextPlugin.processBidirectionalText = rtlTextPlugin.processBidirectionalText;
+ globalRTLTextPlugin.processStyledBidirectionalText = rtlTextPlugin.processStyledBidirectionalText;
};
- }
- setReferrer(mapID: string, referrer: string) {
- this.referrer = referrer;
- }
+ this.actor.registerMessageHandler('loadDEMTile', (mapId: string, params: WorkerDEMTileParameters) => {
+ return this._getDEMWorkerSource(mapId, params.source).loadTile(params);
+ });
- setImages(mapId: string, images: Array, callback: WorkerTileCallback) {
- this.availableImages[mapId] = images;
- for (const workerSource in this.workerSources[mapId]) {
- const ws = this.workerSources[mapId][workerSource];
- for (const source in ws) {
- ws[source].availableImages = images;
- }
- }
- callback();
- }
+ this.actor.registerMessageHandler('removeDEMTile', async (mapId: string, params: TileParameters) => {
+ this._getDEMWorkerSource(mapId, params.source).removeTile(params);
+ });
- setLayers(mapId: string, layers: Array, callback: WorkerTileCallback) {
- this.getLayerIndex(mapId).replace(layers);
- callback();
- }
+ this.actor.registerMessageHandler('getClusterExpansionZoom', async (mapId: string, params: ClusterIDAndSource) => {
+ return (this._getWorkerSource(mapId, params.type, params.source) as GeoJSONWorkerSource).getClusterExpansionZoom(params);
+ });
- updateLayers(mapId: string, params: {
- layers: Array;
- removedIds: Array;
- }, callback: WorkerTileCallback) {
- this.getLayerIndex(mapId).update(params.layers, params.removedIds);
- callback();
- }
+ this.actor.registerMessageHandler('getClusterChildren', async (mapId: string, params: ClusterIDAndSource) => {
+ return (this._getWorkerSource(mapId, params.type, params.source) as GeoJSONWorkerSource).getClusterChildren(params);
+ });
- loadTile(mapId: string, params: WorkerTileParameters & {
- type: string;
- }, callback: WorkerTileCallback) {
- this.getWorkerSource(mapId, params.type, params.source).loadTile(params, callback);
- }
+ this.actor.registerMessageHandler('getClusterLeaves', async (mapId: string, params: GetClusterLeavesParams) => {
+ return (this._getWorkerSource(mapId, params.type, params.source) as GeoJSONWorkerSource).getClusterLeaves(params);
+ });
- loadDEMTile(mapId: string, params: WorkerDEMTileParameters, callback: WorkerDEMTileCallback) {
- this.getDEMWorkerSource(mapId, params.source).loadTile(params, callback);
- }
+ this.actor.registerMessageHandler('loadData', (mapId: string, params: LoadGeoJSONParameters) => {
+ return (this._getWorkerSource(mapId, params.type, params.source) as GeoJSONWorkerSource).loadData(params);
+ });
- reloadTile(mapId: string, params: WorkerTileParameters & {
- type: string;
- }, callback: WorkerTileCallback) {
- this.getWorkerSource(mapId, params.type, params.source).reloadTile(params, callback);
- }
+ this.actor.registerMessageHandler('loadTile', (mapId: string, params: WorkerTileParameters) => {
+ return this._getWorkerSource(mapId, params.type, params.source).loadTile(params);
+ });
- abortTile(mapId: string, params: TileParameters & {
- type: string;
- }, callback: WorkerTileCallback) {
- this.getWorkerSource(mapId, params.type, params.source).abortTile(params, callback);
- }
+ this.actor.registerMessageHandler('reloadTile', (mapId: string, params: WorkerTileParameters) => {
+ return this._getWorkerSource(mapId, params.type, params.source).reloadTile(params);
+ });
- removeTile(mapId: string, params: TileParameters & {
- type: string;
- }, callback: WorkerTileCallback) {
- this.getWorkerSource(mapId, params.type, params.source).removeTile(params, callback);
- }
+ this.actor.registerMessageHandler('abortTile', (mapId: string, params: TileParameters) => {
+ return this._getWorkerSource(mapId, params.type, params.source).abortTile(params);
+ });
- removeDEMTile(mapId: string, params: TileParameters) {
- this.getDEMWorkerSource(mapId, params.source).removeTile(params);
- }
+ this.actor.registerMessageHandler('removeTile', (mapId: string, params: TileParameters) => {
+ return this._getWorkerSource(mapId, params.type, params.source).removeTile(params);
+ });
- removeSource(mapId: string, params: {
- source: string;
- } & {
- type: string;
- }, callback: WorkerTileCallback) {
+ this.actor.registerMessageHandler('removeSource', async (mapId: string, params: RemoveSourceParams) => {
+ if (!this.workerSources[mapId] ||
+ !this.workerSources[mapId][params.type] ||
+ !this.workerSources[mapId][params.type][params.source]) {
+ return;
+ }
- if (!this.workerSources[mapId] ||
- !this.workerSources[mapId][params.type] ||
- !this.workerSources[mapId][params.type][params.source]) {
- return;
- }
+ const worker = this.workerSources[mapId][params.type][params.source];
+ delete this.workerSources[mapId][params.type][params.source];
- const worker = this.workerSources[mapId][params.type][params.source];
- delete this.workerSources[mapId][params.type][params.source];
+ if (worker.removeSource !== undefined) {
+ worker.removeSource(params);
+ }
+ });
+
+ this.actor.registerMessageHandler('setReferrer', async (_mapId: string, params: string) => {
+ this.referrer = params;
+ });
+
+ this.actor.registerMessageHandler('syncRTLPluginState', (mapId: string, params: PluginState) => {
+ return this._syncRTLPluginState(mapId, params);
+ });
+
+ this.actor.registerMessageHandler('loadWorkerSource', async (_mapId: string, params: string) => {
+ try {
+ this.self.importScripts(params);
+ } catch (ex) {
+ // This is done since some error messages are not serializable
+ throw ex.toString();
+ }
+ });
- if (worker.removeSource !== undefined) {
- worker.removeSource(params, callback);
- } else {
- callback();
- }
+ this.actor.registerMessageHandler('setImages', (mapId: string, params: string[]) => {
+ return this._setImages(mapId, params);
+ });
+
+ this.actor.registerMessageHandler('updateLayers', async (mapId: string, params: UpdateLayersParamaeters) => {
+ this._getLayerIndex(mapId).update(params.layers, params.removedIds);
+ });
+
+ this.actor.registerMessageHandler('setLayers', async (mapId: string, params: Array) => {
+ this._getLayerIndex(mapId).replace(params);
+ });
}
- /**
- * Load a {@link WorkerSource} script at params.url. The script is run
- * (using importScripts) with `registerWorkerSource` in scope, which is a
- * function taking `(name, workerSourceObject)`.
- */
- loadWorkerSource(map: string, params: {
- url: string;
- }, callback: Callback) {
- try {
- this.self.importScripts(params.url);
- callback();
- } catch (e) {
- callback(e.toString());
+ private async _setImages(mapId: string, images: Array): Promise {
+ this.availableImages[mapId] = images;
+ for (const workerSource in this.workerSources[mapId]) {
+ const ws = this.workerSources[mapId][workerSource];
+ for (const source in ws) {
+ ws[source].availableImages = images;
+ }
}
}
- syncRTLPluginState(map: string, state: PluginState, callback: Callback) {
+ private async _syncRTLPluginState(map: string, state: PluginState): Promise {
try {
globalRTLTextPlugin.setState(state);
const pluginURL = globalRTLTextPlugin.getPluginURL();
if (
globalRTLTextPlugin.isLoaded() &&
- !globalRTLTextPlugin.isParsed() &&
- pluginURL != null // Not possible when `isLoaded` is true, but keeps flow happy
+ !globalRTLTextPlugin.isParsed() &&
+ pluginURL != null // Not possible when `isLoaded` is true, but keeps flow happy
) {
this.self.importScripts(pluginURL);
const complete = globalRTLTextPlugin.isParsed();
- const error = complete ? undefined : new Error(`RTL Text Plugin failed to import scripts from ${pluginURL}`);
- callback(error, complete);
+ if (complete) {
+ return complete;
+ }
+ throw new Error(`RTL Text Plugin failed to import scripts from ${pluginURL}`);
}
- } catch (e) {
- callback(e.toString());
+ return false;
+ } catch (ex) {
+ // This is done since some error messages are not serializable
+ throw ex.toString();
+
}
}
- getAvailableImages(mapId: string) {
+ private _getAvailableImages(mapId: string) {
let availableImages = this.availableImages[mapId];
if (!availableImages) {
@@ -214,7 +215,7 @@ export default class Worker {
return availableImages;
}
- getLayerIndex(mapId: string) {
+ private _getLayerIndex(mapId: string) {
let layerIndexes = this.layerIndexes[mapId];
if (!layerIndexes) {
layerIndexes = this.layerIndexes[mapId] = new StyleLayerIndex();
@@ -222,7 +223,14 @@ export default class Worker {
return layerIndexes;
}
- getWorkerSource(mapId: string, sourceType: string, sourceName: string): WorkerSource {
+ /**
+ * This is basically a lazy initialization of a worker per mapId and sourceType and sourceName
+ * @param mapId - the mapId
+ * @param sourceType - the source type - 'vector' for example
+ * @param sourceName - the source name - 'osm' for example
+ * @returns a new instance or a cached one
+ */
+ private _getWorkerSource(mapId: string, sourceType: string, sourceName: string): WorkerSource {
if (!this.workerSources[mapId])
this.workerSources[mapId] = {};
if (!this.workerSources[mapId][sourceType])
@@ -230,30 +238,47 @@ export default class Worker {
if (!this.workerSources[mapId][sourceType][sourceName]) {
// use a wrapped actor so that we can attach a target mapId param
- // to any messages invoked by the WorkerSource
+ // to any messages invoked by the WorkerSource, this is very important when there are multiple maps
const actor = {
- send: (type, data, callback) => {
- this.actor.send(type, data, callback, mapId);
+ sendAsync: (message, abortController) => {
+ message.targetMapId = mapId;
+ return this.actor.sendAsync(message, abortController);
}
};
- this.workerSources[mapId][sourceType][sourceName] = new (this.workerSourceTypes[sourceType] as any)((actor as any), this.getLayerIndex(mapId), this.getAvailableImages(mapId));
+ switch (sourceType) {
+ case 'vector':
+ this.workerSources[mapId][sourceType][sourceName] = new VectorTileWorkerSource(actor, this._getLayerIndex(mapId), this._getAvailableImages(mapId));
+ break;
+ case 'geojson':
+ this.workerSources[mapId][sourceType][sourceName] = new GeoJSONWorkerSource(actor, this._getLayerIndex(mapId), this._getAvailableImages(mapId));
+ break;
+ default:
+ this.workerSources[mapId][sourceType][sourceName] = new (this.externalWorkerSourceTypes[sourceType])(actor, this._getLayerIndex(mapId), this._getAvailableImages(mapId));
+ break;
+ }
}
return this.workerSources[mapId][sourceType][sourceName];
}
- getDEMWorkerSource(mapId: string, source: string) {
+ /**
+ * This is basically a lazy initialization of a worker per mapId and source
+ * @param mapId - the mapId
+ * @param sourceType - the source type - 'raster-dem' for example
+ * @returns a new instance or a cached one
+ */
+ private _getDEMWorkerSource(mapId: string, sourceType: string) {
if (!this.demWorkerSources[mapId])
this.demWorkerSources[mapId] = {};
- if (!this.demWorkerSources[mapId][source]) {
- this.demWorkerSources[mapId][source] = new RasterDEMTileWorkerSource();
+ if (!this.demWorkerSources[mapId][sourceType]) {
+ this.demWorkerSources[mapId][sourceType] = new RasterDEMTileWorkerSource();
}
- return this.demWorkerSources[mapId][source];
+ return this.demWorkerSources[mapId][sourceType];
}
}
-if (isWorker()) {
- (self as any).worker = new Worker(self as any);
+if (isWorker(self)) {
+ self.worker = new Worker(self);
}
diff --git a/src/source/worker_source.ts b/src/source/worker_source.ts
index fd6741b446..8f5306ca42 100644
--- a/src/source/worker_source.ts
+++ b/src/source/worker_source.ts
@@ -1,4 +1,4 @@
-import type {RequestParameters} from '../util/ajax';
+import type {ExpiryData, RequestParameters} from '../util/ajax';
import type {RGBAImage, AlphaImage} from '../util/image';
import type {GlyphPositions} from '../render/glyph_atlas';
import type {ImageAtlas} from '../render/image_atlas';
@@ -6,21 +6,31 @@ import type {OverscaledTileID} from './tile_id';
import type {Bucket} from '../data/bucket';
import type {FeatureIndex} from '../data/feature_index';
import type {CollisionBoxArray} from '../data/array_types.g';
-import type {DEMData, DEMEncoding} from '../data/dem_data';
+import type {DEMEncoding} from '../data/dem_data';
import type {StyleGlyph} from '../style/style_glyph';
import type {StyleImage} from '../style/style_image';
import type {PromoteIdSpecification} from '@maplibre/maplibre-gl-style-spec';
+import {RemoveSourceParams as RemoveSourceParameters} from '../util/actor_messages';
+import type {IActor} from '../util/actor';
+import type {StyleLayerIndex} from '../style/style_layer_index';
+/**
+ * Parameters to identify a tile
+ */
export type TileParameters = {
+ type: string;
source: string;
- uid: string;
+ uid: string | number;
};
+/**
+ * Parameters that are send when requesting to load a tile to the worker
+ */
export type WorkerTileParameters = TileParameters & {
tileID: OverscaledTileID;
- request: RequestParameters;
+ request?: RequestParameters;
zoom: number;
- maxZoom: number;
+ maxZoom?: number;
tileSize: number;
promoteId: PromoteIdSpecification;
pixelRatio: number;
@@ -29,14 +39,11 @@ export type WorkerTileParameters = TileParameters & {
returnDependencies?: boolean;
};
+/**
+ * The paremeters needed in order to load a DEM tile
+ */
export type WorkerDEMTileParameters = TileParameters & {
- coord: {
- z: number;
- x: number;
- y: number;
- w: number;
- };
- rawImageData: RGBAImage | ImageBitmap;
+ rawImageData: RGBAImage | ImageBitmap | ImageData;
encoding: DEMEncoding;
redFactor: number;
greenFactor: number;
@@ -45,10 +52,9 @@ export type WorkerDEMTileParameters = TileParameters & {
};
/**
- * @internal
* The worker tile's result type
*/
-export type WorkerTileResult = {
+export type WorkerTileResult = ExpiryData & {
buckets: Array;
imageAtlas: ImageAtlas;
glyphAtlasImage: AlphaImage;
@@ -68,51 +74,44 @@ export type WorkerTileResult = {
glyphPositions?: GlyphPositions | null;
};
-export type WorkerTileCallback = (error?: Error | null, result?: WorkerTileResult | null) => void;
-export type WorkerDEMTileCallback = (err?: Error | null, result?: DEMData | null) => void;
+/**
+ * This is how the @see {@link WorkerSource} constructor should look like.
+ */
+export interface WorkerSourceConstructor {
+ new (actor: IActor, layerIndex: StyleLayerIndex, availableImages: Array): WorkerSource;
+}
/**
- * May be implemented by custom source types to provide code that can be run on
- * the WebWorkers. In addition to providing a custom
- * {@link WorkerSource#loadTile}, any other methods attached to a `WorkerSource`
- * implementation may also be targeted by the {@link Source} via
- * `dispatcher.getActor().send('source-type.methodname', params, callback)`.
- *
+ * `WorkerSource` should be implemented by custom source types to provide code that can be run on the WebWorkers.
+ * Each of the methods has a relevant event that triggers it from the main thread with the relevant parameters.
* @see {@link Map#addSourceType}
*/
export interface WorkerSource {
availableImages: Array;
- // Disabled due to https://github.com/facebook/flow/issues/5208
- // constructor(actor: Actor, layerIndex: StyleLayerIndex): WorkerSource;
/**
* Loads a tile from the given params and parse it into buckets ready to send
* back to the main thread for rendering. Should call the callback with:
* `{ buckets, featureIndex, collisionIndex, rawTileData}`.
*/
- loadTile(params: WorkerTileParameters, callback: WorkerTileCallback): void;
+ loadTile(params: WorkerTileParameters): Promise;
/**
* Re-parses a tile that has already been loaded. Yields the same data as
* {@link WorkerSource#loadTile}.
*/
- reloadTile(params: WorkerTileParameters, callback: WorkerTileCallback): void;
+ reloadTile(params: WorkerTileParameters): Promise;
/**
* Aborts loading a tile that is in progress.
*/
- abortTile(params: TileParameters, callback: WorkerTileCallback): void;
+ abortTile(params: TileParameters): Promise;
/**
* Removes this tile from any local caches.
*/
- removeTile(params: TileParameters, callback: WorkerTileCallback): void;
+ removeTile(params: TileParameters): Promise;
/**
* Tells the WorkerSource to abort in-progress tasks and release resources.
* The foreground Source is responsible for ensuring that 'removeSource' is
* the last message sent to the WorkerSource.
*/
- removeSource?: (
- params: {
- source: string;
- },
- callback: WorkerTileCallback
- ) => void;
+ removeSource?: (params: RemoveSourceParameters) => Promise;
}
diff --git a/src/source/worker_tile.test.ts b/src/source/worker_tile.test.ts
index c914ee28d0..813bc71e56 100644
--- a/src/source/worker_tile.test.ts
+++ b/src/source/worker_tile.test.ts
@@ -3,7 +3,6 @@ import {GeoJSONWrapper, Feature} from '../source/geojson_wrapper';
import {OverscaledTileID} from '../source/tile_id';
import {StyleLayerIndex} from '../style/style_layer_index';
import {WorkerTileParameters} from './worker_source';
-import {Actor} from '../util/actor';
import {VectorTile} from '@mapbox/vector-tile';
function createWorkerTile() {
@@ -27,7 +26,7 @@ function createWrapper() {
}
describe('worker tile', () => {
- test('WorkerTile#parse', done => {
+ test('WorkerTile#parse', async () => {
const layerIndex = new StyleLayerIndex([{
id: 'test',
source: 'source',
@@ -35,14 +34,11 @@ describe('worker tile', () => {
}]);
const tile = createWorkerTile();
- tile.parse(createWrapper(), layerIndex, [], {} as Actor, (err, result) => {
- expect(err).toBeFalsy();
- expect(result.buckets[0]).toBeTruthy();
- done();
- });
+ const result = await tile.parse(createWrapper(), layerIndex, [], {} as any);
+ expect(result.buckets[0]).toBeTruthy();
});
- test('WorkerTile#parse skips hidden layers', done => {
+ test('WorkerTile#parse skips hidden layers', async () => {
const layerIndex = new StyleLayerIndex([{
id: 'test-hidden',
source: 'source',
@@ -51,14 +47,11 @@ describe('worker tile', () => {
}]);
const tile = createWorkerTile();
- tile.parse(createWrapper(), layerIndex, [], {} as Actor, (err, result) => {
- expect(err).toBeFalsy();
- expect(result.buckets).toHaveLength(0);
- done();
- });
+ const result = await tile.parse(createWrapper(), layerIndex, [], {} as any);
+ expect(result.buckets).toHaveLength(0);
});
- test('WorkerTile#parse skips layers without a corresponding source layer', done => {
+ test('WorkerTile#parse skips layers without a corresponding source layer', async () => {
const layerIndex = new StyleLayerIndex([{
id: 'test',
source: 'source',
@@ -67,14 +60,11 @@ describe('worker tile', () => {
}]);
const tile = createWorkerTile();
- tile.parse({layers: {}}, layerIndex, [], {} as Actor, (err, result) => {
- expect(err).toBeFalsy();
- expect(result.buckets).toHaveLength(0);
- done();
- });
+ const result = await tile.parse({layers: {}}, layerIndex, [], {} as any);
+ expect(result.buckets).toHaveLength(0);
});
- test('WorkerTile#parse warns once when encountering a v1 vector tile layer', done => {
+ test('WorkerTile#parse warns once when encountering a v1 vector tile layer', async () => {
const layerIndex = new StyleLayerIndex([{
id: 'test',
source: 'source',
@@ -93,14 +83,11 @@ describe('worker tile', () => {
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const tile = createWorkerTile();
- tile.parse(data, layerIndex, [], {} as Actor, (err) => {
- expect(err).toBeFalsy();
- expect(spy.mock.calls[0][0]).toMatch(/does not use vector tile spec v2/);
- done();
- });
+ await tile.parse(data, layerIndex, [], {} as any);
+ expect(spy.mock.calls[0][0]).toMatch(/does not use vector tile spec v2/);
});
- test('WorkerTile#parse would request all types of dependencies', done => {
+ test('WorkerTile#parse would request all types of dependencies', async () => {
const tile = createWorkerTile();
const layerIndex = new StyleLayerIndex([{
id: '1',
@@ -144,29 +131,25 @@ describe('worker tile', () => {
}
} as any as VectorTile;
- const send = jest.fn().mockImplementation((type: string, data: unknown, callback: Function) => {
- setTimeout(() => callback(null,
- type === 'getImages' ?
- {'hello': {width: 1, height: 1, data: new Uint8Array([0])}} :
- {'StandardFont-Bold': {width: 1, height: 1, data: new Uint8Array([0])}}
- ));
+ const sendAsync = jest.fn().mockImplementation((message: {type: string; data: any}) => {
+ const response = message.type === 'getImages' ?
+ {'hello': {width: 1, height: 1, data: new Uint8Array([0])}} :
+ {'StandardFont-Bold': {width: 1, height: 1, data: new Uint8Array([0])}};
+ return Promise.resolve(response);
});
const actorMock = {
- send
- } as unknown as Actor;
- tile.parse(data, layerIndex, ['hello'], actorMock, (err, result) => {
- expect(err).toBeFalsy();
- expect(result).toBeDefined();
- expect(send).toHaveBeenCalledTimes(3);
- expect(send).toHaveBeenCalledWith('getImages', expect.objectContaining({'icons': ['hello'], 'type': 'icons'}), expect.any(Function));
- expect(send).toHaveBeenCalledWith('getImages', expect.objectContaining({'icons': ['hello'], 'type': 'patterns'}), expect.any(Function));
- expect(send).toHaveBeenCalledWith('getGlyphs', expect.objectContaining({'source': 'source', 'type': 'glyphs', 'stacks': {'StandardFont-Bold': [101, 115, 116]}}), expect.any(Function));
- done();
- });
+ sendAsync
+ };
+ const result = await tile.parse(data, layerIndex, ['hello'], actorMock);
+ expect(result).toBeDefined();
+ expect(sendAsync).toHaveBeenCalledTimes(3);
+ expect(sendAsync).toHaveBeenCalledWith(expect.objectContaining({data: expect.objectContaining({'icons': ['hello'], 'type': 'icons'})}), expect.any(Object));
+ expect(sendAsync).toHaveBeenCalledWith(expect.objectContaining({data: expect.objectContaining({'icons': ['hello'], 'type': 'patterns'})}), expect.any(Object));
+ expect(sendAsync).toHaveBeenCalledWith(expect.objectContaining({data: expect.objectContaining({'source': 'source', 'type': 'glyphs', 'stacks': {'StandardFont-Bold': [101, 115, 116]}})}), expect.any(Object));
});
- test('WorkerTile#parse would cancel and only event once on repeated reparsing', done => {
+ test('WorkerTile#parse would cancel and only event once on repeated reparsing', async () => {
const tile = createWorkerTile();
const layerIndex = new StyleLayerIndex([{
id: '1',
@@ -211,35 +194,33 @@ describe('worker tile', () => {
} as any as VectorTile;
let cancelCount = 0;
- const send = jest.fn().mockImplementation((type: string, data: unknown, callback: Function) => {
- const res = setTimeout(() => callback(null,
- type === 'getImages' ?
- {'hello': {width: 1, height: 1, data: new Uint8Array([0])}} :
- {'StandardFont-Bold': {width: 1, height: 1, data: new Uint8Array([0])}}
- ));
-
- return {
- cancel: () => {
+ const sendAsync = jest.fn().mockImplementation((message: {type: string; data: unknown}, abortController: AbortController) => {
+ return new Promise((resolve, _reject) => {
+ const res = setTimeout(() => {
+ const response = message.type === 'getImages' ?
+ {'hello': {width: 1, height: 1, data: new Uint8Array([0])}} :
+ {'StandardFont-Bold': {width: 1, height: 1, data: new Uint8Array([0])}};
+ resolve(response);
+ }
+ );
+ abortController.signal.addEventListener('abort', () => {
cancelCount += 1;
clearTimeout(res);
- }
- };
+ });
+ });
});
const actorMock = {
- send
- } as unknown as Actor;
- tile.parse(data, layerIndex, ['hello'], actorMock, () => done.fail('should not be called'));
- tile.parse(data, layerIndex, ['hello'], actorMock, () => done.fail('should not be called'));
- tile.parse(data, layerIndex, ['hello'], actorMock, (err, result) => {
- expect(err).toBeFalsy();
- expect(result).toBeDefined();
- expect(cancelCount).toBe(6);
- expect(send).toHaveBeenCalledTimes(9);
- expect(send).toHaveBeenCalledWith('getImages', expect.objectContaining({'icons': ['hello'], 'type': 'icons'}), expect.any(Function));
- expect(send).toHaveBeenCalledWith('getImages', expect.objectContaining({'icons': ['hello'], 'type': 'patterns'}), expect.any(Function));
- expect(send).toHaveBeenCalledWith('getGlyphs', expect.objectContaining({'source': 'source', 'type': 'glyphs', 'stacks': {'StandardFont-Bold': [101, 115, 116]}}), expect.any(Function));
- done();
- });
+ sendAsync
+ };
+ tile.parse(data, layerIndex, ['hello'], actorMock).then(() => expect(false).toBeTruthy());
+ tile.parse(data, layerIndex, ['hello'], actorMock).then(() => expect(false).toBeTruthy());
+ const result = await tile.parse(data, layerIndex, ['hello'], actorMock);
+ expect(result).toBeDefined();
+ expect(cancelCount).toBe(6);
+ expect(sendAsync).toHaveBeenCalledTimes(9);
+ expect(sendAsync).toHaveBeenCalledWith(expect.objectContaining({data: expect.objectContaining({'icons': ['hello'], 'type': 'icons'})}), expect.any(Object));
+ expect(sendAsync).toHaveBeenCalledWith(expect.objectContaining({data: expect.objectContaining({'icons': ['hello'], 'type': 'patterns'})}), expect.any(Object));
+ expect(sendAsync).toHaveBeenCalledWith(expect.objectContaining({data: expect.objectContaining({'source': 'source', 'type': 'glyphs', 'stacks': {'StandardFont-Bold': [101, 115, 116]}})}), expect.any(Object));
});
});
diff --git a/src/source/worker_tile.ts b/src/source/worker_tile.ts
index 3a13b4ce5f..35c8770cc4 100644
--- a/src/source/worker_tile.ts
+++ b/src/source/worker_tile.ts
@@ -13,22 +13,20 @@ import {EvaluationParameters} from '../style/evaluation_parameters';
import {OverscaledTileID} from './tile_id';
import type {Bucket} from '../data/bucket';
-import type {Actor} from '../util/actor';
+import type {IActor} from '../util/actor';
import type {StyleLayer} from '../style/style_layer';
import type {StyleLayerIndex} from '../style/style_layer_index';
-import type {StyleImage} from '../style/style_image';
-import type {StyleGlyph} from '../style/style_glyph';
import type {
WorkerTileParameters,
- WorkerTileCallback,
+ WorkerTileResult,
} from '../source/worker_source';
import type {PromoteIdSpecification} from '@maplibre/maplibre-gl-style-spec';
import type {VectorTile} from '@mapbox/vector-tile';
-import {Cancelable} from '../types/cancelable';
+import type {GetGlyphsResponse, GetImagesResponse} from '../util/actor_messages';
export class WorkerTile {
tileID: OverscaledTileID;
- uid: string;
+ uid: string | number;
zoom: number;
pixelRatio: number;
tileSize: number;
@@ -43,10 +41,9 @@ export class WorkerTile {
data: VectorTile;
collisionBoxArray: CollisionBoxArray;
- abort: (() => void);
+ abort: AbortController;
vectorTile: VectorTile;
- inFlightDependencies: Cancelable[];
- dependencySentinel: number;
+ inFlightDependencies: AbortController[];
constructor(params: WorkerTileParameters) {
this.tileID = new OverscaledTileID(params.tileID.overscaledZ, params.tileID.wrap, params.tileID.canonical.z, params.tileID.canonical.x, params.tileID.canonical.y);
@@ -61,10 +58,9 @@ export class WorkerTile {
this.returnDependencies = !!params.returnDependencies;
this.promoteId = params.promoteId;
this.inFlightDependencies = [];
- this.dependencySentinel = -1;
}
- parse(data: VectorTile, layerIndex: StyleLayerIndex, availableImages: Array, actor: Actor, callback: WorkerTileCallback) {
+ async parse(data: VectorTile, layerIndex: StyleLayerIndex, availableImages: Array, actor: IActor): Promise {
this.status = 'parsing';
this.data = data;
@@ -132,114 +128,72 @@ export class WorkerTile {
}
}
- let error: Error;
- let glyphMap: {
- [_: string]: {
- [_: number]: StyleGlyph;
- };
- };
- let iconMap: {[_: string]: StyleImage};
- let patternMap: {[_: string]: StyleImage};
-
const stacks = mapObject(options.glyphDependencies, (glyphs) => Object.keys(glyphs).map(Number));
- this.inFlightDependencies.forEach((request) => request?.cancel());
+ this.inFlightDependencies.forEach((request) => request?.abort());
this.inFlightDependencies = [];
- // cancelling seems to be not sufficient, we seems to still manage to get a callback hit, so use a sentinel to drop stale results
- const dependencySentinel = ++this.dependencySentinel;
+ let getGlyphsPromise = Promise.resolve({});
if (Object.keys(stacks).length) {
- this.inFlightDependencies.push(actor.send('getGlyphs', {uid: this.uid, stacks, source: this.source, tileID: this.tileID, type: 'glyphs'}, (err, result) => {
- if (dependencySentinel !== this.dependencySentinel) {
- return;
- }
- if (!error) {
- error = err;
- glyphMap = result;
- maybePrepare.call(this);
- }
- }));
- } else {
- glyphMap = {};
+ const abortController = new AbortController();
+ this.inFlightDependencies.push(abortController);
+ getGlyphsPromise = actor.sendAsync({type: 'getGlyphs', data: {stacks, source: this.source, tileID: this.tileID, type: 'glyphs'}}, abortController);
}
const icons = Object.keys(options.iconDependencies);
+ let getIconsPromise = Promise.resolve({});
if (icons.length) {
- this.inFlightDependencies.push(actor.send('getImages', {icons, source: this.source, tileID: this.tileID, type: 'icons'}, (err, result) => {
- if (dependencySentinel !== this.dependencySentinel) {
- return;
- }
- if (!error) {
- error = err;
- iconMap = result;
- maybePrepare.call(this);
- }
- }));
- } else {
- iconMap = {};
+ const abortController = new AbortController();
+ this.inFlightDependencies.push(abortController);
+ getIconsPromise = actor.sendAsync({type: 'getImages', data: {icons, source: this.source, tileID: this.tileID, type: 'icons'}}, abortController);
}
const patterns = Object.keys(options.patternDependencies);
+ let getPatternsPromise = Promise.resolve({});
if (patterns.length) {
- this.inFlightDependencies.push(actor.send('getImages', {icons: patterns, source: this.source, tileID: this.tileID, type: 'patterns'}, (err, result) => {
- if (dependencySentinel !== this.dependencySentinel) {
- return;
- }
- if (!error) {
- error = err;
- patternMap = result;
- maybePrepare.call(this);
- }
- }));
- } else {
- patternMap = {};
+ const abortController = new AbortController();
+ this.inFlightDependencies.push(abortController);
+ getPatternsPromise = actor.sendAsync({type: 'getImages', data: {icons: patterns, source: this.source, tileID: this.tileID, type: 'patterns'}}, abortController);
}
- maybePrepare.call(this);
-
- function maybePrepare() {
- if (error) {
- return callback(error);
- } else if (glyphMap && iconMap && patternMap) {
- const glyphAtlas = new GlyphAtlas(glyphMap);
- const imageAtlas = new ImageAtlas(iconMap, patternMap);
-
- for (const key in buckets) {
- const bucket = buckets[key];
- if (bucket instanceof SymbolBucket) {
- recalculateLayers(bucket.layers, this.zoom, availableImages);
- performSymbolLayout({
- bucket,
- glyphMap,
- glyphPositions: glyphAtlas.positions,
- imageMap: iconMap,
- imagePositions: imageAtlas.iconPositions,
- showCollisionBoxes: this.showCollisionBoxes,
- canonical: this.tileID.canonical
- });
- } else if (bucket.hasPattern &&
- (bucket instanceof LineBucket ||
- bucket instanceof FillBucket ||
- bucket instanceof FillExtrusionBucket)) {
- recalculateLayers(bucket.layers, this.zoom, availableImages);
- bucket.addFeatures(options, this.tileID.canonical, imageAtlas.patternPositions);
- }
- }
-
- this.status = 'done';
- callback(null, {
- buckets: Object.values(buckets).filter(b => !b.isEmpty()),
- featureIndex,
- collisionBoxArray: this.collisionBoxArray,
- glyphAtlasImage: glyphAtlas.image,
- imageAtlas,
- // Only used for benchmarking:
- glyphMap: this.returnDependencies ? glyphMap : null,
- iconMap: this.returnDependencies ? iconMap : null,
- glyphPositions: this.returnDependencies ? glyphAtlas.positions : null
+ const [glyphMap, iconMap, patternMap] = await Promise.all([getGlyphsPromise, getIconsPromise, getPatternsPromise]);
+ const glyphAtlas = new GlyphAtlas(glyphMap);
+ const imageAtlas = new ImageAtlas(iconMap, patternMap);
+
+ for (const key in buckets) {
+ const bucket = buckets[key];
+ if (bucket instanceof SymbolBucket) {
+ recalculateLayers(bucket.layers, this.zoom, availableImages);
+ performSymbolLayout({
+ bucket,
+ glyphMap,
+ glyphPositions: glyphAtlas.positions,
+ imageMap: iconMap,
+ imagePositions: imageAtlas.iconPositions,
+ showCollisionBoxes: this.showCollisionBoxes,
+ canonical: this.tileID.canonical
});
+ } else if (bucket.hasPattern &&
+ (bucket instanceof LineBucket ||
+ bucket instanceof FillBucket ||
+ bucket instanceof FillExtrusionBucket)) {
+ recalculateLayers(bucket.layers, this.zoom, availableImages);
+ bucket.addFeatures(options, this.tileID.canonical, imageAtlas.patternPositions);
}
}
+
+ this.status = 'done';
+ return {
+ buckets: Object.values(buckets).filter(b => !b.isEmpty()),
+ featureIndex,
+ collisionBoxArray: this.collisionBoxArray,
+ glyphAtlasImage: glyphAtlas.image,
+ imageAtlas,
+ // Only used for benchmarking:
+ glyphMap: this.returnDependencies ? glyphMap : null,
+ iconMap: this.returnDependencies ? iconMap : null,
+ glyphPositions: this.returnDependencies ? glyphAtlas.positions : null
+ };
}
}
diff --git a/src/style/load_glyph_range.test.ts b/src/style/load_glyph_range.test.ts
index 12500f9ebf..f79334a301 100644
--- a/src/style/load_glyph_range.test.ts
+++ b/src/style/load_glyph_range.test.ts
@@ -5,7 +5,7 @@ import {loadGlyphRange} from './load_glyph_range';
import {fakeServer} from 'nise';
import {bufferToArrayBuffer} from '../util/test/util';
-test('loadGlyphRange', done => {
+test('loadGlyphRange', async () => {
global.fetch = null;
const transform = jest.fn().mockImplementation((url) => {
@@ -17,25 +17,24 @@ test('loadGlyphRange', done => {
const server = fakeServer.create();
server.respondWith(bufferToArrayBuffer(fs.readFileSync(path.join(__dirname, '../../test/unit/assets/0-255.pbf'))));
- loadGlyphRange('Arial Unicode MS', 0, 'https://localhost/fonts/v1/{fontstack}/{range}.pbf', manager, (err, result) => {
- expect(err).toBeFalsy();
- expect(transform).toHaveBeenCalledTimes(1);
- expect(transform).toHaveBeenCalledWith('https://localhost/fonts/v1/Arial Unicode MS/0-255.pbf', 'Glyphs');
-
- expect(Object.keys(result)).toHaveLength(223);
- for (const key in result) {
- const id = Number(key);
- const glyph = result[id];
-
- expect(glyph.id).toBe(Number(id));
- expect(glyph.metrics).toBeTruthy();
- expect(typeof glyph.metrics.width).toBe('number');
- expect(typeof glyph.metrics.height).toBe('number');
- expect(typeof glyph.metrics.top).toBe('number');
- expect(typeof glyph.metrics.advance).toBe('number');
- }
- done();
- });
+ const promise = loadGlyphRange('Arial Unicode MS', 0, 'https://localhost/fonts/v1/{fontstack}/{range}.pbf', manager);
server.respond();
+ const result = await promise;
+
+ expect(transform).toHaveBeenCalledTimes(1);
+ expect(transform).toHaveBeenCalledWith('https://localhost/fonts/v1/Arial Unicode MS/0-255.pbf', 'Glyphs');
+
+ expect(Object.keys(result)).toHaveLength(223);
+ for (const key in result) {
+ const id = Number(key);
+ const glyph = result[id];
+
+ expect(glyph.id).toBe(Number(id));
+ expect(glyph.metrics).toBeTruthy();
+ expect(typeof glyph.metrics.width).toBe('number');
+ expect(typeof glyph.metrics.height).toBe('number');
+ expect(typeof glyph.metrics.top).toBe('number');
+ expect(typeof glyph.metrics.advance).toBe('number');
+ }
expect(server.requests[0].url).toBe('https://localhost/fonts/v1/Arial Unicode MS/0-255.pbf');
});
diff --git a/src/style/load_glyph_range.ts b/src/style/load_glyph_range.ts
index 6a454caa90..ba63c10a4b 100644
--- a/src/style/load_glyph_range.ts
+++ b/src/style/load_glyph_range.ts
@@ -5,15 +5,11 @@ import {parseGlyphPbf} from './parse_glyph_pbf';
import type {StyleGlyph} from './style_glyph';
import type {RequestManager} from '../util/request_manager';
-import type {Callback} from '../types/callback';
-export function loadGlyphRange(fontstack: string,
+export async function loadGlyphRange(fontstack: string,
range: number,
urlTemplate: string,
- requestManager: RequestManager,
- callback: Callback<{
- [_: number]: StyleGlyph | null;
- }>) {
+ requestManager: RequestManager): Promise<{[_: number]: StyleGlyph | null}> {
const begin = range * 256;
const end = begin + 255;
@@ -22,17 +18,15 @@ export function loadGlyphRange(fontstack: string,
ResourceType.Glyphs
);
- getArrayBuffer(request, (err?: Error | null, data?: ArrayBuffer | null) => {
- if (err) {
- callback(err);
- } else if (data) {
- const glyphs = {};
+ const response = await getArrayBuffer(request, new AbortController());
+ if (!response || !response.data) {
+ throw new Error(`Could not load glyph range. range: ${range}, ${begin}-${end}`);
+ }
+ const glyphs = {};
- for (const glyph of parseGlyphPbf(data)) {
- glyphs[glyph.id] = glyph;
- }
+ for (const glyph of parseGlyphPbf(response.data)) {
+ glyphs[glyph.id] = glyph;
+ }
- callback(null, glyphs);
- }
- });
+ return glyphs;
}
diff --git a/src/style/load_sprite.test.ts b/src/style/load_sprite.test.ts
index 6aaa068f0e..e55d849347 100644
--- a/src/style/load_sprite.test.ts
+++ b/src/style/load_sprite.test.ts
@@ -3,26 +3,28 @@ import path from 'path';
import {RequestManager} from '../util/request_manager';
import {loadSprite} from './load_sprite';
import {type FakeServer, fakeServer} from 'nise';
-import * as util from '../util/util';
import {bufferToArrayBuffer} from '../util/test/util';
+import {ABORT_ERROR} from '../util/abort_error';
+import * as util from '../util/util';
describe('loadSprite', () => {
let server: FakeServer;
beforeEach(() => {
- jest.spyOn(util, 'arrayBufferToImageBitmap').mockImplementation((data: ArrayBuffer, callback: (err?: Error | null, image?: ImageBitmap | null) => void) => {
- createImageBitmap(new ImageData(1024, 824)).then((imgBitmap) => {
- callback(null, imgBitmap);
- }).catch((e) => {
- callback(new Error(`Could not load image because of ${e.message}. Please make sure to use a supported image type such as PNG or JPEG. Note that SVGs are not supported.`));
- });
+ jest.spyOn(util, 'arrayBufferToImageBitmap').mockImplementation(async (_data: ArrayBuffer) => {
+ try {
+ const img = await createImageBitmap(new ImageData(1024, 824));
+ return img;
+ } catch (e) {
+ throw new Error(`Could not load image because of ${e.message}. Please make sure to use a supported image type such as PNG or JPEG. Note that SVGs are not supported.`);
+ }
});
global.fetch = null;
server = fakeServer.create();
});
- test('backwards compatibility: single string is treated as a URL for the default sprite', done => {
+ test('backwards compatibility: single string is treated as a URL for the default sprite', async () => {
const transform = jest.fn().mockImplementation((url, type) => {
return {url, type};
});
@@ -32,31 +34,29 @@ describe('loadSprite', () => {
server.respondWith('GET', 'http://localhost:9966/test/unit/assets/sprite1.json', fs.readFileSync(path.join(__dirname, '../../test/unit/assets/sprite1.json')).toString());
server.respondWith('GET', 'http://localhost:9966/test/unit/assets/sprite1.png', bufferToArrayBuffer(fs.readFileSync(path.join(__dirname, '../../test/unit/assets/sprite1.png'))));
- loadSprite('http://localhost:9966/test/unit/assets/sprite1', manager, 1, (err, result) => {
- expect(err).toBeFalsy();
+ const promise = loadSprite('http://localhost:9966/test/unit/assets/sprite1', manager, 1, new AbortController());
- expect(transform).toHaveBeenCalledTimes(2);
- expect(transform).toHaveBeenNthCalledWith(1, 'http://localhost:9966/test/unit/assets/sprite1.json', 'SpriteJSON');
- expect(transform).toHaveBeenNthCalledWith(2, 'http://localhost:9966/test/unit/assets/sprite1.png', 'SpriteImage');
+ server.respond();
- expect(Object.keys(result)).toHaveLength(1);
- expect(Object.keys(result)[0]).toBe('default');
+ const result = await promise;
- Object.values(result['default']).forEach(styleImage => {
- expect(styleImage.spriteData).toBeTruthy();
- expect(styleImage.spriteData.context).toBeInstanceOf(CanvasRenderingContext2D);
- });
+ expect(transform).toHaveBeenCalledTimes(2);
+ expect(transform).toHaveBeenNthCalledWith(1, 'http://localhost:9966/test/unit/assets/sprite1.json', 'SpriteJSON');
+ expect(transform).toHaveBeenNthCalledWith(2, 'http://localhost:9966/test/unit/assets/sprite1.png', 'SpriteImage');
- done();
- });
+ expect(Object.keys(result)).toHaveLength(1);
+ expect(Object.keys(result)[0]).toBe('default');
- server.respond();
+ Object.values(result['default']).forEach(styleImage => {
+ expect(styleImage.spriteData).toBeTruthy();
+ expect(styleImage.spriteData.context).toBeInstanceOf(CanvasRenderingContext2D);
+ });
expect(server.requests[0].url).toBe('http://localhost:9966/test/unit/assets/sprite1.json');
expect(server.requests[1].url).toBe('http://localhost:9966/test/unit/assets/sprite1.png');
});
- test('array of objects support', done => {
+ test('array of objects support', async () => {
const transform = jest.fn().mockImplementation((url, type) => {
return {url, type};
});
@@ -68,40 +68,38 @@ describe('loadSprite', () => {
server.respondWith('GET', 'http://localhost:9966/test/unit/assets/sprite2.json', fs.readFileSync(path.join(__dirname, '../../test/unit/assets/sprite2.json')).toString());
server.respondWith('GET', 'http://localhost:9966/test/unit/assets/sprite2.png', bufferToArrayBuffer(fs.readFileSync(path.join(__dirname, '../../test/unit/assets/sprite2.png'))));
- loadSprite([{id: 'sprite1', url: 'http://localhost:9966/test/unit/assets/sprite1'}, {id: 'sprite2', url: 'http://localhost:9966/test/unit/assets/sprite2'}], manager, 1, (err, result) => {
- expect(err).toBeFalsy();
+ const promise = loadSprite([{id: 'sprite1', url: 'http://localhost:9966/test/unit/assets/sprite1'}, {id: 'sprite2', url: 'http://localhost:9966/test/unit/assets/sprite2'}], manager, 1, new AbortController());
- expect(transform).toHaveBeenCalledTimes(4);
- expect(transform).toHaveBeenNthCalledWith(1, 'http://localhost:9966/test/unit/assets/sprite1.json', 'SpriteJSON');
- expect(transform).toHaveBeenNthCalledWith(2, 'http://localhost:9966/test/unit/assets/sprite1.png', 'SpriteImage');
- expect(transform).toHaveBeenNthCalledWith(3, 'http://localhost:9966/test/unit/assets/sprite2.json', 'SpriteJSON');
- expect(transform).toHaveBeenNthCalledWith(4, 'http://localhost:9966/test/unit/assets/sprite2.png', 'SpriteImage');
+ server.respond();
- expect(Object.keys(result)).toHaveLength(2);
- expect(Object.keys(result)[0]).toBe('sprite1');
- expect(Object.keys(result)[1]).toBe('sprite2');
+ const result = await promise;
+ expect(transform).toHaveBeenCalledTimes(4);
+ expect(transform).toHaveBeenNthCalledWith(1, 'http://localhost:9966/test/unit/assets/sprite1.json', 'SpriteJSON');
+ expect(transform).toHaveBeenNthCalledWith(2, 'http://localhost:9966/test/unit/assets/sprite1.png', 'SpriteImage');
+ expect(transform).toHaveBeenNthCalledWith(3, 'http://localhost:9966/test/unit/assets/sprite2.json', 'SpriteJSON');
+ expect(transform).toHaveBeenNthCalledWith(4, 'http://localhost:9966/test/unit/assets/sprite2.png', 'SpriteImage');
- Object.values(result['sprite1']).forEach(styleImage => {
- expect(styleImage.spriteData).toBeTruthy();
- expect(styleImage.spriteData.context).toBeInstanceOf(CanvasRenderingContext2D);
- });
+ expect(Object.keys(result)).toHaveLength(2);
+ expect(Object.keys(result)[0]).toBe('sprite1');
+ expect(Object.keys(result)[1]).toBe('sprite2');
- Object.values(result['sprite2']).forEach(styleImage => {
- expect(styleImage.spriteData).toBeTruthy();
- expect(styleImage.spriteData.context).toBeInstanceOf(CanvasRenderingContext2D);
- });
+ Object.values(result['sprite1']).forEach(styleImage => {
+ expect(styleImage.spriteData).toBeTruthy();
+ expect(styleImage.spriteData.context).toBeInstanceOf(CanvasRenderingContext2D);
+ });
- done();
+ Object.values(result['sprite2']).forEach(styleImage => {
+ expect(styleImage.spriteData).toBeTruthy();
+ expect(styleImage.spriteData.context).toBeInstanceOf(CanvasRenderingContext2D);
});
- server.respond();
expect(server.requests[0].url).toBe('http://localhost:9966/test/unit/assets/sprite1.json');
expect(server.requests[1].url).toBe('http://localhost:9966/test/unit/assets/sprite1.png');
expect(server.requests[2].url).toBe('http://localhost:9966/test/unit/assets/sprite2.json');
expect(server.requests[3].url).toBe('http://localhost:9966/test/unit/assets/sprite2.png');
});
- test('error in callback', done => {
+ test('server returns error', async () => {
const transform = jest.fn().mockImplementation((url, type) => {
return {url, type};
});
@@ -109,21 +107,14 @@ describe('loadSprite', () => {
const manager = new RequestManager(transform);
server.respondWith((xhr) => xhr.respond(500));
- let last = false;
- loadSprite([{id: 'sprite1', url: 'http://localhost:9966/test/unit/assets/sprite1'}], manager, 1, (err, result) => {
- expect(err).toBeTruthy();
- expect(result).toBeUndefined();
- if (!last) {
- done();
- last = true;
- }
- });
-
+ const promise = loadSprite([{id: 'sprite1', url: 'http://localhost:9966/test/unit/assets/sprite1'}], manager, 1, new AbortController());
server.respond();
+
+ await expect(promise).rejects.toThrow(/AJAXError.*500.*/);
expect(server.requests[0].url).toBe('http://localhost:9966/test/unit/assets/sprite1.json');
});
- test('request canceling', done => {
+ test('request canceling', async () => {
const transform = jest.fn().mockImplementation((url, type) => {
return {url, type};
});
@@ -133,25 +124,20 @@ describe('loadSprite', () => {
server.respondWith('GET', 'http://localhost:9966/test/unit/assets/sprite1.json', fs.readFileSync(path.join(__dirname, '../../test/unit/assets/sprite1.json')).toString());
server.respondWith('GET', 'http://localhost:9966/test/unit/assets/sprite1.png', bufferToArrayBuffer(fs.readFileSync(path.join(__dirname, '../../test/unit/assets/sprite1.png'))));
- const cancelable = loadSprite([{id: 'sprite1', url: 'http://localhost:9966/test/unit/assets/sprite1'}], manager, 1, () => {});
-
- setTimeout(() => {
- cancelable.cancel();
+ const abortController = new AbortController();
+ const promise = loadSprite([{id: 'sprite1', url: 'http://localhost:9966/test/unit/assets/sprite1'}], manager, 1, abortController);
+ abortController.abort();
- expect((server.requests[0] as any).aborted).toBeTruthy();
- expect((server.requests[1] as any).aborted).toBeTruthy();
+ expect((server.requests[0] as any).aborted).toBeTruthy();
+ expect((server.requests[1] as any).aborted).toBeTruthy();
- done();
- });
-
- setTimeout(() => {
- server.respond();
- expect(server.requests[0].url).toBe('http://localhost:9966/test/unit/assets/sprite1.json');
- expect(server.requests[1].url).toBe('http://localhost:9966/test/unit/assets/sprite1.png');
- }, 10);
+ await expect(promise).rejects.toThrow(ABORT_ERROR);
+ server.respond();
+ expect(server.requests[0].url).toBe('http://localhost:9966/test/unit/assets/sprite1.json');
+ expect(server.requests[1].url).toBe('http://localhost:9966/test/unit/assets/sprite1.png');
});
- test('pixelRatio is respected', done => {
+ test('pixelRatio is respected', async () => {
const transform = jest.fn().mockImplementation((url, type) => {
return {url, type};
});
@@ -161,25 +147,22 @@ describe('loadSprite', () => {
server.respondWith('GET', 'http://localhost:9966/test/unit/assets/sprite1@2x.json', fs.readFileSync(path.join(__dirname, '../../test/unit/assets/sprite1.json')).toString());
server.respondWith('GET', 'http://localhost:9966/test/unit/assets/sprite1@2x.png', bufferToArrayBuffer(fs.readFileSync(path.join(__dirname, '../../test/unit/assets/sprite1.png'))));
- loadSprite('http://localhost:9966/test/unit/assets/sprite1', manager, 2, (err, result) => {
- expect(err).toBeFalsy();
-
- expect(transform).toHaveBeenCalledTimes(2);
- expect(transform).toHaveBeenNthCalledWith(1, 'http://localhost:9966/test/unit/assets/sprite1@2x.json', 'SpriteJSON');
- expect(transform).toHaveBeenNthCalledWith(2, 'http://localhost:9966/test/unit/assets/sprite1@2x.png', 'SpriteImage');
+ const promise = loadSprite('http://localhost:9966/test/unit/assets/sprite1', manager, 2, new AbortController());
+ server.respond();
- expect(Object.keys(result)).toHaveLength(1);
- expect(Object.keys(result)[0]).toBe('default');
+ const result = await promise;
+ expect(transform).toHaveBeenCalledTimes(2);
+ expect(transform).toHaveBeenNthCalledWith(1, 'http://localhost:9966/test/unit/assets/sprite1@2x.json', 'SpriteJSON');
+ expect(transform).toHaveBeenNthCalledWith(2, 'http://localhost:9966/test/unit/assets/sprite1@2x.png', 'SpriteImage');
- Object.values(result['default']).forEach(styleImage => {
- expect(styleImage.spriteData).toBeTruthy();
- expect(styleImage.spriteData.context).toBeInstanceOf(CanvasRenderingContext2D);
- });
+ expect(Object.keys(result)).toHaveLength(1);
+ expect(Object.keys(result)[0]).toBe('default');
- done();
+ Object.values(result['default']).forEach(styleImage => {
+ expect(styleImage.spriteData).toBeTruthy();
+ expect(styleImage.spriteData.context).toBeInstanceOf(CanvasRenderingContext2D);
});
- server.respond();
expect(server.requests[0].url).toBe('http://localhost:9966/test/unit/assets/sprite1@2x.json');
expect(server.requests[1].url).toBe('http://localhost:9966/test/unit/assets/sprite1@2x.png');
});
diff --git a/src/style/load_sprite.ts b/src/style/load_sprite.ts
index 2a36afe710..ebe5a021b7 100644
--- a/src/style/load_sprite.ts
+++ b/src/style/load_sprite.ts
@@ -1,4 +1,4 @@
-import {getJSON} from '../util/ajax';
+import {GetResourceResponse, getJSON} from '../util/ajax';
import {ImageRequest} from '../util/image_request';
import {ResourceType} from '../util/request_manager';
@@ -6,82 +6,53 @@ import {browser} from '../util/browser';
import {coerceSpriteToArray} from '../util/style';
import type {SpriteSpecification} from '@maplibre/maplibre-gl-style-spec';
-import type {StyleImage} from './style_image';
+import type {SpriteJSON, StyleImage} from './style_image';
import type {RequestManager} from '../util/request_manager';
-import type {Callback} from '../types/callback';
-import type {Cancelable} from '../types/cancelable';
-export function loadSprite(
+export type LoadSpriteResult = {
+ [spriteName: string]: {
+ [id: string]: StyleImage;
+ };
+}
+
+export async function loadSprite(
originalSprite: SpriteSpecification,
requestManager: RequestManager,
pixelRatio: number,
- callback: Callback<{[spriteName: string]: {[id: string]: StyleImage}}>
-): Cancelable {
+ abortController: AbortController,
+): Promise {
const spriteArray = coerceSpriteToArray(originalSprite);
- const spriteArrayLength = spriteArray.length;
const format = pixelRatio > 1 ? '@2x' : '';
- const combinedRequestsMap: {[requestKey: string]: Cancelable} = {};
- const jsonsMap: {[id: string]: any} = {};
- const imagesMap: {[id: string]: (HTMLImageElement | ImageBitmap)} = {};
+ const jsonsMap: {[id: string]: Promise>} = {};
+ const imagesMap: {[id: string]: Promise>} = {};
for (const {id, url} of spriteArray) {
const jsonRequestParameters = requestManager.transformRequest(requestManager.normalizeSpriteURL(url, format, '.json'), ResourceType.SpriteJSON);
- const jsonRequestKey = `${id}_${jsonRequestParameters.url}`; // use id_url as requestMap key to make sure it is unique
- combinedRequestsMap[jsonRequestKey] = getJSON(jsonRequestParameters, (err?: Error | null, data?: any | null) => {
- delete combinedRequestsMap[jsonRequestKey];
- jsonsMap[id] = data;
- doOnceCompleted(callback, jsonsMap, imagesMap, err, spriteArrayLength);
- });
+ jsonsMap[id] = getJSON(jsonRequestParameters, abortController);
const imageRequestParameters = requestManager.transformRequest(requestManager.normalizeSpriteURL(url, format, '.png'), ResourceType.SpriteImage);
- const imageRequestKey = `${id}_${imageRequestParameters.url}`; // use id_url as requestMap key to make sure it is unique
- combinedRequestsMap[imageRequestKey] = ImageRequest.getImage(imageRequestParameters, (err, img) => {
- delete combinedRequestsMap[imageRequestKey];
- imagesMap[id] = img;
- doOnceCompleted(callback, jsonsMap, imagesMap, err, spriteArrayLength);
- });
+ imagesMap[id] = ImageRequest.getImage(imageRequestParameters, abortController);
}
- return {
- cancel() {
- for (const requst of Object.values(combinedRequestsMap)) {
- requst.cancel();
- }
- }
- };
+ await Promise.all([...Object.values(jsonsMap), ...Object.values(imagesMap)]);
+ return doOnceCompleted(jsonsMap, imagesMap);
}
/**
- * @param callbackFunc - the callback function (both erro and success)
* @param jsonsMap - JSON data map
* @param imagesMap - image data map
- * @param err - error object
- * @param expectedResultCounter - number of expected JSON or Image results when everything is finished, respectively.
*/
-function doOnceCompleted(
- callbackFunc:Callback<{[spriteName: string]: {[id: string]: StyleImage}}>,
- jsonsMap:{[id: string]: any},
- imagesMap:{[id: string]: (HTMLImageElement | ImageBitmap)},
- err: Error,
- expectedResultCounter: number): void {
-
- if (err) {
- callbackFunc(err);
- return;
- }
-
- if (expectedResultCounter !== Object.values(jsonsMap).length || expectedResultCounter !== Object.values(imagesMap).length) {
- // not done yet, nothing to do
- return;
- }
+async function doOnceCompleted(
+ jsonsMap:{[id: string]: Promise>},
+ imagesMap:{[id: string]: Promise>}): Promise {
const result = {} as {[spriteName: string]: {[id: string]: StyleImage}};
for (const spriteName in jsonsMap) {
result[spriteName] = {};
- const context = browser.getImageCanvasContext(imagesMap[spriteName]);
- const json = jsonsMap[spriteName];
+ const context = browser.getImageCanvasContext((await imagesMap[spriteName]).data);
+ const json = (await jsonsMap[spriteName]).data;
for (const id in json) {
const {width, height, x, y, sdf, pixelRatio, stretchX, stretchY, content} = json[id];
@@ -90,5 +61,5 @@ function doOnceCompleted(
}
}
- callbackFunc(null, result);
+ return result;
}
diff --git a/src/style/style.test.ts b/src/style/style.test.ts
index bdc325c83d..946f85ebdc 100644
--- a/src/style/style.test.ts
+++ b/src/style/style.test.ts
@@ -80,12 +80,13 @@ function createStyle(map = getStubMap()) {
}
let server: FakeServer;
-let mockConsoleError;
+let mockConsoleError: jest.SpyInstance;
beforeEach(() => {
global.fetch = null;
server = fakeServer.create();
mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => { });
+ clearRTLTextPlugin();
});
afterEach(() => {
@@ -95,12 +96,8 @@ afterEach(() => {
describe('Style', () => {
test('registers plugin state change listener', () => {
- clearRTLTextPlugin();
-
- jest.spyOn(Style, 'registerForPluginStateChange');
const style = new Style(getStubMap());
const mockStyleDispatcherBroadcast = jest.spyOn(style.dispatcher, 'broadcast');
- expect(Style.registerForPluginStateChange).toHaveBeenCalledTimes(1);
setRTLTextPlugin('/plugin.js', undefined);
expect(mockStyleDispatcherBroadcast.mock.calls[0][0]).toBe('syncRTLPluginState');
@@ -111,7 +108,6 @@ describe('Style', () => {
});
test('loads plugin immediately if already registered', done => {
- clearRTLTextPlugin();
server.respondWith('/plugin.js', 'doesn\'t matter');
setRTLTextPlugin('/plugin.js', (error) => {
expect(error).toMatch(/Cannot set the state of the rtl-text-plugin when not in the web-worker context/);
@@ -151,13 +147,12 @@ describe('Style', () => {
clearRTLTextPlugin();
server.respondWith('/plugin.js', 'doesn\'t matter');
const _broadcast = style.dispatcher.broadcast;
- style.dispatcher.broadcast = function (type, state, callback) {
+ style.dispatcher.broadcast = function (type, state) {
if (type === 'syncRTLPluginState') {
// Mock a response from four workers saying they've loaded the plugin
- callback(undefined, [true, true, true, true]);
- } else {
- _broadcast(type, state, callback);
+ return Promise.resolve([true, true, true, true]) as any;
}
+ return _broadcast(type, state);
};
setRTLTextPlugin('/plugin.js', (error) => {
expect(error).toBeUndefined();
@@ -291,7 +286,7 @@ describe('Style#loadJSON', () => {
});
});
- test('Validate sprite image extraction', done => {
+ test('Validate sprite image extraction', async () => {
// Stubbing to bypass Web APIs that supported by jsdom:
// * `URL.createObjectURL` in ajax.getImage (https://github.com/tmpvar/jsdom/issues/1721)
// * `canvas.getContext('2d')` in browser.getImageData
@@ -311,25 +306,23 @@ describe('Style#loadJSON', () => {
'sprite': 'http://example.com/sprite'
});
- style.once('data', (e) => {
- expect(e.target).toBe(style);
- expect(e.dataType).toBe('style');
+ const firstDataEvent = await style.once('data');
+ expect(firstDataEvent.target).toBe(style);
+ expect(firstDataEvent.dataType).toBe('style');
- style.once('data', (e) => {
- expect(e.target).toBe(style);
- expect(e.dataType).toBe('style');
- style.imageManager.getImages(['image1'], (error, response) => {
- const image = response['image1'];
- expect(image.data).toBeInstanceOf(RGBAImage);
- expect(image.data.width).toBe(1);
- expect(image.data.height).toBe(1);
- expect(image.pixelRatio).toBe(1);
- done();
- });
- });
+ const secondDataPromise = style.once('data');
- server.respond();
- });
+ server.respond();
+
+ const secondDateEvent = await secondDataPromise;
+ expect(secondDateEvent.target).toBe(style);
+ expect(secondDateEvent.dataType).toBe('style');
+ const response = await style.imageManager.getImages(['image1']);
+ const image = response['image1'];
+ expect(image.data).toBeInstanceOf(RGBAImage);
+ expect(image.data.width).toBe(1);
+ expect(image.data.height).toBe(1);
+ expect(image.pixelRatio).toBe(1);
});
test('validates the style', done => {
@@ -563,9 +556,10 @@ describe('Style#_load', () => {
});
const _broadcastSpyOn = jest.spyOn(style.dispatcher, 'broadcast')
- .mockImplementation((type: string, data) => {
+ .mockImplementation((type, data) => {
dispatchType = type;
dispatchData = data;
+ return Promise.resolve({} as any);
});
style._load(styleSpec, {});
@@ -577,7 +571,7 @@ describe('Style#_load', () => {
expect(dispatchData[0].id).toBe('background');
// cleanup
- _broadcastSpyOn.mockReset();
+ _broadcastSpyOn.mockRestore();
});
test('validate style when validate option is true', () => {
@@ -643,18 +637,16 @@ describe('Style#_remove', () => {
});
});
- test('deregisters plugin listener', done => {
+ test('deregisters plugin listener', async () => {
const style = new Style(getStubMap());
style.loadJSON(createStyleJSON());
const mockStyleDispatcherBroadcast = jest.spyOn(style.dispatcher, 'broadcast');
- style.on('style.load', () => {
- style._remove();
+ await style.once('style.load');
+ style._remove();
- rtlTextPluginEvented.fire(new Event('pluginStateChange'));
- expect(mockStyleDispatcherBroadcast).not.toHaveBeenCalledWith('syncRTLPluginState');
- done();
- });
+ rtlTextPluginEvented.fire(new Event('pluginStateChange'));
+ expect(mockStyleDispatcherBroadcast).not.toHaveBeenCalledWith('syncRTLPluginState');
});
});
@@ -688,6 +680,7 @@ describe('Style#update', () => {
expect(value['layers'].map((layer) => { return layer.id; })).toEqual(['first', 'third']);
expect(value['removedIds']).toEqual(['second']);
done();
+ return Promise.resolve({} as any);
};
style.update({} as EvaluationParameters);
@@ -1972,6 +1965,7 @@ describe('Style#setFilter', () => {
expect(value['layers'][0].id).toBe('symbol');
expect(value['layers'][0].filter).toEqual(['==', 'id', 1]);
done();
+ return Promise.resolve({} as any);
};
style.setFilter('symbol', ['==', 'id', 1]);
@@ -2010,6 +2004,7 @@ describe('Style#setFilter', () => {
expect(value['layers'][0].id).toBe('symbol');
expect(value['layers'][0].filter).toEqual(['==', 'id', 2]);
done();
+ return Promise.resolve({} as any);
};
filter[2] = 2;
style.setFilter('symbol', filter);
@@ -2069,6 +2064,7 @@ describe('Style#setFilter', () => {
expect(value['layers'][0].id).toBe('symbol');
expect(value['layers'][0].filter).toBe('notafilter');
done();
+ return Promise.resolve({} as any);
};
style.setFilter('symbol', 'notafilter' as any as FilterSpecification, {validate: false});
@@ -2108,6 +2104,7 @@ describe('Style#setLayerZoomRange', () => {
expect(key).toBe('updateLayers');
expect(value['layers'].map((layer) => { return layer.id; })).toEqual(['symbol']);
done();
+ return Promise.resolve({} as any);
};
style.setLayerZoomRange('symbol', 5, 12);
expect(style.getLayer('symbol').minzoom).toBe(5);
@@ -2516,6 +2513,7 @@ describe('Style#addSourceType', () => {
if (type === 'loadWorkerSource') {
done('test failed');
}
+ return Promise.resolve({} as any);
};
style.addSourceType('foo', sourceType, (arg1, arg2) => {
@@ -2532,9 +2530,9 @@ describe('Style#addSourceType', () => {
style.dispatcher.broadcast = (type, params) => {
if (type === 'loadWorkerSource') {
- expect(params['name']).toBe('bar');
- expect(params['url']).toBe('worker-source.js');
+ expect(params).toBe('worker-source.js');
done();
+ return Promise.resolve({} as any);
}
};
diff --git a/src/style/style.ts b/src/style/style.ts
index a43d0ad4c2..c8b98c42df 100644
--- a/src/style/style.ts
+++ b/src/style/style.ts
@@ -43,12 +43,10 @@ const emitValidationErrors = (evented: Evented, errors?: ReadonlyArray<{
import type {Map} from '../ui/map';
import type {Transform} from '../geo/transform';
import type {StyleImage} from './style_image';
-import type {StyleGlyph} from './style_glyph';
import type {Callback} from '../types/callback';
import type {EvaluationParameters} from './evaluation_parameters';
import type {Placement} from '../symbol/placement';
-import type {Cancelable} from '../types/cancelable';
-import type {RequestParameters, ResponseCallback} from '../util/ajax';
+import type {GetResourceResponse, RequestParameters} from '../util/ajax';
import type {
LayerSpecification,
FilterSpecification,
@@ -59,7 +57,7 @@ import type {
} from '@maplibre/maplibre-gl-style-spec';
import type {CustomLayerInterface} from './style_layer/custom_style_layer';
import type {Validator} from './validate_style';
-import type {OverscaledTileID} from '../source/tile_id';
+import type {GetGlyphsParamerters, GetGlyphsResponse, GetImagesParamerters, GetImagesResponse} from '../util/actor_messages';
const supportedDiffOperations = pick(diffOperations, [
'addLayer',
@@ -209,8 +207,9 @@ export class Style extends Evented {
lineAtlas: LineAtlas;
light: Light;
- _request: Cancelable;
- _spriteRequest: Cancelable;
+ _frameRequest: AbortController;
+ _loadStyleRequest: AbortController;
+ _spriteRequest: AbortController;
_layers: {[_: string]: StyleLayer};
_serializedLayers: {[_: string]: LayerSpecification};
_order: Array;
@@ -236,13 +235,20 @@ export class Style extends Evented {
placement: Placement;
z: number;
- static registerForPluginStateChange: typeof registerForPluginStateChange;
-
constructor(map: Map, options: StyleOptions = {}) {
super();
this.map = map;
- this.dispatcher = new Dispatcher(getGlobalWorkerPool(), this, map._getMapId());
+ this.dispatcher = new Dispatcher(getGlobalWorkerPool(), map._getMapId());
+ this.dispatcher.registerMessageHandler('getGlyphs', (mapId, params) => {
+ return this.getGlyphs(mapId, params);
+ });
+ this.dispatcher.registerMessageHandler('getImages', (mapId, params) => {
+ return this.getImages(mapId, params);
+ });
+ this.dispatcher.registerMessageHandler('getResource', (mapId, params, abortController) => {
+ return this.getResource(mapId, params, abortController);
+ });
this.imageManager = new ImageManager();
this.imageManager.setEventedParent(this);
this.glyphManager = new GlyphManager(map._requestManager, options.localIdeographFontFamily);
@@ -263,29 +269,31 @@ export class Style extends Evented {
this.dispatcher.broadcast('setReferrer', getReferrer());
const self = this;
- this._rtlTextPluginCallback = Style.registerForPluginStateChange((event) => {
+ this._rtlTextPluginCallback = registerForPluginStateChange((event) => {
const state = {
pluginStatus: event.pluginStatus,
pluginURL: event.pluginURL
};
- self.dispatcher.broadcast('syncRTLPluginState', state, (err, results) => {
- triggerPluginCompletionEvent(err);
- if (results) {
+ self.dispatcher.broadcast('syncRTLPluginState', state)
+ .then((results) => {
+ triggerPluginCompletionEvent(undefined);
+ if (!results) {
+ return;
+ }
const allComplete = results.every((elem) => elem);
- if (allComplete) {
- for (const id in self.sourceCaches) {
- const sourceType = self.sourceCaches[id].getSource().type;
- if (sourceType === 'vector' || sourceType === 'geojson') {
- // Non-vector sources don't have any symbols buckets to reload when the RTL text plugin loads
- // They also load more quickly, so they're more likely to have already displaying tiles
- // that would be unnecessarily booted by the plugin load event
- self.sourceCaches[id].reload(); // Should be a no-op if the plugin loads before any tiles load
- }
+ if (!allComplete) {
+ return;
+ }
+ for (const id in self.sourceCaches) {
+ const sourceType = self.sourceCaches[id].getSource().type;
+ if (sourceType === 'vector' || sourceType === 'geojson') {
+ // Non-vector sources don't have any symbols buckets to reload when the RTL text plugin loads
+ // They also load more quickly, so they're more likely to have already displaying tiles
+ // that would be unnecessarily booted by the plugin load event
+ self.sourceCaches[id].reload(); // Should be a no-op if the plugin loads before any tiles load
}
}
- }
-
- });
+ }).catch((err: string) => triggerPluginCompletionEvent(err));
});
this.on('data', (event) => {
@@ -319,12 +327,14 @@ export class Style extends Evented {
options.validate : true;
const request = this.map._requestManager.transformRequest(url, ResourceType.Style);
- this._request = getJSON(request, (error?: Error | null, json?: any | null) => {
- this._request = null;
+ this._loadStyleRequest = new AbortController();
+ getJSON(request, this._loadStyleRequest).then((response) => {
+ this._loadStyleRequest = null;
+ this._load(response.data, options, previousStyle);
+ }).catch((error) => {
+ this._loadStyleRequest = null;
if (error) {
this.fire(new ErrorEvent(error));
- } else if (json) {
- this._load(json, options, previousStyle);
}
});
}
@@ -332,11 +342,12 @@ export class Style extends Evented {
loadJSON(json: StyleSpecification, options: StyleSetterOptions & StyleSwapOptions = {}, previousStyle?: StyleSpecification) {
this.fire(new Event('dataloading', {dataType: 'style'}));
- this._request = browser.frame(() => {
- this._request = null;
+ this._frameRequest = new AbortController();
+ browser.frameAsync(this._frameRequest).then(() => {
+ this._frameRequest = null;
options.validate = options.validate !== false;
this._load(json, options, previousStyle);
- });
+ }).catch(() => {}); // ignore abort
}
loadEmpty() {
@@ -396,11 +407,11 @@ export class Style extends Evented {
_loadSprite(sprite: SpriteSpecification, isUpdate: boolean = false, completion: (err: Error) => void = undefined) {
this.imageManager.setLoaded(false);
- this._spriteRequest = loadSprite(sprite, this.map._requestManager, this.map.getPixelRatio(), (err, images) => {
+ this._spriteRequest = new AbortController();
+ let err: Error;
+ loadSprite(sprite, this.map._requestManager, this.map.getPixelRatio(), this._spriteRequest).then((images) => {
this._spriteRequest = null;
- if (err) {
- this.fire(new ErrorEvent(err));
- } else if (images) {
+ if (images) {
for (const spriteId in images) {
this._spritesImagesIds[spriteId] = [];
@@ -428,7 +439,11 @@ export class Style extends Evented {
}
}
}
-
+ }).catch((error) => {
+ this._spriteRequest = null;
+ err = error;
+ this.fire(new ErrorEvent(err));
+ }).finally(() => {
this.imageManager.setLoaded(true);
this._availableImages = this.imageManager.listImages();
@@ -1411,10 +1426,7 @@ export class Style extends Evented {
return callback(null, null);
}
- this.dispatcher.broadcast('loadWorkerSource', {
- name,
- url: SourceType.workerSourceURL
- }, callback);
+ this.dispatcher.broadcast('loadWorkerSource', SourceType.workerSourceURL.toString()).then(() => callback()).catch(callback);
}
getLight() {
@@ -1461,12 +1473,16 @@ export class Style extends Evented {
}
_remove(mapRemoved: boolean = true) {
- if (this._request) {
- this._request.cancel();
- this._request = null;
+ if (this._frameRequest) {
+ this._frameRequest.abort();
+ this._frameRequest = null;
+ }
+ if (this._loadStyleRequest) {
+ this._loadStyleRequest.abort();
+ this._loadStyleRequest = null;
}
if (this._spriteRequest) {
- this._spriteRequest.cancel();
+ this._spriteRequest.abort();
this._spriteRequest = null;
}
rtlTextPluginEvented.off('pluginStateChange', this._rtlTextPluginCallback);
@@ -1583,17 +1599,8 @@ export class Style extends Evented {
// Callbacks from web workers
- getImages(
- mapId: string,
- params: {
- icons: Array;
- source: string;
- tileID: OverscaledTileID;
- type: string;
- },
- callback: Callback<{[_: string]: StyleImage}>
- ) {
- this.imageManager.getImages(params.icons, callback);
+ async getImages(mapId: string | number, params: GetImagesParamerters): Promise {
+ const images = await this.imageManager.getImages(params.icons);
// Apply queued image changes before setting the tile's dependencies so that the tile
// is not reloaded unnecessarily. Without this forced update the reload could happen in cases
@@ -1609,29 +1616,22 @@ export class Style extends Evented {
if (sourceCache) {
sourceCache.setDependencies(params.tileID.key, params.type, params.icons);
}
+ return images;
}
- getGlyphs(
- mapId: string,
- params: {
- stacks: {[_: string]: Array};
- source: string;
- tileID: OverscaledTileID;
- type: string;
- },
- callback: Callback<{[_: string]: {[_: number]: StyleGlyph}}>
- ) {
- this.glyphManager.getGlyphs(params.stacks, callback);
+ async getGlyphs(mapId: string | number, params: GetGlyphsParamerters): Promise {
+ const glypgs = await this.glyphManager.getGlyphs(params.stacks);
const sourceCache = this.sourceCaches[params.source];
if (sourceCache) {
// we are not setting stacks as dependencies since for now
// we just need to know which tiles have glyph dependencies
sourceCache.setDependencies(params.tileID.key, params.type, ['']);
}
+ return glypgs;
}
- getResource(mapId: string, params: RequestParameters, callback: ResponseCallback): Cancelable {
- return makeRequest(params, callback);
+ getResource(mapId: string | number, params: RequestParameters, abortController: AbortController): Promise> {
+ return makeRequest(params, abortController);
}
getGlyphsUrl() {
@@ -1741,5 +1741,3 @@ export class Style extends Evented {
}
}
}
-
-Style.registerForPluginStateChange = registerForPluginStateChange;
diff --git a/src/style/style_glyph.ts b/src/style/style_glyph.ts
index 35efaad542..29dd48ac0a 100644
--- a/src/style/style_glyph.ts
+++ b/src/style/style_glyph.ts
@@ -1,5 +1,8 @@
import type {AlphaImage} from '../util/image';
+/**
+ * Some metices related to a glyph
+ */
export type GlyphMetrics = {
width: number;
height: number;
@@ -13,7 +16,6 @@ export type GlyphMetrics = {
};
/**
- * @internal
* A style glyph type
*/
export type StyleGlyph = {
diff --git a/src/style/style_image.ts b/src/style/style_image.ts
index bcfadccab7..e3b2106150 100644
--- a/src/style/style_image.ts
+++ b/src/style/style_image.ts
@@ -2,6 +2,13 @@ import {RGBAImage} from '../util/image';
import type {Map} from '../ui/map';
+export type SpriteJSON = {[id: string]: StyleImageMetadata & {
+ width: number;
+ height: number;
+ x: number;
+ y: number;
+};}
+
/**
* The sprite data
*/
diff --git a/src/types/tilejson.ts b/src/types/tilejson.ts
index a2542eb397..b5ba81fba6 100644
--- a/src/types/tilejson.ts
+++ b/src/types/tilejson.ts
@@ -12,4 +12,6 @@ export type TileJSON = {
maxzoom?: number;
bounds?: [number, number, number, number];
center?: [number, number, number];
+ vectorLayers: any;
+ vectorLayerIds: Array;
};
diff --git a/src/ui/control/geolocate_control.test.ts b/src/ui/control/geolocate_control.test.ts
index 00b3186fa9..49d518bb7c 100644
--- a/src/ui/control/geolocate_control.test.ts
+++ b/src/ui/control/geolocate_control.test.ts
@@ -1,6 +1,6 @@
import geolocation from 'mock-geolocation';
import {LngLatBounds} from '../../geo/lng_lat_bounds';
-import {createMap, beforeMapTest} from '../../util/test/util';
+import {createMap, beforeMapTest, sleep} from '../../util/test/util';
import {GeolocateControl} from './geolocate_control';
jest.mock('../../util/geolocation_support', () => (
{
@@ -30,7 +30,7 @@ describe('GeolocateControl with no options', () => {
beforeEach(() => {
beforeMapTest();
map = createMap(undefined, undefined);
- (checkGeolocationSupport as any as jest.SpyInstance).mockImplementationOnce((cb) => cb(true));
+ (checkGeolocationSupport as any as jest.SpyInstance).mockImplementationOnce(() => Promise.resolve(true));
});
afterEach(() => {
@@ -38,15 +38,19 @@ describe('GeolocateControl with no options', () => {
});
test('is disabled when there\'s no support', async () => {
- (checkGeolocationSupport as any as jest.SpyInstance).mockReset().mockImplementationOnce((cb) => cb(false));
+ (checkGeolocationSupport as any as jest.SpyInstance).mockReset().mockImplementationOnce(() => Promise.resolve(false));
const geolocate = new GeolocateControl(undefined);
+ const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
map.addControl(geolocate);
+ await sleep(0);
expect(geolocate._geolocateButton.disabled).toBeTruthy();
+ spy.mockRestore();
});
test('is enabled when there no support', async () => {
const geolocate = new GeolocateControl(undefined);
map.addControl(geolocate);
+ await sleep(0);
expect(geolocate._geolocateButton.disabled).toBeFalsy();
});
@@ -59,33 +63,33 @@ describe('GeolocateControl with no options', () => {
const geolocate = new GeolocateControl(undefined);
map.addControl(geolocate);
- await new Promise(process.nextTick);
+ await sleep(0);
expect(geolocate._geolocateButton.disabled).toBeFalsy();
});
- test('error event', done => {
+ test('error event', async () => {
const geolocate = new GeolocateControl(undefined);
map.addControl(geolocate);
-
+ await sleep(0);
const click = new window.Event('click');
-
- geolocate.on('error', (error) => {
- expect(error.code).toBe(2);
- expect(error.message).toBe('error message');
- done();
- });
+ const errorPromise = geolocate.once('error');
geolocate._geolocateButton.dispatchEvent(click);
+
geolocation.sendError({code: 2, message: 'error message'});
+ const error = await errorPromise;
+
+ expect(error.code).toBe(2);
+ expect(error.message).toBe('error message');
});
- test('does not throw if removed quickly', done => {
+ test('does not throw if removed quickly', () => {
(checkGeolocationSupport as any as jest.SpyInstance).mockReset()
- .mockImplementationOnce((cb) => {
- return Promise.resolve(true)
- .then(result => {
- expect(() => cb(result)).not.toThrow();
- })
- .finally(done);
+ .mockImplementationOnce(() => {
+ return new Promise(resolve => {
+ setTimeout(() => {
+ resolve(true);
+ }, 10);
+ });
});
const geolocate = new GeolocateControl(undefined);
@@ -93,73 +97,75 @@ describe('GeolocateControl with no options', () => {
map.removeControl(geolocate);
});
- test('outofmaxbounds event in active lock state', done => {
+ test('outofmaxbounds event in active lock state', async () => {
const geolocate = new GeolocateControl(undefined);
map.addControl(geolocate);
+ await sleep(0);
map.setMaxBounds([[0, 0], [10, 10]]);
geolocate._watchState = 'ACTIVE_LOCK';
const click = new window.Event('click');
- geolocate.on('outofmaxbounds', (position) => {
- expect(geolocate._watchState).toBe('ACTIVE_ERROR');
- expect(position.coords.latitude).toBe(10);
- expect(position.coords.longitude).toBe(20);
- expect(position.coords.accuracy).toBe(3);
- expect(position.timestamp).toBe(4);
- done();
- });
+ const outofmaxboundsPromise = geolocate.once('outofmaxbounds');
geolocate._geolocateButton.dispatchEvent(click);
geolocation.send({latitude: 10, longitude: 20, accuracy: 3, timestamp: 4});
+ const position = await outofmaxboundsPromise;
+
+ expect(geolocate._watchState).toBe('ACTIVE_ERROR');
+ expect(position.coords.latitude).toBe(10);
+ expect(position.coords.longitude).toBe(20);
+ expect(position.coords.accuracy).toBe(3);
+ expect(position.timestamp).toBe(4);
});
- test('outofmaxbounds event in background state', done => {
+ test('outofmaxbounds event in background state', async () => {
const geolocate = new GeolocateControl(undefined);
map.addControl(geolocate);
+ await sleep(0);
map.setMaxBounds([[0, 0], [10, 10]]);
geolocate._watchState = 'BACKGROUND';
const click = new window.Event('click');
- geolocate.on('outofmaxbounds', (position) => {
- expect(geolocate._watchState).toBe('BACKGROUND_ERROR');
- expect(position.coords.latitude).toBe(10);
- expect(position.coords.longitude).toBe(20);
- expect(position.coords.accuracy).toBe(3);
- expect(position.timestamp).toBe(4);
- done();
- });
+ const promise = geolocate.once('outofmaxbounds');
geolocate._geolocateButton.dispatchEvent(click);
geolocation.send({latitude: 10, longitude: 20, accuracy: 3, timestamp: 4});
+ const position = await promise;
+ expect(geolocate._watchState).toBe('BACKGROUND_ERROR');
+ expect(position.coords.latitude).toBe(10);
+ expect(position.coords.longitude).toBe(20);
+ expect(position.coords.accuracy).toBe(3);
+ expect(position.timestamp).toBe(4);
});
- test('geolocate event', done => {
+ test('geolocate event', async () => {
const geolocate = new GeolocateControl(undefined);
map.addControl(geolocate);
-
+ await sleep(0);
const click = new window.Event('click');
- geolocate.on('geolocate', (position) => {
- expect(position.coords.latitude).toBe(10);
- expect(position.coords.longitude).toBe(20);
- expect(position.coords.accuracy).toBe(30);
- expect(position.timestamp).toBe(40);
- done();
- });
+ const promise = geolocate.once('geolocate');
geolocate._geolocateButton.dispatchEvent(click);
geolocation.send({latitude: 10, longitude: 20, accuracy: 30, timestamp: 40});
+
+ const position = await promise;
+ expect(position.coords.latitude).toBe(10);
+ expect(position.coords.longitude).toBe(20);
+ expect(position.coords.accuracy).toBe(30);
+ expect(position.timestamp).toBe(40);
});
- test('trigger', () => {
+ test('trigger', async () => {
const geolocate = new GeolocateControl(undefined);
map.addControl(geolocate);
-
+ await sleep(0);
expect(geolocate.trigger()).toBeTruthy();
});
- test('trigger and then error when tracking user location should get to active error state', () => {
+ test('trigger and then error when tracking user location should get to active error state', async () => {
const geolocate = new GeolocateControl({trackUserLocation: true});
map.addControl(geolocate);
+ await sleep(0);
geolocate.trigger();
geolocation.sendError({code: 2, message: 'error message'});
@@ -184,7 +190,7 @@ describe('GeolocateControl with no options', () => {
}
});
map.addControl(geolocate);
-
+ await sleep(0);
const click = new window.Event('click');
const moveEndPromise = map.once('moveend');
@@ -194,7 +200,7 @@ describe('GeolocateControl with no options', () => {
expect(map.getZoom()).toBe(10);
});
- test('with removed before Geolocation callback', () => {
+ test('was removed before Geolocation support was checked', () => {
expect(() => {
const geolocate = new GeolocateControl(undefined);
map.addControl(geolocate);
@@ -212,7 +218,7 @@ describe('GeolocateControl with no options', () => {
}
});
map.addControl(geolocate);
-
+ await sleep(0);
const click = new window.Event('click');
const moveEndPromise = map.once('moveend');
@@ -232,7 +238,7 @@ describe('GeolocateControl with no options', () => {
}
});
map.addControl(geolocate);
-
+ await sleep(0);
const click = new window.Event('click');
const moveEndPromise = map.once('moveend');
@@ -264,7 +270,7 @@ describe('GeolocateControl with no options', () => {
).toBeFalsy();
});
- test('watching map updates recenter on location with dot', done => {
+ test('watching map updates recenter on location with dot', async () => {
const geolocate = new GeolocateControl({
trackUserLocation: true,
showUserLocation: true,
@@ -273,38 +279,32 @@ describe('GeolocateControl with no options', () => {
}
});
map.addControl(geolocate);
-
+ await sleep(0);
const click = new window.Event('click');
- let moveendCount = 0;
- map.once('moveend', () => {
- // moveend was being called a second time, this ensures that we don't run the tests a second time
- if (moveendCount > 0) return;
- moveendCount++;
-
- expect(lngLatAsFixed(map.getCenter(), 4)).toEqual({lat: '10.0000', lng: '20.0000'});
- expect(geolocate._userLocationDotMarker._map).toBeTruthy();
- expect(
- geolocate._userLocationDotMarker._element.classList.contains('maplibregl-user-location-dot-stale')
- ).toBeFalsy();
- map.once('moveend', () => {
- expect(lngLatAsFixed(map.getCenter(), 4)).toEqual({lat: '40.0000', lng: '50.0000'});
- geolocate.once('error', () => {
- expect(geolocate._userLocationDotMarker._map).toBeTruthy();
- expect(
- geolocate._userLocationDotMarker._element.classList.contains('maplibregl-user-location-dot-stale')
- ).toBeTruthy();
- done();
- });
- geolocation.changeError({code: 2, message: 'position unavaliable'});
- });
- geolocation.change({latitude: 40, longitude: 50, accuracy: 60});
- });
+ const firstMoveEnd = map.once('moveend');
geolocate._geolocateButton.dispatchEvent(click);
geolocation.send({latitude: 10, longitude: 20, accuracy: 30});
+
+ await firstMoveEnd;
+
+ expect(lngLatAsFixed(map.getCenter(), 4)).toEqual({lat: '10.0000', lng: '20.0000'});
+ expect(geolocate._userLocationDotMarker._map).toBeTruthy();
+ expect(
+ geolocate._userLocationDotMarker._element.classList.contains('maplibregl-user-location-dot-stale')
+ ).toBeFalsy();
+ const secontMoveEnd = map.once('moveend');
+ geolocation.change({latitude: 40, longitude: 50, accuracy: 60});
+ await secontMoveEnd;
+ expect(lngLatAsFixed(map.getCenter(), 4)).toEqual({lat: '40.0000', lng: '50.0000'});
+ const errorPromise = geolocate.once('error');
+ geolocation.changeError({code: 2, message: 'position unavaliable'});
+ await errorPromise;
+ expect(geolocate._userLocationDotMarker._map).toBeTruthy();
+ expect(geolocate._userLocationDotMarker._element.classList.contains('maplibregl-user-location-dot-stale')).toBeTruthy();
});
- test('watching map background event', done => {
+ test('watching map background event', async () => {
const geolocate = new GeolocateControl({
trackUserLocation: true,
fitBoundsOptions: {
@@ -312,32 +312,25 @@ describe('GeolocateControl with no options', () => {
}
});
map.addControl(geolocate);
-
+ await sleep(0);
const click = new window.Event('click');
- let moveendCount = 0;
- map.once('moveend', () => {
- // moveend was being called a second time, this ensures that we don't run the tests a second time
- if (moveendCount > 0) return;
- moveendCount++;
-
- geolocate.once('trackuserlocationend', () => {
- expect(map.getCenter()).toEqual({lng: 10, lat: 5});
- done();
- });
-
- // manually pan the map away from the geolocation position which should trigger the 'trackuserlocationend' event above
- map.jumpTo({
- center: [10, 5]
- });
- });
+ const moveEndPromise = map.once('moveend');
// click the button to activate it into the enabled watch state
geolocate._geolocateButton.dispatchEvent(click);
// send through a location update which should reposition the map and trigger the 'moveend' event above
geolocation.send({latitude: 10, longitude: 20, accuracy: 30});
+ await moveEndPromise;
+ const trackPromise = geolocate.once('trackuserlocationend');
+ // manually pan the map away from the geolocation position which should trigger the 'trackuserlocationend' event above
+ map.jumpTo({
+ center: [10, 5]
+ });
+ await trackPromise;
+ expect(map.getCenter()).toEqual({lng: 10, lat: 5});
});
- test('watching map background state', done => {
+ test('watching map background state', async () => {
const geolocate = new GeolocateControl({
trackUserLocation: true,
fitBoundsOptions: {
@@ -345,33 +338,26 @@ describe('GeolocateControl with no options', () => {
}
});
map.addControl(geolocate);
-
+ await sleep(0);
const click = new window.Event('click');
- let moveendCount = 0;
- map.once('moveend', () => {
- // moveend was being called a second time, this ensures that we don't run the tests a second time
- if (moveendCount > 0) return;
- moveendCount++;
-
- map.once('moveend', () => {
- geolocate.once('geolocate', () => {
- expect(map.getCenter()).toEqual({lng: 10, lat: 5});
- done();
- });
- // update the geolocation position, since we are in background state when 'geolocate' is triggered above, the camera shouldn't have changed
- geolocation.change({latitude: 0, longitude: 0, accuracy: 10});
- });
-
- // manually pan the map away from the geolocation position which should trigger the 'moveend' event above
- map.jumpTo({
- center: [10, 5]
- });
- });
+ const moveEndPromise = map.once('moveend');
// click the button to activate it into the enabled watch state
geolocate._geolocateButton.dispatchEvent(click);
// send through a location update which should reposition the map and trigger the 'moveend' event above
geolocation.send({latitude: 10, longitude: 20, accuracy: 30});
+ await moveEndPromise;
+ const secondMoveEnd = map.once('moveend');
+ // manually pan the map away from the geolocation position which should trigger the 'moveend' event above
+ map.jumpTo({
+ center: [10, 5]
+ });
+ await secondMoveEnd;
+ const geolocatePromise = geolocate.once('geolocate');
+ // update the geolocation position, since we are in background state when 'geolocate' is triggered above, the camera shouldn't have changed
+ geolocation.change({latitude: 0, longitude: 0, accuracy: 10});
+ await geolocatePromise;
+ expect(map.getCenter()).toEqual({lng: 10, lat: 5});
});
test('trackuserlocationstart event', async () => {
@@ -382,7 +368,7 @@ describe('GeolocateControl with no options', () => {
}
});
map.addControl(geolocate);
-
+ await sleep(0);
const click = new window.Event('click');
const promise = geolocate.once('trackuserlocationstart');
@@ -396,7 +382,7 @@ describe('GeolocateControl with no options', () => {
trackUserLocation: true,
});
map.addControl(geolocate);
-
+ await sleep(0);
const click = new window.Event('click');
const geolocatePromise = geolocate.once('geolocate');
@@ -413,7 +399,7 @@ describe('GeolocateControl with no options', () => {
trackUserLocation: true,
});
map.addControl(geolocate);
-
+ await sleep(0);
const click = new window.Event('click');
const geolocatePromise = geolocate.once('geolocate');
@@ -434,7 +420,7 @@ describe('GeolocateControl with no options', () => {
showAccuracyCircle: false,
});
map.addControl(geolocate);
-
+ await sleep(0);
const click = new window.Event('click');
const geolocatePromise = geolocate.once('geolocate');
@@ -456,7 +442,7 @@ describe('GeolocateControl with no options', () => {
showUserLocation: true,
});
map.addControl(geolocate);
-
+ await sleep(0);
const click = new window.Event('click');
const geolocatePromise = geolocate.once('geolocate');
@@ -474,12 +460,10 @@ describe('GeolocateControl with no options', () => {
map.zoomTo(12, {duration: 0});
await zoomendPromise;
expect(geolocate._circleElement.style.width).toBe('79px');
- console.log(geolocate._circleElement.style.width);
zoomendPromise = map.once('zoomend');
map.zoomTo(10, {duration: 0});
await zoomendPromise;
expect(geolocate._circleElement.style.width).toBe('20px');
- console.log(geolocate._circleElement.style.width);
zoomendPromise = map.once('zoomend');
// test with smaller radius
@@ -487,12 +471,10 @@ describe('GeolocateControl with no options', () => {
map.zoomTo(20, {duration: 0});
await zoomendPromise;
expect(geolocate._circleElement.style.width).toBe('19982px');
- console.log(geolocate._circleElement.style.width);
zoomendPromise = map.once('zoomend');
map.zoomTo(18, {duration: 0});
await zoomendPromise;
expect(geolocate._circleElement.style.width).toBe('4996px');
- console.log(geolocate._circleElement.style.width);
});
test('shown even if trackUserLocation = false', async () => {
@@ -502,7 +484,7 @@ describe('GeolocateControl with no options', () => {
showAccuracyCircle: true,
});
map.addControl(geolocate);
-
+ await sleep(0);
const click = new window.Event('click');
const geolocatePromise = geolocate.once('geolocate');
diff --git a/src/ui/control/geolocate_control.ts b/src/ui/control/geolocate_control.ts
index 1399435f87..88c9c1f131 100644
--- a/src/ui/control/geolocate_control.ts
+++ b/src/ui/control/geolocate_control.ts
@@ -234,7 +234,7 @@ export class GeolocateControl extends Evented implements IControl {
onAdd(map: Map) {
this._map = map;
this._container = DOM.create('div', 'maplibregl-ctrl maplibregl-ctrl-group');
- checkGeolocationSupport(this._setupUI);
+ checkGeolocationSupport().then((supported) => this._setupUI(supported));
return this._container;
}
@@ -519,8 +519,7 @@ export class GeolocateControl extends Evented implements IControl {
this._map.on('zoom', this._onZoom);
}
- this._geolocateButton.addEventListener('click',
- this.trigger.bind(this));
+ this._geolocateButton.addEventListener('click', () => this.trigger());
this._setup = true;
diff --git a/src/ui/events.ts b/src/ui/events.ts
index 8d2dd6fad3..ce73398947 100644
--- a/src/ui/events.ts
+++ b/src/ui/events.ts
@@ -438,7 +438,7 @@ export type MapStyleDataEvent = MapLibreEvent & {
*
* @group Event Related
*/
-export type MapSourceDataEvent = MapLibreEvent & {
+export type MapSourceDataEvent = MapLibreEvent & {
dataType: 'source';
/**
* True if the event has a `dataType` of `source` and the source has no outstanding network requests.
diff --git a/src/ui/handler/dblclick_zoom.test.ts b/src/ui/handler/dblclick_zoom.test.ts
index 33f762a7f6..d4d231f9c3 100644
--- a/src/ui/handler/dblclick_zoom.test.ts
+++ b/src/ui/handler/dblclick_zoom.test.ts
@@ -55,37 +55,31 @@ describe('dbclick_zoom', () => {
map.remove();
});
- test('DoubleClickZoomHandler zooms on double tap if touchstart events are < 300ms apart', done => {
+ test('DoubleClickZoomHandler zooms on double tap if touchstart events are < 300ms apart', async () => {
const map = createMap();
const zoom = jest.fn();
map.on('zoomstart', zoom);
- simulateDoubleTap(map, 100).then(() => {
- expect(zoom).toHaveBeenCalled();
-
- map.remove();
- done();
- });
+ await simulateDoubleTap(map, 100);
+ expect(zoom).toHaveBeenCalled();
+ map.remove();
});
- test('DoubleClickZoomHandler does not zoom on double tap if touchstart events are > 500ms apart', done => {
+ test('DoubleClickZoomHandler does not zoom on double tap if touchstart events are > 500ms apart', async () => {
const map = createMap();
const zoom = jest.fn();
map.on('zoom', zoom);
- simulateDoubleTap(map, 500).then(() => {
- expect(zoom).not.toHaveBeenCalled();
-
- map.remove();
- done();
- });
+ await simulateDoubleTap(map, 500);
+ expect(zoom).not.toHaveBeenCalled();
+ map.remove();
});
- test('DoubleClickZoomHandler does not zoom on double tap if touchstart events are in different locations', done => {
+ test('DoubleClickZoomHandler does not zoom on double tap if touchstart events are in different locations', async () => {
const map = createMap();
const zoom = jest.fn();
@@ -93,26 +87,20 @@ describe('dbclick_zoom', () => {
const canvas = map.getCanvas();
- const simulateTwoDifferentTaps = () => {
- return new Promise(resolve => {
- simulate.touchstart(canvas, {touches: [{clientX: 0, clientY: 0}]});
+ await new Promise(resolve => {
+ simulate.touchstart(canvas, {touches: [{clientX: 0, clientY: 0}]});
+ simulate.touchend(canvas);
+ setTimeout(() => {
+ simulate.touchstart(canvas, {touches: [{clientX: 30.5, clientY: 30.5}]});
simulate.touchend(canvas);
- setTimeout(() => {
- simulate.touchstart(canvas, {touches: [{clientX: 30.5, clientY: 30.5}]});
- simulate.touchend(canvas);
- map._renderTaskQueue.run();
- resolve(undefined);
- }, 100);
- });
- };
-
- simulateTwoDifferentTaps().then(() => {
- expect(zoom).not.toHaveBeenCalled();
-
- map.remove();
- done();
+ map._renderTaskQueue.run();
+ resolve(undefined);
+ }, 100);
});
+ expect(zoom).not.toHaveBeenCalled();
+
+ map.remove();
});
test('DoubleClickZoomHandler zooms on the second touchend event of a double tap', () => {
@@ -149,7 +137,7 @@ describe('dbclick_zoom', () => {
});
- test('DoubleClickZoomHandler does not zoom on double tap if second touchend is >300ms after first touchstart', done => {
+ test('DoubleClickZoomHandler does not zoom on double tap if second touchend is >300ms after first touchstart', async () => {
const map = createMap();
const zoom = jest.fn();
@@ -157,23 +145,17 @@ describe('dbclick_zoom', () => {
const canvas = map.getCanvas();
- const simulateSlowSecondTap = () => {
- return new Promise(resolve => {
- simulate.touchstart(canvas);
+ await new Promise(resolve => {
+ simulate.touchstart(canvas);
+ simulate.touchend(canvas);
+ simulate.touchstart(canvas);
+ setTimeout(() => {
simulate.touchend(canvas);
- simulate.touchstart(canvas);
- setTimeout(() => {
- simulate.touchend(canvas);
- map._renderTaskQueue.run();
- resolve(undefined);
- }, 300);
- });
- };
-
- simulateSlowSecondTap().then(() => {
- expect(zoom).not.toHaveBeenCalled();
-
- done();
+ map._renderTaskQueue.run();
+ resolve(undefined);
+ }, 300);
});
+
+ expect(zoom).not.toHaveBeenCalled();
});
});
diff --git a/src/ui/map.test.ts b/src/ui/map.test.ts
index ad577fc75e..3676fef015 100755
--- a/src/ui/map.test.ts
+++ b/src/ui/map.test.ts
@@ -1,5 +1,5 @@
import {Map, MapOptions} from './map';
-import {createMap, setErrorWebGlContext, beforeMapTest} from '../util/test/util';
+import {createMap, setErrorWebGlContext, beforeMapTest, sleep} from '../util/test/util';
import {LngLat} from '../geo/lng_lat';
import {Tile} from '../source/tile';
import {OverscaledTileID} from '../source/tile_id';
@@ -288,7 +288,7 @@ describe('Map', () => {
});
- test('setStyle back to the first style should work', done => {
+ test('setStyle back to the first style should work', async () => {
const redStyle = {version: 8 as const, sources: {}, layers: [
{id: 'background', type: 'background' as const, paint: {'background-color': 'red'}},
]};
@@ -296,13 +296,13 @@ describe('Map', () => {
{id: 'background', type: 'background' as const, paint: {'background-color': 'blue'}},
]};
const map = createMap({style: redStyle});
+ const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
map.setStyle(blueStyle);
- map.once('style.load', () => {
- map.setStyle(redStyle);
- const serializedStyle = map.style.serialize();
- expect(serializedStyle.layers[0].paint['background-color']).toBe('red');
- done();
- });
+ await map.once('style.load');
+ map.setStyle(redStyle);
+ const serializedStyle = map.style.serialize();
+ expect(serializedStyle.layers[0].paint['background-color']).toBe('red');
+ spy.mockRestore();
});
test('style transform overrides unmodified map transform', done => {
@@ -554,39 +554,42 @@ describe('Map', () => {
describe('#is_Loaded', () => {
- test('Map#isSourceLoaded', done => {
+ test('Map#isSourceLoaded', async () => {
const style = createStyle();
const map = createMap({style});
- map.on('load', () => {
+ await map.once('load');
+ const promise = new Promise((resolve) => {
map.on('data', (e) => {
if (e.dataType === 'source' && e.sourceDataType === 'idle') {
expect(map.isSourceLoaded('geojson')).toBe(true);
- done();
+ resolve();
}
});
- map.addSource('geojson', createStyleSource());
- expect(map.isSourceLoaded('geojson')).toBe(false);
});
+ map.addSource('geojson', createStyleSource());
+ expect(map.isSourceLoaded('geojson')).toBe(false);
+ await promise;
});
- test('Map#isSourceLoaded (equivalent to event.isSourceLoaded)', done => {
+ test('Map#isSourceLoaded (equivalent to event.isSourceLoaded)', async () => {
const style = createStyle();
const map = createMap({style});
- map.on('load', () => {
- map.on('data', (e) => {
+ await map.once('load');
+ const promise = new Promise((resolve) => {
+ map.on('data', (e: MapSourceDataEvent) => {
if (e.dataType === 'source' && 'source' in e) {
- const sourceDataEvent = e as MapSourceDataEvent;
- expect(map.isSourceLoaded('geojson')).toBe(sourceDataEvent.isSourceLoaded);
- if (sourceDataEvent.sourceDataType === 'idle') {
- done();
+ expect(map.isSourceLoaded('geojson')).toBe(e.isSourceLoaded);
+ if (e.sourceDataType === 'idle') {
+ resolve();
}
}
});
- map.addSource('geojson', createStyleSource());
- expect(map.isSourceLoaded('geojson')).toBe(false);
});
+ map.addSource('geojson', createStyleSource());
+ expect(map.isSourceLoaded('geojson')).toBe(false);
+ await promise;
});
test('Map#isStyleLoaded', done => {
@@ -930,7 +933,7 @@ describe('Map', () => {
observerCallback();
observerCallback();
expect(resizeSpy).toHaveBeenCalledTimes(1);
- await new Promise((resolve) => { setTimeout(resolve, 100); });
+ await sleep(100);
expect(resizeSpy).toHaveBeenCalledTimes(2);
});
@@ -1519,6 +1522,7 @@ describe('Map', () => {
map.style.dispatcher.broadcast = function (key, value: any) {
expect(key).toBe('updateLayers');
expect(value.layers.map((layer) => { return layer.id; })).toEqual(['symbol']);
+ return Promise.resolve({} as any);
};
map.setLayoutProperty('symbol', 'text-transform', 'lowercase');
@@ -2205,9 +2209,11 @@ describe('Map', () => {
describe('error event', () => {
test('logs errors to console when it has NO listeners', () => {
+ // to avoid seeing error in the console in Jest
+ let stub = jest.spyOn(console, 'error').mockImplementation(() => {});
const map = createMap();
- const stub = jest.spyOn(console, 'error').mockImplementation(() => {});
stub.mockReset();
+ stub = jest.spyOn(console, 'error').mockImplementation(() => {});
const error = new Error('test');
map.fire(new ErrorEvent(error));
expect(stub).toHaveBeenCalledTimes(1);
@@ -2387,7 +2393,7 @@ describe('Map', () => {
});
- test('map fires `styleimagemissing` for missing icons', done => {
+ test('map fires `styleimagemissing` for missing icons', async () => {
const map = createMap();
const id = 'missing-image';
@@ -2402,14 +2408,12 @@ describe('Map', () => {
expect(map.hasImage(id)).toBeFalsy();
- map.style.imageManager.getImages([id], (alwaysNull, generatedImage) => {
- expect(generatedImage[id].data.width).toEqual(sampleImage.width);
- expect(generatedImage[id].data.height).toEqual(sampleImage.height);
- expect(generatedImage[id].data.data).toEqual(sampleImage.data);
- expect(called).toBe(id);
- expect(map.hasImage(id)).toBeTruthy();
- done();
- });
+ const generatedImage = await map.style.imageManager.getImages([id]);
+ expect(generatedImage[id].data.width).toEqual(sampleImage.width);
+ expect(generatedImage[id].data.height).toEqual(sampleImage.height);
+ expect(generatedImage[id].data.data).toEqual(sampleImage.data);
+ expect(called).toBe(id);
+ expect(map.hasImage(id)).toBeTruthy();
});
test('map getImage matches addImage, uintArray', () => {
diff --git a/src/ui/map.ts b/src/ui/map.ts
index 4ecd20d645..ab33791f36 100644
--- a/src/ui/map.ts
+++ b/src/ui/map.ts
@@ -3,7 +3,7 @@ import {browser} from '../util/browser';
import {DOM} from '../util/dom';
import packageJSON from '../../package.json' assert {type: 'json'};
-import {getJSON} from '../util/ajax';
+import {GetResourceResponse, getJSON} from '../util/ajax';
import {ImageRequest} from '../util/image_request';
import type {GetImageCallback} from '../util/image_request';
@@ -49,7 +49,6 @@ import type {DoubleClickZoomHandler} from './handler/shim/dblclick_zoom';
import type {TwoFingersTouchZoomRotateHandler} from './handler/shim/two_fingers_touch';
import {defaultLocale} from './default_locale';
import type {TaskID} from '../util/task_queue';
-import type {Cancelable} from '../types/cancelable';
import type {
FilterSpecification,
StyleSpecification,
@@ -468,7 +467,7 @@ export class Map extends Camera {
_canvas: HTMLCanvasElement;
_maxTileCacheSize: number;
_maxTileCacheZoomLevels: number;
- _frame: Cancelable;
+ _frameRequest: AbortController;
_styleDirty: boolean;
_sourcesDirty: boolean;
_placementDirty: boolean;
@@ -1828,11 +1827,11 @@ export class Map extends Camera {
if (typeof style === 'string') {
const url = style;
const request = this._requestManager.transformRequest(url, ResourceType.Style);
- getJSON(request, (error?: Error | null, json?: any | null) => {
+ getJSON(request, new AbortController()).then((response) => {
+ this._updateDiff(response.data, options);
+ }).catch((error) => {
if (error) {
this.fire(new ErrorEvent(error));
- } else if (json) {
- this._updateDiff(json, options);
}
});
} else if (typeof style === 'object') {
@@ -2305,21 +2304,25 @@ export class Map extends Camera {
* domains must support [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS).
*
* @param url - The URL of the image file. Image file must be in png, webp, or jpg format.
- * @param callback - Expecting `callback(error, data)`. Called when the image has loaded or with an error argument if there is an error.
+ * @param callback - if not provided, this method will return a promise, otherwise use `callback(error, data)`. Called when the image has loaded or with an error argument if there is an error.
*
* @example
* Load an image from an external URL.
* ```ts
- * map.loadImage('http://placekitten.com/50/50', function(error, image) {
- * if (error) throw error;
- * // Add the loaded image to the style's sprite with the ID 'kitten'.
- * map.addImage('kitten', image);
- * });
+ * const response = await map.loadImage('http://placekitten.com/50/50');
+ * // Add the loaded image to the style's sprite with the ID 'kitten'.
+ * map.addImage('kitten', response.data);
* ```
* @see [Add an icon to the map](https://maplibre.org/maplibre-gl-js/docs/examples/add-image/)
*/
- loadImage(url: string, callback: GetImageCallback) {
- ImageRequest.getImage(this._requestManager.transformRequest(url, ResourceType.Image), callback);
+ loadImage(url: string, callback?: GetImageCallback): Promise> {
+ if (!callback) {
+ return ImageRequest.getImage(this._requestManager.transformRequest(url, ResourceType.Image), new AbortController());
+ } else {
+ ImageRequest.getImage(this._requestManager.transformRequest(url, ResourceType.Image), new AbortController())
+ .then((response) => callback(null, response.data, {cacheControl: response.cacheControl, expires: response.expires}))
+ .catch(callback);
+ }
}
/**
@@ -3038,9 +3041,9 @@ export class Map extends Camera {
_contextLost = (event: any) => {
event.preventDefault();
- if (this._frame) {
- this._frame.cancel();
- this._frame = null;
+ if (this._frameRequest) {
+ this._frameRequest.abort();
+ this._frameRequest = null;
}
this.fire(new Event('webglcontextlost', {originalEvent: event}));
};
@@ -3253,9 +3256,9 @@ export class Map extends Camera {
redraw(): this {
if (this.style) {
// cancel the scheduled update
- if (this._frame) {
- this._frame.cancel();
- this._frame = null;
+ if (this._frameRequest) {
+ this._frameRequest.abort();
+ this._frameRequest = null;
}
this._render(0);
}
@@ -3277,9 +3280,9 @@ export class Map extends Camera {
for (const control of this._controls) control.onRemove(this);
this._controls = [];
- if (this._frame) {
- this._frame.cancel();
- this._frame = null;
+ if (this._frameRequest) {
+ this._frameRequest.abort();
+ this._frameRequest = null;
}
this._renderTaskQueue.clear();
this.painter.destroy();
@@ -3322,12 +3325,13 @@ export class Map extends Camera {
* @see [Add an animated icon to the map](https://maplibre.org/maplibre-gl-js/docs/examples/add-image-animated/)
*/
triggerRepaint() {
- if (this.style && !this._frame) {
- this._frame = browser.frame((paintStartTimeStamp: number) => {
+ if (this.style && !this._frameRequest) {
+ this._frameRequest = new AbortController();
+ browser.frameAsync(this._frameRequest).then((paintStartTimeStamp: number) => {
PerformanceUtils.frame(paintStartTimeStamp);
- this._frame = null;
+ this._frameRequest = null;
this._render(paintStartTimeStamp);
- });
+ }).catch(() => {}); // ignore abort error
}
}
diff --git a/src/util/abort_error.ts b/src/util/abort_error.ts
new file mode 100644
index 0000000000..0deb738f8c
--- /dev/null
+++ b/src/util/abort_error.ts
@@ -0,0 +1,21 @@
+/**
+ * An error message to use when an operation is aborted
+ */
+export const ABORT_ERROR = 'AbortError';
+
+/**
+ * Check if an error is an abort error
+ * @param error - An error object
+ * @returns - true if the error is an abort error
+ */
+export function isAbortError(error: Error): boolean {
+ return error.message === ABORT_ERROR;
+}
+
+/**
+ * Use this when you need to create an abort error.
+ * @returns An error object with the message "AbortError"
+ */
+export function createAbortError(): Error {
+ return new Error(ABORT_ERROR);
+}
diff --git a/src/util/actor.test.ts b/src/util/actor.test.ts
index 06ad375df5..d1759aea8d 100644
--- a/src/util/actor.test.ts
+++ b/src/util/actor.test.ts
@@ -1,106 +1,189 @@
import {Actor, ActorTarget} from './actor';
-import {workerFactory} from './web_worker';
-import {MessageBus} from '../../test/unit/lib/web_worker_mock';
-
-const originalWorker = global.Worker;
-
-function setTestWorker(MockWorker: { new(...args: any): any}) {
- (global as any).Worker = function Worker(_: string) {
- const parentListeners = [];
- const workerListeners = [];
- const parentBus = new MessageBus(workerListeners, parentListeners);
- const workerBus = new MessageBus(parentListeners, workerListeners);
-
- parentBus.target = workerBus;
- workerBus.target = parentBus;
-
- new MockWorker(workerBus);
-
- return parentBus;
- };
+import {WorkerGlobalScopeInterface, workerFactory} from './web_worker';
+import {setGlobalWorker} from '../../test/unit/lib/web_worker_mock';
+import {sleep} from './test/util';
+import {ABORT_ERROR, createAbortError} from './abort_error';
+
+class MockWorker {
+ self: any;
+ actor: Actor;
+ constructor(self) {
+ this.self = self;
+ this.actor = new Actor(self);
+ }
}
describe('Actor', () => {
+ let originalWorker;
+ beforeAll(() => {
+ originalWorker = global.Worker;
+ setGlobalWorker(MockWorker);
+ });
afterAll(() => {
global.Worker = originalWorker;
});
- test('forwards responses to correct callback', done => {
- setTestWorker(class MockWorker {
- self: any;
- actor: Actor;
- constructor(self) {
- this.self = self;
- this.actor = new Actor(self, this);
- }
- getTile(mapId, params, callback) {
- setTimeout(callback, 0, null, params);
- }
- getWorkerSource() { return null; }
+ test('forwards responses to correct handler', async () => {
+ const worker = workerFactory() as any as WorkerGlobalScopeInterface & ActorTarget;
+ worker.worker.actor.registerMessageHandler('getClusterExpansionZoom', async (_mapId, params) => {
+ await sleep(0);
+ return params.clusterId;
});
- const worker = workerFactory();
+ const m1 = new Actor(worker, '1');
+ const m2 = new Actor(worker, '2');
- const m1 = new Actor(worker, {} as any, '1');
- const m2 = new Actor(worker, {} as any, '2');
+ const p1 = m1.sendAsync({type: 'getClusterExpansionZoom', data: {type: 'geojson', source: '', clusterId: 1729}});
+ const p2 = m2.sendAsync({type: 'getClusterExpansionZoom', data: {type: 'geojson', source: '', clusterId: 4104}});
- let callbackCount = 0;
- m1.send('getTile', {value: 1729}, (err, response) => {
- expect(err).toBeFalsy();
- expect(response).toEqual({value: 1729});
- callbackCount++;
- if (callbackCount === 2) {
- done();
- }
+ await Promise.all([p1, p2]);
+ await expect(p1).resolves.toBe(1729);
+ await expect(p2).resolves.toBe(4104);
+ });
+
+ test('cancel a request does not reject or resolve a promise', async () => {
+ const worker = workerFactory() as any as WorkerGlobalScopeInterface & ActorTarget;
+ worker.worker.actor.registerMessageHandler('getClusterExpansionZoom', async (_mapId, params) => {
+ await sleep(200);
+ return params.clusterId;
});
- m2.send('getTile', {value: 4104}, (err, response) => {
- expect(err).toBeFalsy();
- expect(response).toEqual({value: 4104});
- callbackCount++;
- if (callbackCount === 2) {
- done();
- }
+
+ const m1 = new Actor(worker, '1');
+
+ let received = false;
+ const abortController = new AbortController();
+ const p1 = m1.sendAsync({type: 'getClusterExpansionZoom', data: {type: 'geojson', source: '', clusterId: 1729}}, abortController)
+ .then(() => { received = true; })
+ .catch(() => { received = true; });
+
+ abortController.abort();
+
+ const p2 = new Promise((resolve) => (setTimeout(resolve, 500)));
+
+ await Promise.any([p1, p2]);
+ expect(received).toBeFalsy();
+ });
+
+ test('aborting a request will successfully abort it', async () => {
+ const worker = workerFactory() as any as WorkerGlobalScopeInterface & ActorTarget;
+ let gotAbortSignal = false;
+ worker.worker.actor.registerMessageHandler('getClusterExpansionZoom', (_mapId, _params, handlerAbortController) => {
+ return new Promise((resolve, reject) => {
+ handlerAbortController.signal.addEventListener('abort', () => {
+ gotAbortSignal = true;
+ reject(createAbortError());
+ });
+ setTimeout(resolve, 200);
+ });
});
+
+ const m1 = new Actor(worker, '1');
+
+ let received = false;
+ const abortController = new AbortController();
+ m1.sendAsync({type: 'getClusterExpansionZoom', data: {type: 'geojson', source: '', clusterId: 1729}}, abortController)
+ .then(() => { received = true; })
+ .catch(() => { received = true; });
+
+ abortController.abort();
+
+ await sleep(500);
+
+ expect(received).toBeFalsy();
+ expect(gotAbortSignal).toBeTruthy();
});
- test('targets worker-initiated messages to correct map instance', done => {
- let workerActor;
+ test('cancel a request that must be queued will not call the method at all', async () => {
+ const worker = workerFactory() as any as WorkerGlobalScopeInterface & ActorTarget;
+ const actor = new Actor(worker, '1');
- setTestWorker(class MockWorker {
- self: any;
- actor: Actor;
- constructor(self) {
- this.self = self;
- this.actor = workerActor = new Actor(self, this);
- }
- getWorkerSource() { return null; }
- });
+ const spy = jest.fn().mockReturnValue(Promise.resolve({}));
+ worker.worker.actor.registerMessageHandler('getClusterExpansionZoom', spy);
- const worker = workerFactory();
+ let received = false;
+ const abortController = new AbortController();
+ const p1 = actor.sendAsync({type: 'getClusterExpansionZoom', data: {type: 'geojson', source: '', clusterId: 1729}, mustQueue: true}, abortController)
+ .then(() => { received = true; })
+ .catch(() => { received = true; });
- new Actor(worker, {
- test () { done(); }
- } as any, '1');
- new Actor(worker, {
- test () {
- done('test failed');
- }
- } as any, '2');
+ abortController.abort();
+
+ const p2 = new Promise((resolve) => (setTimeout(resolve, 500)));
- workerActor.send('test', {}, () => {}, '1');
+ await Promise.any([p1, p2]);
+ expect(received).toBeFalsy();
+ expect(spy).not.toHaveBeenCalled();
});
test('#remove unbinds event listener', done => {
const actor = new Actor({
- addEventListener (type, callback, useCapture) {
+ addEventListener(type, callback, useCapture) {
this._addEventListenerArgs = [type, callback, useCapture];
},
- removeEventListener (type, callback, useCapture) {
+ removeEventListener(type, callback, useCapture) {
expect([type, callback, useCapture]).toEqual(this._addEventListenerArgs);
done();
}
- } as ActorTarget, {} as any, null);
+ } as ActorTarget, null);
actor.remove();
});
+ test('send a messege that is rejected', async () => {
+ const worker = workerFactory() as any as WorkerGlobalScopeInterface & ActorTarget;
+ const actor = new Actor(worker, '1');
+
+ worker.worker.actor.registerMessageHandler('abortTile', () => Promise.reject(createAbortError()));
+
+ await expect(async () => actor.sendAsync({type: 'abortTile', data: {} as any})).rejects.toThrow(ABORT_ERROR);
+ });
+
+ test('send a messege that must be queued, it should still arrive', async () => {
+ const worker = workerFactory() as any as WorkerGlobalScopeInterface & ActorTarget;
+ const actor = new Actor(worker, '1');
+
+ worker.worker.actor.registerMessageHandler('getClusterExpansionZoom', () => Promise.resolve(42));
+
+ const response = await actor.sendAsync({type: 'getClusterExpansionZoom', data: {} as any, mustQueue: true});
+
+ expect(response).toBe(42);
+ });
+
+ test('send a messege is not registered should throw', async () => {
+ const worker = workerFactory() as any as WorkerGlobalScopeInterface & ActorTarget;
+ const actor = new Actor(worker, '1');
+
+ worker.worker.actor.registerMessageHandler('getClusterExpansionZoom', () => Promise.resolve(42));
+
+ await expect(async () => actor.sendAsync({type: 'abortTile', data: {} as any})).rejects.toThrow(/Could not find a registered handler for.*/);
+ });
+
+ test('should not process a message with the wrong map id', async () => {
+ const worker = workerFactory() as any as WorkerGlobalScopeInterface & ActorTarget;
+ const actor = new Actor(worker, '1');
+
+ worker.worker.actor.mapId = '2';
+
+ const spy = jest.fn().mockReturnValue(Promise.resolve({}));
+ worker.worker.actor.registerMessageHandler('getClusterExpansionZoom', spy);
+
+ actor.sendAsync({type: 'getClusterExpansionZoom', data: {} as any, targetMapId: '1'});
+
+ await sleep(100);
+
+ expect(spy).not.toHaveBeenCalled();
+ });
+
+ test('should not process a message with the wrong origin', async () => {
+ const worker = workerFactory() as any as WorkerGlobalScopeInterface & ActorTarget;
+ const actor = new Actor(worker, '1');
+
+ const spy = jest.fn().mockReturnValue(Promise.resolve({}));
+ worker.worker.actor.registerMessageHandler('getClusterExpansionZoom', spy);
+
+ actor.target.postMessage({type: 'getClusterExpansionZoom', data: {} as any, origin: 'https://example.com'});
+
+ await sleep(100);
+
+ expect(spy).not.toHaveBeenCalled();
+ });
});
diff --git a/src/util/actor.ts b/src/util/actor.ts
index 5bdc7d3291..654bcf2526 100644
--- a/src/util/actor.ts
+++ b/src/util/actor.ts
@@ -1,14 +1,13 @@
-import {isWorker} from './util';
+import {Subscription, isWorker, subscribe} from './util';
import {serialize, deserialize, Serialized} from './web_worker_transfer';
import {ThrottledInvoker} from './throttled_invoker';
import type {Transferable} from '../types/transferable';
-import type {Cancelable} from '../types/cancelable';
-import type {WorkerSource} from '../source/worker_source';
-import type {OverscaledTileID} from '../source/tile_id';
-import type {Callback} from '../types/callback';
-import type {StyleGlyph} from '../style/style_glyph';
+import type {ActorMessage, MessageType, RequestResponseMessageMap} from './actor_messages';
+/**
+ * An interface to be sent to the actor in order for it to allow communication between the worker and the main thread
+ */
export interface ActorTarget {
addEventListener: typeof window.addEventListener;
removeEventListener: typeof window.removeEventListener;
@@ -16,173 +15,159 @@ export interface ActorTarget {
terminate?: () => void;
}
-export interface WorkerSourceProvider {
- getWorkerSource(mapId: string | number, sourceType: string, sourceName: string): WorkerSource;
-}
-
-export interface GlyphsProvider {
- getGlyphs(mapId: string, params: {
- stacks: {[_: string]: Array};
- source: string;
- tileID: OverscaledTileID;
- type: string;
- },
- callback: Callback<{[_: string]: {[_: number]: StyleGlyph}}>
- );
-}
-
-export type MessageType = '' | '' |
-'geojson.getClusterExpansionZoom' | 'geojson.getClusterChildren' | 'geojson.getClusterLeaves' | 'geojson.loadData' |
-'removeSource' | 'loadWorkerSource' | 'loadDEMTile' | 'removeDEMTile' |
-'removeTile' | 'reloadTile' | 'abortTile' | 'loadTile' | 'getTile' |
-'getGlyphs' | 'getImages' | 'setImages' |
-'syncRTLPluginState' | 'setReferrer' | 'setLayers' | 'updateLayers';
-
-export type MessageData = {
+/**
+ * This is used to define the parameters of the message that is sent to the worker and back
+ */
+type MessageData = {
id: string;
- type: MessageType;
+ type: MessageType | '' | '';
+ origin: string;
data?: Serialized;
targetMapId?: string | number | null;
mustQueue?: boolean;
error?: Serialized | null;
- hasCallback?: boolean;
sourceMapId: string | number | null;
}
-export type Message = {
- data: MessageData;
+type ResolveReject = {
+ resolve: (value?: RequestResponseMessageMap[MessageType][1]) => void;
+ reject: (reason?: Error) => void;
}
+/**
+ * This interface allowing to substitute only the sendAsync method of the Actor class.
+ */
+export interface IActor {
+ sendAsync(message: ActorMessage, abortController?: AbortController): Promise;
+}
+
+export type MessageHandler = (mapId: string | number, params: RequestResponseMessageMap[T][0], abortController?: AbortController) => Promise
+
/**
* An implementation of the [Actor design pattern](http://en.wikipedia.org/wiki/Actor_model)
* that maintains the relationship between asynchronous tasks and the objects
* that spin them off - in this case, tasks like parsing parts of styles,
* owned by the styles
*/
-export class Actor {
+export class Actor implements IActor {
target: ActorTarget;
- parent: WorkerSourceProvider | GlyphsProvider;
mapId: string | number | null;
- callbacks: { [x: number]: Function};
+ resolveRejects: { [x: string]: ResolveReject};
name: string;
tasks: { [x: number]: MessageData };
taskQueue: Array;
- cancelCallbacks: { [x: number]: () => void };
+ abortControllers: { [x: number | string]: AbortController };
invoker: ThrottledInvoker;
globalScope: ActorTarget;
+ messageHandlers: { [x in MessageType]?: MessageHandler};
+ subscription: Subscription;
/**
* @param target - The target
* @param parent - The parent
* @param mapId - A unique identifier for the Map instance using this Actor.
*/
- constructor(target: ActorTarget, parent: WorkerSourceProvider | GlyphsProvider, mapId?: string | number) {
+ constructor(target: ActorTarget, mapId?: string | number) {
this.target = target;
- this.parent = parent;
this.mapId = mapId;
- this.callbacks = {};
+ this.resolveRejects = {};
this.tasks = {};
this.taskQueue = [];
- this.cancelCallbacks = {};
- this.invoker = new ThrottledInvoker(this.process);
- this.target.addEventListener('message', this.receive, false);
- this.globalScope = isWorker() ? target : window;
+ this.abortControllers = {};
+ this.messageHandlers = {};
+ this.invoker = new ThrottledInvoker(() => this.process());
+ this.subscription = subscribe(this.target, 'message', (message) => this.receive(message), false);
+ this.globalScope = isWorker(self) ? target : window;
+ }
+
+ registerMessageHandler(type: T, handler: MessageHandler) {
+ this.messageHandlers[type] = handler;
}
/**
* Sends a message from a main-thread map to a Worker or from a Worker back to
* a main-thread map instance.
- *
- * @param type - The name of the target method to invoke or '[source-type].[source-name].name' for a method on a WorkerSource.
- * @param targetMapId - A particular mapId to which to send this message.
+ * @param message - the message to send
+ * @param abortController - an optional AbortController to abort the request
+ * @returns a promise that will be resolved with the response data
*/
- send(
- type: MessageType,
- data: unknown,
- callback?: Function | null,
- targetMapId?: string | null,
- mustQueue: boolean = false
- ): Cancelable {
- // We're using a string ID instead of numbers because they are being used as object keys
- // anyway, and thus stringified implicitly. We use random IDs because an actor may receive
- // message from multiple other actors which could run in different execution context. A
- // linearly increasing ID could produce collisions.
- const id = Math.round((Math.random() * 1e18)).toString(36).substring(0, 10);
- if (callback) {
- this.callbacks[id] = callback;
- }
- const buffers: Array = [];
- const message: MessageData = {
- id,
- type,
- hasCallback: !!callback,
- targetMapId,
- mustQueue,
- sourceMapId: this.mapId,
- data: serialize(data, buffers)
- };
-
- this.target.postMessage(message, {transfer: buffers});
- return {
- cancel: () => {
- if (callback) {
- // Set the callback to null so that it never fires after the request is aborted.
- delete this.callbacks[id];
- }
- const cancelMessage: MessageData = {
- id,
- type: '',
- targetMapId,
- sourceMapId: this.mapId
- };
- this.target.postMessage(cancelMessage);
+ sendAsync(message: ActorMessage, abortController?: AbortController): Promise {
+ return new Promise((resolve, reject) => {
+ // We're using a string ID instead of numbers because they are being used as object keys
+ // anyway, and thus stringified implicitly. We use random IDs because an actor may receive
+ // message from multiple other actors which could run in different execution context. A
+ // linearly increasing ID could produce collisions.
+ const id = Math.round((Math.random() * 1e18)).toString(36).substring(0, 10);
+ this.resolveRejects[id] = {
+ resolve,
+ reject
+ };
+ if (abortController) {
+ abortController.signal.addEventListener('abort', () => {
+ delete this.resolveRejects[id];
+ const cancelMessage: MessageData = {
+ id,
+ type: '',
+ origin: location.origin,
+ targetMapId: message.targetMapId,
+ sourceMapId: this.mapId
+ };
+ this.target.postMessage(cancelMessage);
+ // In case of abort the current behavior is to keep the promise pending.
+ }, {once: true});
}
- };
+ const buffers: Array = [];
+ const messageToPost: MessageData = {
+ ...message,
+ id,
+ sourceMapId: this.mapId,
+ origin: location.origin,
+ data: serialize(message.data, buffers)
+ };
+ this.target.postMessage(messageToPost, {transfer: buffers});
+ });
}
- receive = (message: Message) => {
+ receive(message: {data: MessageData}) {
const data = message.data;
const id = data.id;
-
- if (!id) {
+ if (data.origin !== location.origin) {
return;
}
-
if (data.targetMapId && this.mapId !== data.targetMapId) {
return;
}
-
if (data.type === '') {
// Remove the original request from the queue. This is only possible if it
// hasn't been kicked off yet. The id will remain in the queue, but because
// there is no associated task, it will be dropped once it's time to execute it.
delete this.tasks[id];
- const cancel = this.cancelCallbacks[id];
- delete this.cancelCallbacks[id];
- if (cancel) {
- cancel();
- }
- } else {
- if (isWorker() || data.mustQueue) {
- // In workers, store the tasks that we need to process before actually processing them. This
- // is necessary because we want to keep receiving messages, and in particular,
- // messages. Some tasks may take a while in the worker thread, so before
- // executing the next task in our queue, postMessage preempts this and
- // messages can be processed. We're using a MessageChannel object to get throttle the
- // process() flow to one at a time.
- this.tasks[id] = data;
- this.taskQueue.push(id);
- this.invoker.trigger();
- } else {
- // In the main thread, process messages immediately so that other work does not slip in
- // between getting partial data back from workers.
- this.processTask(id, data);
+ const abortController = this.abortControllers[id];
+ delete this.abortControllers[id];
+ if (abortController) {
+ abortController.abort();
}
+ return;
+ }
+ if (isWorker(self) || data.mustQueue) {
+ // In workers, store the tasks that we need to process before actually processing them. This
+ // is necessary because we want to keep receiving messages, and in particular,
+ // messages. Some tasks may take a while in the worker thread, so before
+ // executing the next task in our queue, postMessage preempts this and
+ // messages can be processed. We're using a MessageChannel object to get throttle the
+ // process() flow to one at a time.
+ this.tasks[id] = data;
+ this.taskQueue.push(id);
+ this.invoker.trigger();
+ return;
}
- };
+ // In the main thread, process messages immediately so that other work does not slip in
+ // between getting partial data back from workers.
+ this.processTask(id, data);
+ }
- process = () => {
- if (!this.taskQueue.length) {
+ process() {
+ if (this.taskQueue.length === 0) {
return;
}
const id = this.taskQueue.shift();
@@ -191,7 +176,7 @@ export class Actor {
// Schedule another process call if we know there's more to process _before_ invoking the
// current task. This is necessary so that processing continues even if the current task
// doesn't execute successfully.
- if (this.taskQueue.length) {
+ if (this.taskQueue.length > 0) {
this.invoker.trigger();
}
if (!task) {
@@ -200,64 +185,56 @@ export class Actor {
}
this.processTask(id, task);
- };
+ }
- processTask(id: string, task: MessageData) {
+ async processTask(id: string, task: MessageData) {
if (task.type === '') {
- // The done() function in the counterpart has been called, and we are now
- // firing the callback in the originating actor, if there is one.
- const callback = this.callbacks[id];
- delete this.callbacks[id];
- if (callback) {
- // If we get a response, but don't have a callback, the request was canceled.
- if (task.error) {
- callback(deserialize(task.error));
- } else {
- callback(null, deserialize(task.data));
- }
+ // The `completeTask` function in the counterpart actor has been called, and we are now
+ // resolving or rejecting the promise in the originating actor, if there is one.
+ const resolveReject = this.resolveRejects[id];
+ delete this.resolveRejects[id];
+ if (!resolveReject) {
+ // If we get a response, but don't have a resolve or reject, the request was canceled.
+ return;
}
- } else {
- let completed = false;
- const buffers: Array = [];
- const done = task.hasCallback ? (err: Error, data?: any) => {
- completed = true;
- delete this.cancelCallbacks[id];
- const responseMessage: MessageData = {
- id,
- type: '',
- sourceMapId: this.mapId,
- error: err ? serialize(err) : null,
- data: serialize(data, buffers)
- };
- this.target.postMessage(responseMessage, {transfer: buffers});
- } : (_) => {
- completed = true;
- };
-
- let callback: Cancelable = null;
- const params = deserialize(task.data);
- if (this.parent[task.type]) {
- // task.type == 'loadTile', 'removeTile', etc.
- callback = this.parent[task.type](task.sourceMapId, params, done);
- } else if ('getWorkerSource' in this.parent) {
- // task.type == sourcetype.method
- const keys = task.type.split('.');
- const scope = this.parent.getWorkerSource(task.sourceMapId, keys[0], (params as any).source);
- callback = scope[keys[1]](params, done);
+ if (task.error) {
+ resolveReject.reject(deserialize(task.error) as Error);
} else {
- // No function was found.
- done(new Error(`Could not find function ${task.type}`));
- }
-
- if (!completed && callback && callback.cancel) {
- // Allows canceling the task as long as it hasn't been completed yet.
- this.cancelCallbacks[id] = callback.cancel;
+ resolveReject.resolve(deserialize(task.data));
}
+ return;
}
+ if (!this.messageHandlers[task.type]) {
+ this.completeTask(id, new Error(`Could not find a registered handler for ${task.type}`));
+ return;
+ }
+ const params = deserialize(task.data) as RequestResponseMessageMap[MessageType][0];
+ const abortController = new AbortController();
+ this.abortControllers[id] = abortController;
+ try {
+ const data = await this.messageHandlers[task.type](task.sourceMapId, params, abortController);
+ this.completeTask(id, null, data);
+ } catch (err) {
+ this.completeTask(id, err);
+ }
+ }
+
+ completeTask(id: string, err: Error, data?: RequestResponseMessageMap[MessageType][1]) {
+ const buffers: Array = [];
+ delete this.abortControllers[id];
+ const responseMessage: MessageData = {
+ id,
+ type: '',
+ sourceMapId: this.mapId,
+ origin: location.origin,
+ error: err ? serialize(err) : null,
+ data: serialize(data, buffers)
+ };
+ this.target.postMessage(responseMessage, {transfer: buffers});
}
remove() {
this.invoker.remove();
- this.target.removeEventListener('message', this.receive, false);
+ this.subscription.unsubscribe();
}
}
diff --git a/src/util/actor_messages.ts b/src/util/actor_messages.ts
new file mode 100644
index 0000000000..474b565232
--- /dev/null
+++ b/src/util/actor_messages.ts
@@ -0,0 +1,124 @@
+import type {LoadGeoJSONParameters} from '../source/geojson_worker_source';
+import type {TileParameters, WorkerDEMTileParameters, WorkerTileParameters, WorkerTileResult} from '../source/worker_source';
+import type {DEMData} from '../data/dem_data';
+import type {StyleImage} from '../style/style_image';
+import type {StyleGlyph} from '../style/style_glyph';
+import type {PluginState} from '../source/rtl_text_plugin';
+import type {LayerSpecification} from '@maplibre/maplibre-gl-style-spec';
+import type {OverscaledTileID} from '../source/tile_id';
+import type {GetResourceResponse, RequestParameters} from './ajax';
+
+/**
+ * The parameters needed in order to get information about the cluster
+ */
+export type ClusterIDAndSource = {
+ type: 'geojson';
+ clusterId: number;
+ source: string;
+};
+
+/**
+ * Parameters needed to get the leaves of a cluster
+ */
+export type GetClusterLeavesParams = ClusterIDAndSource & { limit: number; offset: number };
+
+/**
+ * The result of the call to load a geojson source
+ */
+export type GeoJSONWorkerSourceLoadDataResult = {
+ resourceTiming?: {[_: string]: Array};
+ abandoned?: boolean;
+};
+
+/**
+ * Parameters needed to remove a source
+ */
+export type RemoveSourceParams = {
+ source: string;
+ type: string;
+}
+
+/**
+ * Parameters needed to update the layers
+ */
+export type UpdateLayersParamaeters = {
+ layers: Array;
+ removedIds: Array;
+}
+
+/**
+ * Parameters needed to get the images
+ */
+export type GetImagesParamerters = {
+ icons: Array;
+ source: string;
+ tileID: OverscaledTileID;
+ type: string;
+}
+
+/**
+ * Parameters needed to get the glyphs
+ */
+export type GetGlyphsParamerters = {
+ type: string;
+ stacks: {[_: string]: Array};
+ source: string;
+ tileID: OverscaledTileID;
+}
+
+/**
+ * A response object returned when requesting glyphs
+ */
+export type GetGlyphsResponse = {
+ [stack: string]: {
+ [id: number]: StyleGlyph;
+ };
+}
+
+/**
+ * A response object returned when requesting images
+ */
+export type GetImagesResponse = {[_: string]: StyleImage}
+
+/**
+ * This is basically a mapping between all the calls that are made to and from the workers.
+ * The key is the event name, the first parameter is the event input type, and the last parameter is the output type.
+ */
+export type RequestResponseMessageMap = {
+ 'loadDEMTile': [WorkerDEMTileParameters, DEMData];
+ 'getClusterExpansionZoom': [ClusterIDAndSource, number];
+ 'getClusterChildren': [ClusterIDAndSource, Array];
+ 'getClusterLeaves': [GetClusterLeavesParams, Array];
+ 'loadData': [LoadGeoJSONParameters, GeoJSONWorkerSourceLoadDataResult];
+ 'loadTile': [WorkerTileParameters, WorkerTileResult];
+ 'reloadTile': [WorkerTileParameters, WorkerTileResult];
+ 'getGlyphs': [GetGlyphsParamerters, GetGlyphsResponse];
+ 'getImages': [GetImagesParamerters, GetImagesResponse];
+ 'setImages': [string[], void];
+ 'setLayers': [Array, void];
+ 'updateLayers': [UpdateLayersParamaeters, void];
+ 'syncRTLPluginState': [PluginState, boolean];
+ 'setReferrer': [string, void];
+ 'removeSource': [RemoveSourceParams, void];
+ 'loadWorkerSource': [string, void];
+ 'removeTile': [TileParameters, void];
+ 'abortTile': [TileParameters, void];
+ 'removeDEMTile': [TileParameters, void];
+ 'getResource': [RequestParameters, GetResourceResponse];
+}
+
+/**
+ * All the possible message types that can be sent to and from the worker
+ */
+export type MessageType = keyof RequestResponseMessageMap;
+
+/**
+ * The message to be sent by the actor
+ */
+export type ActorMessage = {
+ type: T;
+ data: RequestResponseMessageMap[T][0];
+ targetMapId?: string | number | null;
+ mustQueue?: boolean;
+ sourceMapId?: string | number | null;
+};
diff --git a/src/util/ajax.test.ts b/src/util/ajax.test.ts
index f3a6146cf6..c7df501a3b 100644
--- a/src/util/ajax.test.ts
+++ b/src/util/ajax.test.ts
@@ -1,13 +1,11 @@
import {
getArrayBuffer,
getJSON,
- postData,
AJAXError,
sameOrigin
} from './ajax';
import {fakeServer, type FakeServer} from 'nise';
-import {destroyFetchMock, FetchMock, RequestMock, setupFetchMock} from './test/mock_fetch';
function readAsText(blob) {
return new Promise((resolve, reject) => {
@@ -28,70 +26,66 @@ describe('ajax', () => {
server.restore();
});
- test('getArrayBuffer, 404', done => {
+ test('getArrayBuffer, 404', async () => {
server.respondWith(request => {
request.respond(404, undefined, '404 Not Found');
});
- getArrayBuffer({url: 'http://example.com/test.bin'}, async (error) => {
+
+ try {
+ const promise = getArrayBuffer({url: 'http://example.com/test.bin'}, new AbortController());
+ server.respond();
+ await promise;
+ } catch (error) {
const ajaxError = error as AJAXError;
const body = await readAsText(ajaxError.body);
expect(ajaxError.status).toBe(404);
expect(ajaxError.statusText).toBe('Not Found');
expect(ajaxError.url).toBe('http://example.com/test.bin');
expect(body).toBe('404 Not Found');
- done();
- });
- server.respond();
+ }
});
- test('getJSON', done => {
+ test('getJSON', async () => {
server.respondWith(request => {
request.respond(200, {'Content-Type': 'application/json'}, '{"foo": "bar"}');
});
- getJSON({url: ''}, (error, body) => {
- expect(error).toBeFalsy();
- expect(body).toEqual({foo: 'bar'});
- done();
- });
+ const promise = getJSON({url: ''}, new AbortController());
server.respond();
+
+ const body = await promise;
+ expect(body.data).toEqual({foo: 'bar'});
});
- test('getJSON, invalid syntax', done => {
+ test('getJSON, invalid syntax', async () => {
server.respondWith(request => {
request.respond(200, {'Content-Type': 'application/json'}, 'how do i even');
});
- getJSON({url: ''}, (error) => {
- expect(error).toBeTruthy();
- done();
- });
+ const promise = getJSON({url: ''}, new AbortController());
server.respond();
+ try {
+ await promise;
+ } catch (error) {
+ expect(error).toBeTruthy();
+ }
});
- test('getJSON, 404', done => {
+ test('getJSON, 404', async () => {
server.respondWith(request => {
request.respond(404, undefined, '404 Not Found');
});
- getJSON({url: 'http://example.com/test.json'}, async (error) => {
+ const promise = getJSON({url: 'http://example.com/test.json'}, new AbortController());
+ server.respond();
+
+ try {
+ await promise;
+ } catch (error) {
const ajaxError = error as AJAXError;
const body = await readAsText(ajaxError.body);
expect(ajaxError.status).toBe(404);
expect(ajaxError.statusText).toBe('Not Found');
expect(ajaxError.url).toBe('http://example.com/test.json');
expect(body).toBe('404 Not Found');
- done();
- });
- server.respond();
- });
-
- test('postData, 204(no content): no error', done => {
- server.respondWith(request => {
- request.respond(204, undefined, undefined);
- });
- postData({url: 'api.mapbox.com'}, (error) => {
- expect(error).toBeNull();
- done();
- });
- server.respond();
+ }
});
test('sameOrigin method', () => {
@@ -144,34 +138,34 @@ describe('ajax', () => {
});
describe('requests parameters', () => {
- let fetch: FetchMock;
- beforeEach(() => {
- fetch = setupFetchMock();
- });
+ test('should be provided to fetch API in getArrayBuffer function', async () => {
+ server.respondWith(new ArrayBuffer(1));
+
+ const promise = getArrayBuffer({url: 'http://example.com/test-params.json', cache: 'force-cache', headers: {'Authorization': 'Bearer 123'}}, new AbortController());
+ server.respond();
+ await promise;
- afterEach(() => {
- destroyFetchMock();
+ expect(server.requests).toHaveLength(1);
+ expect(server.requests[0].url).toBe('http://example.com/test-params.json');
+ expect(server.requests[0].method).toBe('GET');
+ expect(server.requests[0].requestHeaders['Authorization']).toBe('Bearer 123');
});
- test('should be provided to fetch API in getArrayBuffer function', (done) => {
- getArrayBuffer({url: 'http://example.com/test-params.json', cache: 'force-cache', headers: {'Authorization': 'Bearer 123'}}, () => {
- expect(fetch).toHaveBeenCalledTimes(1);
- expect(fetch).toHaveBeenCalledWith(expect.objectContaining({url: 'http://example.com/test-params.json', method: 'GET', cache: 'force-cache'}));
- expect((fetch.mock.calls[0][0] as RequestMock).headers.get('Authorization')).toBe('Bearer 123');
+ test('should be provided to fetch API in getJSON function', async () => {
- done();
+ server.respondWith(request => {
+ request.respond(200, {'Content-Type': 'application/json'}, '{"foo": "bar"}');
});
- });
- test('should be provided to fetch API in getJSON function', (done) => {
- getJSON({url: 'http://example.com/test-params.json', cache: 'force-cache', headers: {'Authorization': 'Bearer 123'}}, () => {
- expect(fetch).toHaveBeenCalledTimes(1);
- expect(fetch).toHaveBeenCalledWith(expect.objectContaining({url: 'http://example.com/test-params.json', method: 'GET', cache: 'force-cache'}));
- expect((fetch.mock.calls[0][0] as RequestMock).headers.get('Authorization')).toBe('Bearer 123');
+ const promise = getJSON({url: 'http://example.com/test-params.json', cache: 'force-cache', headers: {'Authorization': 'Bearer 123'}}, new AbortController());
+ server.respond();
+ await promise;
- done();
- });
+ expect(server.requests).toHaveLength(1);
+ expect(server.requests[0].url).toBe('http://example.com/test-params.json');
+ expect(server.requests[0].method).toBe('GET');
+ expect(server.requests[0].requestHeaders['Authorization']).toBe('Bearer 123');
});
});
});
diff --git a/src/util/ajax.ts b/src/util/ajax.ts
index 8119d208ca..bd27e08e92 100644
--- a/src/util/ajax.ts
+++ b/src/util/ajax.ts
@@ -1,9 +1,14 @@
-import {extend, warnOnce, isWorker} from './util';
+import {extend, isWorker} from './util';
import {config} from './config';
+import {createAbortError} from './abort_error';
-import type {Callback} from '../types/callback';
import type {Cancelable} from '../types/cancelable';
+/**
+ * A type used to store the tile's expiration date and cache control definition
+ */
+export type ExpiryData = {cacheControl?: string | null; expires?: Date | string | null};
+
/**
* A `RequestParameters` object to be returned from Map.options.transformRequest callbacks.
* @example
@@ -38,7 +43,7 @@ export type RequestParameters = {
*/
body?: string;
/**
- * Response body type to be returned `'string' | 'json' | 'arrayBuffer'`.
+ * Response body type to be returned.
*/
type?: 'string' | 'json' | 'arrayBuffer' | 'image';
/**
@@ -55,6 +60,13 @@ export type RequestParameters = {
cache?: RequestCache;
};
+/**
+ * The response object returned from a successful AJAx request
+ */
+export type GetResourceResponse = ExpiryData & {
+ data: T;
+}
+
/**
* The response callback used in various places
*/
@@ -62,7 +74,7 @@ export type ResponseCallback = (
error?: Error | null,
data?: T | null,
cacheControl?: string | null,
- expires?: string | null
+ expires?: string | Date | null
) => void;
/**
@@ -104,24 +116,28 @@ export class AJAXError extends Error {
}
}
-// Ensure that we're sending the correct referrer from blob URL worker bundles.
-// For files loaded from the local file system, `location.origin` will be set
-// to the string(!) "null" (Firefox), or "file://" (Chrome, Safari, Edge, IE),
-// and we will set an empty referrer. Otherwise, we're using the document's URL.
-/* global self */
-export const getReferrer = isWorker() ?
- () => (self as any).worker && (self as any).worker.referrer :
- () => (window.location.protocol === 'blob:' ? window.parent : window).location.href;
+/**
+ * Ensure that we're sending the correct referrer from blob URL worker bundles.
+ * For files loaded from the local file system, `location.origin` will be set
+ * to the string(!) "null" (Firefox), or "file://" (Chrome, Safari, Edge),
+ * and we will set an empty referrer. Otherwise, we're using the document's URL.
+ */
+export const getReferrer = () => isWorker(self) ?
+ self.worker && self.worker.referrer :
+ (window.location.protocol === 'blob:' ? window.parent : window).location.href;
export const getProtocolAction = url => config.REGISTERED_PROTOCOLS[url.substring(0, url.indexOf('://'))];
-// Determines whether a URL is a file:// URL. This is obviously the case if it begins
-// with file://. Relative URLs are also file:// URLs iff the original document was loaded
-// via a file:// URL.
+/**
+ * Determines whether a URL is a file:// URL. This is obviously the case if it begins
+ * with file://. Relative URLs are also file:// URLs iff the original document was loaded
+ * via a file:// URL.
+ * @param url - The URL to check
+ * @returns `true` if the URL is a file:// URL, `false` otherwise
+ */
const isFileURL = url => /^file:/.test(url) || (/^file:/.test(getReferrer()) && !/^\w+:/.test(url));
-function makeFetchRequest(requestParameters: RequestParameters, callback: ResponseCallback): Cancelable {
- const controller = new AbortController();
+async function makeFetchRequest(requestParameters: RequestParameters, abortController: AbortController): Promise> {
const request = new Request(requestParameters.url, {
method: requestParameters.method || 'GET',
body: requestParameters.body,
@@ -129,160 +145,142 @@ function makeFetchRequest(requestParameters: RequestParameters, callback: Respon
headers: requestParameters.headers,
cache: requestParameters.cache,
referrer: getReferrer(),
- signal: controller.signal
+ signal: abortController.signal
});
- let complete = false;
- let aborted = false;
if (requestParameters.type === 'json') {
request.headers.set('Accept', 'application/json');
}
- const validateOrFetch = (err, cachedResponse?, responseIsFresh?) => {
- if (aborted) return;
+ const response = await fetch(request);
+ if (!response.ok) {
+ const body = await response.blob();
+ throw new AJAXError(response.status, response.statusText, requestParameters.url, body);
+ }
+ const parsePromise = (requestParameters.type === 'arrayBuffer' || requestParameters.type === 'image') ? response.arrayBuffer() :
+ requestParameters.type === 'json' ? response.json() :
+ response.text();
+ const result = await parsePromise;
+ if (abortController.signal.aborted) {
+ throw createAbortError();
+ }
+ return {data: result, cacheControl: response.headers.get('Cache-Control'), expires: response.headers.get('Expires')};
+}
+
+function makeXMLHttpRequest(requestParameters: RequestParameters, abortController: AbortController): Promise> {
+ return new Promise((resolve, reject) => {
+ const xhr: XMLHttpRequest = new XMLHttpRequest();
- if (err) {
- // Do fetch in case of cache error.
- // HTTP pages in Edge trigger a security error that can be ignored.
- if (err.message !== 'SecurityError') {
- warnOnce(err);
- }
+ xhr.open(requestParameters.method || 'GET', requestParameters.url, true);
+ if (requestParameters.type === 'arrayBuffer' || requestParameters.type === 'image') {
+ xhr.responseType = 'arraybuffer';
}
-
- if (cachedResponse && responseIsFresh) {
- return finishRequest(cachedResponse);
+ for (const k in requestParameters.headers) {
+ xhr.setRequestHeader(k, requestParameters.headers[k]);
}
-
- if (cachedResponse) {
- // We can't do revalidation with 'If-None-Match' because then the
- // request doesn't have simple cors headers.
+ if (requestParameters.type === 'json') {
+ xhr.responseType = 'text';
+ xhr.setRequestHeader('Accept', 'application/json');
}
-
- fetch(request).then(response => {
- if (response.ok) {
- return finishRequest(response);
-
- } else {
- return response.blob().then(body => callback(new AJAXError(response.status, response.statusText, requestParameters.url, body)));
- }
- }).catch(error => {
- if (error.code === 20) {
- // silence expected AbortError
+ xhr.withCredentials = requestParameters.credentials === 'include';
+ xhr.onerror = () => {
+ reject(new Error(xhr.statusText));
+ };
+ xhr.onload = () => {
+ if (abortController.signal.aborted) {
return;
}
- callback(new Error(error.message));
- });
- };
-
- const finishRequest = (response) => {
- (
- (requestParameters.type === 'arrayBuffer' || requestParameters.type === 'image') ? response.arrayBuffer() :
- requestParameters.type === 'json' ? response.json() :
- response.text()
- ).then(result => {
- if (aborted) return;
- complete = true;
- callback(null, result, response.headers.get('Cache-Control'), response.headers.get('Expires'));
- }).catch(err => {
- if (!aborted) callback(new Error(err.message));
- });
- };
-
- validateOrFetch(null, null);
-
- return {cancel: () => {
- aborted = true;
- if (!complete) controller.abort();
- }};
-}
-
-function makeXMLHttpRequest(requestParameters: RequestParameters, callback: ResponseCallback): Cancelable {
- const xhr: XMLHttpRequest = new XMLHttpRequest();
-
- xhr.open(requestParameters.method || 'GET', requestParameters.url, true);
- if (requestParameters.type === 'arrayBuffer' || requestParameters.type === 'image') {
- xhr.responseType = 'arraybuffer';
- }
- for (const k in requestParameters.headers) {
- xhr.setRequestHeader(k, requestParameters.headers[k]);
- }
- if (requestParameters.type === 'json') {
- xhr.responseType = 'text';
- xhr.setRequestHeader('Accept', 'application/json');
- }
- xhr.withCredentials = requestParameters.credentials === 'include';
- xhr.onerror = () => {
- callback(new Error(xhr.statusText));
- };
- xhr.onload = () => {
- if (((xhr.status >= 200 && xhr.status < 300) || xhr.status === 0) && xhr.response !== null) {
- let data: unknown = xhr.response;
- if (requestParameters.type === 'json') {
- // We're manually parsing JSON here to get better error messages.
- try {
- data = JSON.parse(xhr.response);
- } catch (err) {
- return callback(err);
+ if (((xhr.status >= 200 && xhr.status < 300) || xhr.status === 0) && xhr.response !== null) {
+ let data: unknown = xhr.response;
+ if (requestParameters.type === 'json') {
+ // We're manually parsing JSON here to get better error messages.
+ try {
+ data = JSON.parse(xhr.response);
+ } catch (err) {
+ reject(err);
+ return;
+ }
}
+ resolve({data, cacheControl: xhr.getResponseHeader('Cache-Control'), expires: xhr.getResponseHeader('Expires')});
+ } else {
+ const body = new Blob([xhr.response], {type: xhr.getResponseHeader('Content-Type')});
+ reject(new AJAXError(xhr.status, xhr.statusText, requestParameters.url, body));
}
- callback(null, data, xhr.getResponseHeader('Cache-Control'), xhr.getResponseHeader('Expires'));
- } else {
- const body = new Blob([xhr.response], {type: xhr.getResponseHeader('Content-Type')});
- callback(new AJAXError(xhr.status, xhr.statusText, requestParameters.url, body));
- }
- };
- xhr.send(requestParameters.body);
- return {cancel: () => xhr.abort()};
+ };
+ abortController.signal.addEventListener('abort', () => {
+ xhr.abort();
+ reject(createAbortError());
+ });
+ xhr.send(requestParameters.body);
+ });
}
-export const makeRequest = function(requestParameters: RequestParameters, callback: ResponseCallback): Cancelable {
- // We're trying to use the Fetch API if possible. However, in some situations we can't use it:
- // - IE11 doesn't support it at all. In this case, we dispatch the request to the main thread so
- // that we can get an accruate referrer header.
- // - Safari exposes window.AbortController, but it doesn't work actually abort any requests in
- // some versions (see https://bugs.webkit.org/show_bug.cgi?id=174980#c2)
- // - Requests for resources with the file:// URI scheme don't work with the Fetch API either. In
- // this case we unconditionally use XHR on the current thread since referrers don't matter.
+/**
+ * We're trying to use the Fetch API if possible. However, requests for resources with the file:// URI scheme don't work with the Fetch API.
+ * In this case we unconditionally use XHR on the current thread since referrers don't matter.
+ * This method can also use the registered method if `addProtocol` was called.
+ * @param requestParameters - The request parameters
+ * @param abortController - The abort controller allowing to cancel the request
+ * @returns a promise resolving to the response, including cache control and expiry data
+ */
+export const makeRequest = function(requestParameters: RequestParameters, abortController: AbortController): Promise> {
if (/:\/\//.test(requestParameters.url) && !(/^https?:|^file:/.test(requestParameters.url))) {
- if (isWorker() && (self as any).worker && (self as any).worker.actor) {
- return (self as any).worker.actor.send('getResource', requestParameters, callback);
+ if (isWorker(self) && self.worker && self.worker.actor) {
+ return self.worker.actor.sendAsync({type: 'getResource', data: requestParameters}, abortController);
}
- if (!isWorker()) {
- const action = getProtocolAction(requestParameters.url) || makeFetchRequest;
- return action(requestParameters, callback);
+ if (!isWorker(self) && getProtocolAction(requestParameters.url)) {
+ return promiseFromAddProtocolCallback(getProtocolAction(requestParameters.url))(requestParameters, abortController);
}
}
if (!isFileURL(requestParameters.url)) {
if (fetch && Request && AbortController && Object.prototype.hasOwnProperty.call(Request.prototype, 'signal')) {
- return makeFetchRequest(requestParameters, callback);
+ return silenceOnAbort(makeFetchRequest(requestParameters, abortController), abortController);
}
- if (isWorker() && (self as any).worker && (self as any).worker.actor) {
- const queueOnMainThread = true;
- return (self as any).worker.actor.send('getResource', requestParameters, callback, undefined, queueOnMainThread);
+ if (isWorker(self) && self.worker && self.worker.actor) {
+ return self.worker.actor.sendAsync({type: 'getResource', data: requestParameters, mustQueue: true}, abortController);
}
}
- return makeXMLHttpRequest(requestParameters, callback);
+ return makeXMLHttpRequest(requestParameters, abortController);
};
-export const getJSON = function(requestParameters: RequestParameters, callback: ResponseCallback): Cancelable {
- return makeRequest(extend(requestParameters, {type: 'json'}), callback);
-};
+// This needs to be removed in general, see #3308
+function silenceOnAbort(promise: Promise, abortController: AbortController): Promise {
+ return new Promise((resolve, reject) => {
+ promise
+ .then(result => { if (!abortController.signal.aborted) resolve(result); })
+ .catch(error => { if (!abortController.signal.aborted) reject(error); });
+ });
+}
+
+function promiseFromAddProtocolCallback(method: (requestParameters: RequestParameters, callback: ResponseCallback) => Cancelable): (requestParameters: RequestParameters, abortController: AbortController) => Promise> {
+ return (requestParameters: RequestParameters, abortController: AbortController): Promise> => {
+ return new Promise>((resolve, reject) => {
+ const callback = (err: Error, data: any, cacheControl: string | null, expires: string | null) => {
+ if (err) {
+ reject(err);
+ } else {
+ resolve({data, cacheControl, expires});
+ }
+ };
+ const canelable = method(requestParameters, callback);
+ abortController.signal.addEventListener('abort', () => {
+ canelable.cancel();
+ reject(createAbortError());
+ });
+ });
+ };
+}
-export const getArrayBuffer = function(
- requestParameters: RequestParameters,
- callback: ResponseCallback
-): Cancelable {
- return makeRequest(extend(requestParameters, {type: 'arrayBuffer'}), callback);
+export const getJSON = (requestParameters: RequestParameters, abortController: AbortController): Promise<{data: T} & ExpiryData> => {
+ return makeRequest(extend(requestParameters, {type: 'json'}), abortController);
};
-export const postData = function(requestParameters: RequestParameters, callback: ResponseCallback): Cancelable {
- return makeRequest(extend(requestParameters, {method: 'POST'}), callback);
+export const getArrayBuffer = (requestParameters: RequestParameters, abortController: AbortController): Promise<{data: ArrayBuffer} & ExpiryData> => {
+ return makeRequest(extend(requestParameters, {type: 'arrayBuffer'}), abortController);
};
export function sameOrigin(inComingUrl: string) {
- // URL class should be available everywhere
- // https://developer.mozilla.org/en-US/docs/Web/API/URL
- // In addtion, a relative URL "/foo" or "./foo" will throw exception in its ctor,
+ // A relative URL "/foo" or "./foo" will throw exception in URL's ctor,
// try-catch is expansive so just use a heuristic check to avoid it
// also check data URL
if (!inComingUrl ||
@@ -295,23 +293,21 @@ export function sameOrigin(inComingUrl: string) {
const locationObj = window.location;
return urlObj.protocol === locationObj.protocol && urlObj.host === locationObj.host;
}
-/**
- * A type used to store the tile's expiration date and cache control definition
- */
-export type ExpiryData = {cacheControl?: string | null; expires?: Date | string | null};
-export const getVideo = function(urls: Array, callback: Callback): Cancelable {
+
+export const getVideo = (urls: Array): Promise => {
const video: HTMLVideoElement = window.document.createElement('video');
video.muted = true;
- video.onloadstart = function() {
- callback(null, video);
- };
- for (let i = 0; i < urls.length; i++) {
- const s: HTMLSourceElement = window.document.createElement('source');
- if (!sameOrigin(urls[i])) {
- video.crossOrigin = 'Anonymous';
+ return new Promise((resolve) => {
+ video.onloadstart = () => {
+ resolve(video);
+ };
+ for (const url of urls) {
+ const s: HTMLSourceElement = window.document.createElement('source');
+ if (!sameOrigin(url)) {
+ video.crossOrigin = 'Anonymous';
+ }
+ s.src = url;
+ video.appendChild(s);
}
- s.src = urls[i];
- video.appendChild(s);
- }
- return {cancel: () => {}};
+ });
};
diff --git a/src/util/browser.ts b/src/util/browser.ts
index 6ba3b6f1e7..bf2c2a0e1d 100755
--- a/src/util/browser.ts
+++ b/src/util/browser.ts
@@ -1,3 +1,4 @@
+import {createAbortError} from './abort_error';
import type {Cancelable} from '../types/cancelable';
const now = typeof performance !== 'undefined' && performance && performance.now ?
@@ -21,6 +22,16 @@ export const browser = {
return {cancel: () => cancelAnimationFrame(frame)};
},
+ frameAsync(abortController: AbortController): Promise {
+ return new Promise((resolve, reject) => {
+ const frame = requestAnimationFrame(resolve);
+ abortController.signal.addEventListener('abort', () => {
+ cancelAnimationFrame(frame);
+ reject(createAbortError());
+ });
+ });
+ },
+
getImageData(img: HTMLImageElement | ImageBitmap, padding: number = 0): ImageData {
const context = this.getImageCanvasContext(img);
return context.getImageData(-padding, -padding, img.width as number + 2 * padding, img.height as number + 2 * padding);
diff --git a/src/util/dispatcher.test.ts b/src/util/dispatcher.test.ts
index c1e78a0dee..7c3fe66c6b 100644
--- a/src/util/dispatcher.test.ts
+++ b/src/util/dispatcher.test.ts
@@ -17,7 +17,7 @@ describe('Dispatcher', () => {
}
} as any as WorkerPool;
- const dispatcher = new Dispatcher(workerPool, {} as any, mapId);
+ const dispatcher = new Dispatcher(workerPool, mapId);
expect(dispatcher.actors.map((actor) => { return actor.target; })).toEqual(workers);
dispatcher.remove();
expect(dispatcher.actors).toHaveLength(0);
@@ -42,7 +42,7 @@ describe('Dispatcher', () => {
}
} as any as WorkerPool;
- let dispatcher = new Dispatcher(workerPool, {} as any, mapId);
+ let dispatcher = new Dispatcher(workerPool, mapId);
expect(dispatcher.actors.map((actor) => { return actor.target; })).toEqual(workers);
// Remove dispatcher, but map is not disposed (During style change)
@@ -51,7 +51,7 @@ describe('Dispatcher', () => {
expect(releaseCalled).toHaveLength(0);
// Create new instance of dispatcher
- dispatcher = new Dispatcher(workerPool, {} as any, mapId);
+ dispatcher = new Dispatcher(workerPool, mapId);
expect(dispatcher.actors.map((actor) => { return actor.target; })).toEqual(workers);
dispatcher.remove(true); // mapRemoved = true
expect(dispatcher.actors).toHaveLength(0);
@@ -67,7 +67,7 @@ describe('Dispatcher', () => {
WorkerPool.workerCount = 4;
const workerPool = new WorkerPool();
- const dispatcher = new Dispatcher(workerPool, {} as any, mapId);
+ const dispatcher = new Dispatcher(workerPool, mapId);
dispatcher.remove();
expect(actorsRemoved).toHaveLength(4);
});
diff --git a/src/util/dispatcher.ts b/src/util/dispatcher.ts
index fd9b15dc42..70baa75fd8 100644
--- a/src/util/dispatcher.ts
+++ b/src/util/dispatcher.ts
@@ -1,8 +1,8 @@
-import {asyncAll} from './util';
-import {Actor, GlyphsProvider, MessageType} from './actor';
+import {Actor, MessageHandler} from './actor';
import type {WorkerPool} from './worker_pool';
import type {WorkerSource} from '../source/worker_source'; /* eslint-disable-line */ // this is used for the docs' import
+import type {MessageType, RequestResponseMessageMap} from './actor_messages';
/**
* Responsible for sending messages from a {@link Source} to an associated
* {@link WorkerSource}.
@@ -13,7 +13,7 @@ export class Dispatcher {
currentActor: number;
id: string | number;
- constructor(workerPool: WorkerPool, parent: GlyphsProvider, mapId: string | number) {
+ constructor(workerPool: WorkerPool, mapId: string | number) {
this.workerPool = workerPool;
this.actors = [];
this.currentActor = 0;
@@ -21,7 +21,7 @@ export class Dispatcher {
const workers = this.workerPool.acquire(mapId);
for (let i = 0; i < workers.length; i++) {
const worker = workers[i];
- const actor = new Actor(worker, parent, mapId);
+ const actor = new Actor(worker, mapId);
actor.name = `Worker ${i}`;
this.actors.push(actor);
}
@@ -31,11 +31,12 @@ export class Dispatcher {
/**
* Broadcast a message to all Workers.
*/
- broadcast(type: MessageType, data: unknown, cb?: (...args: any[]) => any) {
- cb = cb || function () {};
- asyncAll(this.actors, (actor, done) => {
- actor.send(type, data, done);
- }, cb);
+ broadcast(type: T, data: RequestResponseMessageMap[T][0]): Promise {
+ const promises: Promise[] = [];
+ for (const actor of this.actors) {
+ promises.push(actor.sendAsync({type, data}));
+ }
+ return Promise.all(promises);
}
/**
@@ -52,4 +53,10 @@ export class Dispatcher {
this.actors = [];
if (mapRemoved) this.workerPool.release(this.id);
}
+
+ public registerMessageHandler(type: T, handler: MessageHandler) {
+ for (const actor of this.actors) {
+ actor.registerMessageHandler(type, handler);
+ }
+ }
}
diff --git a/src/util/geolocation_support.test.ts b/src/util/geolocation_support.test.ts
index 7641a40471..45f0ff6106 100644
--- a/src/util/geolocation_support.test.ts
+++ b/src/util/geolocation_support.test.ts
@@ -5,51 +5,39 @@ describe('checkGeolocationSupport', () => {
jest.resetModules();
});
- test('it should return false if geolocation is not defined', done => {
- checkGeolocationSupport((returnValue: boolean) => {
- expect(returnValue).toBeFalsy();
- done();
- });
+ test('it should return false if geolocation is not defined', async () => {
+ await expect(checkGeolocationSupport()).resolves.toBeFalsy();
});
- test('it should return the cached value on second call', done => {
- checkGeolocationSupport((returnValue) => {
- expect(returnValue).toBeFalsy();
- (window.navigator as any).geolocation = {};
- checkGeolocationSupport((rv) => {
- expect(rv).toBe(returnValue);
- done();
- });
- });
+ test('it should return the cached value on second call', async () => {
+ const returnValue = await checkGeolocationSupport();
+ expect(returnValue).toBeFalsy();
+ (window.navigator as any).geolocation = {};
+ const rv = await checkGeolocationSupport();
+ expect(rv).toBe(returnValue);
});
- test('it should return the true if geolocation is defined', done => {
+ test('it should return the true if geolocation is defined', async () => {
(window.navigator as any).geolocation = {};
- checkGeolocationSupport((returnValue) => {
- expect(returnValue).toBeTruthy();
- done();
- }, true);
+ const returnValue = await checkGeolocationSupport(true);
+ expect(returnValue).toBeTruthy();
});
- test('it should check permissions if possible', done => {
+ test('it should check permissions if possible', async () => {
(window.navigator as any).geolocation = {};
(window.navigator as any).permissions = {
query: () => Promise.resolve({state: 'granted'})
};
- checkGeolocationSupport((returnValue) => {
- expect(returnValue).toBeTruthy();
- done();
- }, true);
+ const returnValue = await checkGeolocationSupport(true);
+ expect(returnValue).toBeTruthy();
});
- test('it should check permissions and geolocation for iOS 16 promise rejection', done => {
+ test('it should check permissions and geolocation for iOS 16 promise rejection', async () => {
(window.navigator as any).geolocation = undefined;
(window.navigator as any).permissions = {
query: () => Promise.reject(new Error('pemissions error'))
};
- checkGeolocationSupport((returnValue) => {
- expect(returnValue).toBeFalsy();
- done();
- }, true);
+ const returnValue = await checkGeolocationSupport(true);
+ expect(returnValue).toBeFalsy();
});
});
diff --git a/src/util/geolocation_support.ts b/src/util/geolocation_support.ts
index d64940a0a0..e0da3c3f94 100644
--- a/src/util/geolocation_support.ts
+++ b/src/util/geolocation_support.ts
@@ -1,24 +1,23 @@
let supportsGeolocation;
-export function checkGeolocationSupport(callback: (supported: boolean) => void, forceRecalculation = false): void {
+export async function checkGeolocationSupport(forceRecalculation = false): Promise {
if (supportsGeolocation !== undefined && !forceRecalculation) {
- callback(supportsGeolocation);
- } else if (window.navigator.permissions !== undefined) {
- // navigator.permissions has incomplete browser support
- // http://caniuse.com/#feat=permissions-api
- // Test for the case where a browser disables Geolocation because of an
- // insecure origin
- window.navigator.permissions.query({name: 'geolocation'}).then((p) => {
- supportsGeolocation = p.state !== 'denied';
- callback(supportsGeolocation);
- }).catch(() => {
- // Fix for iOS16 which rejects query but still supports geolocation
- supportsGeolocation = !!window.navigator.geolocation;
- callback(supportsGeolocation);
- });
-
- } else {
+ return supportsGeolocation;
+ }
+ if (window.navigator.permissions === undefined) {
supportsGeolocation = !!window.navigator.geolocation;
- callback(supportsGeolocation);
+ return supportsGeolocation;
+ }
+ // navigator.permissions has incomplete browser support
+ // http://caniuse.com/#feat=permissions-api
+ // Test for the case where a browser disables Geolocation because of an
+ // insecure origin
+ try {
+ const permissions = await window.navigator.permissions.query({name: 'geolocation'});
+ supportsGeolocation = permissions.state !== 'denied'; // eslint-disable-line require-atomic-updates
+ } catch {
+ // Fix for iOS16 which rejects query but still supports geolocation
+ supportsGeolocation = !!window.navigator.geolocation; // eslint-disable-line require-atomic-updates
}
+ return supportsGeolocation;
}
diff --git a/src/util/image.ts b/src/util/image.ts
index 27b13c815d..b3878dc06b 100644
--- a/src/util/image.ts
+++ b/src/util/image.ts
@@ -82,7 +82,6 @@ function copyImage(srcImg: any, dstImg: any, srcPt: Point2D, dstPt: Point2D, siz
}
/**
- * @internal
* An image with alpha color value
*/
export class AlphaImage {
diff --git a/src/util/image_request.test.ts b/src/util/image_request.test.ts
index b1f9724883..93ffca8252 100644
--- a/src/util/image_request.test.ts
+++ b/src/util/image_request.test.ts
@@ -1,8 +1,9 @@
import {config} from './config';
import {webpSupported} from './webp_supported';
-import {stubAjaxGetImage} from './test/util';
+import {sleep, stubAjaxGetImage} from './test/util';
import {fakeServer, type FakeServer} from 'nise';
-import {ImageRequest, ImageRequestQueueItem} from './image_request';
+import {ImageRequest} from './image_request';
+import {isAbortError} from './abort_error';
import * as ajax from './ajax';
describe('ImageRequest', () => {
@@ -17,73 +18,86 @@ describe('ImageRequest', () => {
server.restore();
});
- test('getImage respects maxParallelImageRequests', done => {
+ test('getImage respects maxParallelImageRequests', async () => {
server.respondWith(request => request.respond(200, {'Content-Type': 'image/png'}, ''));
const maxRequests = config.MAX_PARALLEL_IMAGE_REQUESTS;
- let callbackCount = 0;
- function callback(err) {
- if (err) return;
- // last request is only added after we got a response from one of the previous ones
- expect(server.requests).toHaveLength(maxRequests + callbackCount);
- callbackCount++;
- if (callbackCount === 2) {
- done();
- }
- }
- for (let i = 0; i < maxRequests + 1; i++) {
- ImageRequest.getImage({url: ''}, callback);
+ const promises: Promise[] = [];
+ for (let i = 0; i < maxRequests + 5; i++) {
+ promises.push(ImageRequest.getImage({url: ''}, new AbortController()));
+
}
expect(server.requests).toHaveLength(maxRequests);
- server.requests[0].respond(undefined, undefined, undefined);
- server.requests[1].respond(undefined, undefined, undefined);
+ server.requests[0].respond(200);
+ await promises[0];
+ expect(server.requests).toHaveLength(maxRequests + 1);
+ server.requests[1].respond(200);
+ await promises[1];
+ expect(server.requests).toHaveLength(maxRequests + 2);
+ });
+
+ test('getImage respects maxParallelImageRequests and continues to respond even when server returns 404', async () => {
+ server.respondWith(request => request.respond(404));
+
+ const maxRequests = config.MAX_PARALLEL_IMAGE_REQUESTS;
+
+ for (let i = 0; i < maxRequests + 5; i++) {
+ ImageRequest.getImage({url: ''}, new AbortController()).catch(() => {});
+ }
+ expect(server.requests).toHaveLength(maxRequests);
+ server.respond();
+ await sleep(0);
+ expect(server.requests).toHaveLength(maxRequests + 5);
});
- test('Cancel: getImage cancelling frees up request for maxParallelImageRequests', done => {
- server.respondWith(request => request.respond(200, {'Content-Type': 'image/png'}, ''));
+ test('Cancel: getImage cancelling frees up request for maxParallelImageRequests', async () => {
const maxRequests = config.MAX_PARALLEL_IMAGE_REQUESTS;
for (let i = 0; i < maxRequests + 1; i++) {
- ImageRequest.getImage({url: ''}, () => done('test failed: getImage callback was called')).cancel();
+ const abortController = new AbortController();
+ ImageRequest.getImage({url: ''}, abortController).catch((e) => expect(isAbortError(e)).toBeTruthy());
+ abortController.abort();
+ await sleep(0);
}
expect(server.requests).toHaveLength(maxRequests + 1);
- done();
});
- test('Cancel: getImage requests that were once queued are still abortable', done => {
+ test('Cancel: getImage requests that were once queued are still abortable', async () => {
const maxRequests = config.MAX_PARALLEL_IMAGE_REQUESTS;
- const requests = [];
+ const abortControllers: AbortController[] = [];
for (let i = 0; i < maxRequests; i++) {
- requests.push(ImageRequest.getImage({url: ''}, () => {}));
+ const abortController = new AbortController();
+ abortControllers.push(abortController);
+ ImageRequest.getImage({url: ''}, abortController).catch(() => {});
}
// the limit of allowed requests is reached
expect(server.requests).toHaveLength(maxRequests);
const queuedURL = 'this-is-the-queued-request';
- const queued = ImageRequest.getImage({url: queuedURL}, () => done('test failed: getImage callback was called'));
+ const abortController = new AbortController();
+ ImageRequest.getImage({url: queuedURL}, abortController).catch((e) => expect(isAbortError(e)).toBeTruthy());
// the new requests is queued because the limit is reached
expect(server.requests).toHaveLength(maxRequests);
// cancel the first request to let the queued request start
- requests[0].cancel();
+ abortControllers[0].abort();
+ await sleep(0);
expect(server.requests).toHaveLength(maxRequests + 1);
// abort the previously queued request and confirm that it is aborted
const queuedRequest = server.requests[server.requests.length - 1];
expect(queuedRequest.url).toBe(queuedURL);
expect((queuedRequest as any).aborted).toBeUndefined();
- queued.cancel();
+ abortController.abort();
expect((queuedRequest as any).aborted).toBe(true);
-
- done();
});
- test('getImage sends accept/webp when supported', done => {
+ test('getImage sends accept/webp when supported', async () => {
server.respondWith((request) => {
expect(request.requestHeaders.accept.includes('image/webp')).toBeTruthy();
request.respond(200, {'Content-Type': 'image/webp'}, '');
@@ -92,133 +106,123 @@ describe('ImageRequest', () => {
// mock webp support
webpSupported.supported = true;
- ImageRequest.getImage({url: ''}, () => { done(); });
+ const promise = ImageRequest.getImage({url: ''}, new AbortController());
server.respond();
+
+ await expect(promise).resolves.toBeDefined();
});
- test('getImage uses createImageBitmap when supported', done => {
+ test('getImage uses createImageBitmap when supported', async () => {
server.respondWith(request => request.respond(200, {'Content-Type': 'image/png',
'Cache-Control': 'cache',
'Expires': 'expires'}, ''));
stubAjaxGetImage(() => Promise.resolve(new ImageBitmap()));
+ const promise = ImageRequest.getImage({url: ''}, new AbortController());
+ server.respond();
- ImageRequest.getImage({url: ''}, (err, img, expiry) => {
- if (err) done(err);
- expect(img).toBeInstanceOf(ImageBitmap);
- expect(expiry.cacheControl).toBe('cache');
- expect(expiry.expires).toBe('expires');
- done();
- });
+ const response = await promise;
- server.respond();
+ expect(response.data).toBeInstanceOf(ImageBitmap);
+ expect(response.cacheControl).toBe('cache');
+ expect(response.expires).toBe('expires');
});
- test('getImage using createImageBitmap throws exception', done => {
+ test('getImage using createImageBitmap throws exception', async () => {
server.respondWith(request => request.respond(200, {'Content-Type': 'image/png',
'Cache-Control': 'cache',
'Expires': 'expires'}, ''));
stubAjaxGetImage(() => Promise.reject(new Error('error')));
- ImageRequest.getImage({url: ''}, (err, img) => {
- expect(img).toBeFalsy();
- if (err) done();
- });
+ const promise = ImageRequest.getImage({url: ''}, new AbortController());
server.respond();
+
+ await expect(promise).rejects.toThrow();
});
- test('getImage uses HTMLImageElement when createImageBitmap is not supported', done => {
+ test('getImage uses HTMLImageElement when createImageBitmap is not supported', async () => {
const makeRequestSky = jest.spyOn(ajax, 'makeRequest');
server.respondWith(request => request.respond(200, {'Content-Type': 'image/png',
'Cache-Control': 'cache',
'Expires': 'expires'}, ''));
- ImageRequest.getImage({url: ''}, (err, img, expiry) => {
- if (err) done(`get image failed with error ${err.message}`);
- expect(img).toBeInstanceOf(HTMLImageElement);
- expect(expiry.cacheControl).toBe('cache');
- expect(expiry.expires).toBe('expires');
- done();
- });
+ const promise = ImageRequest.getImage({url: ''}, new AbortController());
server.respond();
expect(makeRequestSky).toHaveBeenCalledTimes(1);
makeRequestSky.mockClear();
+ const response = await promise;
+ expect(response.data).toBeInstanceOf(HTMLImageElement);
+ expect(response.cacheControl).toBe('cache');
+ expect(response.expires).toBe('expires');
});
- test('getImage using HTMLImageElement with same-origin credentials', done => {
+ test('getImage using HTMLImageElement with same-origin credentials', async () => {
const makeRequestSky = jest.spyOn(ajax, 'makeRequest');
- ImageRequest.getImage({url: '', credentials: 'same-origin'}, (err, img: HTMLImageElement) => {
- if (err) done(err);
- expect(img).toBeInstanceOf(HTMLImageElement);
- expect(img.crossOrigin).toBe('anonymous');
- done();
- }, false);
+ const promise = ImageRequest.getImage({url: '', credentials: 'same-origin'}, new AbortController(), false);
expect(makeRequestSky).toHaveBeenCalledTimes(0);
makeRequestSky.mockClear();
+
+ const response = await promise;
+
+ expect(response.data).toBeInstanceOf(HTMLImageElement);
+ expect((response.data as HTMLImageElement).crossOrigin).toBe('anonymous');
});
- test('getImage using HTMLImageElement with include credentials', done => {
+ test('getImage using HTMLImageElement with include credentials', async () => {
const makeRequestSky = jest.spyOn(ajax, 'makeRequest');
- ImageRequest.getImage({url: '', credentials: 'include'}, (err, img: HTMLImageElement) => {
- if (err) done(err);
- expect(img).toBeInstanceOf(HTMLImageElement);
- expect(img.crossOrigin).toBe('use-credentials');
- done();
- }, false);
+ const promise = ImageRequest.getImage({url: '', credentials: 'include'}, new AbortController(), false);
expect(makeRequestSky).toHaveBeenCalledTimes(0);
makeRequestSky.mockClear();
+
+ const response = await promise;
+
+ expect(response.data).toBeInstanceOf(HTMLImageElement);
+ expect((response.data as HTMLImageElement).crossOrigin).toBe('use-credentials');
});
- test('getImage using HTMLImageElement with accept header', done => {
+ test('getImage using HTMLImageElement with accept header', async () => {
const makeRequestSky = jest.spyOn(ajax, 'makeRequest');
- ImageRequest.getImage({url: '', credentials: 'include', headers: {accept: 'accept'}},
- (err, img: HTMLImageElement) => {
- if (err) done(err);
- expect(img).toBeInstanceOf(HTMLImageElement);
- expect(img.crossOrigin).toBe('use-credentials');
- done();
- }, false);
+ const promise = ImageRequest.getImage({url: '', credentials: 'include', headers: {accept: 'accept'}}, new AbortController(), false);
expect(makeRequestSky).toHaveBeenCalledTimes(0);
makeRequestSky.mockClear();
+
+ const response = await promise;
+ expect(response.data).toBeInstanceOf(HTMLImageElement);
+ expect((response.data as HTMLImageElement).crossOrigin).toBe('use-credentials');
});
test('getImage uses makeRequest when custom Headers are added', () => {
const makeRequestSky = jest.spyOn(ajax, 'makeRequest');
- ImageRequest.getImage({url: '', credentials: 'include', headers: {custom: 'test', accept: 'image'}},
- () => {},
- false);
+ ImageRequest.getImage({url: '', credentials: 'include', headers: {custom: 'test', accept: 'image'}}, new AbortController(), false);
expect(makeRequestSky).toHaveBeenCalledTimes(1);
makeRequestSky.mockClear();
});
- test('getImage request returned 404 response for fetch request', done => {
+ test('getImage request returned 404 response for fetch request', async () => {
server.respondWith(request => request.respond(404));
- ImageRequest.getImage({url: ''}, (err) => {
- if (err) done();
- else done('Image download should have failed');
- });
+ const promise = ImageRequest.getImage({url: ''}, new AbortController());
server.respond();
+
+ await expect(promise).rejects.toThrow('Not Found');
});
- test('getImage request failed for HTTPImageRequest', done => {
- ImageRequest.getImage({url: 'error'}, (err) => {
- if (err) done();
- else done('Image download should have failed');
- }, false);
+ test('getImage request failed for HTTPImageRequest', async () => {
+ const promise = ImageRequest.getImage({url: 'error'}, new AbortController(), false);
+ await expect(promise).rejects.toThrow(/Could not load image.*/);
});
- test('Cancel: getImage request cancelled for HTTPImageRequest', done => {
+ test('Cancel: getImage request cancelled for HTTPImageRequest', async () => {
let imageUrl;
const requestUrl = 'test';
// eslint-disable-next-line accessor-pairs
@@ -228,51 +232,50 @@ describe('ImageRequest', () => {
}
});
- const request = ImageRequest.getImage({url: requestUrl}, () => {
- done('Callback should not be called in case image request is cancelled');
- }, false);
+ const abortController = new AbortController();
+ ImageRequest.getImage({url: requestUrl}, abortController, false).catch(() => {});
expect(imageUrl).toBe(requestUrl);
- expect(request.cancelled).toBeFalsy();
- request.cancel();
- expect(request.cancelled).toBeTruthy();
+ expect(abortController.signal.aborted).toBeFalsy();
+ abortController.abort();
+ expect(abortController.signal.aborted).toBeTruthy();
expect(imageUrl).toBe('');
- done();
});
- test('Cancel: getImage request cancelled', done => {
+ test('Cancel: getImage request cancelled', async () => {
server.respondWith(request => request.respond(200, {'Content-Type': 'image/png',
'Cache-Control': 'cache',
'Expires': 'expires'}, ''));
- const request = ImageRequest.getImage({url: ''}, () => {
- done('Callback should not be called in case image request is cancelled');
- });
+ const abortController = new AbortController();
+ let response = false;
+ ImageRequest.getImage({url: ''}, abortController)
+ .then(() => { response = true; })
+ .catch(() => { response = true; });
- expect(request.cancelled).toBeFalsy();
- request.cancel();
- expect(request.cancelled).toBeTruthy();
+ abortController.abort();
server.respond();
- done();
+
+ expect(response).toBeFalsy();
});
- test('Cancel: Cancellation of an image which has not yet been requested', () => {
+ test('Cancel: Cancellation of an image which has not yet been requested', async () => {
const maxRequests = config.MAX_PARALLEL_IMAGE_REQUESTS;
let callbackCounter = 0;
- function callback() {
- callbackCounter++;
- }
- const requests: ImageRequestQueueItem[] = [];
+ const promiseCallback = () => { callbackCounter++; };
+
+ const abortConstollers: {url: string; abortController: AbortController}[] = [];
for (let i = 0; i < maxRequests + 100; i++) {
- requests.push(ImageRequest.getImage({url: `${i}`}, callback));
+ const url = `${i}`;
+ const abortController = new AbortController();
+ abortConstollers.push({url, abortController});
+ ImageRequest.getImage({url}, abortController).then(promiseCallback).catch(() => {});
}
- // Request should have been initiated
- expect(requests[0].innerRequest).toBeDefined();
- requests[0].cancel();
-
+ abortConstollers[0].abortController.abort();
+ await sleep(0);
// Queue should move forward and next request is made
expect(server.requests).toHaveLength(maxRequests + 1);
@@ -280,10 +283,9 @@ describe('ImageRequest', () => {
expect(callbackCounter).toBe(0);
// Cancel request which is not yet issued. It should not fire callback
- const nextRequestInQueue = requests[server.requests.length];
- expect(nextRequestInQueue.innerRequest).toBeUndefined();
- const cancelledImageUrl = nextRequestInQueue.requestParameters.url;
- nextRequestInQueue.cancel();
+ const nextRequestInQueue = abortConstollers[server.requests.length];
+ const cancelledImageUrl = nextRequestInQueue.url;
+ nextRequestInQueue.abortController.abort();
// Queue should not move forward as cancelled image was sitting in queue
expect(server.requests).toHaveLength(maxRequests + 1);
@@ -291,6 +293,7 @@ describe('ImageRequest', () => {
// On server response, next image queued should not be the cancelled image
server.requests[1].respond(200);
+ await sleep(0);
expect(callbackCounter).toBe(1);
expect(server.requests).toHaveLength(maxRequests + 2);
// Verify that the last request made skipped the cancelled image request
@@ -306,12 +309,10 @@ describe('ImageRequest', () => {
callbackHandles.push(ImageRequest.addThrottleControl(() => true));
let callbackCounter = 0;
- function callback() {
- callbackCounter++;
- }
+ const promiseCallback = () => { callbackCounter++; };
for (let i = 0; i < maxRequestsPerFrame + 1; i++) {
- ImageRequest.getImage({url: ''}, callback);
+ ImageRequest.getImage({url: ''}, new AbortController()).then(promiseCallback);
}
expect(server.requests).toHaveLength(maxRequestsPerFrame);
@@ -330,12 +331,10 @@ describe('ImageRequest', () => {
const controlId = ImageRequest.addThrottleControl(() => true);
let callbackCounter = 0;
- function callback() {
- callbackCounter++;
- }
+ const promiseCallback = () => { callbackCounter++; };
for (let i = 0; i < maxRequests; i++) {
- ImageRequest.getImage({url: ''}, callback);
+ ImageRequest.getImage({url: ''}, new AbortController()).then(promiseCallback);
}
// Should only fire request to a max allowed per frame
@@ -352,12 +351,10 @@ describe('ImageRequest', () => {
const controlId = ImageRequest.addThrottleControl(() => false);
let callbackCounter = 0;
- function callback() {
- callbackCounter++;
- }
+ const promiseCallback = () => { callbackCounter++; };
for (let i = 0; i < maxRequests + 100; i++) {
- ImageRequest.getImage({url: ''}, callback);
+ ImageRequest.getImage({url: ''}, new AbortController()).then(promiseCallback);
}
// all should be processed because throttle control is returning false
@@ -369,7 +366,7 @@ describe('ImageRequest', () => {
ImageRequest.removeThrottleControl(controlId);
});
- test('throttling: removing throttling client will process all requests', () => {
+ test('throttling: removing throttling client will process all requests', async () => {
const requestParameter = {'Content-Type': 'image/png', url: ''};
const maxRequestsPerFrame = config.MAX_PARALLEL_IMAGE_REQUESTS_PER_FRAME;
@@ -380,16 +377,13 @@ describe('ImageRequest', () => {
ImageRequest.addThrottleControl(() => throttlingClient);
}
- let callbackCounter = 0;
- function callback() {
- callbackCounter++;
- }
-
// make 2 times + 1 more requests
const requestsMade = 2 * maxRequestsPerFrame + 1;
- const imageResults: ImageRequestQueueItem[] = [];
+ const completedMap: {[index: number]: boolean} = {};
for (let i = 0; i < requestsMade; i++) {
- imageResults.push(ImageRequest.getImage(requestParameter, callback));
+ const promise = ImageRequest.getImage(requestParameter, new AbortController());
+ promise.catch(() => {});
+ promise.then(() => { completedMap[i] = true; });
}
// up to the config value
@@ -400,14 +394,15 @@ describe('ImageRequest', () => {
// unleash it by removing the throttling client
ImageRequest.removeThrottleControl(throttlingIndex);
+ await sleep(0);
expect(server.requests).toHaveLength(requestsMade);
// all pending
- expect(callbackCounter).toBe(1);
+ expect(Object.keys(completedMap)).toHaveLength(1);
// everything should still be pending except itemIndexToComplete
for (let i = 0; i < maxRequestsPerFrame + 1; i++) {
- expect(imageResults[i].completed).toBe(i === itemIndexToComplete);
+ expect(completedMap[i]).toBe(i === itemIndexToComplete ? true : undefined);
}
});
});
diff --git a/src/util/image_request.ts b/src/util/image_request.ts
index e00b395e1f..c8c0163161 100644
--- a/src/util/image_request.ts
+++ b/src/util/image_request.ts
@@ -1,10 +1,9 @@
-import type {Cancelable} from '../types/cancelable';
-import {RequestParameters, ExpiryData, makeRequest, sameOrigin, getProtocolAction} from './ajax';
-import type {Callback} from '../types/callback';
+import {RequestParameters, ExpiryData, makeRequest, sameOrigin, getProtocolAction, GetResourceResponse} from './ajax';
import {arrayBufferToImageBitmap, arrayBufferToImage, extend, isWorker, isImageBitmap} from './util';
import {webpSupported} from './webp_supported';
import {config} from './config';
+import {createAbortError} from './abort_error';
/**
* The callback that is being called after an image was fetched
@@ -13,13 +12,13 @@ export type GetImageCallback = (error?: Error | null, image?: HTMLImageElement |
type ImageQueueThrottleControlCallback = () => boolean;
-export type ImageRequestQueueItem = Cancelable & {
+export type ImageRequestQueueItem = {
requestParameters: RequestParameters;
supportImageRefresh: boolean;
- callback: GetImageCallback;
- cancelled: boolean;
- completed: boolean;
- innerRequest?: Cancelable;
+ state: 'queued' | 'running' | 'completed';
+ abortController: AbortController;
+ onError: (error: Error) => void;
+ onSuccess: (response: GetResourceResponse) => void;
}
type ImageQueueThrottleCallbackDictionary = {
@@ -95,78 +94,60 @@ export namespace ImageRequest {
* @returns `true` if any callback is causing the queue to be throttled.
*/
const isThrottled = (): boolean => {
- const allControlKeys = Object.keys(throttleControlCallbacks);
- let throttleingRequested = false;
- if (allControlKeys.length > 0) {
- for (const key of allControlKeys) {
- throttleingRequested = throttleControlCallbacks[key]();
- if (throttleingRequested) {
- break;
- }
+ for (const key of Object.keys(throttleControlCallbacks)) {
+ if (throttleControlCallbacks[key]()) {
+ return true;
}
}
- return throttleingRequested;
+ return false;
};
/**
* Request to load an image.
* @param requestParameters - Request parameters.
- * @param callback - Callback to issue when the request completes.
+ * @param abortController - allows to abort the request.
* @param supportImageRefresh - `true`, if the image request need to support refresh based on cache headers.
- * @returns Cancelable request.
+ * @returns - A promise resolved when the image is loaded.
*/
- export const getImage = (
- requestParameters: RequestParameters,
- callback: GetImageCallback,
- supportImageRefresh: boolean = true
- ): ImageRequestQueueItem => {
- if (webpSupported.supported) {
- if (!requestParameters.headers) {
- requestParameters.headers = {};
- }
- requestParameters.headers.accept = 'image/webp,*/*';
- }
-
- const request:ImageRequestQueueItem = {
- requestParameters,
- supportImageRefresh,
- callback,
- cancelled: false,
- completed: false,
- cancel: () => {
- if (!request.completed && !request.cancelled) {
- request.cancelled = true;
-
- // Only reduce currentParallelImageRequests, if the image request was issued.
- if (request.innerRequest) {
- request.innerRequest.cancel();
- currentParallelImageRequests--;
- }
-
- // in the case of cancelling, it WILL move on
- processQueue();
+ export const getImage = (requestParameters: RequestParameters, abortController: AbortController, supportImageRefresh: boolean = true): Promise> => {
+ return new Promise>((resolve, reject) => {
+ if (webpSupported.supported) {
+ if (!requestParameters.headers) {
+ requestParameters.headers = {};
}
+ requestParameters.headers.accept = 'image/webp,*/*';
}
- };
+ extend(requestParameters, {type: 'image'});
+ const request: ImageRequestQueueItem = {
+ abortController,
+ requestParameters,
+ supportImageRefresh,
+ state: 'queued',
+ onError: (error: Error) => {
+ reject(error);
+ },
+ onSuccess: (response) => {
+ resolve(response);
+ }
+ };
- imageRequestQueue.push(request);
- processQueue();
- return request;
+ imageRequestQueue.push(request);
+ processQueue();
+ });
};
- const arrayBufferToCanvasImageSource = (data: ArrayBuffer, callback: Callback) => {
+ const arrayBufferToCanvasImageSource = (data: ArrayBuffer): Promise => {
const imageBitmapSupported = typeof createImageBitmap === 'function';
if (imageBitmapSupported) {
- arrayBufferToImageBitmap(data, callback);
+ return arrayBufferToImageBitmap(data);
} else {
- arrayBufferToImage(data, callback);
+ return arrayBufferToImage(data);
}
};
- const doImageRequest = (itemInQueue: ImageRequestQueueItem): Cancelable => {
- const {requestParameters, supportImageRefresh, callback} = itemInQueue;
- extend(requestParameters, {type: 'image'});
-
+ const doImageRequest = async (itemInQueue: ImageRequestQueueItem) => {
+ itemInQueue.state = 'running';
+ const {requestParameters, supportImageRefresh, onError, onSuccess, abortController} = itemInQueue;
// - If refreshExpiredTiles is false, then we can use HTMLImageElement to download raster images.
// - Fetch/XHR (via MakeRequest API) will be used to download images for following scenarios:
// 1. Style image sprite will had a issue with HTMLImageElement as described
@@ -177,49 +158,34 @@ export namespace ImageRequest {
// let makeRequest handle it.
// - HtmlImageElement request automatically adds accept header for all the browser supported images
const canUseHTMLImageElement = supportImageRefresh === false &&
- !isWorker() &&
+ !isWorker(self) &&
!getProtocolAction(requestParameters.url) &&
(!requestParameters.headers ||
Object.keys(requestParameters.headers).reduce((acc, item) => acc && item === 'accept', true));
- const action = canUseHTMLImageElement ? getImageUsingHtmlImage : makeRequest;
- return action(
- requestParameters,
- (err?: Error | null,
- data?: HTMLImageElement | ImageBitmap | ArrayBuffer | null,
- cacheControl?: string | null,
- expires?: string | null) => {
- onImageResponse(itemInQueue, callback, err, data, cacheControl, expires);
- });
- };
-
- const onImageResponse = (
- itemInQueue: ImageRequestQueueItem,
- callback:GetImageCallback,
- err?: Error | null,
- data?: HTMLImageElement | ImageBitmap | ArrayBuffer | null,
- cacheControl?: string | null,
- expires?: string | null): void => {
- if (err) {
- callback(err);
- } else if (data instanceof HTMLImageElement || isImageBitmap(data)) {
- // User using addProtocol can directly return HTMLImageElement/ImageBitmap type
- // If HtmlImageElement is used to get image then response type will be HTMLImageElement
- callback(null, data);
- } else if (data) {
- const decoratedCallback = (imgErr?: Error | null, imgResult?: CanvasImageSource | null) => {
- if (imgErr != null) {
- callback(imgErr);
- } else if (imgResult != null) {
- callback(null, imgResult as (HTMLImageElement | ImageBitmap), {cacheControl, expires});
- }
- };
- arrayBufferToCanvasImageSource(data, decoratedCallback);
- }
- if (!itemInQueue.cancelled) {
- itemInQueue.completed = true;
+ currentParallelImageRequests++;
+
+ const getImagePromise = canUseHTMLImageElement ?
+ getImageUsingHtmlImage(requestParameters, abortController) :
+ makeRequest(requestParameters, abortController);
+
+ try {
+ const response = await getImagePromise;
+ delete itemInQueue.abortController;
+ itemInQueue.state = 'completed';
+ if (response.data instanceof HTMLImageElement || isImageBitmap(response.data)) {
+ // User using addProtocol can directly return HTMLImageElement/ImageBitmap type
+ // If HtmlImageElement is used to get image then response type will be HTMLImageElement
+ onSuccess(response as GetResourceResponse);
+ } else if (response.data) {
+ const img = await arrayBufferToCanvasImageSource(response.data);
+ onSuccess({data: img, cacheControl: response.cacheControl, expires: response.expires});
+ }
+ } catch (err) {
+ delete itemInQueue.abortController;
+ onError(err);
+ } finally {
currentParallelImageRequests--;
-
processQueue();
}
};
@@ -239,49 +205,46 @@ export namespace ImageRequest {
numImageRequests++) {
const topItemInQueue: ImageRequestQueueItem = imageRequestQueue.shift();
- if (topItemInQueue.cancelled) {
+ if (topItemInQueue.abortController.signal.aborted) {
numImageRequests--;
continue;
}
-
- const innerRequest = doImageRequest(topItemInQueue);
-
- currentParallelImageRequests++;
-
- topItemInQueue.innerRequest = innerRequest;
+ doImageRequest(topItemInQueue);
}
};
- const getImageUsingHtmlImage = (requestParameters: RequestParameters, callback: GetImageCallback): Cancelable => {
- const image = new Image() as HTMLImageElementWithPriority;
- const url = requestParameters.url;
- let requestCancelled = false;
- const credentials = requestParameters.credentials;
- if (credentials && credentials === 'include') {
- image.crossOrigin = 'use-credentials';
- } else if ((credentials && credentials === 'same-origin') || !sameOrigin(url)) {
- image.crossOrigin = 'anonymous';
- }
+ const getImageUsingHtmlImage = (requestParameters: RequestParameters, abortController: AbortController): Promise> => {
+ return new Promise>((resolve, reject) => {
- image.fetchPriority = 'high';
- image.onload = () => {
- callback(null, image);
- image.onerror = image.onload = null;
- };
- image.onerror = () => {
- if (!requestCancelled) {
- callback(new Error('Could not load image. Please make sure to use a supported image type such as PNG or JPEG. Note that SVGs are not supported.'));
+ const image = new Image() as HTMLImageElementWithPriority;
+ const url = requestParameters.url;
+ const credentials = requestParameters.credentials;
+ if (credentials && credentials === 'include') {
+ image.crossOrigin = 'use-credentials';
+ } else if ((credentials && credentials === 'same-origin') || !sameOrigin(url)) {
+ image.crossOrigin = 'anonymous';
}
- image.onerror = image.onload = null;
- };
- image.src = url;
- return {
- cancel: () => {
- requestCancelled = true;
+
+ abortController.signal.addEventListener('abort', () => {
// Set src to '' to actually cancel the request
image.src = '';
- }
- };
+ reject(createAbortError());
+ });
+
+ image.fetchPriority = 'high';
+ image.onload = () => {
+ image.onerror = image.onload = null;
+ resolve({data: image});
+ };
+ image.onerror = () => {
+ image.onerror = image.onload = null;
+ if (abortController.signal.aborted) {
+ return;
+ }
+ reject(new Error('Could not load image. Please make sure to use a supported image type such as PNG or JPEG. Note that SVGs are not supported.'));
+ };
+ image.src = url;
+ });
};
}
diff --git a/src/util/task_queue.ts b/src/util/task_queue.ts
index b7389e9ed6..1718bee66d 100644
--- a/src/util/task_queue.ts
+++ b/src/util/task_queue.ts
@@ -1,4 +1,4 @@
-export type TaskID = number; // can't mark opaque due to https://github.com/flowtype/flow-remove-types/pull/61
+export type TaskID = number;
type Task = {
callback: (timeStamp: number) => void;
diff --git a/src/util/test/mock_fetch.ts b/src/util/test/mock_fetch.ts
deleted file mode 100644
index db6e7b13cc..0000000000
--- a/src/util/test/mock_fetch.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-export class RequestMock implements Partial {
- public readonly cache: RequestCache;
- public readonly headers: Headers = new Headers();
- public readonly method?: string;
- public readonly url?: string;
-
- public get signal(): AbortSignal {
- return null;
- }
-
- constructor(input: RequestInfo | URL, init?: RequestInit) {
- this.cache = typeof input === 'object' && 'cache' in input ? input.cache : init.cache;
- this.method = typeof input === 'object' && 'method' in input ? input.method : init.method;
- this.url = typeof input === 'object' && 'url' in input ? input.url : input.toString();
- this.headers = typeof input === 'object' && 'headers' in input ? new Headers(input.headers) : new Headers(init.headers || {});
- }
-}
-
-class AbortControllerMock {
- public signal: AbortSignal;
-
- public abort(): void {}
-}
-
-export type FetchMock = jest.Mock, [input: RequestInfo | URL, init?: RequestInit], any>;
-
-let _AbortController: typeof AbortController;
-let _Request: typeof Request;
-let _fetch: typeof fetch;
-
-export function destroyFetchMock(): void {
- global.AbortController = _AbortController ?? global.AbortController;
- global.Request = _Request ?? global.Request;
- global.fetch = _fetch ?? global.fetch;
-}
-
-export function setupFetchMock(): FetchMock {
- _AbortController = _AbortController ?? global.AbortController;
- _Request = _Request ?? global.Request;
- _fetch = _fetch ?? global.fetch;
-
- const fetchMock = jest.fn(async (_input: RequestInfo | URL, _init?: RequestInit): Promise => {
- return {};
- });
-
- global.AbortController = AbortControllerMock;
- global.Request = RequestMock as unknown as typeof Request;
- global.fetch = fetchMock as any;
-
- return fetchMock;
-}
diff --git a/src/util/test/util.ts b/src/util/test/util.ts
index 39e63097ad..75514e39a1 100644
--- a/src/util/test/util.ts
+++ b/src/util/test/util.ts
@@ -2,6 +2,8 @@ import {Map} from '../../ui/map';
import {extend} from '../../util/util';
import {Dispatcher} from '../../util/dispatcher';
import {setWebGlContext} from './mock_webgl';
+import {IActor} from '../actor';
+import type {Evented} from '../evented';
export function createMap(options?, callback?) {
const container = window.document.createElement('div');
@@ -96,10 +98,10 @@ export function beforeMapTest() {
}
export function getWrapDispatcher() {
- const wrapDispatcher = (dispatcher) => {
+ const wrapDispatcher = (actor: IActor) => {
return {
getActor() {
- return dispatcher;
+ return actor;
}
} as any as Dispatcher;
};
@@ -111,7 +113,7 @@ export function getMockDispatcher() {
const wrapDispatcher = getWrapDispatcher();
const mockDispatcher = wrapDispatcher({
- send() {}
+ sendAsync() { return Promise.resolve({}); },
});
return mockDispatcher;
@@ -128,7 +130,9 @@ export function stubAjaxGetImage(createImageBitmap) {
set(url: string) {
if (url === 'error') {
this.onerror();
- } else this.onload();
+ } else if (this.onload) {
+ this.onload();
+ }
}
});
}
@@ -144,3 +148,22 @@ export function bufferToArrayBuffer(data: Buffer): ArrayBuffer {
data.copy(view);
return view.buffer;
}
+
+/**
+ * This allows test to wait for a certain amount of time before continuing.
+ * @param milliseconds - the amount of time to wait in milliseconds
+ * @returns - a promise that resolves after the specified amount of time
+ */
+export const sleep = (milliseconds: number = 0) => {
+ return new Promise(resolve => setTimeout(resolve, milliseconds));
+};
+
+export function waitForMetadataEvent(source: Evented): Promise {
+ return new Promise((resolve) => {
+ source.on('data', (e) => {
+ if (e.sourceDataType === 'metadata') {
+ resolve();
+ }
+ });
+ });
+}
diff --git a/src/util/throttled_invoker.ts b/src/util/throttled_invoker.ts
index f697a23652..e16522662c 100644
--- a/src/util/throttled_invoker.ts
+++ b/src/util/throttled_invoker.ts
@@ -1,40 +1,41 @@
/**
- * Invokes the wrapped function in a non-blocking way when trigger() is called. Invocation requests
- * are ignored until the function was actually invoked.
+ * Invokes the wrapped function in a non-blocking way when trigger() is called.
+ * Invocation requests are ignored until the function was actually invoked.
*/
export class ThrottledInvoker {
_channel: MessageChannel;
_triggered: boolean;
- _callback: Function;
+ _methodToThrottle: Function;
- constructor(callback: Function) {
- this._callback = callback;
+ constructor(methodToThrottle: Function) {
+ this._methodToThrottle = methodToThrottle;
this._triggered = false;
if (typeof MessageChannel !== 'undefined') {
this._channel = new MessageChannel();
this._channel.port2.onmessage = () => {
this._triggered = false;
- this._callback();
+ this._methodToThrottle();
};
}
}
trigger() {
- if (!this._triggered) {
- this._triggered = true;
- if (this._channel) {
- this._channel.port1.postMessage(true);
- } else {
- setTimeout(() => {
- this._triggered = false;
- this._callback();
- }, 0);
- }
+ if (this._triggered) {
+ return;
+ }
+ this._triggered = true;
+ if (this._channel) {
+ this._channel.port1.postMessage(true);
+ } else {
+ setTimeout(() => {
+ this._triggered = false;
+ this._methodToThrottle();
+ }, 0);
}
}
remove() {
delete this._channel;
- this._callback = () => {};
+ this._methodToThrottle = () => {};
}
}
diff --git a/src/util/util.test.ts b/src/util/util.test.ts
index 47ede250b3..2754108c8f 100644
--- a/src/util/util.test.ts
+++ b/src/util/util.test.ts
@@ -1,5 +1,5 @@
import Point from '@mapbox/point-geometry';
-import {arraysIntersect, asyncAll, bezier, clamp, clone, deepEqual, easeCubicInOut, extend, filterObject, findLineIntersection, isClosedPolygon, isCounterClockwise, isPowerOfTwo, keysDifference, mapObject, nextPowerOfTwo, parseCacheControl, pick, readImageDataUsingOffscreenCanvas, readImageUsingVideoFrame, uniqueId, wrap} from './util';
+import {arraysIntersect, bezier, clamp, clone, deepEqual, easeCubicInOut, extend, filterObject, findLineIntersection, isClosedPolygon, isCounterClockwise, isPowerOfTwo, keysDifference, mapObject, nextPowerOfTwo, parseCacheControl, pick, readImageDataUsingOffscreenCanvas, readImageUsingVideoFrame, uniqueId, wrap} from './util';
import {Canvas} from 'canvas';
describe('util', () => {
@@ -14,50 +14,6 @@ describe('util', () => {
expect(pick({a: 1, b: 2, c: 3}, ['a', 'c', 'd'])).toEqual({a: 1, c: 3});
expect(typeof uniqueId() === 'number').toBeTruthy();
- test('asyncAll - sync', done => {
- expect(asyncAll([0, 1, 2], (data, callback) => {
- callback(null, data);
- }, (err, results) => {
- expect(err).toBeFalsy();
- expect(results).toEqual([0, 1, 2]);
- })).toBeUndefined();
- done();
- });
-
- test('asyncAll - async', done => {
- expect(asyncAll([4, 0, 1, 2], (data, callback) => {
- setTimeout(() => {
- callback(null, data);
- }, data);
- }, (err, results) => {
- expect(err).toBeFalsy();
- expect(results).toEqual([4, 0, 1, 2]);
- done();
- })).toBeUndefined();
- });
-
- test('asyncAll - error', done => {
- expect(asyncAll([4, 0, 1, 2], (data, callback) => {
- setTimeout(() => {
- callback(new Error('hi'), data);
- }, data);
- }, (err, results) => {
- expect(err && err.message).toBe('hi');
- expect(results).toEqual([4, 0, 1, 2]);
- done();
- })).toBeUndefined();
- });
-
- test('asyncAll - empty', done => {
- expect(asyncAll([], (data, callback) => {
- callback(null, 'foo');
- }, (err, results) => {
- expect(err).toBeFalsy();
- expect(results).toEqual([]);
- })).toBeUndefined();
- done();
- });
-
test('isPowerOfTwo', done => {
expect(isPowerOfTwo(1)).toBe(true);
expect(isPowerOfTwo(2)).toBe(true);
@@ -116,20 +72,6 @@ describe('util', () => {
done();
});
- test('asyncAll', done => {
- let expectedValue = 1;
- asyncAll([], (callback) => { callback(); }, () => {
- expect('immediate callback').toBeTruthy();
- });
- asyncAll([1, 2, 3], (number, callback) => {
- expect(number).toBe(expectedValue++);
- expect(callback instanceof Function).toBeTruthy();
- callback(null, 0);
- }, () => {
- done();
- });
- });
-
test('mapObject', () => {
expect.assertions(6);
expect(mapObject({}, () => { expect(false).toBeTruthy(); })).toEqual({});
diff --git a/src/util/util.ts b/src/util/util.ts
index 13e0c7db9e..0c182a1db9 100644
--- a/src/util/util.ts
+++ b/src/util/util.ts
@@ -1,8 +1,8 @@
import Point from '@mapbox/point-geometry';
import UnitBezier from '@mapbox/unitbezier';
-import type {Callback} from '../types/callback';
import {isOffscreenCanvasDistorted} from './offscreen_canvas_distorted';
import type {Size} from './image';
+import type {WorkerGlobalScopeInterface} from './web_worker';
/**
* Given a value `t` that varies between 0 and 1, return
@@ -65,33 +65,6 @@ export function wrap(n: number, min: number, max: number): number {
return (w === min) ? max : w;
}
-/**
- * Call an asynchronous function on an array of arguments,
- * calling `callback` with the completed results of all calls.
- *
- * @param array - input to each call of the async function.
- * @param fn - an async function with signature (data, callback)
- * @param callback - a callback run after all async work is done.
- * called with an array, containing the results of each async call.
- */
-export function asyncAll- (
- array: Array
- ,
- fn: (item: Item, fnCallback: Callback) => void,
- callback: Callback>
-) {
- if (!array.length) { return callback(null, []); }
- let remaining = array.length;
- const results = new Array(array.length);
- let error = null;
- array.forEach((item, i) => {
- fn(item, (err, result) => {
- if (err) error = err;
- results[i] = (result as any as Result); // https://github.com/facebook/flow/issues/2123
- if (--remaining === 0) callback(error, results);
- });
- });
-}
-
/**
* Compute the difference between the keys in one object and the keys
* in another object.
@@ -384,7 +357,7 @@ export function sphericalToCartesian([r, azimuthal, polar]: [number, number, num
*
* @returns `true` if the when run in the web-worker context.
*/
-export function isWorker(): boolean {
+export function isWorker(self: any): self is WorkerGlobalScopeInterface {
// @ts-ignore
return typeof WorkerGlobalScope !== 'undefined' && typeof self !== 'undefined' && self instanceof WorkerGlobalScope;
}
@@ -481,16 +454,16 @@ export function isImageBitmap(image: any): image is ImageBitmap {
* ArrayBuffers.
*
* @param data - Data to convert
- * @param callback - A callback executed after the conversion is finished. Invoked with error (if any) as the first argument and resulting image bitmap (when no error) as the second
+ * @returns - A promise resolved when the conversion is finished
*/
-export function arrayBufferToImageBitmap(data: ArrayBuffer, callback: (err?: Error | null, image?: ImageBitmap | null) => void) {
+export const arrayBufferToImageBitmap = async (data: ArrayBuffer): Promise => {
const blob: Blob = new Blob([new Uint8Array(data)], {type: 'image/png'});
- createImageBitmap(blob).then((imgBitmap) => {
- callback(null, imgBitmap);
- }).catch((e) => {
- callback(new Error(`Could not load image because of ${e.message}. Please make sure to use a supported image type such as PNG or JPEG. Note that SVGs are not supported.`));
- });
-}
+ try {
+ return createImageBitmap(blob);
+ } catch (e) {
+ throw new Error(`Could not load image because of ${e.message}. Please make sure to use a supported image type such as PNG or JPEG. Note that SVGs are not supported.`);
+ }
+};
const transparentPngUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQYV2NgAAIAAAUAAarVyFEAAAAASUVORK5CYII=';
@@ -502,23 +475,25 @@ const transparentPngUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAA
* ArrayBuffers.
*
* @param data - Data to convert
- * @param callback - A callback executed after the conversion is finished. Invoked with error (if any) as the first argument and resulting image element (when no error) as the second
- */
-export function arrayBufferToImage(data: ArrayBuffer, callback: (err?: Error | null, image?: HTMLImageElement | null) => void) {
- const img: HTMLImageElement = new Image();
- img.onload = () => {
- callback(null, img);
- URL.revokeObjectURL(img.src);
- // prevent image dataURI memory leak in Safari;
- // but don't free the image immediately because it might be uploaded in the next frame
- // https://github.com/mapbox/mapbox-gl-js/issues/10226
- img.onload = null;
- window.requestAnimationFrame(() => { img.src = transparentPngUrl; });
- };
- img.onerror = () => callback(new Error('Could not load image. Please make sure to use a supported image type such as PNG or JPEG. Note that SVGs are not supported.'));
- const blob: Blob = new Blob([new Uint8Array(data)], {type: 'image/png'});
- img.src = data.byteLength ? URL.createObjectURL(blob) : transparentPngUrl;
-}
+ * @returns - A promise resolved when the conversion is finished
+ */
+export const arrayBufferToImage = (data: ArrayBuffer): Promise => {
+ return new Promise((resolve, reject) => {
+ const img: HTMLImageElement = new Image();
+ img.onload = () => {
+ resolve(img);
+ URL.revokeObjectURL(img.src);
+ // prevent image dataURI memory leak in Safari;
+ // but don't free the image immediately because it might be uploaded in the next frame
+ // https://github.com/mapbox/mapbox-gl-js/issues/10226
+ img.onload = null;
+ window.requestAnimationFrame(() => { img.src = transparentPngUrl; });
+ };
+ img.onerror = () => reject(new Error('Could not load image. Please make sure to use a supported image type such as PNG or JPEG. Note that SVGs are not supported.'));
+ const blob: Blob = new Blob([new Uint8Array(data)], {type: 'image/png'});
+ img.src = data.byteLength ? URL.createObjectURL(blob) : transparentPngUrl;
+ });
+};
/**
* Computes the webcodecs VideoFrame API options to select a rectangle out of
@@ -667,3 +642,30 @@ export async function getImageData(
}
return readImageDataUsingOffscreenCanvas(image, x, y, width, height);
}
+
+export interface Subscription {
+ unsubscribe(): void;
+}
+
+export interface Subscriber {
+ addEventListener: typeof window.addEventListener;
+ removeEventListener: typeof window.removeEventListener;
+}
+
+/**
+ * This method is used in order to register an event listener using a lambda function.
+ * The return value will allow unsubscribing from the event, without the need to store the method reference.
+ * @param target - The target
+ * @param message - The message
+ * @param listener - The listener
+ * @param options - The options
+ * @returns a subscription object that can be used to unsubscribe from the event
+ */
+export function subscribe(target: Subscriber, message: keyof WindowEventMap, listener: (...args: any) => void, options: boolean | AddEventListenerOptions): Subscription {
+ target.addEventListener(message, listener, options);
+ return {
+ unsubscribe: () => {
+ target.removeEventListener(message, listener, options);
+ }
+ };
+}
diff --git a/src/util/web_worker.ts b/src/util/web_worker.ts
index 6a2db7e576..e26bc74090 100644
--- a/src/util/web_worker.ts
+++ b/src/util/web_worker.ts
@@ -1,16 +1,12 @@
import {config} from './config';
-
-import type {WorkerSource} from '../source/worker_source';
+import type {default as MaplibreWorker} from '../source/worker';
+import type {WorkerSourceConstructor} from '../source/worker_source';
export interface WorkerGlobalScopeInterface {
importScripts(...urls: Array): void;
- registerWorkerSource: (
- b: string,
- a: {
- new(...args: any): WorkerSource;
- }
- ) => void;
+ registerWorkerSource: (sourceName: string, sourceConstrucor: WorkerSourceConstructor) => void;
registerRTLTextPlugin: (_: any) => void;
+ worker: MaplibreWorker;
}
export function workerFactory() {
diff --git a/src/util/web_worker_transfer.ts b/src/util/web_worker_transfer.ts
index 49fb990e42..026b1a12ad 100644
--- a/src/util/web_worker_transfer.ts
+++ b/src/util/web_worker_transfer.ts
@@ -7,10 +7,16 @@ import {AJAXError} from './ajax';
import type {Transferable} from '../types/transferable';
import {isImageBitmap} from './util';
+/**
+ * A class that is serizlized to and json, that can be constructed back to the original class in the worker or in the main thread
+ */
type SerializedObject
= {
[_: string]: S;
};
+/**
+ * All the possible values that can be serialized and sent to and from the worker
+ */
export type Serialized = null | void | boolean | number | string | Boolean | Number | String | Date | RegExp | ArrayBuffer | ArrayBufferView | ImageData | ImageBitmap | Blob | Array | SerializedObject;
type Registry = {
@@ -156,7 +162,7 @@ export function serialize(input: unknown, transferables?: Array |
const klass = (input.constructor as any);
const name = klass._classRegistryKey;
if (!name) {
- throw new Error('can\'t serialize object of unregistered class');
+ throw new Error(`can't serialize object of unregistered class ${klass.name}`);
}
if (!registry[name]) throw new Error(`${name} is not registered.`);
diff --git a/test/bench/lib/create_map.ts b/test/bench/lib/create_map.ts
index 1ab43afa79..def8226abf 100644
--- a/test/bench/lib/create_map.ts
+++ b/test/bench/lib/create_map.ts
@@ -27,9 +27,9 @@ const createMap = (options: any): Promise