diff --git a/docs/api-reference/geo-layers/tile-layer.md b/docs/api-reference/geo-layers/tile-layer.md index f0326a28cfe..bc6f53f314c 100644 --- a/docs/api-reference/geo-layers/tile-layer.md +++ b/docs/api-reference/geo-layers/tile-layer.md @@ -325,6 +325,8 @@ If `<= 0`, no throttling will occur, and `getTileData` may be called an unlimite If `> 0`, a maximum of `maxRequests` instances of `getTileData` will be called concurrently. Requests may never be called if the tile wasn't visible long enough to be scheduled and started. Requests may also be aborted (through the `signal` passed to `getTileData`) if there are more than `maxRequests` ongoing requests and some of those are for tiles that are no longer visible. +When requests are queued, tiles closer to the viewport center are scheduled first. + If `getTileData` makes `fetch` requests against an HTTP 1 web server, then `maxRequests` should correlate to the browser's maximum number of concurrent `fetch` requests. For Chrome, the max is 6 per domain. If you use the `data` prop and specify multiple domains, you can increase this limit. For example, with Chrome and 3 domains specified, you can set `maxRequests=18`. If the web server supports HTTP/2 (Open Chrome dev tools and look for "h2" in the Protocol column), then you can make an unlimited number of concurrent requests (and can set `maxRequests=-1`). Note that this will request data for every tile, no matter how long the tile was visible, and may increase server load. 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..82dc182d100 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; + getRequestPriority: (tile: Tile2DHeader) => number; onLoad: (tile: Tile2DHeader) => void; onError: (error: any, tile: Tile2DHeader) => void; }; @@ -106,6 +107,7 @@ export class Tile2DHeader { /* eslint-disable max-statements */ private async _loadData({ getData, + getRequestPriority, requestScheduler, onLoad, onError @@ -116,10 +118,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, getRequestPriority); 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..d74059b0d56 100644 --- a/modules/geo-layers/src/tileset-2d/tileset-2d.ts +++ b/modules/geo-layers/src/tileset-2d/tileset-2d.ts @@ -10,7 +10,7 @@ import {Matrix4, equals, NumericArray} from '@math.gl/core'; import {Tile2DHeader} from './tile-2d-header'; import {getTileIndices, tileToBoundingBox, getCullBounds, transformBox} from './utils'; -import {Bounds, TileIndex, ZRange} from './types'; +import {Bounds, TileBoundingBox, TileIndex, ZRange} from './types'; import {TileLoadProps} from './types'; import {memoize} from './memoize'; @@ -48,6 +48,9 @@ export type RefinementStrategy = | RefinementStrategyFunction; const DEFAULT_CACHE_SCALE = 5; +const SELECTED_TILE_PRIORITY = 0; +const VISIBLE_TILE_PRIORITY = 1e8; +const MAX_TILE_DISTANCE_PRIORITY = VISIBLE_TILE_PRIORITY - SELECTED_TILE_PRIORITY - 1; const STRATEGIES = { [STRATEGY_DEFAULT]: updateTileStateDefault, @@ -437,6 +440,102 @@ export class Tileset2D { private _getCullBounds = memoize(getCullBounds); + private _getRequestPriority(tile: Tile2DHeader): number { + 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 { + const {width, height} = this._viewport || {}; + if (!this._viewport || !width || !height) { + return 0; + } + + try { + const points = this._getTileScreenCorners(tile.bbox); + const center: [number, number] = [width / 2, height / 2]; + if (points.length === 4) { + if (this._isPointInPolygon(center, points)) { + return 0; + } + const distance = points.reduce((minDistance, point, i) => { + const nextPoint = points[(i + 1) % points.length]; + return Math.min( + minDistance, + this._getPointToSegmentDistanceSquared(center, point, nextPoint) + ); + }, Number.MAX_SAFE_INTEGER); + return Math.min(distance, MAX_TILE_DISTANCE_PRIORITY); + } + } catch { + // Some viewport/tile combinations are not projectable. Keep them valid but last in tier. + } + return MAX_TILE_DISTANCE_PRIORITY; + } + + private _getTileScreenCorners(bbox: TileBoundingBox): [number, number][] { + const coordinates: [number, number][] = + 'west' in bbox + ? [ + [bbox.west, bbox.south], + [bbox.east, bbox.south], + [bbox.east, bbox.north], + [bbox.west, bbox.north] + ] + : [ + [bbox.left, bbox.top], + [bbox.right, bbox.top], + [bbox.right, bbox.bottom], + [bbox.left, bbox.bottom] + ]; + + return coordinates + .map(coordinate => this._viewport!.project(coordinate)) + .filter(([x, y]) => Number.isFinite(x) && Number.isFinite(y)) as [number, number][]; + } + + private _isPointInPolygon(point: [number, number], polygon: [number, number][]): boolean { + let inside = false; + const [x, y] = point; + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const [xi, yi] = polygon[i]; + const [xj, yj] = polygon[j]; + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) { + inside = !inside; + } + } + return inside; + } + + private _getPointToSegmentDistanceSquared( + point: [number, number], + segmentStart: [number, number], + segmentEnd: [number, number] + ): number { + const [x, y] = point; + const [x1, y1] = segmentStart; + const [x2, y2] = segmentEnd; + const dx = x2 - x1; + const dy = y2 - y1; + const lengthSquared = dx * dx + dy * dy; + const t = lengthSquared + ? Math.max(0, Math.min(1, ((x - x1) * dx + (y - y1) * dy) / lengthSquared)) + : 0; + const segmentX = x1 + t * dx; + const segmentY = y1 + t * dy; + const distanceX = x - segmentX; + const distanceY = y - segmentY; + return distanceX * distanceX + distanceY * distanceY; + } + private _pruneRequests(): void { const {maxRequests = 0} = this.opts; @@ -542,6 +641,7 @@ export class Tileset2D { // eslint-disable-next-line @typescript-eslint/no-floating-promises tile.loadData({ getData: this.opts.getTileData, + getRequestPriority: this._getRequestPriority.bind(this), 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 578ea3b595d..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,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 getRequestPriority = 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, + getRequestPriority, getData: () => 'loaded data', onLoad: () => (onTileLoadCalled = true), onError: () => (onTileErrorCalled = true) @@ -26,6 +29,7 @@ test('Tile2DHeader', async () => { tile2d = new Tile2DHeader({}); await tile2d.loadData({ requestScheduler, + getRequestPriority, 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, + getRequestPriority, 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, + getRequestPriority: 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, + getRequestPriority, getData: () => null, onLoad: () => (onTileLoadCalled = true), onError: () => (onTileErrorCalled = true) @@ -104,6 +139,7 @@ test('Tile2DHeader#reload', async () => { let onTileErrorCalled = 0; const opts = { requestScheduler, + getRequestPriority, 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..e5b86e81768 100644 --- a/test/modules/geo-layers/tileset-2d/tileset-2d.spec.ts +++ b/test/modules/geo-layers/tileset-2d/tileset-2d.spec.ts @@ -55,6 +55,97 @@ test('Tileset2D#update', () => { expect(tileset.tiles[0].bbox, 'tile has metadata').toBeTruthy(); }); +test('Tileset2D#getRequestPriority ranks tiles by viewport center distance', () => { + const tileset = new Tileset2D({ + getTileData, + onTileLoad: () => {} + }); + Object.assign(tileset, { + _viewport: { + width: 100, + height: 100, + project: ([x, y]) => [x, y] + } + }); + + const selectedAtCenterEdge = { + bbox: {left: 0, top: 0, right: 50, bottom: 100}, + index: {x: 0, y: 0, z: 10}, + isSelected: true, + isVisible: true + }; + const selectedNearCenter = { + bbox: {left: 60, top: 45, right: 70, bottom: 55}, + index: {x: 1, y: 0, z: 10}, + isSelected: true, + isVisible: true + }; + const visibleAtCenter = { + bbox: {left: 0, top: 0, right: 100, bottom: 100}, + index: {x: 0, y: 0, z: 8}, + isSelected: false, + isVisible: true + }; + + expect((tileset as any)._getRequestPriority(selectedAtCenterEdge)).toBeLessThan( + (tileset as any)._getRequestPriority(selectedNearCenter) + ); + expect((tileset as any)._getRequestPriority(selectedNearCenter)).toBeLessThan( + (tileset as any)._getRequestPriority(visibleAtCenter) + ); + + tileset.finalize(); +}); + +test('Tileset2D#getRequestPriority keeps unprojectable tiles within priority tiers', () => { + const tileset = new Tileset2D({ + getTileData, + onTileLoad: () => {} + }); + Object.assign(tileset, { + _viewport: { + width: 100, + height: 100, + project: ([x, y]) => (x < 0 || y < 0 ? [Number.NaN, Number.NaN] : [x, y]) + } + }); + + const selectedUnprojectable = { + bbox: {left: -20, top: -20, right: -10, bottom: -10}, + index: {x: 0, y: 0, z: 10}, + isSelected: true, + isVisible: true + }; + const visibleAtCenter = { + bbox: {left: 0, top: 0, right: 100, bottom: 100}, + index: {x: 1, y: 0, z: 10}, + isSelected: false, + isVisible: true + }; + const visibleUnprojectable = { + bbox: {left: -20, top: -20, right: -10, bottom: -10}, + index: {x: 2, y: 0, z: 10}, + isSelected: false, + isVisible: true + }; + const staleAtCenter = { + bbox: {left: 0, top: 0, right: 100, bottom: 100}, + index: {x: 3, y: 0, z: 10}, + isSelected: false, + isVisible: false + }; + + expect((tileset as any)._getRequestPriority(selectedUnprojectable)).toBeLessThan( + (tileset as any)._getRequestPriority(visibleAtCenter) + ); + expect((tileset as any)._getRequestPriority(visibleAtCenter)).toBeLessThan( + (tileset as any)._getRequestPriority(visibleUnprojectable) + ); + expect((tileset as any)._getRequestPriority(staleAtCenter)).toBeLessThan(0); + + tileset.finalize(); +}); + test('Tileset2D#updateOnModelMatrix', () => { const tileset = new Tileset2D({ getTileData,