diff --git a/docs/api-reference/geo-layers/tile-layer.md b/docs/api-reference/geo-layers/tile-layer.md index f0326a28cfe..09d85f53d56 100644 --- a/docs/api-reference/geo-layers/tile-layer.md +++ b/docs/api-reference/geo-layers/tile-layer.md @@ -428,6 +428,25 @@ Properties: - `isSelected` (boolean) - if the tile is expected to show up in the current viewport - `isVisible` (boolean) - if the tile should be rendered - `isLoaded` (boolean) - if the content of the tile has been loaded +- `isFailed` (boolean) - if the most recent load attempt for this tile failed (i.e. `getTileData` rejected). Reset to `false` once the tile reloads successfully. +- `error` (any | null) - the error reported by `getTileData`, or `null` if the request succeeded or has not completed. + +## TileLayer.getTileLoadingState + +Returns counts of tiles in the current viewport selection by load state, plus an `isComplete` flag that is true when no tiles are pending. Returns `null` if the tileset has not yet computed a viewport selection (e.g. before the first render). + +```ts +const state = tileLayer.getTileLoadingState(); +// state: { +// pending: number, // tiles being fetched/decoded +// loaded: number, // tiles successfully loaded +// failed: number, // tiles whose load attempt failed +// total: number, // total tiles in the current viewport selection +// isComplete: boolean // pending === 0 +// } | null +``` + +This is useful for headless capture / video export and progress UIs that need a per-layer view of tile pipeline state. The base `Layer` class returns `null` for non-tile layers; `TileLayer` and its subclasses (including `MVTLayer`) override this to return real counts. ## Tileset2D diff --git a/modules/core/src/lib/layer.ts b/modules/core/src/lib/layer.ts index b7f1d1d585d..a6f9a8dfd34 100644 --- a/modules/core/src/lib/layer.ts +++ b/modules/core/src/lib/layer.ts @@ -325,6 +325,30 @@ export default abstract class Layer extends Component< return this.internalState ? !this.internalState.isAsyncPropLoading() : false; } + /** + * Returns the current tile loading state for tile-based layers, or `null` for non-tile layers. + * + * Subclasses that load data in tiles (e.g. `TileLayer`, `MVTLayer`) override this method to + * report counts of pending, loaded, and failed tiles in the current viewport selection. + * + * Returns `null` for non-tile layers and for tile layers whose tileset has not yet computed + * a viewport selection (e.g. before the first render). + */ + getTileLoadingState(): { + /** Number of tiles currently being fetched or decoded */ + pending: number; + /** Number of tiles successfully loaded */ + loaded: number; + /** Number of tiles whose load attempts failed */ + failed: number; + /** Total tiles in the current viewport selection */ + total: number; + /** True when no tiles are pending */ + isComplete: boolean; + } | null { + return null; + } + /** Returns true if using shader-based WGS84 longitude wrapping */ get wrapLongitude(): boolean { return this.props.wrapLongitude; diff --git a/modules/geo-layers/src/tile-layer/tile-layer.ts b/modules/geo-layers/src/tile-layer/tile-layer.ts index de5e6b3a8fb..6a03f64057a 100644 --- a/modules/geo-layers/src/tile-layer/tile-layer.ts +++ b/modules/geo-layers/src/tile-layer/tile-layer.ts @@ -222,6 +222,15 @@ export default class TileLayer extends ); } + /** + * Returns counts of tiles in the current viewport selection by load state, plus an + * `isComplete` flag that is true when no tiles are pending. Returns `null` if the tileset + * has not yet computed a selection (e.g. before the first viewport update). + */ + getTileLoadingState() { + return this.state?.tileset?.getLoadingState() ?? null; + } + shouldUpdateState({changeFlags}): boolean { return changeFlags.somethingChanged; } diff --git a/modules/geo-layers/src/tileset-2d/tile-2d-header.ts b/modules/geo-layers/src/tileset-2d/tile-2d-header.ts index 32406256cbc..ccbb5b52143 100644 --- a/modules/geo-layers/src/tileset-2d/tile-2d-header.ts +++ b/modules/geo-layers/src/tileset-2d/tile-2d-header.ts @@ -35,6 +35,7 @@ export class Tile2DHeader { private _isLoaded: boolean; private _isCancelled: boolean; private _needsReload: boolean; + private _error: unknown; private _bbox!: TileBoundingBox; constructor(index: TileIndex) { @@ -52,6 +53,17 @@ export class Tile2DHeader { this._isLoaded = false; this._isCancelled = false; this._needsReload = false; + this._error = null; + } + + /** The error reported by `getTileData`, or `null` if the request succeeded or has not completed. */ + get error(): unknown { + return this._error; + } + + /** True if the most recent load attempt for this tile failed (i.e. `getTileData` rejected). */ + get isFailed(): boolean { + return this._error !== null && this._error !== undefined; } /** @deprecated use `boundingBox` instead */ @@ -164,8 +176,10 @@ export class Tile2DHeader { this._isCancelled = false; if (error) { + this._error = error; onError(error, this); } else { + this._error = null; onLoad(this); } } @@ -174,6 +188,7 @@ export class Tile2DHeader { this._isLoaded = false; this._isCancelled = false; this._needsReload = false; + this._error = null; this._loaderId++; this._loader = this._loadData(opts); return this._loader; diff --git a/modules/geo-layers/src/tileset-2d/tileset-2d.ts b/modules/geo-layers/src/tileset-2d/tileset-2d.ts index e0752ec13a8..87456fe5130 100644 --- a/modules/geo-layers/src/tileset-2d/tileset-2d.ts +++ b/modules/geo-layers/src/tileset-2d/tileset-2d.ts @@ -197,6 +197,37 @@ export class Tileset2D { return this._selectedTiles !== null && this._selectedTiles.every(tile => tile.isLoaded); } + /** + * Returns counts of tiles in the current viewport selection by load state, plus an `isComplete` + * flag that is true when all selected tiles have settled (no pending loads). Returns `null` + * when no selection has been computed yet (e.g. before the first `update()` call). + */ + getLoadingState(): { + pending: number; + loaded: number; + failed: number; + total: number; + isComplete: boolean; + } | null { + if (this._selectedTiles === null) { + return null; + } + let pending = 0; + let loaded = 0; + let failed = 0; + for (const tile of this._selectedTiles) { + if (tile.isFailed) { + failed++; + } else if (tile.isLoaded) { + loaded++; + } else { + pending++; + } + } + const total = this._selectedTiles.length; + return {pending, loaded, failed, total, isComplete: pending === 0}; + } + get needsReload(): boolean { return this._selectedTiles !== null && this._selectedTiles.some(tile => tile.needsReload); } diff --git a/test/modules/core/lib/layer.spec.ts b/test/modules/core/lib/layer.spec.ts index 37ff959a396..115ddc894a5 100644 --- a/test/modules/core/lib/layer.spec.ts +++ b/test/modules/core/lib/layer.spec.ts @@ -118,6 +118,11 @@ test('Layer#clone', () => { expect(newLayer.props.data, 'cloned layer has correct data').toEqual([0, 1]); }); +test('Layer#getTileLoadingState returns null on the base class', () => { + const layer = new SubLayer({id: 'plain-layer', data: [0, 1]}); + expect(layer.getTileLoadingState(), 'non-tile layers report null tile loading state').toBeNull(); +}); + test('Layer#constructor(multi prop objects)', () => { for (const tc of LAYER_CONSTRUCT_MULTIPROP_TEST_CASES) { const layer = new Layer(...tc.props); diff --git a/test/modules/geo-layers/mvt-layer.spec.ts b/test/modules/geo-layers/mvt-layer.spec.ts index 28bbe2b01f3..a538fd3dc85 100644 --- a/test/modules/geo-layers/mvt-layer.spec.ts +++ b/test/modules/geo-layers/mvt-layer.spec.ts @@ -782,3 +782,13 @@ test('MVTLayer#GeoJsonLayer.defaultProps', () => { testLayer({Layer: TestMVTLayer, testCases, onError: err => expect(err).toBeFalsy()}); }); + +test('MVTLayer#getTileLoadingState inherits from TileLayer', () => { + // Before initialization the method exists and returns null because no tileset selection yet. + const layer = new MVTLayer({ + id: 'mvt-loading-state', + data: 'https://example.com/{z}/{x}/{y}.mvt' + }); + expect(typeof layer.getTileLoadingState, 'method exists on MVTLayer').toBe('function'); + expect(layer.getTileLoadingState(), 'returns null before initialization').toBeNull(); +}); diff --git a/test/modules/geo-layers/tile-layer/tile-layer.spec.ts b/test/modules/geo-layers/tile-layer/tile-layer.spec.ts index 379373ca3bf..d8b419b1a50 100644 --- a/test/modules/geo-layers/tile-layer/tile-layer.spec.ts +++ b/test/modules/geo-layers/tile-layer/tile-layer.spec.ts @@ -361,6 +361,62 @@ test('TileLayer#debounceTime', async () => { }); }); +test('TileLayer#getTileLoadingState', async () => { + const testViewport = new WebMercatorViewport({ + width: 100, + height: 100, + longitude: 0, + latitude: 0, + zoom: 1 + }); + + let resolveData: (value: unknown[]) => void = () => {}; + const dataReady = new Promise(resolve => { + resolveData = resolve; + }); + + const testCases = [ + { + title: 'before initialize: state is null', + props: { + getTileData: () => dataReady + }, + onBeforeUpdate: ({layer}) => { + expect(layer.getTileLoadingState(), 'null prior to viewport selection').toBeNull(); + } + }, + { + title: 'after viewport: pending tiles tracked', + props: { + getTileData: () => dataReady + }, + onAfterUpdate: ({layer}) => { + const state = layer.getTileLoadingState(); + expect(state, 'state is reported once viewport is selected').not.toBeNull(); + if (!state) return; + expect(state.total, 'one or more tiles selected').toBeGreaterThan(0); + expect(state.pending, 'tiles pending').toBe(state.total); + expect(state.loaded, 'no tiles loaded').toBe(0); + expect(state.failed, 'no tiles failed').toBe(0); + expect(state.isComplete, 'not complete while pending').toBe(false); + } + } + ]; + + testLayer({ + Layer: TileLayer, + viewport: testViewport, + testCases, + onError: err => { + throw err; + } + }); + + // Resolve the deferred data and let the request scheduler drain + resolveData([]); + await sleep(50); +}); + function sleep(ms) { return new Promise(resolve => { /* global setTimeout */ diff --git a/test/modules/geo-layers/tileset-2d/tile-2d-header.spec.ts b/test/modules/geo-layers/tileset-2d/tile-2d-header.spec.ts index 578ea3b595d..6f1bd5a7610 100644 --- a/test/modules/geo-layers/tileset-2d/tile-2d-header.spec.ts +++ b/test/modules/geo-layers/tileset-2d/tile-2d-header.spec.ts @@ -130,3 +130,38 @@ test('Tile2DHeader#reload', async () => { tile.loadData({...opts, getData: () => getTileData('d2', 0)}); expect(await tile.data, 'loaded the result of the last request').toBe('d2'); }); + +test('Tile2DHeader#error and isFailed track load failures', async () => { + const requestScheduler = new RequestScheduler({throttleRequests: false}); + const opts = { + requestScheduler, + onLoad: () => {}, + onError: () => {} + }; + + // Successful load: no error, isFailed false + const okTile = new Tile2DHeader({}); + await okTile.loadData({...opts, getData: () => 'ok'}); + expect(okTile.isLoaded, 'ok tile is loaded').toBe(true); + expect(okTile.isFailed, 'ok tile is not failed').toBe(false); + expect(okTile.error, 'ok tile has no error').toBeNull(); + + // Failed load: error is recorded, isFailed true, but isLoaded still true (terminal state) + const failingError = new Error('boom'); + const failTile = new Tile2DHeader({}); + await failTile.loadData({ + ...opts, + getData: () => { + throw failingError; + } + }); + expect(failTile.isLoaded, 'failed tile reaches terminal isLoaded state').toBe(true); + expect(failTile.isFailed, 'failed tile reports isFailed').toBe(true); + expect(failTile.error, 'failed tile exposes the thrown error').toBe(failingError); + + // Reloading a failed tile clears the error before the new attempt resolves + await failTile.loadData({...opts, getData: () => 'recovered'}); + expect(failTile.isFailed, 'reload clears failed flag').toBe(false); + expect(failTile.error, 'reload clears error').toBeNull(); + expect(failTile.content, 'reload populates content').toBe('recovered'); +}); diff --git a/test/modules/geo-layers/tileset-2d/tileset-2d.spec.ts b/test/modules/geo-layers/tileset-2d/tileset-2d.spec.ts index 1f14df8e444..f185be6428e 100644 --- a/test/modules/geo-layers/tileset-2d/tileset-2d.spec.ts +++ b/test/modules/geo-layers/tileset-2d/tileset-2d.spec.ts @@ -611,6 +611,54 @@ test('Tileset2D#isTileVisibleWithModelMatrix', async () => { } }); +test('Tileset2D#getLoadingState reports null before update', () => { + const tileset = new Tileset2D({ + getTileData: () => Promise.resolve(null) + }); + expect(tileset.getLoadingState(), 'no selection yet, returns null').toBeNull(); +}); + +test('Tileset2D#getLoadingState transitions pending -> loaded', async () => { + const tileset = new Tileset2D({ + getTileData: () => Promise.resolve('content'), + onTileLoad: () => {} + }); + tileset.update(testViewport); + + const pendingState = tileset.getLoadingState(); + expect(pendingState, 'returns state immediately after update').not.toBeNull(); + expect(pendingState!.total, 'one tile selected').toBeGreaterThan(0); + expect(pendingState!.pending, 'tile is pending before sleep').toBe(pendingState!.total); + expect(pendingState!.loaded, 'no tiles loaded yet').toBe(0); + expect(pendingState!.failed, 'no failures before sleep').toBe(0); + expect(pendingState!.isComplete, 'not complete while pending').toBe(false); + + await sleep(100); + + const loadedState = tileset.getLoadingState(); + expect(loadedState!.pending, 'no tiles pending after load').toBe(0); + expect(loadedState!.loaded, 'all tiles loaded').toBe(loadedState!.total); + expect(loadedState!.failed, 'no failures').toBe(0); + expect(loadedState!.isComplete, 'complete').toBe(true); +}); + +test('Tileset2D#getLoadingState counts failed tiles', async () => { + const tileset = new Tileset2D({ + getTileData: () => Promise.reject(new Error('network error')), + onTileLoad: () => {}, + onTileError: () => {} + }); + tileset.update(testViewport); + + await sleep(100); + + const state = tileset.getLoadingState(); + expect(state!.failed, 'failed tiles counted').toBe(state!.total); + expect(state!.loaded, 'no tiles in loaded bucket').toBe(0); + expect(state!.pending, 'no pending tiles').toBe(0); + expect(state!.isComplete, 'complete (no pending) even on failure').toBe(true); +}); + function validateVisibility(strategy, selectedTiles, tiles) { /* eslint-disable default-case */ switch (strategy) {