Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/api-reference/geo-layers/tile-layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 24 additions & 0 deletions modules/core/src/lib/layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,30 @@ export default abstract class Layer<PropsT extends {} = {}> 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(): {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiles aren't a core concept to deck so I don't think this belongs on the base Layer.

Please isolate the changes to the geo-layers module.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure thing I can move to geo-layers

/** 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;
Expand Down
9 changes: 9 additions & 0 deletions modules/geo-layers/src/tile-layer/tile-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,15 @@ export default class TileLayer<DataT = any, ExtraPropsT extends {} = {}> 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;
}
Expand Down
15 changes: 15 additions & 0 deletions modules/geo-layers/src/tileset-2d/tile-2d-header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export class Tile2DHeader<DataT = any> {
private _isLoaded: boolean;
private _isCancelled: boolean;
private _needsReload: boolean;
private _error: unknown;
private _bbox!: TileBoundingBox;

constructor(index: TileIndex) {
Expand All @@ -52,6 +53,17 @@ export class Tile2DHeader<DataT = any> {
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 */
Expand Down Expand Up @@ -164,8 +176,10 @@ export class Tile2DHeader<DataT = any> {
this._isCancelled = false;

if (error) {
this._error = error;
onError(error, this);
} else {
this._error = null;
onLoad(this);
}
}
Expand All @@ -174,6 +188,7 @@ export class Tile2DHeader<DataT = any> {
this._isLoaded = false;
this._isCancelled = false;
this._needsReload = false;
this._error = null;
this._loaderId++;
this._loader = this._loadData(opts);
return this._loader;
Expand Down
31 changes: 31 additions & 0 deletions modules/geo-layers/src/tileset-2d/tileset-2d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
5 changes: 5 additions & 0 deletions test/modules/core/lib/layer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions test/modules/geo-layers/mvt-layer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
56 changes: 56 additions & 0 deletions test/modules/geo-layers/tile-layer/tile-layer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown[]>(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 */
Expand Down
35 changes: 35 additions & 0 deletions test/modules/geo-layers/tileset-2d/tile-2d-header.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
48 changes: 48 additions & 0 deletions test/modules/geo-layers/tileset-2d/tileset-2d.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading