From 2b5d3ccfffda75f1da9cad713b13f9abbb6bbf10 Mon Sep 17 00:00:00 2001 From: Charles Richardson Date: Wed, 10 Jun 2026 16:03:06 -0400 Subject: [PATCH 1/2] Add coverage LOD strategy for tile layers --- docs/api-reference/geo-layers/tile-layer.md | 13 + .../src/terrain-layer/terrain-layer.ts | 6 +- .../geo-layers/src/tile-layer/tile-layer.ts | 11 + modules/geo-layers/src/tileset-2d/index.ts | 4 +- .../src/tileset-2d/tile-2d-header.ts | 10 +- .../geo-layers/src/tileset-2d/tileset-2d.ts | 215 ++++++- test/apps/lod-strategy-demo/app.tsx | 545 ++++++++++++++++++ test/apps/lod-strategy-demo/index.html | 26 + test/apps/lod-strategy-demo/package.json | 17 + .../tileset-2d/tile-2d-header.spec.ts | 36 ++ .../geo-layers/tileset-2d/tileset-2d.spec.ts | 190 ++++++ 11 files changed, 1047 insertions(+), 26 deletions(-) create mode 100644 test/apps/lod-strategy-demo/app.tsx create mode 100644 test/apps/lod-strategy-demo/index.html create mode 100644 test/apps/lod-strategy-demo/package.json diff --git a/docs/api-reference/geo-layers/tile-layer.md b/docs/api-reference/geo-layers/tile-layer.md index f0326a28cfe..65e82669a9b 100644 --- a/docs/api-reference/geo-layers/tile-layer.md +++ b/docs/api-reference/geo-layers/tile-layer.md @@ -317,6 +317,19 @@ Apps may define a custom `refinementStrategy` by supplying its own callback func When called, the function receives an array of [Tile](#tile) instances representing every tile that is currently in the cache. It is an opportunity to manipulate `tile.isVisible` before sub layers are rendered. `isVisible` is initially set to the value of `isSelected` (equivalent to `refinementStrategy: 'never'`). +#### `lodStrategy` (string, optional) {#lodstrategy} + +Controls whether the layer prefetches lower-resolution tiles to provide instant coverage during viewport transitions (panning, zooming, or camera flights). + +* `'none'`: Only request tiles at the current zoom level. No additional lower-resolution coverage tiles are fetched. +* `'coverage'`: In addition to the current zoom tiles, prefetch ancestor tiles at progressively lower zoom levels. These low-resolution tiles load quickly and act as fallback coverage while higher-resolution tiles are still loading, reducing blank areas during navigation. + +Use `'coverage'` when your application involves frequent camera movement (e.g. animated transitions, user exploration) and visual continuity matters more than minimizing network requests. Use `'none'` when bandwidth is constrained, tiles are expensive to fetch, or the viewport is mostly static. + +Note: The `'coverage'` strategy is designed for geospatial views (e.g. `MapView`) where tiles follow a power-of-2 hierarchy. It is not recommended for non-geospatial views (e.g. `FirstPersonView`, `OrthographicView`) where viewport movement patterns differ and ancestor tiles may not provide meaningful coverage. + +- Default: `'none'` + #### `maxRequests` (number, optional) {#maxrequests} The maximum number of concurrent `getTileData` calls. diff --git a/modules/geo-layers/src/terrain-layer/terrain-layer.ts b/modules/geo-layers/src/terrain-layer/terrain-layer.ts index 74eefb62696..41bb77f2aee 100644 --- a/modules/geo-layers/src/terrain-layer/terrain-layer.ts +++ b/modules/geo-layers/src/terrain-layer/terrain-layer.ts @@ -355,7 +355,8 @@ export default class TerrainLayer extends Composite onTileError, maxCacheSize, maxCacheByteSize, - refinementStrategy + refinementStrategy, + lodStrategy } = this.props; if (this.state.isTiled) { @@ -387,7 +388,8 @@ export default class TerrainLayer extends Composite onTileError, maxCacheSize, maxCacheByteSize, - refinementStrategy + refinementStrategy, + lodStrategy } ); } diff --git a/modules/geo-layers/src/tile-layer/tile-layer.ts b/modules/geo-layers/src/tile-layer/tile-layer.ts index de5e6b3a8fb..ce9f655345b 100644 --- a/modules/geo-layers/src/tile-layer/tile-layer.ts +++ b/modules/geo-layers/src/tile-layer/tile-layer.ts @@ -22,6 +22,7 @@ import { Tileset2D, Tile2DHeader, RefinementStrategy, + LODStrategy, STRATEGY_DEFAULT, Tileset2DProps } from '../tileset-2d/index'; @@ -47,6 +48,7 @@ const defaultProps: DefaultProps = { maxCacheSize: null, maxCacheByteSize: null, refinementStrategy: STRATEGY_DEFAULT, + lodStrategy: 'none', zRange: null, maxRequests: 6, debounceTime: 0, @@ -126,6 +128,13 @@ type _TileLayerProps = { */ refinementStrategy?: RefinementStrategy; + /** + * How the tile layer prefetches lower resolution coverage for smooth transitions. + * + * @default 'none' + */ + lodStrategy?: LODStrategy; + /** Range of minimum and maximum heights in the tile. */ zRange?: ZRange | null; @@ -261,6 +270,7 @@ export default class TileLayer extends maxCacheSize, maxCacheByteSize, refinementStrategy, + lodStrategy, extent, maxZoom, minZoom, @@ -278,6 +288,7 @@ export default class TileLayer extends minZoom, tileSize, refinementStrategy, + lodStrategy, extent, maxRequests, debounceTime, diff --git a/modules/geo-layers/src/tileset-2d/index.ts b/modules/geo-layers/src/tileset-2d/index.ts index d6ed77d3ccc..dbfe01fd3e8 100644 --- a/modules/geo-layers/src/tileset-2d/index.ts +++ b/modules/geo-layers/src/tileset-2d/index.ts @@ -13,8 +13,8 @@ export type { TileBoundingBox } from './types'; -export type {Tileset2DProps, RefinementStrategy} from './tileset-2d'; -export {Tileset2D, STRATEGY_DEFAULT} from './tileset-2d'; +export type {Tileset2DProps, RefinementStrategy, LODStrategy} from './tileset-2d'; +export {Tileset2D, STRATEGY_DEFAULT, LOD_STRATEGY_COVERAGE, LOD_STRATEGY_NONE} from './tileset-2d'; export {Tile2DHeader} from './tile-2d-header'; 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..6e4e5f662eb 100644 --- a/modules/geo-layers/src/tileset-2d/tile-2d-header.ts +++ b/modules/geo-layers/src/tileset-2d/tile-2d-header.ts @@ -10,6 +10,7 @@ import type {Layer} from '@deck.gl/core'; export type TileLoadDataProps = { requestScheduler: RequestScheduler; getData: (props: TileLoadProps) => Promise; + getPriority: (tile: Tile2DHeader) => number; onLoad: (tile: Tile2DHeader) => void; onError: (error: any, tile: Tile2DHeader) => void; }; @@ -18,6 +19,7 @@ export class Tile2DHeader { index: TileIndex; isVisible: boolean; isSelected: boolean; + isPrefetch: boolean; parent: Tile2DHeader | null; children: Tile2DHeader[] | null; content: DataT | null; @@ -41,6 +43,7 @@ export class Tile2DHeader { this.index = index; this.isVisible = false; this.isSelected = false; + this.isPrefetch = false; this.parent = null; this.children = []; @@ -106,6 +109,7 @@ export class Tile2DHeader { /* eslint-disable max-statements */ private async _loadData({ getData, + getPriority, requestScheduler, onLoad, onError @@ -116,10 +120,8 @@ export class Tile2DHeader { this._abortController = new AbortController(); const {signal} = this._abortController; - // @ts-expect-error (2345) Argument of type '(tile: any) => 1 | -1' is not assignable ... - const requestToken = await requestScheduler.scheduleRequest(this, tile => { - return tile.isSelected ? 1 : -1; - }); + // @ts-expect-error (2345) loaders.gl's RequestScheduler callback type is too narrow. + const requestToken = await requestScheduler.scheduleRequest(this, getPriority); if (!requestToken) { this._isCancelled = true; diff --git a/modules/geo-layers/src/tileset-2d/tileset-2d.ts b/modules/geo-layers/src/tileset-2d/tileset-2d.ts index e0752ec13a8..e595c491a68 100644 --- a/modules/geo-layers/src/tileset-2d/tileset-2d.ts +++ b/modules/geo-layers/src/tileset-2d/tileset-2d.ts @@ -39,6 +39,8 @@ const TILE_STATE_VISIBLE = 2; export const STRATEGY_NEVER = 'never'; export const STRATEGY_REPLACE = 'no-overlap'; export const STRATEGY_DEFAULT = 'best-available'; +export const LOD_STRATEGY_NONE = 'none'; +export const LOD_STRATEGY_COVERAGE = 'coverage'; export type RefinementStrategyFunction = (tiles: Tile2DHeader[]) => void; export type RefinementStrategy = @@ -46,8 +48,15 @@ export type RefinementStrategy = | 'no-overlap' | 'best-available' | RefinementStrategyFunction; +export type LODStrategy = 'none' | 'coverage'; const DEFAULT_CACHE_SCALE = 5; +const COVERAGE_ZOOM_DELTA = 1; +const MIN_RESOLUTION_COVERAGE_ZOOM = 6; +const MIN_RESOLUTION_FALLBACK_ZOOM = 4; +const SELECTED_TILE_PRIORITY = 0; +const VISIBLE_TILE_PRIORITY = 1e8; +const PREFETCH_TILE_PRIORITY = 2e8; const STRATEGIES = { [STRATEGY_DEFAULT]: updateTileStateDefault, @@ -73,6 +82,8 @@ export type Tileset2DProps = { maxCacheByteSize?: number | null; /** How the tile layer refines the visibility of tiles. @default 'best-available' */ refinementStrategy?: RefinementStrategy; + /** How the tile layer prefetches lower resolution coverage. @default 'none' */ + lodStrategy?: LODStrategy; /** Range of minimum and maximum heights in the tile. */ zRange?: ZRange | null; /** The maximum number of concurrent getTileData calls. @default 6 */ @@ -108,6 +119,7 @@ export const DEFAULT_TILESET2D_PROPS: Omit, 'getTileDat maxCacheSize: null, maxCacheByteSize: null, refinementStrategy: 'best-available', + lodStrategy: 'none', zRange: null, maxRequests: 6, debounceTime: 0, @@ -137,6 +149,8 @@ export class Tileset2D { private _viewport: Viewport | null; private _zRange: ZRange | null; private _selectedTiles: Tile2DHeader[] | null; + private _prefetchTiles: Tile2DHeader[]; + private _prefetchTilePriority: Map; private _frameNumber: number; private _modelMatrix: Matrix4; private _modelMatrixInverse: Matrix4; @@ -178,6 +192,8 @@ export class Tileset2D { this._viewport = null; this._zRange = null; this._selectedTiles = null; + this._prefetchTiles = []; + this._prefetchTilePriority = new Map(); this._frameNumber = 0; this._modelMatrix = new Matrix4(); @@ -269,6 +285,7 @@ export class Tileset2D { modelMatrixInverse: this._modelMatrixInverse }); this._selectedTiles = tileIndices.map(index => this._getTile(index, true)); + this._updatePrefetchTiles(); if (this._dirty) { // Some new tiles are added @@ -277,6 +294,7 @@ export class Tileset2D { // Check for needed reloads explicitly even if the view/matrix has not changed. } else if (this.needsReload) { this._selectedTiles = this._selectedTiles!.map(tile => this._getTile(tile.index, true)); + this._updatePrefetchTiles(); } // Update tile states @@ -410,17 +428,29 @@ export class Tileset2D { visibilities[i++] = tile.isVisible; tile.isSelected = false; tile.isVisible = false; + tile.isPrefetch = false; } // @ts-expect-error called only when _selectedTiles is already defined for (const tile of this._selectedTiles) { tile.isSelected = true; tile.isVisible = true; } + for (const tile of this._prefetchTiles) { + tile.isPrefetch = true; + } // Strategy-specific state logic - (typeof refinementStrategy === 'function' - ? refinementStrategy - : STRATEGIES[refinementStrategy])(Array.from(this._cache.values())); + const tiles = Array.from(this._cache.values()); + if ( + refinementStrategy === STRATEGY_DEFAULT && + this.opts.lodStrategy === LOD_STRATEGY_COVERAGE + ) { + updateTileStateCoverage(tiles, this._getMinCoverageFallbackZoom()); + } else { + (typeof refinementStrategy === 'function' + ? refinementStrategy + : STRATEGIES[refinementStrategy])(tiles); + } i = 0; // Check if any visibility has changed @@ -437,6 +467,23 @@ export class Tileset2D { private _getCullBounds = memoize(getCullBounds); + private _getRequestPriority(tile: Tile2DHeader): number { + // RequestScheduler loads lower priority values first. + if (tile.isSelected) { + return SELECTED_TILE_PRIORITY; + } + if (tile.isVisible) { + return VISIBLE_TILE_PRIORITY; + } + if (tile.isPrefetch) { + const prefetchPriority = this._prefetchTilePriority.get(tile.id); + const zoomPriority = + prefetchPriority === undefined ? this.getTileZoom(tile.index) : prefetchPriority; + return PREFETCH_TILE_PRIORITY + zoomPriority; + } + return -1; + } + private _pruneRequests(): void { const {maxRequests = 0} = this.opts; @@ -446,7 +493,7 @@ export class Tileset2D { // Keep track of all the ongoing requests if (tile.isLoading) { ongoingRequestCount++; - if (!tile.isSelected && !tile.isVisible) { + if (!tile.isSelected && !tile.isVisible && !tile.isPrefetch) { abortCandidates.push(tile); } } @@ -498,16 +545,10 @@ export class Tileset2D { const overflown = _cache.size > maxCacheSize || this._cacheByteSize > maxCacheByteSize; if (overflown) { - for (const [id, tile] of _cache) { - if (!tile.isVisible && !tile.isSelected) { - // delete tile - this._cacheByteSize -= opts.maxCacheByteSize !== null ? tile.byteLength : 0; - _cache.delete(id); - this.opts.onTileUnload?.(tile); - } - if (_cache.size <= maxCacheSize && this._cacheByteSize <= maxCacheByteSize) { - break; - } + this._evictTiles(tile => !tile.isVisible && !tile.isSelected && !tile.isPrefetch); + + if (_cache.size > maxCacheSize || this._cacheByteSize > maxCacheByteSize) { + this._evictTiles(tile => !tile.isVisible && !tile.isSelected); } this._rebuildTree(); this._dirty = true; @@ -521,6 +562,26 @@ export class Tileset2D { } /* eslint-enable complexity */ + private _evictTiles(shouldEvict: (tile: Tile2DHeader) => boolean): void { + const {_cache, opts} = this; + const maxCacheSize = + opts.maxCacheSize ?? + // @ts-expect-error called only when selectedTiles is initialized + (opts.maxCacheByteSize !== null ? Infinity : DEFAULT_CACHE_SCALE * this.selectedTiles.length); + const maxCacheByteSize = opts.maxCacheByteSize ?? Infinity; + + for (const [id, tile] of _cache) { + if (shouldEvict(tile)) { + this._cacheByteSize -= opts.maxCacheByteSize !== null ? tile.byteLength : 0; + _cache.delete(id); + this.opts.onTileUnload?.(tile); + } + if (_cache.size <= maxCacheSize && this._cacheByteSize <= maxCacheByteSize) { + break; + } + } + } + private _getTile(index: TileIndex, create: true): Tile2DHeader; private _getTile(index: TileIndex, create?: false): Tile2DHeader | undefined; private _getTile(index: TileIndex, create?: boolean): Tile2DHeader | undefined { @@ -542,6 +603,7 @@ export class Tileset2D { // eslint-disable-next-line @typescript-eslint/no-floating-promises tile.loadData({ getData: this.opts.getTileData, + getPriority: this._getRequestPriority.bind(this), requestScheduler: this._requestScheduler, onLoad: this.onTileLoad, onError: this.opts.onTileError @@ -551,6 +613,89 @@ export class Tileset2D { return tile; } + private _updatePrefetchTiles(): void { + this._prefetchTiles = []; + this._prefetchTilePriority.clear(); + if (this.opts.lodStrategy !== LOD_STRATEGY_COVERAGE || !this._selectedTiles) { + return; + } + + const minZoom = this._getMinCoverageZoom(); + const fallbackZoom = this._getMinCoverageFallbackZoom(); + const seen = new Set(); + for (const selectedTile of this._selectedTiles) { + const selectedZoom = this.getTileZoom(selectedTile.index); + if (selectedZoom > minZoom) { + this._updateCoveragePrefetchTiles(selectedTile.index, minZoom, fallbackZoom, seen); + } + } + } + + private _updateCoveragePrefetchTiles( + selectedIndex: TileIndex, + minZoom: number, + fallbackZoom: number, + seen: Set + ): void { + const selectedZoom = this.getTileZoom(selectedIndex); + const coverageZoom = Math.max(minZoom, selectedZoom - COVERAGE_ZOOM_DELTA); + const coverageZooms = [coverageZoom, minZoom]; + if (fallbackZoom < minZoom) { + coverageZooms.push(fallbackZoom); + } + + for (let priority = 0; priority < coverageZooms.length; priority++) { + const index = this._getAncestorIndex(selectedIndex, coverageZooms[priority]); + this._addCoveragePrefetchTile(index, priority, seen); + } + } + + private _addCoveragePrefetchTile( + index: TileIndex, + prefetchPriority: number, + seen: Set + ): void { + const id = this.getTileId(index); + const existingPriority = this._prefetchTilePriority.get(id); + if (existingPriority === undefined || prefetchPriority < existingPriority) { + this._prefetchTilePriority.set(id, prefetchPriority); + } + if (seen.has(id)) { + return; + } + seen.add(id); + + const tile = this._getTile(index, true); + if (tile && !tile.isSelected) { + tile.isPrefetch = true; + this._prefetchTiles.push(tile); + } + } + + private _getAncestorIndex(index: TileIndex, zoom: number): TileIndex { + let ancestor = index; + while (this.getTileZoom(ancestor) > zoom) { + ancestor = this.getParentIndex(ancestor); + } + return ancestor; + } + + private _getMinCoverageZoom(): number { + const minZoom = this._getMinCoverageFallbackZoom(); + if (this._viewport?.resolution) { + return Math.max(minZoom, MIN_RESOLUTION_COVERAGE_ZOOM); + } + return minZoom; + } + + private _getMinCoverageFallbackZoom(): number { + const minZoom = this._minZoom ?? 0; + if (this._viewport?.resolution) { + return Math.max(minZoom, MIN_RESOLUTION_FALLBACK_ZOOM); + } + return minZoom; + } + _getNearestAncestor(tile: Tile2DHeader): Tile2DHeader | null { const {_minZoom = 0} = this; @@ -586,6 +731,28 @@ function updateTileStateDefault(allTiles: Tile2DHeader[]) { } } +// Coverage LOD intentionally keeps coarse ancestors available. For pending selected tiles, +// render only immediate loaded children above the ancestor so the displayed LOD steps down +// gradually instead of jumping between stale deep children and very coarse coverage. +function updateTileStateCoverage(allTiles: Tile2DHeader[], minPlaceholderZoom: number) { + for (const tile of allTiles) { + tile.state = 0; + } + for (const tile of allTiles) { + if (tile.isSelected) { + if (isTileLoaded(tile)) { + tile.state! |= TILE_STATE_VISIBLE; + } else { + setPlaceholderInImmediateChildren(tile); + getPlaceholderInAncestors(tile, minPlaceholderZoom); + } + } + } + for (const tile of allTiles) { + tile.isVisible = Boolean(tile.state! & TILE_STATE_VISIBLE); + } +} + // Until a selected tile and all its selected siblings are loaded, use the closest ancestor as placeholder function updateTileStateReplace(allTiles: Tile2DHeader[]) { for (const tile of allTiles) { @@ -612,11 +779,15 @@ function updateTileStateReplace(allTiles: Tile2DHeader[]) { } } +function isTileLoaded(tile: Tile2DHeader): boolean { + return tile.isLoaded || Boolean(tile.content); +} + // Walk up the tree until we find one ancestor that is loaded. Returns true if successful. -function getPlaceholderInAncestors(startTile: Tile2DHeader) { +function getPlaceholderInAncestors(startTile: Tile2DHeader, minZoom = -Infinity) { let tile: Tile2DHeader | null = startTile; - while (tile) { - if (tile.isLoaded || tile.content) { + while (tile && tile.zoom >= minZoom) { + if (isTileLoaded(tile)) { tile.state! |= TILE_STATE_VISIBLE; return true; } @@ -625,10 +796,18 @@ function getPlaceholderInAncestors(startTile: Tile2DHeader) { return false; } +function setPlaceholderInImmediateChildren(tile: Tile2DHeader): void { + for (const child of tile.children || []) { + if (isTileLoaded(child) && child.zoom === tile.zoom + 1) { + child.state! |= TILE_STATE_VISIBLE; + } + } +} + // Recursively set children as placeholder function getPlaceholderInChildren(tile) { for (const child of tile.children) { - if (child.isLoaded || child.content) { + if (isTileLoaded(child)) { child.state |= TILE_STATE_VISIBLE; } else { getPlaceholderInChildren(child); diff --git a/test/apps/lod-strategy-demo/app.tsx b/test/apps/lod-strategy-demo/app.tsx new file mode 100644 index 00000000000..e02e140e774 --- /dev/null +++ b/test/apps/lod-strategy-demo/app.tsx @@ -0,0 +1,545 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {createRoot} from 'react-dom/client'; +import {DeckGL} from '@deck.gl/react'; +import {TileLayer} from '@deck.gl/geo-layers'; +import {BitmapLayer, PathLayer, TextLayer} from '@deck.gl/layers'; +import {FlyToInterpolator, _GlobeView as GlobeView} from '@deck.gl/core'; + +type DemoMode = 'before' | 'after'; + +type TileRecord = { + id: string; + order: number; + status: 'loading' | 'loaded' | 'aborted'; +}; + +type DemoTile = { + id: string; + order: number; + image: ImageBitmap; +}; + +type ViewState = { + longitude: number; + latitude: number; + zoom: number; + pitch: number; + bearing: number; + maxZoom: number; +}; + +const BASEMAP_TILE_URL = + 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'; + +const MIAMI_VIEW_STATE: ViewState = { + longitude: -80.1918, + latitude: 25.7617, + zoom: 12, + pitch: 32, + bearing: -14, + maxZoom: 20 +}; + +const MID_ROUTE_VIEW_STATE: ViewState = { + longitude: -77.04, + latitude: 35.32, + zoom: 8.8, + pitch: 78, + bearing: 34, + maxZoom: 20 +}; + +const NEW_YORK_VIEW_STATE: ViewState = { + longitude: -73.9851, + latitude: 40.7589, + zoom: 14.2, + pitch: 72, + bearing: 28, + maxZoom: 20 +}; + +const START_HOLD_MS = 5000; +const MID_ROUTE_DURATION_MS = 10000; +const NEW_YORK_DURATION_MS = 9000; +const FLY_DURATION_MS = MID_ROUTE_DURATION_MS + NEW_YORK_DURATION_MS; +const HOLD_DURATION_MS = 20000; +const MAX_REQUESTS = 6; + +const styles: Record = { + stage: { + position: 'fixed', + inset: 0, + display: 'grid', + gridTemplateColumns: '1fr 1fr', + background: '#07090b', + overflow: 'hidden' + }, + pane: { + position: 'relative', + minWidth: 0, + overflow: 'hidden' + }, + divider: { + position: 'fixed', + left: '50%', + top: 0, + bottom: 0, + width: 1, + background: 'linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.52), transparent)', + zIndex: 20 + }, + paneLabel: { + position: 'absolute', + top: 18, + left: 18, + zIndex: 10, + display: 'grid', + gap: 6, + minWidth: 188, + padding: '11px 12px', + border: '1px solid rgba(210, 228, 245, 0.18)', + borderRadius: 8, + background: 'linear-gradient(180deg, rgba(14, 20, 24, 0.8), rgba(7, 10, 13, 0.88))', + backdropFilter: 'blur(14px) saturate(135%)', + boxShadow: '0 18px 44px rgba(0, 0, 0, 0.34)', + color: '#f8fafc' + }, + labelTitle: { + display: 'flex', + alignItems: 'center', + gap: 8, + fontSize: 12, + fontWeight: 850 + }, + labelDot: { + width: 8, + height: 8, + borderRadius: '50%', + flex: '0 0 auto' + }, + labelSubtitle: { + color: '#a9b7c4', + fontSize: 11, + fontWeight: 650 + }, + labelStats: { + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: 6, + marginTop: 2, + fontVariantNumeric: 'tabular-nums' + }, + stat: { + border: '1px solid rgba(210, 228, 245, 0.13)', + borderRadius: 6, + padding: '6px 7px', + background: 'rgba(255, 255, 255, 0.05)' + }, + statLabel: { + color: '#96a5b3', + fontSize: 9, + fontWeight: 800 + }, + statValue: { + color: '#fff6b8', + fontSize: 16, + fontWeight: 850 + }, + routeBadge: { + position: 'fixed', + left: '50%', + top: 18, + transform: 'translateX(-50%)', + zIndex: 30, + padding: '9px 14px', + border: '1px solid rgba(210, 228, 245, 0.2)', + borderRadius: 999, + background: 'rgba(7, 10, 13, 0.78)', + backdropFilter: 'blur(14px) saturate(135%)', + color: '#f8fafc', + fontSize: 12, + fontWeight: 800, + boxShadow: '0 14px 36px rgba(0, 0, 0, 0.36)', + fontVariantNumeric: 'tabular-nums' + }, + progressTrack: { + position: 'fixed', + left: '50%', + bottom: 18, + transform: 'translateX(-50%)', + zIndex: 30, + width: 360, + height: 5, + borderRadius: 999, + background: 'rgba(255, 255, 255, 0.16)', + overflow: 'hidden' + }, + progressFill: { + height: '100%', + borderRadius: 999, + background: 'linear-gradient(90deg, #fff06a, #67c4ff)' + }, + crosshair: { + position: 'absolute', + left: '50%', + top: '50%', + zIndex: 9, + width: 34, + height: 34, + marginLeft: -17, + marginTop: -17, + border: '2px solid #fff6b8', + borderRadius: '50%', + pointerEvents: 'none' + }, + crosshairLineH: { + position: 'absolute', + left: -18, + top: 15, + width: 70, + height: 2, + background: '#fff6b8' + }, + crosshairLineV: { + position: 'absolute', + left: 15, + top: -18, + width: 2, + height: 70, + background: '#fff6b8' + } +}; + +function getOrderColor(mode: DemoMode, order: number): [number, number, number, number] { + if (mode === 'before') { + return order <= 4 ? [255, 143, 82, 230] : [130, 171, 202, 205]; + } + if (order <= 4) { + return [255, 240, 106, 235]; + } + if (order <= 8) { + return [103, 196, 255, 220]; + } + return [149, 223, 185, 205]; +} + +function getTileCenter({ + bbox +}: { + bbox: {west: number; south: number; east: number; north: number}; +}): [number, number] { + return [(bbox.west + bbox.east) / 2, (bbox.south + bbox.north) / 2]; +} + +function getTilePath({ + bbox +}: { + bbox: {west: number; south: number; east: number; north: number}; +}): number[][] { + const {west, south, east, north} = bbox; + return [ + [west, south], + [east, south], + [east, north], + [west, north], + [west, south] + ]; +} + +function wait(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timeout = window.setTimeout(resolve, ms); + signal?.addEventListener( + 'abort', + () => { + window.clearTimeout(timeout); + reject(new Error('Tile request aborted')); + }, + {once: true} + ); + }); +} + +function interpolateFlyToViewState(start: ViewState, end: ViewState, t: number): ViewState { + const interpolator = new FlyToInterpolator({curve: 1.15, speed: 0.65}); + const width = Math.max(1, window.innerWidth / 2); + const height = Math.max(1, window.innerHeight); + const easedT = easeInOutCubic(t); + const viewport = interpolator.interpolateProps( + {...start, width, height}, + {...end, width, height}, + easedT + ) as ViewState; + + return { + longitude: viewport.longitude, + latitude: viewport.latitude, + zoom: viewport.zoom, + pitch: viewport.pitch, + bearing: viewport.bearing, + maxZoom: end.maxZoom + }; +} + +function easeInOutCubic(t: number): number { + const clampedT = Math.max(0, Math.min(t, 1)); + return clampedT < 0.5 ? 4 * clampedT ** 3 : 1 - (-2 * clampedT + 2) ** 3 / 2; +} + +function makeTileId(url: string): string { + const tilePath = url.match(/\/(\d+)\/(\d+)\/(\d+)(?:\.\w+)?(?:[?#].*)?$/); + return tilePath ? `${tilePath[1]}-${tilePath[3]}-${tilePath[2]}` : 'tile'; +} + +function DemoPane({ + mode, + viewState, + generation, + title, + subtitle +}: { + mode: DemoMode; + viewState: ViewState; + generation: number; + title: string; + subtitle: string; +}) { + const [records, setRecords] = useState([]); + const requestOrderRef = useRef(0); + const generationRef = useRef(generation); + + useEffect(() => { + generationRef.current = generation; + requestOrderRef.current = 0; + setRecords([]); + }, [generation]); + + const fetchTile = useCallback(async (url: string, {signal}: {signal?: AbortSignal}) => { + const requestGeneration = generationRef.current; + const id = makeTileId(url); + const order = ++requestOrderRef.current; + + setRecords(current => [ + {id, order, status: 'loading'}, + ...current.filter(item => item.id !== id) + ]); + + try { + const response = await fetch(url, {signal}); + if (!response.ok) { + throw new Error(`Tile request failed: ${response.status}`); + } + const blob = await response.blob(); + const image = await createImageBitmap(blob); + await wait(250, signal); + setRecords(current => + requestGeneration === generationRef.current + ? current.map(item => (item.id === id ? {...item, status: 'loaded'} : item)) + : current + ); + return {id, order, image}; + } catch (error) { + setRecords(current => + requestGeneration === generationRef.current + ? current.map(item => (item.id === id ? {...item, status: 'aborted'} : item)) + : current + ); + throw error; + } + }, []); + + const layer = useMemo( + () => + new TileLayer({ + id: `${mode}-tiles`, + minZoom: 0, + maxZoom: 20, + tileSize: 512, + maxRequests: MAX_REQUESTS, + refinementStrategy: 'best-available', + lodStrategy: mode === 'after' ? 'coverage' : 'none', + data: [BASEMAP_TILE_URL], + fetch: fetchTile, + renderSubLayers: props => { + const {data, tile} = props; + if (!data) { + return null; + } + const {west, south, east, north} = tile.bbox; + return [ + new BitmapLayer(props, { + id: `${props.id}-satellite`, + image: data.image, + bounds: [west, south, east, north] + }), + new PathLayer(props, { + id: `${props.id}-lod-border`, + data: [{bbox: tile.bbox, order: data.order}], + getPath: getTilePath, + getColor: d => getOrderColor(mode, d.order), + widthMinPixels: mode === 'after' ? 3 : 2 + }), + new TextLayer(props, { + id: `${props.id}-label`, + data: [data], + getPosition: () => getTileCenter(tile), + getText: d => `#${d.order}`, + getSize: 28, + getColor: d => getOrderColor(mode, d.order), + getBackgroundColor: [6, 10, 14, 215], + background: true, + backgroundPadding: [7, 4], + getTextAnchor: 'middle', + getAlignmentBaseline: 'center' + }) + ]; + } + }), + [fetchTile, generation, mode] + ); + + const loadingCount = records.filter(record => record.status === 'loading').length; + const loadedCount = records.filter(record => record.status === 'loaded').length; + const dotColor = mode === 'before' ? '#ff8f52' : '#fff06a'; + + return ( +
+ +
+
+
+
+
+
+ + {title} +
+
{subtitle}
+
+
+
Loading
+
{loadingCount}
+
+
+
Loaded
+
{loadedCount}
+
+
+
+
+ ); +} + +export default function App() { + const [viewState, setViewState] = useState(MIAMI_VIEW_STATE); + const [progress, setProgress] = useState(0); + const [generation, setGeneration] = useState(0); + const generationRef = useRef(0); + const loopStartTimeRef = useRef(performance.now()); + + const startLoop = useCallback((now = performance.now()) => { + const nextGeneration = generationRef.current + 1; + generationRef.current = nextGeneration; + loopStartTimeRef.current = now; + + setGeneration(nextGeneration); + setProgress(0); + setViewState({...MIAMI_VIEW_STATE}); + }, []); + + useEffect(() => { + let frameId = 0; + const loopDuration = START_HOLD_MS + FLY_DURATION_MS + HOLD_DURATION_MS; + + const frame = (now: number) => { + let elapsed = now - loopStartTimeRef.current; + if (elapsed >= loopDuration) { + startLoop(now); + elapsed = 0; + } + + const flightElapsed = elapsed - START_HOLD_MS; + const nextProgress = Math.max(0, Math.min(flightElapsed / FLY_DURATION_MS, 1)); + setProgress(nextProgress); + + if (flightElapsed <= 0) { + setViewState({...MIAMI_VIEW_STATE}); + } else if (flightElapsed < MID_ROUTE_DURATION_MS) { + setViewState( + interpolateFlyToViewState( + MIAMI_VIEW_STATE, + MID_ROUTE_VIEW_STATE, + flightElapsed / MID_ROUTE_DURATION_MS + ) + ); + } else if (flightElapsed < FLY_DURATION_MS) { + setViewState( + interpolateFlyToViewState( + MID_ROUTE_VIEW_STATE, + NEW_YORK_VIEW_STATE, + (flightElapsed - MID_ROUTE_DURATION_MS) / NEW_YORK_DURATION_MS + ) + ); + } else { + setViewState({...NEW_YORK_VIEW_STATE}); + } + + frameId = requestAnimationFrame(frame); + }; + + startLoop(); + frameId = requestAnimationFrame(frame); + return () => { + cancelAnimationFrame(frameId); + }; + }, [startLoop]); + + return ( +
+ + +
+
+ Miami to New York | Pitch {Math.round(viewState.pitch)} | Bearing{' '} + {Math.round(viewState.bearing)} +
+
+
+
+
+ ); +} + +export function renderToDOM(container: HTMLDivElement) { + createRoot(container).render(); +} + +const container = document.getElementById('app'); + +if (container) { + renderToDOM(container as HTMLDivElement); +} diff --git a/test/apps/lod-strategy-demo/index.html b/test/apps/lod-strategy-demo/index.html new file mode 100644 index 00000000000..0cdebe2bc34 --- /dev/null +++ b/test/apps/lod-strategy-demo/index.html @@ -0,0 +1,26 @@ + + + + + LOD strategy demo + + + +
+ + + diff --git a/test/apps/lod-strategy-demo/package.json b/test/apps/lod-strategy-demo/package.json new file mode 100644 index 00000000000..4bee96ea029 --- /dev/null +++ b/test/apps/lod-strategy-demo/package.json @@ -0,0 +1,17 @@ +{ + "scripts": { + "start": "vite --open", + "start-local": "vite --config ../vite.config.local.mjs" + }, + "dependencies": { + "@deck.gl/core": "^9.3.0-beta.2", + "@deck.gl/geo-layers": "^9.3.0-beta.2", + "@deck.gl/layers": "^9.3.0-beta.2", + "@deck.gl/react": "^9.3.0-beta.2", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "devDependencies": { + "vite": "^7.3.1" + } +} 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..dd01f4057da 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 @@ -6,6 +6,8 @@ import {test, expect} from 'vitest'; import {_Tile2DHeader as Tile2DHeader} from '@deck.gl/geo-layers'; import {RequestScheduler} from '@loaders.gl/loader-utils'; +const getPriority = tile => (tile.isSelected ? 1 : -1); + test('Tile2DHeader', async () => { let onTileLoadCalled = false; let onTileErrorCalled = false; @@ -14,6 +16,7 @@ test('Tile2DHeader', async () => { let tile2d = new Tile2DHeader({}); await tile2d.loadData({ requestScheduler, + getPriority, getData: () => 'loaded data', onLoad: () => (onTileLoadCalled = true), onError: () => (onTileErrorCalled = true) @@ -26,6 +29,7 @@ test('Tile2DHeader', async () => { tile2d = new Tile2DHeader({}); await tile2d.loadData({ requestScheduler, + getPriority, getData: () => { throw new Error('getTileData error'); }, @@ -44,6 +48,7 @@ test('Tile2DHeader#Cancel request if not selected', async () => { const requestScheduler = new RequestScheduler({throttleRequests: true, maxRequests: 1}); const opts = { requestScheduler, + getPriority, getData: () => tileRequestCount++, onLoad: () => onTileLoadCalled++, onError: () => onTileErrorCalled++ @@ -66,6 +71,35 @@ test('Tile2DHeader#Cancel request if not selected', async () => { expect(onTileLoadCalled === 1 && onTileErrorCalled === 0, 'Callbacks invoked').toBeTruthy(); }); +test('Tile2DHeader#request priority', async () => { + const requestOrder: string[] = []; + const requestScheduler = new RequestScheduler({throttleRequests: true, maxRequests: 1}); + const opts = { + requestScheduler, + getPriority: tile => (tile.id === 'preferred' ? 0 : 10), + getData: ({id}) => { + requestOrder.push(id); + return id; + }, + onLoad: () => {}, + onError: () => {} + }; + + const edgeTile = new Tile2DHeader({}); + edgeTile.id = 'edge'; + edgeTile.isSelected = true; + const preferredTile = new Tile2DHeader({}); + preferredTile.id = 'preferred'; + preferredTile.isSelected = true; + + const edgeLoader = edgeTile.loadData(opts); + const preferredLoader = preferredTile.loadData(opts); + await edgeLoader; + await preferredLoader; + + expect(requestOrder, 'lower request priority values load first').toEqual(['preferred', 'edge']); +}); + test('Tile2DHeader#abort', async () => { const requestScheduler = new RequestScheduler({throttleRequests: true, maxRequests: 1}); let onTileLoadCalled = false; @@ -73,6 +107,7 @@ test('Tile2DHeader#abort', async () => { const opts = { requestScheduler, + getPriority, getData: () => null, onLoad: () => (onTileLoadCalled = true), onError: () => (onTileErrorCalled = true) @@ -104,6 +139,7 @@ test('Tile2DHeader#reload', async () => { let onTileErrorCalled = 0; const opts = { requestScheduler, + getPriority, onLoad: () => onTileLoadCalled++, onError: () => onTileErrorCalled++ }; 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..add64f14c30 100644 --- a/test/modules/geo-layers/tileset-2d/tileset-2d.spec.ts +++ b/test/modules/geo-layers/tileset-2d/tileset-2d.spec.ts @@ -22,6 +22,12 @@ const testViewState = { // testViewState should load tile 12-1171-1566 const testViewport = new WebMercatorViewport(testViewState); +class ResolutionViewport extends WebMercatorViewport { + get resolution() { + return 1; + } +} + const getTileData = () => Promise.resolve(null); test('Tileset2D#constructor', () => { @@ -55,6 +61,178 @@ test('Tileset2D#update', () => { expect(tileset.tiles[0].bbox, 'tile has metadata').toBeTruthy(); }); +test('Tileset2D#update with coverage LOD', () => { + const tileset = new Tileset2D({ + getTileData, + lodStrategy: 'coverage', + onTileLoad: () => {} + }); + tileset.update(testViewport); + + expect(tileset._cache.get('0-0-0')?.isPrefetch, 'root coverage tile is prefetched').toBe(true); + expect( + tileset._cache.get('585-783-11')?.isPrefetch, + 'nearest lower resolution coverage tile is prefetched' + ).toBe(true); + expect(tileset._cache.get('1171-1566-12')?.isSelected, 'target tile remains selected').toBe(true); + + tileset.finalize(); +}); + +test('Tileset2D#coverage LOD uses higher minimum coverage zoom for resolution viewports', () => { + const tileset = new Tileset2D({ + getTileData, + lodStrategy: 'coverage', + onTileLoad: () => {} + }); + const resolutionViewport = new ResolutionViewport(testViewState); + + tileset.update(resolutionViewport); + + expect(tileset._cache.get('0-0-0')?.isPrefetch, 'root tile is not prefetched').not.toBe(true); + expect(tileset._cache.get('4-6-4')?.isPrefetch, 'fallback coverage tile is prefetched').toBe( + true + ); + expect(tileset._cache.get('18-24-6')?.isPrefetch, 'minimum coverage tile is prefetched').toBe( + true + ); + expect( + tileset._cache.get('585-783-11')?.isPrefetch, + 'nearest lower resolution coverage tile is prefetched' + ).toBe(true); + + tileset.finalize(); +}); + +test('Tileset2D#coverage LOD prioritizes closer prefetch ancestors before safety fallback', () => { + const tileset = new Tileset2D({ + getTileData, + lodStrategy: 'coverage', + onTileLoad: () => {} + }); + const resolutionViewport = new ResolutionViewport(testViewState); + + tileset.update(resolutionViewport); + + expect((tileset as any)._getRequestPriority(tileset._cache.get('585-783-11'))).toBeLessThan( + (tileset as any)._getRequestPriority(tileset._cache.get('18-24-6')) + ); + expect((tileset as any)._getRequestPriority(tileset._cache.get('18-24-6'))).toBeLessThan( + (tileset as any)._getRequestPriority(tileset._cache.get('4-6-4')) + ); + + tileset.finalize(); +}); + +test('Tileset2D#coverage LOD keeps immediate child placeholders over coarse ancestors', () => { + const tileset = new Tileset2D({ + getTileData: () => new Promise(() => {}), + lodStrategy: 'coverage', + refinementStrategy: 'best-available', + onTileLoad: () => {} + }); + Object.assign(tileset, { + _viewport: new WebMercatorViewport({...testViewState, width: 100, height: 100, zoom: 2}) + }); + + const root = seedTile(tileset, {x: 0, y: 0, z: 0}, true); + const selected = seedTile(tileset, {x: 0, y: 0, z: 1}, false); + const child = seedTile(tileset, {x: 0, y: 0, z: 2}, true); + const grandchild = seedTile(tileset, {x: 0, y: 0, z: 3}, true); + + (tileset as any)._rebuildTree(); + (tileset as any)._selectedTiles = [selected]; + (tileset as any).updateTileStates(); + + expect(root.isVisible, 'coarse ancestor remains visible underneath').toBe(true); + expect(child.isVisible, 'immediate loaded child remains visible as the closest placeholder').toBe( + true + ); + expect(selected.isVisible, 'pending selected tile is hidden').toBe(false); + expect(grandchild.isVisible, 'deeper stale child is not used').toBe(false); + + tileset.finalize(); +}); + +test('Tileset2D#coverage LOD keeps direct child placeholders while zooming out', () => { + const tileset = new Tileset2D({ + getTileData: () => new Promise(() => {}), + lodStrategy: 'coverage', + refinementStrategy: 'best-available', + onTileLoad: () => {} + }); + Object.assign(tileset, { + _viewport: new WebMercatorViewport({...testViewState, width: 100, height: 100, zoom: 2.4}) + }); + + const root = seedTile(tileset, {x: 0, y: 0, z: 0}, true); + const selected = seedTile(tileset, {x: 0, y: 0, z: 2}, false); + const child = seedTile(tileset, {x: 0, y: 0, z: 3}, true); + + (tileset as any)._rebuildTree(); + (tileset as any)._selectedTiles = [selected]; + (tileset as any).updateTileStates(); + + expect(root.isVisible, 'coarse ancestor remains visible underneath').toBe(true); + expect(child.isVisible, 'direct child remains visible while selected tile is pending').toBe(true); + + tileset.finalize(); +}); + +test('Tileset2D#coverage LOD ignores cached descendants that are not direct children', () => { + const tileset = new Tileset2D({ + getTileData: () => new Promise(() => {}), + lodStrategy: 'coverage', + refinementStrategy: 'best-available', + onTileLoad: () => {} + }); + Object.assign(tileset, { + _viewport: new WebMercatorViewport({...testViewState, width: 100, height: 100, zoom: 2}) + }); + + const root = seedTile(tileset, {x: 0, y: 0, z: 0}, true); + const selected = seedTile(tileset, {x: 0, y: 0, z: 2}, false); + const staleDescendant = seedTile(tileset, {x: 0, y: 0, z: 4}, true); + + (tileset as any)._rebuildTree(); + (tileset as any)._selectedTiles = [selected]; + (tileset as any).updateTileStates(); + + expect(root.isVisible, 'coarse ancestor covers pending selected tile').toBe(true); + expect(selected.isVisible, 'pending selected tile is hidden').toBe(false); + expect(staleDescendant.isVisible, 'deeper cached descendant is not used as placeholder').toBe( + false + ); + + tileset.finalize(); +}); + +test('Tileset2D#coverage LOD keeps cached root below the resolution fallback floor', () => { + const tileset = new Tileset2D({ + getTileData: () => new Promise(() => {}), + lodStrategy: 'coverage', + refinementStrategy: 'best-available', + onTileLoad: () => {} + }); + Object.assign(tileset, { + _viewport: new ResolutionViewport({...testViewState, width: 100, height: 100, zoom: 6}) + }); + + const root = seedTile(tileset, {x: 0, y: 0, z: 0}, true); + const fallback = seedTile(tileset, {x: 4, y: 6, z: 4}, true); + const selected = seedTile(tileset, {x: 18, y: 24, z: 6}, false); + + (tileset as any)._rebuildTree(); + (tileset as any)._selectedTiles = [selected]; + (tileset as any).updateTileStates(); + + expect(fallback.isVisible, 'fallback ancestor covers pending selected tile').toBe(true); + expect(root.isVisible, 'root remains below the visible fallback floor').toBe(false); + expect(selected.isVisible, 'pending selected tile is hidden').toBe(false); + + tileset.finalize(); +}); + test('Tileset2D#updateOnModelMatrix', () => { const tileset = new Tileset2D({ getTileData, @@ -669,6 +847,18 @@ function validateVisibility(strategy, selectedTiles, tiles) { return null; } +function seedTile(tileset, index, loaded) { + const tile = (tileset as any)._getTile(index, true); + tile.content = loaded ? {} : null; + Object.assign(tile, { + _isLoaded: loaded, + _loader: undefined, + _isCancelled: false, + _needsReload: false + }); + return tile; +} + function contains(bbox, point) { return ( point[0] >= bbox.west && From 6cd87e4cb8139fd93d2c18bad0a07df813cc1f94 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:50:20 +0000 Subject: [PATCH 2/2] fix: resolve merge conflicts with origin/master --- .../src/terrain-layer/terrain-layer.ts | 10 ++---- .../src/tileset-2d/tile-2d-header.ts | 13 -------- .../geo-layers/src/tileset-2d/tileset-2d.ts | 31 +++++-------------- .../tileset-2d/tile-2d-header.spec.ts | 28 ----------------- .../geo-layers/tileset-2d/tileset-2d.spec.ts | 14 +++++---- 5 files changed, 17 insertions(+), 79 deletions(-) diff --git a/modules/geo-layers/src/terrain-layer/terrain-layer.ts b/modules/geo-layers/src/terrain-layer/terrain-layer.ts index b45f430c1a3..9f08b000e98 100644 --- a/modules/geo-layers/src/terrain-layer/terrain-layer.ts +++ b/modules/geo-layers/src/terrain-layer/terrain-layer.ts @@ -357,11 +357,8 @@ export default class TerrainLayer extends Composite maxCacheSize, maxCacheByteSize, refinementStrategy, -<<<<<<< HEAD - lodStrategy -======= + lodStrategy, zoomOffset ->>>>>>> origin/master } = this.props; if (this.state.isTiled) { @@ -395,11 +392,8 @@ export default class TerrainLayer extends Composite maxCacheSize, maxCacheByteSize, refinementStrategy, -<<<<<<< HEAD - lodStrategy -======= + lodStrategy, zoomOffset ->>>>>>> origin/master } ); } 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 872607702dc..26f7d620df2 100644 --- a/modules/geo-layers/src/tileset-2d/tile-2d-header.ts +++ b/modules/geo-layers/src/tileset-2d/tile-2d-header.ts @@ -10,11 +10,7 @@ import type {Layer} from '@deck.gl/core'; export type TileLoadDataProps = { requestScheduler: RequestScheduler; getData: (props: TileLoadProps) => Promise; -<<<<<<< HEAD - getPriority: (tile: Tile2DHeader) => number; -======= getRequestPriority: (tile: Tile2DHeader) => number; ->>>>>>> origin/master onLoad: (tile: Tile2DHeader) => void; onError: (error: any, tile: Tile2DHeader) => void; }; @@ -113,11 +109,7 @@ export class Tile2DHeader { /* eslint-disable max-statements */ private async _loadData({ getData, -<<<<<<< HEAD - getPriority, -======= getRequestPriority, ->>>>>>> origin/master requestScheduler, onLoad, onError @@ -128,12 +120,7 @@ export class Tile2DHeader { this._abortController = new AbortController(); const {signal} = this._abortController; - // @ts-expect-error (2345) loaders.gl's RequestScheduler callback type is too narrow. -<<<<<<< HEAD - const requestToken = await requestScheduler.scheduleRequest(this, getPriority); -======= const requestToken = await requestScheduler.scheduleRequest(this, getRequestPriority); ->>>>>>> origin/master if (!requestToken) { this._isCancelled = true; diff --git a/modules/geo-layers/src/tileset-2d/tileset-2d.ts b/modules/geo-layers/src/tileset-2d/tileset-2d.ts index 84726986aaf..45657db0f31 100644 --- a/modules/geo-layers/src/tileset-2d/tileset-2d.ts +++ b/modules/geo-layers/src/tileset-2d/tileset-2d.ts @@ -51,18 +51,13 @@ export type RefinementStrategy = export type LODStrategy = 'none' | 'coverage'; const DEFAULT_CACHE_SCALE = 5; -<<<<<<< HEAD const COVERAGE_ZOOM_DELTA = 1; const MIN_RESOLUTION_COVERAGE_ZOOM = 6; const MIN_RESOLUTION_FALLBACK_ZOOM = 4; const SELECTED_TILE_PRIORITY = 0; const VISIBLE_TILE_PRIORITY = 1e8; const PREFETCH_TILE_PRIORITY = 2e8; -======= -const SELECTED_TILE_PRIORITY = 0; -const VISIBLE_TILE_PRIORITY = 1e8; const MAX_TILE_DISTANCE_PRIORITY = VISIBLE_TILE_PRIORITY - SELECTED_TILE_PRIORITY - 1; ->>>>>>> origin/master const STRATEGIES = { [STRATEGY_DEFAULT]: updateTileStateDefault, @@ -474,13 +469,17 @@ export class Tileset2D { private _getCullBounds = memoize(getCullBounds); private _getRequestPriority(tile: Tile2DHeader): number { -<<<<<<< HEAD // RequestScheduler loads lower priority values first. + if (!tile.isSelected && !tile.isVisible && !tile.isPrefetch) { + return -1; + } + + const distance = this._getTileDistancePriority(tile); if (tile.isSelected) { - return SELECTED_TILE_PRIORITY; + return SELECTED_TILE_PRIORITY + distance; } if (tile.isVisible) { - return VISIBLE_TILE_PRIORITY; + return VISIBLE_TILE_PRIORITY + distance; } if (tile.isPrefetch) { const prefetchPriority = this._prefetchTilePriority.get(tile.id); @@ -489,17 +488,6 @@ export class Tileset2D { return PREFETCH_TILE_PRIORITY + zoomPriority; } return -1; -======= - if (!tile.isSelected && !tile.isVisible) { - return -1; - } - - // RequestScheduler loads lower priority values first. - const distance = this._getTileDistancePriority(tile); - if (tile.isSelected) { - return SELECTED_TILE_PRIORITY + distance; - } - return VISIBLE_TILE_PRIORITY + distance; } private _getTileDistancePriority(tile: Tile2DHeader): number { @@ -583,7 +571,6 @@ export class Tileset2D { const distanceX = x - segmentX; const distanceY = y - segmentY; return distanceX * distanceX + distanceY * distanceY; ->>>>>>> origin/master } private _pruneRequests(): void { @@ -705,11 +692,7 @@ export class Tileset2D { // eslint-disable-next-line @typescript-eslint/no-floating-promises tile.loadData({ getData: this.opts.getTileData, -<<<<<<< HEAD - getPriority: this._getRequestPriority.bind(this), -======= getRequestPriority: this._getRequestPriority.bind(this), ->>>>>>> origin/master requestScheduler: this._requestScheduler, onLoad: this.onTileLoad, onError: this.opts.onTileError 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 b97cb538059..c804724af37 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 @@ -6,11 +6,7 @@ import {test, expect} from 'vitest'; import {_Tile2DHeader as Tile2DHeader} from '@deck.gl/geo-layers'; import {RequestScheduler} from '@loaders.gl/loader-utils'; -<<<<<<< HEAD -const getPriority = tile => (tile.isSelected ? 1 : -1); -======= const getRequestPriority = tile => (tile.isSelected ? 1 : -1); ->>>>>>> origin/master test('Tile2DHeader', async () => { let onTileLoadCalled = false; @@ -20,11 +16,7 @@ test('Tile2DHeader', async () => { let tile2d = new Tile2DHeader({}); await tile2d.loadData({ requestScheduler, -<<<<<<< HEAD - getPriority, -======= getRequestPriority, ->>>>>>> origin/master getData: () => 'loaded data', onLoad: () => (onTileLoadCalled = true), onError: () => (onTileErrorCalled = true) @@ -37,11 +29,7 @@ test('Tile2DHeader', async () => { tile2d = new Tile2DHeader({}); await tile2d.loadData({ requestScheduler, -<<<<<<< HEAD - getPriority, -======= getRequestPriority, ->>>>>>> origin/master getData: () => { throw new Error('getTileData error'); }, @@ -60,11 +48,7 @@ test('Tile2DHeader#Cancel request if not selected', async () => { const requestScheduler = new RequestScheduler({throttleRequests: true, maxRequests: 1}); const opts = { requestScheduler, -<<<<<<< HEAD - getPriority, -======= getRequestPriority, ->>>>>>> origin/master getData: () => tileRequestCount++, onLoad: () => onTileLoadCalled++, onError: () => onTileErrorCalled++ @@ -92,11 +76,7 @@ test('Tile2DHeader#request priority', async () => { const requestScheduler = new RequestScheduler({throttleRequests: true, maxRequests: 1}); const opts = { requestScheduler, -<<<<<<< HEAD - getPriority: tile => (tile.id === 'preferred' ? 0 : 10), -======= getRequestPriority: tile => (tile.id === 'preferred' ? 0 : 10), ->>>>>>> origin/master getData: ({id}) => { requestOrder.push(id); return id; @@ -127,11 +107,7 @@ test('Tile2DHeader#abort', async () => { const opts = { requestScheduler, -<<<<<<< HEAD - getPriority, -======= getRequestPriority, ->>>>>>> origin/master getData: () => null, onLoad: () => (onTileLoadCalled = true), onError: () => (onTileErrorCalled = true) @@ -163,11 +139,7 @@ test('Tile2DHeader#reload', async () => { let onTileErrorCalled = 0; const opts = { requestScheduler, -<<<<<<< HEAD - getPriority, -======= getRequestPriority, ->>>>>>> origin/master onLoad: () => onTileLoadCalled++, onError: () => onTileErrorCalled++ }; 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 b38261ab804..3bbef8376c3 100644 --- a/test/modules/geo-layers/tileset-2d/tileset-2d.spec.ts +++ b/test/modules/geo-layers/tileset-2d/tileset-2d.spec.ts @@ -61,7 +61,6 @@ test('Tileset2D#update', () => { expect(tileset.tiles[0].bbox, 'tile has metadata').toBeTruthy(); }); -<<<<<<< HEAD test('Tileset2D#update with coverage LOD', () => { const tileset = new Tileset2D({ getTileData, @@ -76,7 +75,10 @@ test('Tileset2D#update with coverage LOD', () => { 'nearest lower resolution coverage tile is prefetched' ).toBe(true); expect(tileset._cache.get('1171-1566-12')?.isSelected, 'target tile remains selected').toBe(true); -======= + + tileset.finalize(); +}); + test('Tileset2D#getRequestPriority ranks tiles by viewport center distance', () => { const tileset = new Tileset2D({ getTileData, @@ -115,12 +117,10 @@ test('Tileset2D#getRequestPriority ranks tiles by viewport center distance', () expect((tileset as any)._getRequestPriority(selectedNearCenter)).toBeLessThan( (tileset as any)._getRequestPriority(visibleAtCenter) ); ->>>>>>> origin/master tileset.finalize(); }); -<<<<<<< HEAD test('Tileset2D#coverage LOD uses higher minimum coverage zoom for resolution viewports', () => { const tileset = new Tileset2D({ getTileData, @@ -271,7 +271,10 @@ test('Tileset2D#coverage LOD keeps cached root below the resolution fallback flo expect(fallback.isVisible, 'fallback ancestor covers pending selected tile').toBe(true); expect(root.isVisible, 'root remains below the visible fallback floor').toBe(false); expect(selected.isVisible, 'pending selected tile is hidden').toBe(false); -======= + + tileset.finalize(); +}); + test('Tileset2D#getRequestPriority keeps unprojectable tiles within priority tiers', () => { const tileset = new Tileset2D({ getTileData, @@ -317,7 +320,6 @@ test('Tileset2D#getRequestPriority keeps unprojectable tiles within priority tie (tileset as any)._getRequestPriority(visibleUnprojectable) ); expect((tileset as any)._getRequestPriority(staleAtCenter)).toBeLessThan(0); ->>>>>>> origin/master tileset.finalize(); });