From 67229a2959cb16ce13e6b851b9d219189922ba40 Mon Sep 17 00:00:00 2001 From: Adam Krebs Date: Thu, 11 Jun 2026 21:46:23 -0400 Subject: [PATCH 1/4] feat(geo-layers): Add getTileLoadingState utility for granular tile loading info Adds a new utility function getTileLoadingState() that provides detailed information about tile loading progress in TileLayer instances. Background: ----------- deck.gl's layer.isLoaded returns true once all tile requests are 'settled' (completed OR failed). This is intentional - it prevents waiting forever for tiles that will never load (404s, network errors, etc.). However, this makes it impossible to distinguish between 'loaded successfully' and 'loaded with errors' using just the boolean isLoaded property. Solution: --------- getTileLoadingState() provides granular information: - Total, loaded, failed, and pending tile counts - Percentage loaded for progress indicators - isComplete and isSuccess flags for state detection This allows applications to: - Show loading progress (e.g., '47/50 tiles loaded') - Detect and handle failed tiles - Implement retry logic for failed tiles - Display appropriate error states Use Cases: ---------- - Video export: Wait for all tiles to settle before capturing frames - Progress indicators: Show percentage loaded with error counts - Error recovery: Retry failed tiles up to N times - Analytics: Track tile loading success rates Related: -------- This addresses feedback from PR #10360 about improving tile loading observability without changing the core isLoaded behavior (which is correct by design). --- .../geo-layers/get-tile-loading-state.md | 185 ++++++++++++++++++ modules/geo-layers/src/index.ts | 2 + .../src/tile-layer/get-tile-loading-state.ts | 97 +++++++++ .../tile-layer/get-tile-loading-state.spec.ts | 160 +++++++++++++++ 4 files changed, 444 insertions(+) create mode 100644 docs/api-reference/geo-layers/get-tile-loading-state.md create mode 100644 modules/geo-layers/src/tile-layer/get-tile-loading-state.ts create mode 100644 test/modules/geo-layers/tile-layer/get-tile-loading-state.spec.ts diff --git a/docs/api-reference/geo-layers/get-tile-loading-state.md b/docs/api-reference/geo-layers/get-tile-loading-state.md new file mode 100644 index 00000000000..dc5f165c0ef --- /dev/null +++ b/docs/api-reference/geo-layers/get-tile-loading-state.md @@ -0,0 +1,185 @@ +# getTileLoadingState + +Get detailed loading state for tiles in a TileLayer's viewport. + +```typescript +import {getTileLoadingState} from '@deck.gl/geo-layers'; +import type {TileLoadingState} from '@deck.gl/geo-layers'; +``` + +## Usage + +```typescript +import {Deck} from '@deck.gl/core'; +import {TileLayer, getTileLoadingState} from '@deck.gl/geo-layers'; + +const tileLayer = new TileLayer({ + data: 'https://tile.example.com/tiles/{z}/{x}/{y}.json', + getTileData: async ({index, id, bbox}) => { + const response = await fetch(getURLFromTemplate(id, index)); + if (!response.ok) throw new Error('Tile not found'); + return await response.json(); + }, + renderSubLayers: props => { + return new ScatterplotLayer(props); + } +}); + +// Check detailed loading state +const state = getTileLoadingState(tileLayer); + +console.log(`${state.loaded}/${state.total} tiles loaded`); +console.log(`${state.failed} tiles failed`); +console.log(`${state.percentLoaded}% complete`); + +if (state.isComplete && !state.isSuccess) { + console.warn('Some tiles failed to load'); +} +``` + +## Parameters + +### `layer` (TileLayer) + +A TileLayer instance to inspect. + +## Returns + +`TileLoadingState` object with the following properties: + +- **`total`** (`number`): Total number of tiles in the current viewport +- **`loaded`** (`number`): Number of tiles that loaded successfully (with content) +- **`failed`** (`number`): Number of tiles that failed to load (404, network error, etc.) +- **`pending`** (`number`): Number of tiles still loading +- **`percentLoaded`** (`number`): Percentage of successfully loaded tiles (0-100) +- **`isComplete`** (`boolean`): True if all tile requests are settled (loaded or failed) +- **`isSuccess`** (`boolean`): True if all tiles loaded successfully (no failures) + +## Understanding layer.isLoaded vs getTileLoadingState + +deck.gl's `layer.isLoaded` property returns `true` once all tile requests are "settled" (completed or failed). This is **intentional** to prevent waiting forever for tiles that will never load (404s, network errors, missing tiles, etc.). + +From the source code comments: + +> "Error / empty tiles resolve to `content === null`. Once Tile2DHeader marks those requests as loaded, do not wait for generated sublayers because there is nothing to render for that tile and `tile.layers` will remain null." + +### When to use each + +**Use `layer.isLoaded`** when you want to know: +- Are all tile requests complete? (boolean) +- Is the layer ready to render? +- Should I wait or proceed with what's available? + +**Use `getTileLoadingState()`** when you need: +- How many tiles loaded vs failed? (granular counts) +- Progress percentage for a loading indicator +- To distinguish between "loading", "loaded successfully", and "loaded with errors" +- To show error states in the UI + +## Examples + +### Progress Bar + +```typescript +function ProgressBar({tileLayer}) { + const state = getTileLoadingState(tileLayer); + + return ( +
+
+ {state.loaded}/{state.total} tiles loaded + {state.failed > 0 && ( + {state.failed} failed + )} +
+ ); +} +``` + +### Wait for Complete Load + +```typescript +// Wait for all tiles to load successfully +async function waitForTiles(layer: TileLayer): Promise { + return new Promise((resolve, reject) => { + const check = () => { + const state = getTileLoadingState(layer); + + if (!state.isComplete) { + // Still loading, check again + requestAnimationFrame(check); + return; + } + + if (state.isSuccess) { + resolve(); + } else { + reject(new Error(`${state.failed} tiles failed to load`)); + } + }; + + check(); + }); +} +``` + +### Video Export + +```typescript +// For video export, you might want to proceed even if some tiles failed +async function captureFrame(deck: Deck): Promise { + const tileLayers = deck.props.layers.filter(l => l instanceof TileLayer); + + // Check if all tile requests are settled (loaded or failed) + const allSettled = tileLayers.every(layer => { + const state = getTileLoadingState(layer); + return state.isComplete; + }); + + if (!allSettled) { + // Wait for tiles to finish loading or failing + await new Promise(resolve => setTimeout(resolve, 100)); + return captureFrame(deck); + } + + // Proceed with frame capture (may have missing tiles) + const canvas = deck.canvas as HTMLCanvasElement; + const imageData = canvas.toDataURL('image/png'); + + // Log any failures + tileLayers.forEach(layer => { + const state = getTileLoadingState(layer); + if (state.failed > 0) { + console.warn(`Layer ${layer.id}: ${state.failed} tiles failed`); + } + }); +} +``` + +### Error Recovery + +```typescript +function TileLayerWithRetry({...props}) { + const [retryCount, setRetryCount] = useState(0); + const layerRef = useRef(null); + + useEffect(() => { + if (!layerRef.current) return; + + const state = getTileLoadingState(layerRef.current); + + if (state.isComplete && !state.isSuccess && retryCount < 3) { + console.log(`${state.failed} tiles failed, retrying...`); + setRetryCount(retryCount + 1); + // Trigger layer update to retry failed tiles + layerRef.current.setNeedsUpdate(); + } + }, [retryCount]); + + return ; +} +``` + +## Source + +[modules/geo-layers/src/tile-layer/get-tile-loading-state.ts](https://github.com/visgl/deck.gl/tree/master/modules/geo-layers/src/tile-layer/get-tile-loading-state.ts) diff --git a/modules/geo-layers/src/index.ts b/modules/geo-layers/src/index.ts index e9b8fcaba2d..75dbd630460 100644 --- a/modules/geo-layers/src/index.ts +++ b/modules/geo-layers/src/index.ts @@ -28,6 +28,8 @@ export type {H3HexagonLayerProps} from './h3-layers/h3-hexagon-layer'; export type {GreatCircleLayerProps} from './great-circle-layer/great-circle-layer'; export type {S2LayerProps} from './s2-layer/s2-layer'; export type {TileLayerProps, TileLayerPickingInfo} from './tile-layer/tile-layer'; +export type {TileLoadingState} from './tile-layer/get-tile-loading-state'; +export {getTileLoadingState} from './tile-layer/get-tile-loading-state'; export type {TripsLayerProps} from './trips-layer/trips-layer'; export type {QuadkeyLayerProps} from './quadkey-layer/quadkey-layer'; export type {TerrainLayerProps} from './terrain-layer/terrain-layer'; diff --git a/modules/geo-layers/src/tile-layer/get-tile-loading-state.ts b/modules/geo-layers/src/tile-layer/get-tile-loading-state.ts new file mode 100644 index 00000000000..aefb30de199 --- /dev/null +++ b/modules/geo-layers/src/tile-layer/get-tile-loading-state.ts @@ -0,0 +1,97 @@ +import type {TileLayer} from './tile-layer'; +import type {Tile2DHeader} from '../tileset-2d/tile-2d-header'; + +export type TileLoadingState = { + /** Total number of tiles in the current viewport */ + total: number; + /** Number of tiles that have loaded successfully (with content) */ + loaded: number; + /** Number of tiles that failed to load (isLoaded but content is null) */ + failed: number; + /** Number of tiles still loading */ + pending: number; + /** Percentage of tiles loaded (loaded / total) */ + percentLoaded: number; + /** True if all tiles are done loading (loaded or failed) */ + isComplete: boolean; + /** True if all tiles loaded successfully (no failures) */ + isSuccess: boolean; +}; + +/** + * Get detailed loading state for tiles in a TileLayer's viewport. + * + * Unlike `layer.isLoaded` which only returns a boolean, this function provides + * granular information about tile loading progress, including: + * - How many tiles loaded successfully vs failed + * - Loading percentage for progress UI + * - Whether any tiles are still pending + * + * @param layer - A TileLayer instance + * @returns Detailed tile loading state + * + * @example + * ```typescript + * const state = getTileLoadingState(tileLayer); + * console.log(`${state.loaded}/${state.total} tiles loaded`); + * console.log(`${state.failed} tiles failed`); + * console.log(`${state.percentLoaded}% complete`); + * + * if (state.isComplete && !state.isSuccess) { + * console.warn('Some tiles failed to load'); + * } + * ``` + * + * @note + * deck.gl's `layer.isLoaded` returns `true` once all tile requests are "settled" + * (completed or failed). This is intentional to prevent waiting forever for tiles + * that will never load (404s, network errors, etc.). Use this function to distinguish + * between successfully loaded tiles and failed tiles. + */ +export function getTileLoadingState(layer: TileLayer): TileLoadingState { + const tileset = layer.state?.tileset; + const selectedTiles: Tile2DHeader[] = tileset?.selectedTiles || []; + + if (selectedTiles.length === 0) { + return { + total: 0, + loaded: 0, + failed: 0, + pending: 0, + percentLoaded: 100, + isComplete: true, + isSuccess: true + }; + } + + let loaded = 0; + let failed = 0; + let pending = 0; + + for (const tile of selectedTiles) { + if (!tile.isLoaded) { + pending++; + } else if (tile.content === null) { + // Tile request completed but returned no content (404, error, etc.) + failed++; + } else { + // Tile loaded successfully with content + loaded++; + } + } + + const total = selectedTiles.length; + const percentLoaded = total > 0 ? Math.round((loaded / total) * 100) : 100; + const isComplete = pending === 0; + const isSuccess = isComplete && failed === 0; + + return { + total, + loaded, + failed, + pending, + percentLoaded, + isComplete, + isSuccess + }; +} diff --git a/test/modules/geo-layers/tile-layer/get-tile-loading-state.spec.ts b/test/modules/geo-layers/tile-layer/get-tile-loading-state.spec.ts new file mode 100644 index 00000000000..46e282ddbc2 --- /dev/null +++ b/test/modules/geo-layers/tile-layer/get-tile-loading-state.spec.ts @@ -0,0 +1,160 @@ +import test from 'tape-promise/tape'; +import {TileLayer} from '@deck.gl/geo-layers'; +import {getTileLoadingState} from '@deck.gl/geo-layers/tile-layer/get-tile-loading-state'; + +const TEST_DATA = [ + {position: [0, 0]}, + {position: [1, 1]} +]; + +test('getTileLoadingState#empty layer', t => { + const layer = new TileLayer({ + data: 'https://example.com/tiles/{z}/{x}/{y}', + getTileData: () => null + }); + + const state = getTileLoadingState(layer); + + t.equal(state.total, 0, 'total is 0 for empty layer'); + t.equal(state.loaded, 0, 'loaded is 0'); + t.equal(state.failed, 0, 'failed is 0'); + t.equal(state.pending, 0, 'pending is 0'); + t.equal(state.percentLoaded, 100, 'percentLoaded is 100 for empty'); + t.equal(state.isComplete, true, 'isComplete is true'); + t.equal(state.isSuccess, true, 'isSuccess is true'); + + t.end(); +}); + +test('getTileLoadingState#all tiles loaded successfully', t => { + const layer = new TileLayer({ + id: 'test-layer', + data: 'https://example.com/tiles/{z}/{x}/{y}', + getTileData: async () => TEST_DATA + }); + + // Mock tileset state with loaded tiles + layer.state = { + tileset: { + selectedTiles: [ + {isLoaded: true, content: TEST_DATA}, + {isLoaded: true, content: TEST_DATA}, + {isLoaded: true, content: TEST_DATA} + ] + } + } as any; + + const state = getTileLoadingState(layer); + + t.equal(state.total, 3, 'total is 3'); + t.equal(state.loaded, 3, 'loaded is 3'); + t.equal(state.failed, 0, 'failed is 0'); + t.equal(state.pending, 0, 'pending is 0'); + t.equal(state.percentLoaded, 100, 'percentLoaded is 100'); + t.equal(state.isComplete, true, 'isComplete is true'); + t.equal(state.isSuccess, true, 'isSuccess is true'); + + t.end(); +}); + +test('getTileLoadingState#some tiles failed', t => { + const layer = new TileLayer({ + id: 'test-layer', + data: 'https://example.com/tiles/{z}/{x}/{y}', + getTileData: async () => TEST_DATA + }); + + // Mock tileset with successful and failed tiles + layer.state = { + tileset: { + selectedTiles: [ + {isLoaded: true, content: TEST_DATA}, // Success + {isLoaded: true, content: null}, // Failed (404, error, etc.) + {isLoaded: true, content: TEST_DATA}, // Success + {isLoaded: true, content: null} // Failed + ] + } + } as any; + + const state = getTileLoadingState(layer); + + t.equal(state.total, 4, 'total is 4'); + t.equal(state.loaded, 2, 'loaded is 2'); + t.equal(state.failed, 2, 'failed is 2'); + t.equal(state.pending, 0, 'pending is 0'); + t.equal(state.percentLoaded, 50, 'percentLoaded is 50'); + t.equal(state.isComplete, true, 'isComplete is true (all settled)'); + t.equal(state.isSuccess, false, 'isSuccess is false (some failed)'); + + t.end(); +}); + +test('getTileLoadingState#tiles still loading', t => { + const layer = new TileLayer({ + id: 'test-layer', + data: 'https://example.com/tiles/{z}/{x}/{y}', + getTileData: async () => TEST_DATA + }); + + // Mock tileset with loading tiles + layer.state = { + tileset: { + selectedTiles: [ + {isLoaded: true, content: TEST_DATA}, // Loaded + {isLoaded: false, content: null}, // Still loading + {isLoaded: false, content: null}, // Still loading + {isLoaded: true, content: null} // Failed + ] + } + } as any; + + const state = getTileLoadingState(layer); + + t.equal(state.total, 4, 'total is 4'); + t.equal(state.loaded, 1, 'loaded is 1'); + t.equal(state.failed, 1, 'failed is 1'); + t.equal(state.pending, 2, 'pending is 2'); + t.equal(state.percentLoaded, 25, 'percentLoaded is 25 (1/4)'); + t.equal(state.isComplete, false, 'isComplete is false (pending tiles)'); + t.equal(state.isSuccess, false, 'isSuccess is false (not complete)'); + + t.end(); +}); + +test('getTileLoadingState#all tiles failed', t => { + const layer = new TileLayer({ + id: 'test-layer', + data: 'https://example.com/tiles/{z}/{x}/{y}', + getTileData: async () => { + throw new Error('Network error'); + } + }); + + // Mock tileset where all tiles failed + layer.state = { + tileset: { + selectedTiles: [ + {isLoaded: true, content: null}, // Failed + {isLoaded: true, content: null}, // Failed + {isLoaded: true, content: null} // Failed + ] + } + } as any; + + const state = getTileLoadingState(layer); + + t.equal(state.total, 3, 'total is 3'); + t.equal(state.loaded, 0, 'loaded is 0'); + t.equal(state.failed, 3, 'failed is 3'); + t.equal(state.pending, 0, 'pending is 0'); + t.equal(state.percentLoaded, 0, 'percentLoaded is 0'); + t.equal(state.isComplete, true, 'isComplete is true (all settled)'); + t.equal(state.isSuccess, false, 'isSuccess is false (all failed)'); + + // This demonstrates the behavior: layer.isLoaded would return true + // because all tiles are "settled", but isSuccess is false + t.equal(layer.isLoaded, true, 'layer.isLoaded is true even though all failed'); + t.equal(state.isSuccess, false, 'but isSuccess correctly identifies failure'); + + t.end(); +}); From bf1bf57a9cb533aae9470c0ae1a87b4c0bdd8e93 Mon Sep 17 00:00:00 2001 From: Adam Krebs Date: Thu, 11 Jun 2026 22:14:19 -0400 Subject: [PATCH 2/4] fix: Update tests to use vitest and fix file locations - Convert from tape to vitest test framework - Move test file to correct location (test/modules/geo-layers/) - Add copyright headers - Register test in index.ts - Add getTileLoadingState to top-level imports test --- .../src/tile-layer/get-tile-loading-state.ts | 4 + .../get-tile-loading-state.spec.ts | 101 ++++++++---------- test/modules/geo-layers/index.ts | 5 +- 3 files changed, 53 insertions(+), 57 deletions(-) rename test/modules/geo-layers/{tile-layer => }/get-tile-loading-state.spec.ts (50%) diff --git a/modules/geo-layers/src/tile-layer/get-tile-loading-state.ts b/modules/geo-layers/src/tile-layer/get-tile-loading-state.ts index aefb30de199..374f3725d91 100644 --- a/modules/geo-layers/src/tile-layer/get-tile-loading-state.ts +++ b/modules/geo-layers/src/tile-layer/get-tile-loading-state.ts @@ -1,3 +1,7 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + import type {TileLayer} from './tile-layer'; import type {Tile2DHeader} from '../tileset-2d/tile-2d-header'; diff --git a/test/modules/geo-layers/tile-layer/get-tile-loading-state.spec.ts b/test/modules/geo-layers/get-tile-loading-state.spec.ts similarity index 50% rename from test/modules/geo-layers/tile-layer/get-tile-loading-state.spec.ts rename to test/modules/geo-layers/get-tile-loading-state.spec.ts index 46e282ddbc2..d6921bbcb94 100644 --- a/test/modules/geo-layers/tile-layer/get-tile-loading-state.spec.ts +++ b/test/modules/geo-layers/get-tile-loading-state.spec.ts @@ -1,4 +1,8 @@ -import test from 'tape-promise/tape'; +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {test, expect} from 'vitest'; import {TileLayer} from '@deck.gl/geo-layers'; import {getTileLoadingState} from '@deck.gl/geo-layers/tile-layer/get-tile-loading-state'; @@ -7,7 +11,7 @@ const TEST_DATA = [ {position: [1, 1]} ]; -test('getTileLoadingState#empty layer', t => { +test('getTileLoadingState#empty layer', () => { const layer = new TileLayer({ data: 'https://example.com/tiles/{z}/{x}/{y}', getTileData: () => null @@ -15,18 +19,16 @@ test('getTileLoadingState#empty layer', t => { const state = getTileLoadingState(layer); - t.equal(state.total, 0, 'total is 0 for empty layer'); - t.equal(state.loaded, 0, 'loaded is 0'); - t.equal(state.failed, 0, 'failed is 0'); - t.equal(state.pending, 0, 'pending is 0'); - t.equal(state.percentLoaded, 100, 'percentLoaded is 100 for empty'); - t.equal(state.isComplete, true, 'isComplete is true'); - t.equal(state.isSuccess, true, 'isSuccess is true'); - - t.end(); + expect(state.total).toBe(0); + expect(state.loaded).toBe(0); + expect(state.failed).toBe(0); + expect(state.pending).toBe(0); + expect(state.percentLoaded).toBe(100); + expect(state.isComplete).toBe(true); + expect(state.isSuccess).toBe(true); }); -test('getTileLoadingState#all tiles loaded successfully', t => { +test('getTileLoadingState#all tiles loaded successfully', () => { const layer = new TileLayer({ id: 'test-layer', data: 'https://example.com/tiles/{z}/{x}/{y}', @@ -46,18 +48,16 @@ test('getTileLoadingState#all tiles loaded successfully', t => { const state = getTileLoadingState(layer); - t.equal(state.total, 3, 'total is 3'); - t.equal(state.loaded, 3, 'loaded is 3'); - t.equal(state.failed, 0, 'failed is 0'); - t.equal(state.pending, 0, 'pending is 0'); - t.equal(state.percentLoaded, 100, 'percentLoaded is 100'); - t.equal(state.isComplete, true, 'isComplete is true'); - t.equal(state.isSuccess, true, 'isSuccess is true'); - - t.end(); + expect(state.total).toBe(3); + expect(state.loaded).toBe(3); + expect(state.failed).toBe(0); + expect(state.pending).toBe(0); + expect(state.percentLoaded).toBe(100); + expect(state.isComplete).toBe(true); + expect(state.isSuccess).toBe(true); }); -test('getTileLoadingState#some tiles failed', t => { +test('getTileLoadingState#some tiles failed', () => { const layer = new TileLayer({ id: 'test-layer', data: 'https://example.com/tiles/{z}/{x}/{y}', @@ -78,18 +78,16 @@ test('getTileLoadingState#some tiles failed', t => { const state = getTileLoadingState(layer); - t.equal(state.total, 4, 'total is 4'); - t.equal(state.loaded, 2, 'loaded is 2'); - t.equal(state.failed, 2, 'failed is 2'); - t.equal(state.pending, 0, 'pending is 0'); - t.equal(state.percentLoaded, 50, 'percentLoaded is 50'); - t.equal(state.isComplete, true, 'isComplete is true (all settled)'); - t.equal(state.isSuccess, false, 'isSuccess is false (some failed)'); - - t.end(); + expect(state.total).toBe(4); + expect(state.loaded).toBe(2); + expect(state.failed).toBe(2); + expect(state.pending).toBe(0); + expect(state.percentLoaded).toBe(50); + expect(state.isComplete).toBe(true); + expect(state.isSuccess).toBe(false); }); -test('getTileLoadingState#tiles still loading', t => { +test('getTileLoadingState#tiles still loading', () => { const layer = new TileLayer({ id: 'test-layer', data: 'https://example.com/tiles/{z}/{x}/{y}', @@ -110,18 +108,16 @@ test('getTileLoadingState#tiles still loading', t => { const state = getTileLoadingState(layer); - t.equal(state.total, 4, 'total is 4'); - t.equal(state.loaded, 1, 'loaded is 1'); - t.equal(state.failed, 1, 'failed is 1'); - t.equal(state.pending, 2, 'pending is 2'); - t.equal(state.percentLoaded, 25, 'percentLoaded is 25 (1/4)'); - t.equal(state.isComplete, false, 'isComplete is false (pending tiles)'); - t.equal(state.isSuccess, false, 'isSuccess is false (not complete)'); - - t.end(); + expect(state.total).toBe(4); + expect(state.loaded).toBe(1); + expect(state.failed).toBe(1); + expect(state.pending).toBe(2); + expect(state.percentLoaded).toBe(25); + expect(state.isComplete).toBe(false); + expect(state.isSuccess).toBe(false); }); -test('getTileLoadingState#all tiles failed', t => { +test('getTileLoadingState#all tiles failed', () => { const layer = new TileLayer({ id: 'test-layer', data: 'https://example.com/tiles/{z}/{x}/{y}', @@ -143,18 +139,11 @@ test('getTileLoadingState#all tiles failed', t => { const state = getTileLoadingState(layer); - t.equal(state.total, 3, 'total is 3'); - t.equal(state.loaded, 0, 'loaded is 0'); - t.equal(state.failed, 3, 'failed is 3'); - t.equal(state.pending, 0, 'pending is 0'); - t.equal(state.percentLoaded, 0, 'percentLoaded is 0'); - t.equal(state.isComplete, true, 'isComplete is true (all settled)'); - t.equal(state.isSuccess, false, 'isSuccess is false (all failed)'); - - // This demonstrates the behavior: layer.isLoaded would return true - // because all tiles are "settled", but isSuccess is false - t.equal(layer.isLoaded, true, 'layer.isLoaded is true even though all failed'); - t.equal(state.isSuccess, false, 'but isSuccess correctly identifies failure'); - - t.end(); + expect(state.total).toBe(3); + expect(state.loaded).toBe(0); + expect(state.failed).toBe(3); + expect(state.pending).toBe(0); + expect(state.percentLoaded).toBe(0); + expect(state.isComplete).toBe(true); + expect(state.isSuccess).toBe(false); }); diff --git a/test/modules/geo-layers/index.ts b/test/modules/geo-layers/index.ts index 5521444b3ba..5b761e7001d 100644 --- a/test/modules/geo-layers/index.ts +++ b/test/modules/geo-layers/index.ts @@ -15,7 +15,8 @@ import { TileLayer, TripsLayer, TerrainLayer, - GeohashLayer + GeohashLayer, + getTileLoadingState } from '@deck.gl/geo-layers'; test('Top-level imports', () => { @@ -30,6 +31,7 @@ test('Top-level imports', () => { expect(TripsLayer, 'TripsLayer symbol imported').toBeTruthy(); expect(TerrainLayer, 'TerrainLayer symbol imported').toBeTruthy(); expect(GeohashLayer, 'GeohashLayer symbol imported').toBeTruthy(); + expect(getTileLoadingState, 'getTileLoadingState symbol imported').toBeTruthy(); }); import './a5-layer.spec'; @@ -44,5 +46,6 @@ import './tile-3d-layer'; import './terrain-layer.spec'; import './mvt-layer.spec'; import './geohash-layer.spec'; +import './get-tile-loading-state.spec'; import './tileset-2d'; From df2931baeba0f1bd0343f85e79706459ef95e46b Mon Sep 17 00:00:00 2001 From: Adam Krebs Date: Fri, 12 Jun 2026 08:37:48 -0400 Subject: [PATCH 3/4] refactor(geo-layers): getTileLoadingState as TileLayer method --- .../geo-layers/get-tile-loading-state.md | 185 ------------------ docs/api-reference/geo-layers/tile-layer.md | 22 +++ modules/geo-layers/src/index.ts | 4 +- .../src/tile-layer/get-tile-loading-state.ts | 101 ---------- .../geo-layers/src/tile-layer/tile-layer.ts | 20 ++ .../geo-layers/get-tile-loading-state.spec.ts | 62 ++---- test/modules/geo-layers/index.ts | 4 +- 7 files changed, 65 insertions(+), 333 deletions(-) delete mode 100644 docs/api-reference/geo-layers/get-tile-loading-state.md delete mode 100644 modules/geo-layers/src/tile-layer/get-tile-loading-state.ts diff --git a/docs/api-reference/geo-layers/get-tile-loading-state.md b/docs/api-reference/geo-layers/get-tile-loading-state.md deleted file mode 100644 index dc5f165c0ef..00000000000 --- a/docs/api-reference/geo-layers/get-tile-loading-state.md +++ /dev/null @@ -1,185 +0,0 @@ -# getTileLoadingState - -Get detailed loading state for tiles in a TileLayer's viewport. - -```typescript -import {getTileLoadingState} from '@deck.gl/geo-layers'; -import type {TileLoadingState} from '@deck.gl/geo-layers'; -``` - -## Usage - -```typescript -import {Deck} from '@deck.gl/core'; -import {TileLayer, getTileLoadingState} from '@deck.gl/geo-layers'; - -const tileLayer = new TileLayer({ - data: 'https://tile.example.com/tiles/{z}/{x}/{y}.json', - getTileData: async ({index, id, bbox}) => { - const response = await fetch(getURLFromTemplate(id, index)); - if (!response.ok) throw new Error('Tile not found'); - return await response.json(); - }, - renderSubLayers: props => { - return new ScatterplotLayer(props); - } -}); - -// Check detailed loading state -const state = getTileLoadingState(tileLayer); - -console.log(`${state.loaded}/${state.total} tiles loaded`); -console.log(`${state.failed} tiles failed`); -console.log(`${state.percentLoaded}% complete`); - -if (state.isComplete && !state.isSuccess) { - console.warn('Some tiles failed to load'); -} -``` - -## Parameters - -### `layer` (TileLayer) - -A TileLayer instance to inspect. - -## Returns - -`TileLoadingState` object with the following properties: - -- **`total`** (`number`): Total number of tiles in the current viewport -- **`loaded`** (`number`): Number of tiles that loaded successfully (with content) -- **`failed`** (`number`): Number of tiles that failed to load (404, network error, etc.) -- **`pending`** (`number`): Number of tiles still loading -- **`percentLoaded`** (`number`): Percentage of successfully loaded tiles (0-100) -- **`isComplete`** (`boolean`): True if all tile requests are settled (loaded or failed) -- **`isSuccess`** (`boolean`): True if all tiles loaded successfully (no failures) - -## Understanding layer.isLoaded vs getTileLoadingState - -deck.gl's `layer.isLoaded` property returns `true` once all tile requests are "settled" (completed or failed). This is **intentional** to prevent waiting forever for tiles that will never load (404s, network errors, missing tiles, etc.). - -From the source code comments: - -> "Error / empty tiles resolve to `content === null`. Once Tile2DHeader marks those requests as loaded, do not wait for generated sublayers because there is nothing to render for that tile and `tile.layers` will remain null." - -### When to use each - -**Use `layer.isLoaded`** when you want to know: -- Are all tile requests complete? (boolean) -- Is the layer ready to render? -- Should I wait or proceed with what's available? - -**Use `getTileLoadingState()`** when you need: -- How many tiles loaded vs failed? (granular counts) -- Progress percentage for a loading indicator -- To distinguish between "loading", "loaded successfully", and "loaded with errors" -- To show error states in the UI - -## Examples - -### Progress Bar - -```typescript -function ProgressBar({tileLayer}) { - const state = getTileLoadingState(tileLayer); - - return ( -
-
- {state.loaded}/{state.total} tiles loaded - {state.failed > 0 && ( - {state.failed} failed - )} -
- ); -} -``` - -### Wait for Complete Load - -```typescript -// Wait for all tiles to load successfully -async function waitForTiles(layer: TileLayer): Promise { - return new Promise((resolve, reject) => { - const check = () => { - const state = getTileLoadingState(layer); - - if (!state.isComplete) { - // Still loading, check again - requestAnimationFrame(check); - return; - } - - if (state.isSuccess) { - resolve(); - } else { - reject(new Error(`${state.failed} tiles failed to load`)); - } - }; - - check(); - }); -} -``` - -### Video Export - -```typescript -// For video export, you might want to proceed even if some tiles failed -async function captureFrame(deck: Deck): Promise { - const tileLayers = deck.props.layers.filter(l => l instanceof TileLayer); - - // Check if all tile requests are settled (loaded or failed) - const allSettled = tileLayers.every(layer => { - const state = getTileLoadingState(layer); - return state.isComplete; - }); - - if (!allSettled) { - // Wait for tiles to finish loading or failing - await new Promise(resolve => setTimeout(resolve, 100)); - return captureFrame(deck); - } - - // Proceed with frame capture (may have missing tiles) - const canvas = deck.canvas as HTMLCanvasElement; - const imageData = canvas.toDataURL('image/png'); - - // Log any failures - tileLayers.forEach(layer => { - const state = getTileLoadingState(layer); - if (state.failed > 0) { - console.warn(`Layer ${layer.id}: ${state.failed} tiles failed`); - } - }); -} -``` - -### Error Recovery - -```typescript -function TileLayerWithRetry({...props}) { - const [retryCount, setRetryCount] = useState(0); - const layerRef = useRef(null); - - useEffect(() => { - if (!layerRef.current) return; - - const state = getTileLoadingState(layerRef.current); - - if (state.isComplete && !state.isSuccess && retryCount < 3) { - console.log(`${state.failed} tiles failed, retrying...`); - setRetryCount(retryCount + 1); - // Trigger layer update to retry failed tiles - layerRef.current.setNeedsUpdate(); - } - }, [retryCount]); - - return ; -} -``` - -## Source - -[modules/geo-layers/src/tile-layer/get-tile-loading-state.ts](https://github.com/visgl/deck.gl/tree/master/modules/geo-layers/src/tile-layer/get-tile-loading-state.ts) diff --git a/docs/api-reference/geo-layers/tile-layer.md b/docs/api-reference/geo-layers/tile-layer.md index f0326a28cfe..0997d4ef04e 100644 --- a/docs/api-reference/geo-layers/tile-layer.md +++ b/docs/api-reference/geo-layers/tile-layer.md @@ -473,6 +473,28 @@ const quadkeyTileLayer = new TileLayer({ }); ``` +## Methods + +### `getTileLoadingState()` {#gettileloadingstate} + +Returns granular loading counts for the tiles in the current viewport. Unlike `layer.isLoaded`, which is `true` once all tile requests settle (including failures), this method distinguishes between tiles that loaded successfully, failed, and are still pending. + +Returns a `TileLoadingState` object: + +| Field | Type | Description | +| --- | --- | --- | +| `total` | `number` | Total tiles in the current viewport | +| `loaded` | `number` | Tiles that loaded successfully (have content) | +| `failed` | `number` | Tiles that settled with no content (404, error, etc.) | +| `pending` | `number` | Tiles still in flight | + +```ts +import type {TileLoadingState} from '@deck.gl/geo-layers'; + +const state: TileLoadingState = tileLayer.getTileLoadingState(); +console.log(`${state.loaded}/${state.total} tiles loaded, ${state.failed} failed`); +``` + ## Source [modules/geo-layers/src/tile-layer](https://github.com/visgl/deck.gl/tree/master/modules/geo-layers/src/tile-layer) diff --git a/modules/geo-layers/src/index.ts b/modules/geo-layers/src/index.ts index 75dbd630460..2dbe29d6d60 100644 --- a/modules/geo-layers/src/index.ts +++ b/modules/geo-layers/src/index.ts @@ -27,9 +27,7 @@ export type {H3ClusterLayerProps} from './h3-layers/h3-cluster-layer'; export type {H3HexagonLayerProps} from './h3-layers/h3-hexagon-layer'; export type {GreatCircleLayerProps} from './great-circle-layer/great-circle-layer'; export type {S2LayerProps} from './s2-layer/s2-layer'; -export type {TileLayerProps, TileLayerPickingInfo} from './tile-layer/tile-layer'; -export type {TileLoadingState} from './tile-layer/get-tile-loading-state'; -export {getTileLoadingState} from './tile-layer/get-tile-loading-state'; +export type {TileLayerProps, TileLayerPickingInfo, TileLoadingState} from './tile-layer/tile-layer'; export type {TripsLayerProps} from './trips-layer/trips-layer'; export type {QuadkeyLayerProps} from './quadkey-layer/quadkey-layer'; export type {TerrainLayerProps} from './terrain-layer/terrain-layer'; diff --git a/modules/geo-layers/src/tile-layer/get-tile-loading-state.ts b/modules/geo-layers/src/tile-layer/get-tile-loading-state.ts deleted file mode 100644 index 374f3725d91..00000000000 --- a/modules/geo-layers/src/tile-layer/get-tile-loading-state.ts +++ /dev/null @@ -1,101 +0,0 @@ -// deck.gl -// SPDX-License-Identifier: MIT -// Copyright (c) vis.gl contributors - -import type {TileLayer} from './tile-layer'; -import type {Tile2DHeader} from '../tileset-2d/tile-2d-header'; - -export type TileLoadingState = { - /** Total number of tiles in the current viewport */ - total: number; - /** Number of tiles that have loaded successfully (with content) */ - loaded: number; - /** Number of tiles that failed to load (isLoaded but content is null) */ - failed: number; - /** Number of tiles still loading */ - pending: number; - /** Percentage of tiles loaded (loaded / total) */ - percentLoaded: number; - /** True if all tiles are done loading (loaded or failed) */ - isComplete: boolean; - /** True if all tiles loaded successfully (no failures) */ - isSuccess: boolean; -}; - -/** - * Get detailed loading state for tiles in a TileLayer's viewport. - * - * Unlike `layer.isLoaded` which only returns a boolean, this function provides - * granular information about tile loading progress, including: - * - How many tiles loaded successfully vs failed - * - Loading percentage for progress UI - * - Whether any tiles are still pending - * - * @param layer - A TileLayer instance - * @returns Detailed tile loading state - * - * @example - * ```typescript - * const state = getTileLoadingState(tileLayer); - * console.log(`${state.loaded}/${state.total} tiles loaded`); - * console.log(`${state.failed} tiles failed`); - * console.log(`${state.percentLoaded}% complete`); - * - * if (state.isComplete && !state.isSuccess) { - * console.warn('Some tiles failed to load'); - * } - * ``` - * - * @note - * deck.gl's `layer.isLoaded` returns `true` once all tile requests are "settled" - * (completed or failed). This is intentional to prevent waiting forever for tiles - * that will never load (404s, network errors, etc.). Use this function to distinguish - * between successfully loaded tiles and failed tiles. - */ -export function getTileLoadingState(layer: TileLayer): TileLoadingState { - const tileset = layer.state?.tileset; - const selectedTiles: Tile2DHeader[] = tileset?.selectedTiles || []; - - if (selectedTiles.length === 0) { - return { - total: 0, - loaded: 0, - failed: 0, - pending: 0, - percentLoaded: 100, - isComplete: true, - isSuccess: true - }; - } - - let loaded = 0; - let failed = 0; - let pending = 0; - - for (const tile of selectedTiles) { - if (!tile.isLoaded) { - pending++; - } else if (tile.content === null) { - // Tile request completed but returned no content (404, error, etc.) - failed++; - } else { - // Tile loaded successfully with content - loaded++; - } - } - - const total = selectedTiles.length; - const percentLoaded = total > 0 ? Math.round((loaded / total) * 100) : 100; - const isComplete = pending === 0; - const isSuccess = isComplete && failed === 0; - - return { - total, - loaded, - failed, - pending, - percentLoaded, - isComplete, - isSuccess - }; -} diff --git a/modules/geo-layers/src/tile-layer/tile-layer.ts b/modules/geo-layers/src/tile-layer/tile-layer.ts index de5e6b3a8fb..ea9b34025ad 100644 --- a/modules/geo-layers/src/tile-layer/tile-layer.ts +++ b/modules/geo-layers/src/tile-layer/tile-layer.ts @@ -18,6 +18,13 @@ import {GeoJsonLayer} from '@deck.gl/layers'; import {LayersList} from '@deck.gl/core'; import type {TileLoadProps, ZRange} from '../tileset-2d/index'; + +export type TileLoadingState = { + total: number; + loaded: number; + failed: number; + pending: number; +}; import { Tileset2D, Tile2DHeader, @@ -222,6 +229,19 @@ export default class TileLayer extends ); } + getTileLoadingState(): TileLoadingState { + const selectedTiles: Tile2DHeader[] = this.state?.tileset?.selectedTiles ?? []; + let loaded = 0; + let failed = 0; + let pending = 0; + for (const tile of selectedTiles) { + if (!tile.isLoaded) pending++; + else if (tile.content === null) failed++; + else loaded++; + } + return {total: selectedTiles.length, loaded, failed, pending}; + } + shouldUpdateState({changeFlags}): boolean { return changeFlags.somethingChanged; } diff --git a/test/modules/geo-layers/get-tile-loading-state.spec.ts b/test/modules/geo-layers/get-tile-loading-state.spec.ts index d6921bbcb94..e95c1562b9f 100644 --- a/test/modules/geo-layers/get-tile-loading-state.spec.ts +++ b/test/modules/geo-layers/get-tile-loading-state.spec.ts @@ -4,38 +4,33 @@ import {test, expect} from 'vitest'; import {TileLayer} from '@deck.gl/geo-layers'; -import {getTileLoadingState} from '@deck.gl/geo-layers/tile-layer/get-tile-loading-state'; const TEST_DATA = [ {position: [0, 0]}, {position: [1, 1]} ]; -test('getTileLoadingState#empty layer', () => { +test('TileLayer#getTileLoadingState - empty layer', () => { const layer = new TileLayer({ data: 'https://example.com/tiles/{z}/{x}/{y}', getTileData: () => null }); - const state = getTileLoadingState(layer); + const state = layer.getTileLoadingState(); expect(state.total).toBe(0); expect(state.loaded).toBe(0); expect(state.failed).toBe(0); expect(state.pending).toBe(0); - expect(state.percentLoaded).toBe(100); - expect(state.isComplete).toBe(true); - expect(state.isSuccess).toBe(true); }); -test('getTileLoadingState#all tiles loaded successfully', () => { +test('TileLayer#getTileLoadingState - all tiles loaded successfully', () => { const layer = new TileLayer({ id: 'test-layer', data: 'https://example.com/tiles/{z}/{x}/{y}', getTileData: async () => TEST_DATA }); - // Mock tileset state with loaded tiles layer.state = { tileset: { selectedTiles: [ @@ -46,78 +41,67 @@ test('getTileLoadingState#all tiles loaded successfully', () => { } } as any; - const state = getTileLoadingState(layer); + const state = layer.getTileLoadingState(); expect(state.total).toBe(3); expect(state.loaded).toBe(3); expect(state.failed).toBe(0); expect(state.pending).toBe(0); - expect(state.percentLoaded).toBe(100); - expect(state.isComplete).toBe(true); - expect(state.isSuccess).toBe(true); }); -test('getTileLoadingState#some tiles failed', () => { +test('TileLayer#getTileLoadingState - some tiles failed', () => { const layer = new TileLayer({ id: 'test-layer', data: 'https://example.com/tiles/{z}/{x}/{y}', getTileData: async () => TEST_DATA }); - // Mock tileset with successful and failed tiles layer.state = { tileset: { selectedTiles: [ - {isLoaded: true, content: TEST_DATA}, // Success - {isLoaded: true, content: null}, // Failed (404, error, etc.) - {isLoaded: true, content: TEST_DATA}, // Success - {isLoaded: true, content: null} // Failed + {isLoaded: true, content: TEST_DATA}, + {isLoaded: true, content: null}, + {isLoaded: true, content: TEST_DATA}, + {isLoaded: true, content: null} ] } } as any; - const state = getTileLoadingState(layer); + const state = layer.getTileLoadingState(); expect(state.total).toBe(4); expect(state.loaded).toBe(2); expect(state.failed).toBe(2); expect(state.pending).toBe(0); - expect(state.percentLoaded).toBe(50); - expect(state.isComplete).toBe(true); - expect(state.isSuccess).toBe(false); }); -test('getTileLoadingState#tiles still loading', () => { +test('TileLayer#getTileLoadingState - tiles still loading', () => { const layer = new TileLayer({ id: 'test-layer', data: 'https://example.com/tiles/{z}/{x}/{y}', getTileData: async () => TEST_DATA }); - // Mock tileset with loading tiles layer.state = { tileset: { selectedTiles: [ - {isLoaded: true, content: TEST_DATA}, // Loaded - {isLoaded: false, content: null}, // Still loading - {isLoaded: false, content: null}, // Still loading - {isLoaded: true, content: null} // Failed + {isLoaded: true, content: TEST_DATA}, + {isLoaded: false, content: null}, + {isLoaded: false, content: null}, + {isLoaded: true, content: null} ] } } as any; - const state = getTileLoadingState(layer); + const state = layer.getTileLoadingState(); expect(state.total).toBe(4); expect(state.loaded).toBe(1); expect(state.failed).toBe(1); expect(state.pending).toBe(2); - expect(state.percentLoaded).toBe(25); - expect(state.isComplete).toBe(false); - expect(state.isSuccess).toBe(false); }); -test('getTileLoadingState#all tiles failed', () => { +test('TileLayer#getTileLoadingState - all tiles failed', () => { const layer = new TileLayer({ id: 'test-layer', data: 'https://example.com/tiles/{z}/{x}/{y}', @@ -126,24 +110,20 @@ test('getTileLoadingState#all tiles failed', () => { } }); - // Mock tileset where all tiles failed layer.state = { tileset: { selectedTiles: [ - {isLoaded: true, content: null}, // Failed - {isLoaded: true, content: null}, // Failed - {isLoaded: true, content: null} // Failed + {isLoaded: true, content: null}, + {isLoaded: true, content: null}, + {isLoaded: true, content: null} ] } } as any; - const state = getTileLoadingState(layer); + const state = layer.getTileLoadingState(); expect(state.total).toBe(3); expect(state.loaded).toBe(0); expect(state.failed).toBe(3); expect(state.pending).toBe(0); - expect(state.percentLoaded).toBe(0); - expect(state.isComplete).toBe(true); - expect(state.isSuccess).toBe(false); }); diff --git a/test/modules/geo-layers/index.ts b/test/modules/geo-layers/index.ts index 5b761e7001d..abf473bd0c3 100644 --- a/test/modules/geo-layers/index.ts +++ b/test/modules/geo-layers/index.ts @@ -15,8 +15,7 @@ import { TileLayer, TripsLayer, TerrainLayer, - GeohashLayer, - getTileLoadingState + GeohashLayer } from '@deck.gl/geo-layers'; test('Top-level imports', () => { @@ -31,7 +30,6 @@ test('Top-level imports', () => { expect(TripsLayer, 'TripsLayer symbol imported').toBeTruthy(); expect(TerrainLayer, 'TerrainLayer symbol imported').toBeTruthy(); expect(GeohashLayer, 'GeohashLayer symbol imported').toBeTruthy(); - expect(getTileLoadingState, 'getTileLoadingState symbol imported').toBeTruthy(); }); import './a5-layer.spec'; From 5a07c8a9a01b439af59161e13d104faa803f7491 Mon Sep 17 00:00:00 2001 From: Adam Krebs Date: Fri, 12 Jun 2026 12:22:12 -0400 Subject: [PATCH 4/4] fix(test): format get-tile-loading-state.spec.ts with prettier Co-Authored-By: Claude Sonnet 4.6 --- test/modules/geo-layers/get-tile-loading-state.spec.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/test/modules/geo-layers/get-tile-loading-state.spec.ts b/test/modules/geo-layers/get-tile-loading-state.spec.ts index e95c1562b9f..fd505029acc 100644 --- a/test/modules/geo-layers/get-tile-loading-state.spec.ts +++ b/test/modules/geo-layers/get-tile-loading-state.spec.ts @@ -5,10 +5,7 @@ import {test, expect} from 'vitest'; import {TileLayer} from '@deck.gl/geo-layers'; -const TEST_DATA = [ - {position: [0, 0]}, - {position: [1, 1]} -]; +const TEST_DATA = [{position: [0, 0]}, {position: [1, 1]}]; test('TileLayer#getTileLoadingState - empty layer', () => { const layer = new TileLayer({