UNPKG

maplibre-gl

Version:

BSD licensed community fork of mapbox-gl, a WebGL interactive maps library

1,279 lines (1,072 loc) 101 kB
import type {StyleSpecification} from '@maplibre/maplibre-gl-style-spec'; import {describe, beforeEach, afterEach, test, expect, vi} from 'vitest'; import {TileManager} from './tile_manager'; import {type Source, addSourceType} from '../source/source'; import {Tile, FadingRoles, FadingDirections} from './tile'; import {CanonicalTileID, OverscaledTileID} from './tile_id'; import {LngLat} from '../geo/lng_lat'; import Point from '@mapbox/point-geometry'; import {Event, ErrorEvent, Evented} from '../util/evented'; import {extend} from '../util/util'; import {type Dispatcher} from '../util/dispatcher'; import {TileBounds} from './tile_bounds'; import {sleep, waitForEvent, beforeMapTest, createMap as globalCreateMap} from '../util/test/util'; import {now} from '../util/time_control'; import {type Map} from '../ui/map'; import {type TileCache} from './tile_cache'; import {MercatorTransform} from '../geo/projection/mercator_transform'; import {GlobeTransform} from '../geo/projection/globe_transform'; import {coveringTiles} from '../geo/projection/covering_tiles'; class SourceMock extends Evented implements Source { id: string; minzoom: number; maxzoom: number; hasTile: (tileID: OverscaledTileID) => boolean; sourceOptions: any; type: string; tileSize: number; constructor(id: string, sourceOptions: any, _dispatcher: Dispatcher, eventedParent: Evented) { super(); this.id = id; this.minzoom = 0; this.maxzoom = 22; extend(this, sourceOptions); this.sourceOptions = sourceOptions; this.setEventedParent(eventedParent); if (sourceOptions.hasTile) { this.hasTile = sourceOptions.hasTile; } if (sourceOptions.raster) { this.type = 'raster'; } } loadTile(tile: Tile): Promise<void> { if (this.sourceOptions.expires) { tile.setExpiryData({ expires: this.sourceOptions.expires }); } return sleep(0); } loaded() { return true; } onAdd() { if (this.sourceOptions.noLoad) return; if (this.sourceOptions.error) { this.fire(new ErrorEvent(this.sourceOptions.error)); } else { this.fire(new Event('data', {dataType: 'source', sourceDataType: 'metadata'})); } } async abortTile() {} async unloadTile() {} serialize() {} hasTransition(): boolean { return false; } } // Add a mocked source type for use in these tests function createSource(id: string, sourceOptions: any, _dispatcher: any, eventedParent: Evented) { // allow tests to override mocked methods/properties by providing // them in the source definition object that's given to Source.create() const source = new SourceMock(id, sourceOptions, _dispatcher, eventedParent); return source; } addSourceType('mock-source-type', createSource as any); function createTileManager(options?, used?) { const sc = new TileManager('id', extend({ tileSize: 512, minzoom: 0, maxzoom: 14, type: 'mock-source-type' }, options), {} as Dispatcher); const scWithTestLogic = extend(sc, { used: typeof used === 'boolean' ? used : true, addTile(tileID: OverscaledTileID): Tile { return this._addTile(tileID); }, getCache(): TileCache { return this._cache; }, getTiles(): { [_: string]: Tile } { return this._tiles; } }); return scWithTestLogic; } type MapOptions = { style: StyleSpecification; }; function createMap(options: MapOptions) { const container = window.document.createElement('div'); window.document.body.appendChild(container); Object.defineProperty(container, 'clientWidth', {value: 512}); Object.defineProperty(container, 'clientHeight', {value: 512}); return globalCreateMap({container, ...options}); } beforeEach(() => { beforeMapTest(); }); afterEach(() => { vi.clearAllMocks(); }); describe('TileManager.addTile', () => { test('loads tile when uncached', () => { const tileID = new OverscaledTileID(0, 0, 0, 0, 0); const tileManager = createTileManager(); const spy = vi.fn(); tileManager._source.loadTile = spy; tileManager.onAdd(undefined); tileManager._addTile(tileID); expect(spy).toHaveBeenCalledTimes(1); expect(spy.mock.calls[0][0].tileID).toEqual(tileID); expect(spy.mock.calls[0][0].uses).toBe(1); }); test('adds tile when uncached', async () => { const tileID = new OverscaledTileID(0, 0, 0, 0, 0); const tileManager = createTileManager({}); const dataLoadingPromise = tileManager.once('dataloading'); tileManager.onAdd(undefined); tileManager._addTile(tileID); const data = await dataLoadingPromise; expect(data.tile.tileID).toEqual(tileID); expect(data.tile.uses).toBe(1); }); test('updates feature state on added uncached tile', async () => { const tileID = new OverscaledTileID(0, 0, 0, 0, 0); let updateFeaturesSpy; const tileManager = createTileManager({}); let dataPromise: any; tileManager._source.loadTile = async (tile) => { dataPromise = tileManager.once('data'); updateFeaturesSpy = vi.spyOn(tile, 'setFeatureState'); tile.state = 'loaded'; }; tileManager.onAdd(undefined); tileManager._addTile(tileID); await dataPromise; expect(updateFeaturesSpy).toHaveBeenCalledTimes(1); }); test('uses cached tile', () => { const tileID = new OverscaledTileID(0, 0, 0, 0, 0); let load = 0, add = 0; const tileManager = createTileManager({}); tileManager._source.loadTile = async (tile) => { tile.state = 'loaded'; load++; }; tileManager.on('dataloading', () => { add++; }); const tr = new MercatorTransform(); tr.resize(512, 512); tileManager.updateCacheSize(tr); tileManager._addTile(tileID); tileManager._removeTile(tileID.key); tileManager._addTile(tileID); expect(load).toBe(1); expect(add).toBe(1); }); test('updates feature state on cached tile', () => { const tileID = new OverscaledTileID(0, 0, 0, 0, 0); const tileManager = createTileManager({}); tileManager._source.loadTile = async (tile) => { tile.state = 'loaded'; }; const tr = new MercatorTransform(); tr.resize(512, 512); tileManager.updateCacheSize(tr); const tile = tileManager._addTile(tileID); const updateFeaturesSpy = vi.spyOn(tile, 'setFeatureState'); tileManager._removeTile(tileID.key); tileManager._addTile(tileID); expect(updateFeaturesSpy).toHaveBeenCalledTimes(1); }); test('moves timers when adding tile from cache', () => { const tileID = new OverscaledTileID(0, 0, 0, 0, 0); const time = new Date(); time.setSeconds(time.getSeconds() + 5); const tileManager = createTileManager(); tileManager._setTileReloadTimer = (id) => { tileManager._timers[id] = setTimeout(() => {}, 0); }; tileManager._source.loadTile = async (tile) => { tile.state = 'loaded'; tile.getExpiryTimeout = () => 1000 * 60; tileManager._setTileReloadTimer(tileID.key, tile); }; const tr = new MercatorTransform(); tr.resize(512, 512); tileManager.updateCacheSize(tr); const id = tileID.key; expect(tileManager._timers[id]).toBeFalsy(); expect(tileManager._outOfViewCache.has(tileID)).toBeFalsy(); tileManager._addTile(tileID); expect(tileManager._timers[id]).toBeTruthy(); expect(tileManager._outOfViewCache.has(tileID)).toBeFalsy(); tileManager._removeTile(tileID.key); expect(tileManager._timers[id]).toBeFalsy(); expect(tileManager._outOfViewCache.has(tileID)).toBeTruthy(); tileManager._addTile(tileID); expect(tileManager._timers[id]).toBeTruthy(); expect(tileManager._outOfViewCache.has(tileID)).toBeFalsy(); }); test('does not reuse wrapped tile', () => { const tileID = new OverscaledTileID(0, 0, 0, 0, 0); let load = 0, add = 0; const tileManager = createTileManager(); tileManager._source.loadTile = async (tile) => { tile.state = 'loaded'; load++; }; tileManager.on('dataloading', () => { add++; }); const t1 = tileManager._addTile(tileID); const t2 = tileManager._addTile(new OverscaledTileID(0, 1, 0, 0, 0)); expect(load).toBe(2); expect(add).toBe(2); expect(t1).not.toBe(t2); }); test('should load tiles with identical overscaled Z but different canonical Z', () => { const tileManager = createTileManager(); const tileIDs = [ new OverscaledTileID(1, 0, 0, 0, 0), new OverscaledTileID(1, 0, 1, 0, 0), new OverscaledTileID(1, 0, 1, 1, 0), new OverscaledTileID(1, 0, 1, 0, 1), new OverscaledTileID(1, 0, 1, 1, 1) ]; for (let i = 0; i < tileIDs.length; i++) tileManager._addTile(tileIDs[i]); for (let i = 0; i < tileIDs.length; i++) { const id = tileIDs[i]; const key = id.key; expect(tileManager._inViewTiles.getTileById(key)).toBeTruthy(); expect(tileManager._inViewTiles.getTileById(key).tileID).toEqual(id); } }); }); describe('TileManager.removeTile', () => { test('removes tile', async () => { const tileID = new OverscaledTileID(0, 0, 0, 0, 0); const tileManager = createTileManager({}); tileManager._addTile(tileID); await tileManager.once('data'); tileManager._removeTile(tileID.key); expect(tileManager._inViewTiles.getTileById(tileID.key)).toBeFalsy(); }); test('caches (does not unload) loaded tile', () => { const tileID = new OverscaledTileID(0, 0, 0, 0, 0); const tileManager = createTileManager(); tileManager._source.loadTile = async (tile) => { tile.state = 'loaded'; }; tileManager._source.unloadTile = vi.fn(); const tr = new MercatorTransform(); tr.resize(512, 512); tileManager.updateCacheSize(tr); tileManager._addTile(tileID); tileManager._removeTile(tileID.key); expect(tileManager._source.unloadTile).not.toHaveBeenCalled(); }); test('aborts and unloads unfinished tile', () => { const tileID = new OverscaledTileID(0, 0, 0, 0, 0); let abort = 0, unload = 0; const tileManager = createTileManager(); tileManager._source.abortTile = async (tile) => { expect(tile.tileID).toEqual(tileID); abort++; }; tileManager._source.unloadTile = async (tile) => { expect(tile.tileID).toEqual(tileID); unload++; }; tileManager._addTile(tileID); tileManager._removeTile(tileID.key); expect(abort).toBe(1); expect(unload).toBe(1); }); test('_tileLoaded after _removeTile skips tile.added', () => { const tileID = new OverscaledTileID(0, 0, 0, 0, 0); const tileManager = createTileManager(); tileManager._source.loadTile = async () => { tileManager._removeTile(tileID.key); }; tileManager.map = {painter: {crossTileSymbolIndex: '', tileExtentVAO: {}}} as any; tileManager._addTile(tileID); }); test('fires dataabort event', async () => { const tileManager = createTileManager(); tileManager._source.loadTile = () => { // Do not call back in order to make sure the tile is removed before it is loaded. return new Promise(() => {}); }; const tileID = new OverscaledTileID(0, 0, 0, 0, 0); const tile = tileManager._addTile(tileID); const abortPromise = tileManager.once('dataabort'); tileManager._removeTile(tileID.key); const event = await abortPromise; expect(event.dataType).toBe('source'); expect(event.tile).toBe(tile); expect(event.coord).toBe(tileID); }); test('does not fire dataabort event when the tile has already been loaded', () => { const tileManager = createTileManager(); tileManager._source.loadTile = async (tile) => { tile.state = 'loaded'; }; const tileID = new OverscaledTileID(0, 0, 0, 0, 0); tileManager._addTile(tileID); const onAbort = vi.fn(); tileManager.once('dataabort', onAbort); tileManager._removeTile(tileID.key); expect(onAbort).toHaveBeenCalledTimes(0); }); test('does not fire data event when the tile has already been aborted', () => { const onData = vi.fn(); const tileManager = createTileManager(); tileManager._source.loadTile = async (tile) => { tileManager.once('dataabort', () => { tile.state = 'loaded'; expect(onData).toHaveBeenCalledTimes(0); }); }; tileManager.once('data', onData); const tileID = new OverscaledTileID(0, 0, 0, 0, 0); tileManager._addTile(tileID); tileManager._removeTile(tileID.key); }); test('resets raster fade timer upon load of unloaded edge tiles', async () => { const tileManager = createTileManager(); tileManager._rasterFadeDuration = 300; let tile: Tile; let endMs: number; tileManager._source.loadTile = async (_tile) => { tile = _tile; tile.selfFading = true; tile.fadeEndTime = now() + tileManager._rasterFadeDuration; await sleep(100); endMs = now(); }; tileManager._addTile(new OverscaledTileID(0, 0, 0, 0, 0)); await sleep(200); const deltaMs = tile.fadeEndTime - endMs; expect(deltaMs).toBeGreaterThanOrEqual(290); expect(deltaMs).toBeLessThanOrEqual(310); }); }); describe('TileManager / Source lifecycle', () => { test('does not fire load or change before source load event', async () => { const tileManager = createTileManager({noLoad: true}); const spy = vi.fn(); tileManager.on('data', spy); tileManager.onAdd(undefined); await sleep(1); expect(spy).not.toHaveBeenCalled(); }); test('forward load event', async () => { const tileManager = createTileManager({}); const dataPromise = waitForEvent(tileManager, 'data', e => e.sourceDataType === 'metadata'); tileManager.onAdd(undefined); await expect(dataPromise).resolves.toBeDefined(); }); test('forward change event', async () => { const tileManager = createTileManager(); const dataPromise = waitForEvent(tileManager, 'data', e => e.sourceDataType === 'metadata'); tileManager.onAdd(undefined); tileManager.getSource().fire(new Event('data')); await expect(dataPromise).resolves.toBeDefined(); }); test('forward error event', async () => { const tileManager = createTileManager({error: 'Error loading source'}); const errorPromise = tileManager.once('error'); tileManager.onAdd(undefined); const err = await errorPromise; expect(err.error).toBe('Error loading source'); }); test('suppress 404 errors', () => { const tileManager = createTileManager({status: 404, message: 'Not found'}); tileManager.on('error', () => { throw new Error('test failed: error event fired'); }); tileManager.onAdd(undefined); }); test('loaded() true after source error', async () => { const tileManager = createTileManager({error: 'Error loading source'}); const errorPromise = tileManager.once('error'); tileManager.onAdd(undefined); await errorPromise; expect(tileManager.loaded()).toBeTruthy(); }); test('loaded() true after tile error', async () => { const transform = new MercatorTransform(); transform.resize(511, 511); transform.setZoom(0); const tileManager = createTileManager(); tileManager._source.loadTile = async () => { throw new Error('Error loading tile'); }; tileManager.on('data', (e) => { if (e.dataType === 'source' && e.sourceDataType === 'metadata') { tileManager.update(transform); } }); const errorPromise = tileManager.once('error'); tileManager.onAdd(undefined); await errorPromise; expect(tileManager.loaded()).toBeTruthy(); }); test('loaded() false after source begins loading following error', async () => { const tileManager = createTileManager({error: 'Error loading source'}); const errorPromise = tileManager.once('error'); tileManager.onAdd(undefined); await errorPromise; const dataLoadingProimse = tileManager.once('dataloading'); tileManager.getSource().fire(new Event('dataloading')); await dataLoadingProimse; expect(tileManager.loaded()).toBeFalsy(); }); test('loaded() false when error occurs while source is not loaded', async () => { const tileManager = createTileManager({ error: 'Error loading source', loaded() { return false; } }); const errorPromise = tileManager.once('error'); tileManager.onAdd(undefined); await errorPromise; expect(tileManager.loaded()).toBeFalsy(); }); test('reloads tiles after a data event where source is updated', () => { const transform = new MercatorTransform(); transform.resize(511, 511); transform.setZoom(0); const expected = [new OverscaledTileID(0, 0, 0, 0, 0).key, new OverscaledTileID(0, 0, 0, 0, 0).key]; expect.assertions(expected.length); const tileManager = createTileManager(); tileManager._source.loadTile = async (tile) => { expect(tile.tileID.key).toBe(expected.shift()); tile.state = 'loaded'; }; tileManager.on('data', (e) => { if (e.dataType === 'source' && e.sourceDataType === 'metadata') { tileManager.update(transform); tileManager.getSource().fire(new Event('data', {dataType: 'source', sourceDataType: 'content'})); } }); tileManager.onAdd(undefined); }); test('does not reload errored tiles', () => { const transform = new MercatorTransform(); transform.resize(511, 511); transform.setZoom(1); const tileManager = createTileManager(); tileManager._source.loadTile = async (tile) => { // this transform will try to load the four tiles at z1 and a single z0 tile // we only expect _reloadTile to be called with the 'loaded' z0 tile tile.state = tile.tileID.canonical.z === 1 ? 'errored' : 'loaded'; }; const reloadTileSpy = vi.spyOn(tileManager, '_reloadTile'); tileManager.on('data', (e) => { if (e.dataType === 'source' && e.sourceDataType === 'metadata') { tileManager.update(transform); tileManager.getSource().fire(new Event('data', {dataType: 'source', sourceDataType: 'content'})); } }); tileManager.onAdd(undefined); // we expect the tile manager to have five tiles, but only to have reloaded one expect(tileManager._inViewTiles.getAllIds()).toHaveLength(5); expect(reloadTileSpy).toHaveBeenCalledTimes(1); }); test('does reload errored tiles, if event is source data change', () => { const transform = new MercatorTransform(); transform.resize(511, 511); transform.setZoom(1); const tileManager = createTileManager(); tileManager._source.loadTile = async (tile) => { // this transform will try to load the four tiles at z1 and a single z0 tile // we only expect _reloadTile to be called with the 'loaded' z0 tile tile.state = tile.tileID.canonical.z === 1 ? 'errored' : 'loaded'; }; const reloadTileSpy = vi.spyOn(tileManager, '_reloadTile'); tileManager.on('data', (e) => { if (e.dataType === 'source' && e.sourceDataType === 'metadata') { tileManager.update(transform); tileManager.getSource().fire(new Event('data', {dataType: 'source', sourceDataType: 'content', sourceDataChanged: true})); } }); tileManager.onAdd(undefined); // We expect the tile manager to have five tiles, and for all of them // to be reloaded expect(tileManager._inViewTiles.getAllIds()).toHaveLength(5); expect(reloadTileSpy).toHaveBeenCalledTimes(5); }); }); describe('TileManager.update', () => { test('loads no tiles if used is false', async () => { const transform = new MercatorTransform(); transform.resize(512, 512); transform.setZoom(0); const tileManager = createTileManager({}, false); const dataPromise = waitForEvent(tileManager, 'data', e => e.sourceDataType === 'metadata'); tileManager.onAdd(undefined); await dataPromise; tileManager.update(transform); expect(tileManager.getIds()).toEqual([]); }); test('loads covering tiles', async () => { const transform = new MercatorTransform(); transform.resize(511, 511); transform.setZoom(0); const tileManager = createTileManager({}); const dataPromise = waitForEvent(tileManager, 'data', e => e.sourceDataType === 'metadata'); tileManager.onAdd(undefined); await dataPromise; tileManager.update(transform); expect(tileManager.getIds()).toEqual([new OverscaledTileID(0, 0, 0, 0, 0).key]); }); test('adds ideal (covering) tiles only once for zoom level on raster maps', async () => { const transform = new MercatorTransform(); transform.resize(512, 512); transform.setZoom(1); const tileManager = createTileManager({raster: true}); tileManager._source.loadTile = async (tile) => { tile.state = 'loaded'; }; const addSpy = vi.spyOn(tileManager, '_addTile'); const dataPromise = waitForEvent(tileManager, 'data', e => e.sourceDataType === 'metadata'); tileManager.onAdd(undefined); await dataPromise; // on update at zoom 1 there should be 4 ideal tiles added through _addTiles tileManager.update(transform); expect(addSpy).toHaveBeenCalledTimes(4); }); test('bypasses fading logic when raster fading is disabled', async () => { const map = createMap({ style: { version: 8, sources: {rasterSource: {type: 'raster', tiles: [], tileSize: 256}}, layers: [{id: 'rasterLayer', type: 'raster', source: 'rasterSource', paint: {'raster-fade-duration': 0} }] } }); await map.once('styledata'); const style = map.style; const tileManager = style.tileManagers['rasterSource']; tileManager._loadTile = async () => {}; const fakeTile = new Tile(new OverscaledTileID(3, 0, 3, 1, 2), undefined); fakeTile.resetFadeLogic = vi.fn(); (fakeTile as any).texture = {bind: () => {}, size: [256, 256]}; fakeTile.state = 'loaded'; tileManager._inViewTiles.setTile(fakeTile.tileID.key, fakeTile); await map.once('render'); map.setZoom(3); await map.once('render'); expect(fakeTile.resetFadeLogic).not.toHaveBeenCalled(); }); test('respects Source.hasTile method if it is present', async () => { const transform = new MercatorTransform(); transform.resize(511, 511); transform.setZoom(1); const tileManager = createTileManager({ hasTile: (coord) => (coord.canonical.x !== 0) }); const dataPromise = waitForEvent(tileManager, 'data', e => e.sourceDataType === 'metadata'); tileManager.onAdd(undefined); await dataPromise; tileManager.update(transform); expect(tileManager.getIds().sort()).toEqual([ new OverscaledTileID(1, 0, 1, 1, 0).key, new OverscaledTileID(1, 0, 1, 1, 1).key ].sort()); }); test('removes unused tiles', async () => { const transform = new MercatorTransform(); transform.resize(511, 511); transform.setZoom(0); const tileManager = createTileManager(); tileManager._source.loadTile = async (tile) => { tile.state = 'loaded'; }; const dataPromise = waitForEvent(tileManager, 'data', e => e.sourceDataType === 'metadata'); tileManager.onAdd(undefined); await dataPromise; tileManager.update(transform); expect(tileManager.getIds()).toEqual([new OverscaledTileID(0, 0, 0, 0, 0).key]); transform.setZoom(1); tileManager.update(transform); expect(tileManager.getIds()).toEqual([ new OverscaledTileID(1, 0, 1, 1, 1).key, new OverscaledTileID(1, 0, 1, 0, 1).key, new OverscaledTileID(1, 0, 1, 1, 0).key, new OverscaledTileID(1, 0, 1, 0, 0).key ]); }); test('retains parent tiles for pending children', async () => { const transform = new MercatorTransform(); (transform as any)._test = 'retains'; transform.resize(511, 511); transform.setZoom(0); const tileManager = createTileManager(); tileManager._source.loadTile = async (tile) => { tile.state = (tile.tileID.key === new OverscaledTileID(0, 0, 0, 0, 0).key) ? 'loaded' : 'loading'; }; const dataPromise = waitForEvent(tileManager, 'data', e => e.sourceDataType === 'metadata'); tileManager.onAdd(undefined); await dataPromise; tileManager.update(transform); expect(tileManager.getIds()).toEqual([new OverscaledTileID(0, 0, 0, 0, 0).key]); transform.setZoom(1); tileManager.update(transform); expect(tileManager.getIds()).toEqual([ new OverscaledTileID(0, 0, 0, 0, 0).key, new OverscaledTileID(1, 0, 1, 1, 1).key, new OverscaledTileID(1, 0, 1, 0, 1).key, new OverscaledTileID(1, 0, 1, 1, 0).key, new OverscaledTileID(1, 0, 1, 0, 0).key ]); }); test('retains parent tiles for pending children (wrapped)', async () => { const transform = new MercatorTransform(); transform.resize(511, 511); transform.setZoom(0); transform.setCenter(new LngLat(360, 0)); const tileManager = createTileManager(); tileManager._source.loadTile = async (tile) => { tile.state = (tile.tileID.key === new OverscaledTileID(0, 1, 0, 0, 0).key) ? 'loaded' : 'loading'; }; const dataPromise = waitForEvent(tileManager, 'data', e => e.sourceDataType === 'metadata'); tileManager.onAdd(undefined); await dataPromise; tileManager.update(transform); expect(tileManager.getIds()).toEqual([new OverscaledTileID(0, 1, 0, 0, 0).key]); transform.setZoom(1); tileManager.update(transform); expect(tileManager.getIds()).toEqual([ new OverscaledTileID(0, 1, 0, 0, 0).key, new OverscaledTileID(1, 1, 1, 1, 1).key, new OverscaledTileID(1, 1, 1, 0, 1).key, new OverscaledTileID(1, 1, 1, 1, 0).key, new OverscaledTileID(1, 1, 1, 0, 0).key ]); }); test('retains children tiles for pending parents', () => { const transform = new GlobeTransform(); transform.resize(511, 511); transform.setZoom(1); transform.setCenter(new LngLat(360, 0)); const tileManager = createTileManager(); tileManager._source.loadTile = async (tile) => { tile.state = (tile.tileID.key === new OverscaledTileID(0, 1, 0, 0, 0).key) ? 'loading' : 'loaded'; }; tileManager.on('data', (e) => { if (e.sourceDataType === 'metadata') { tileManager.update(transform); expect(tileManager.getIds()).toEqual([ new OverscaledTileID(1, 1, 1, 1, 1).key, new OverscaledTileID(1, 1, 1, 0, 1).key, new OverscaledTileID(1, 1, 1, 1, 0).key, new OverscaledTileID(1, 1, 1, 0, 0).key ]); transform.setZoom(0); tileManager.update(transform); expect(tileManager.getIds()).toEqual([ new OverscaledTileID(0, 1, 0, 0, 0).key, new OverscaledTileID(1, 1, 1, 1, 1).key, new OverscaledTileID(1, 1, 1, 0, 1).key, new OverscaledTileID(1, 1, 1, 1, 0).key, new OverscaledTileID(1, 1, 1, 0, 0).key ]); } }); tileManager.onAdd(undefined); }); test('retains overscaled loaded children', async () => { const transform = new MercatorTransform(); transform.resize(511, 511); transform.setZoom(16); // use slightly offset center so that sort order is better defined transform.setCenter(new LngLat(-0.001, 0.001)); const tileManager = createTileManager({reparseOverscaled: true}); tileManager._source.loadTile = async (tile) => { tile.state = tile.tileID.overscaledZ === 16 ? 'loaded' : 'loading'; }; const dataPromise = waitForEvent(tileManager, 'data', e => e.sourceDataType === 'metadata'); tileManager.onAdd(undefined); await dataPromise; tileManager.update(transform); expect(tileManager.getRenderableIds()).toEqual([ new OverscaledTileID(16, 0, 14, 8192, 8192).key, new OverscaledTileID(16, 0, 14, 8191, 8192).key, new OverscaledTileID(16, 0, 14, 8192, 8191).key, new OverscaledTileID(16, 0, 14, 8191, 8191).key ]); transform.setZoom(15); tileManager.update(transform); expect(tileManager.getRenderableIds()).toEqual([ new OverscaledTileID(16, 0, 14, 8192, 8192).key, new OverscaledTileID(16, 0, 14, 8191, 8192).key, new OverscaledTileID(16, 0, 14, 8192, 8191).key, new OverscaledTileID(16, 0, 14, 8191, 8191).key ]); }); test('reassigns tiles for large jumps in longitude', async () => { const transform = new MercatorTransform(); transform.resize(511, 511); transform.setZoom(0); const tileManager = createTileManager({}); const dataPromise = waitForEvent(tileManager, 'data', e => e.sourceDataType === 'metadata'); tileManager.onAdd(undefined); await dataPromise; transform.setCenter(new LngLat(360, 0)); const tileID = new OverscaledTileID(0, 1, 0, 0, 0); tileManager.update(transform); expect(tileManager.getIds()).toEqual([tileID.key]); const tile = tileManager.getTile(tileID); transform.setCenter(new LngLat(0, 0)); const wrappedTileID = new OverscaledTileID(0, 0, 0, 0, 0); tileManager.update(transform); expect(tileManager.getIds()).toEqual([wrappedTileID.key]); expect(tileManager.getTile(wrappedTileID)).toBe(tile); }); test('retains fading children and applies fading logic when zooming out', async () => { const transform = new MercatorTransform(); transform.resize(1024, 1024); transform.setZoom(10); const tileManager = createTileManager({raster: true}); const loadedTiles: Record<string, Tile> = {}; tileManager._source.loadTile = async (tile) => { loadedTiles[tile.tileID.key] = tile; tile.state = 'loaded'; }; tileManager.on('data', (e) => { if (e.dataType === 'source' && e.sourceDataType === 'metadata') { tileManager.update(transform); } }); tileManager.setRasterFadeDuration(300); tileManager.onAdd(undefined); // get default zoom ideal tiles at zoom specified above await sleep(0); // ideal tiles will become fading children when zooming out const children: Tile[] = Object.values(loadedTiles); // zoom out 1 level - ideal tiles (new children) should fade out transform.setZoom(9); tileManager.update(transform); await sleep(0); // ensure that the loaded child was retained and fading logic was applied for (const child of children) { expect(loadedTiles).toHaveProperty(child.tileID.key); expect(child.fadingRole).toEqual(FadingRoles.Base); expect(child.fadingDirection).toEqual(FadingDirections.Departing); expect(child.fadingParentID).toBeInstanceOf(OverscaledTileID); } }); test('retains fading grandchildren and applies fading logic when zooming out', async () => { const transform = new MercatorTransform(); transform.resize(512, 512); transform.setZoom(10); const tileManager = createTileManager({raster: true}); const loadedTiles: Record<string, Tile> = {}; tileManager._source.loadTile = async (tile) => { loadedTiles[tile.tileID.key] = tile; tile.state = 'loaded'; }; tileManager.on('data', (e) => { if (e.dataType === 'source' && e.sourceDataType === 'metadata') { tileManager.update(transform); } }); tileManager.setRasterFadeDuration(300); tileManager.onAdd(undefined); // get default zoom ideal tiles at zoom specified above await sleep(0); // ideal tiles will become fading grandchildren when zooming out const grandChildren: Tile[] = Object.values(loadedTiles); // zoom out 2 levels - ideal tiles (new grandchildren) should fade out transform.setZoom(8); tileManager.update(transform); await sleep(0); // ensure that the loaded grandchild was retained and fading logic was applied for (const grandChild of grandChildren) { expect(loadedTiles).toHaveProperty(grandChild.tileID.key); expect(grandChild.fadingRole).toEqual(FadingRoles.Base); expect(grandChild.fadingDirection).toEqual(FadingDirections.Departing); expect(grandChild.fadingParentID).toBeInstanceOf(OverscaledTileID); } }); test('retains fading parent and applies fading logic when zooming in', async () => { const transform = new MercatorTransform(); transform.resize(512, 512); transform.setZoom(10); const tileManager = createTileManager({raster: true}); const loadedTiles: Record<string, Tile> = {}; tileManager._source.loadTile = async (tile) => { loadedTiles[tile.tileID.key] = tile; tile.state = 'loaded'; }; tileManager.on('data', (e) => { if (e.dataType === 'source' && e.sourceDataType === 'metadata') { tileManager.update(transform); } }); tileManager.setRasterFadeDuration(300); tileManager.onAdd(undefined); // get default zoom ideal tiles at zoom specified above await sleep(0); // ideal tiles will become fading parent when zooming in const parents: Tile[] = Object.values(loadedTiles); const parentKeys = new Set(parents.map(p => p.tileID.key)); // zoom in 1 level - ideal tiles (new parent) should fade out transform.setZoom(11); tileManager.update(transform); await sleep(0); // ensure that the loaded parents were retained and fading logic was applied for (const parent of parents) { expect(loadedTiles).toHaveProperty(parent.tileID.key); expect(parent.fadingRole).toEqual(FadingRoles.Parent); expect(parent.fadingDirection).toEqual(FadingDirections.Departing); } // check incoming tiles const incoming = Object.values(loadedTiles).filter(tile => !parentKeys.has(tile.tileID.key)); for (const tile of incoming) { expect(tile.fadingRole).toEqual(FadingRoles.Base); expect(tile.fadingDirection).toEqual(FadingDirections.Incoming); expect(tile.fadingParentID).toBeInstanceOf(OverscaledTileID); } }); test('retains fading grandparent and applies fading logic when zooming in', async () => { const transform = new MercatorTransform(); transform.resize(512, 512); transform.setZoom(10); const tileManager = createTileManager({raster: true}); const loadedTiles: Record<string, Tile> = {}; tileManager._source.loadTile = async (tile) => { loadedTiles[tile.tileID.key] = tile; tile.state = 'loaded'; }; tileManager.on('data', (e) => { if (e.dataType === 'source' && e.sourceDataType === 'metadata') { tileManager.update(transform); } }); tileManager.setRasterFadeDuration(300); tileManager.onAdd(undefined); // get default zoom ideal tiles at zoom specified above await sleep(0); // ideal tiles will become fading grandparent when zooming in const grandParents: Tile[] = Object.values(loadedTiles); const grandParentKeys = new Set(grandParents.map(p => p.tileID.key)); // zoom in 2 levels - ideal tiles (new grandparent) should fade out transform.setZoom(12); tileManager.update(transform); await sleep(0); // ensure that the loaded grandparents were retained and fading logic was applied for (const grandParent of grandParents) { expect(loadedTiles).toHaveProperty(grandParent.tileID.key); expect(grandParent.fadingRole).toEqual(FadingRoles.Parent); expect(grandParent.fadingDirection).toEqual(FadingDirections.Departing); } // check incoming tiles const incoming = Object.values(loadedTiles).filter(tile => !grandParentKeys.has(tile.tileID.key)); for (const tile of incoming) { expect(tile.fadingRole).toEqual(FadingRoles.Base); expect(tile.fadingDirection).toEqual(FadingDirections.Incoming); expect(tile.fadingParentID).toBeInstanceOf(OverscaledTileID); } }); }); describe('TileManager._updateRetainedTiles', () => { test('loads ideal tiles if they exist', () => { const stateCache = {}; const tileManager = createTileManager(); tileManager._source.loadTile = async (tile) => { tile.state = stateCache[tile.tileID.key] || 'errored'; }; const getTileSpy = vi.spyOn(tileManager, 'getTile'); const idealTile = new OverscaledTileID(1, 0, 1, 1, 1); stateCache[idealTile.key] = 'loaded'; tileManager._updateRetainedTiles([idealTile], 1); expect(getTileSpy).not.toHaveBeenCalled(); expect(tileManager.getIds()).toEqual([idealTile.key]); }); test('_updateRetainedTiles retains all loaded children (and parent when coverage is incomplete)', () => { const tileManager = createTileManager(); tileManager._source.loadTile = async (tile) => { tile.state = 'errored'; }; const idealTile = new OverscaledTileID(3, 0, 3, 1, 2); tileManager._inViewTiles.setTile(idealTile.key, new Tile(idealTile, undefined)); tileManager._inViewTiles.getTileById(idealTile.key).state = 'errored'; const loadedTiles = [ // loaded children - topmost zoom partially covered new OverscaledTileID(4, 0, 4, 2, 4), //topmost child new OverscaledTileID(4, 0, 4, 3, 4), //topmost child new OverscaledTileID(4, 0, 4, 2, 5), //topmost child // loaded children - 2nd topmost zoom fully covered new OverscaledTileID(5, 0, 5, 6, 10), new OverscaledTileID(5, 0, 5, 7, 10), new OverscaledTileID(5, 0, 5, 6, 11), new OverscaledTileID(5, 0, 5, 7, 11), // loaded parents - to be requested because ideal tile is not completely covered by children (z=4) new OverscaledTileID(0, 0, 0, 0, 0), new OverscaledTileID(2, 0, 2, 0, 1), //parent new OverscaledTileID(1, 0, 1, 0, 0) ]; for (const t of loadedTiles) { tileManager._inViewTiles.setTile(t.key, new Tile(t, undefined)); tileManager._inViewTiles.getTileById(t.key).state = 'loaded'; } const expectedTiles = [ new OverscaledTileID(4, 0, 4, 2, 4), //topmost child new OverscaledTileID(4, 0, 4, 3, 4), //topmost child new OverscaledTileID(4, 0, 4, 2, 5), //topmost child new OverscaledTileID(2, 0, 2, 0, 1), //parent idealTile ]; const retained = tileManager._updateRetainedTiles([idealTile], 3); expect(Object.keys(retained).sort()).toEqual(expectedTiles.map(t => t.key).sort()); }); test('_updateRetainedTiles does not retain parents when 2nd generation children are loaded', () => { const tileManager = createTileManager(); tileManager._source.loadTile = async (tile) => { tile.state = 'errored'; }; const idealTile = new OverscaledTileID(3, 0, 3, 1, 2); tileManager._inViewTiles.setTile(idealTile.key, new Tile(idealTile, undefined)); tileManager._inViewTiles.getTileById(idealTile.key).state = 'errored'; const secondGeneration = idealTile .children(10) .flatMap(child => child.children(10)); expect(secondGeneration.length).toEqual(16); for (const id of secondGeneration) { tileManager._inViewTiles.setTile(id.key, new Tile(id, undefined)); tileManager._inViewTiles.getTileById(id.key).state = 'loaded'; } const expectedTiles = [...secondGeneration, idealTile]; const retained = tileManager._updateRetainedTiles([idealTile], 3); expect(Object.keys(retained).sort()).toEqual(expectedTiles.map(t => t.key).sort()); }); for (const pitch of [0, 20, 40, 65, 75, 85]) { test(`retains loaded children for pitch: ${pitch}`, () => { const transform = new MercatorTransform(); transform.resize(512, 512); transform.setZoom(10); transform.setMaxPitch(90); transform.setPitch(pitch); const tileManager = createTileManager(); tileManager._source.loadTile = async (tile) => { tile.state = 'errored'; //all ideal tiles generated from coveringTiles should be unavailable }; //see covering tile logic in tile_manager.update const idealTileIDs = coveringTiles(transform, { tileSize: tileManager.usedForTerrain ? tileManager.tileSize : tileManager._source.tileSize, minzoom: tileManager._source.minzoom, maxzoom: tileManager._source.maxzoom, roundZoom: tileManager._source.roundZoom, reparseOverscaled: tileManager._source.reparseOverscaled, calculateTileZoom: tileManager._source.calculateTileZoom }); const idealChildIDs = idealTileIDs.flatMap(id => id.children(tileManager._source.maxzoom)); for (const idealID of idealChildIDs) { const tile = new Tile(idealID, undefined); tile.state = 'loaded'; //all children are loaded to be retained for missing ideal tiles tileManager._inViewTiles.setTile(idealID.key, tile); } const retain: {[key: string]: OverscaledTileID} = {}; const missingTiles = new Set<OverscaledTileID>(); // mark all ideal tiles as retained and also as missing with no data for child retainment for (const idealID of idealTileIDs) { retain[idealID.key] = idealID; missingTiles.add(idealID); } tileManager._retainLoadedChildren(retain, missingTiles); expect(Object.keys(retain).sort()).toEqual(idealChildIDs.concat(idealTileIDs).map(id => id.key).sort()); }); } test('retains only uppermost zoom children when multiple zoom levels are loaded', () => { const tileManager = createTileManager(); tileManager._source.loadTile = async (tile) => { tile.state = 'errored'; }; const idealTileID = new OverscaledTileID(2, 0, 2, 1, 1); const idealTiles = new Set<OverscaledTileID>([idealTileID]); const children = [ new OverscaledTileID(3, 0, 3, 2, 2), //keep new OverscaledTileID(3, 0, 3, 3, 2), //keep new OverscaledTileID(4, 0, 4, 4, 4), //discard new OverscaledTileID(5, 0, 5, 8, 8), //discard ]; for (const child of children) { const tile = new Tile(child, undefined); tile.state = 'loaded'; tileManager._inViewTiles.setTile(child.key, tile); } const retain: {[key: string]: OverscaledTileID} = {}; tileManager._retainLoadedChildren(retain, idealTiles); const expectedKeys = children .filter(child => child.overscaledZ === 3) .map(child => child.key) .sort(); expect(Object.keys(retain).sort()).toEqual(expectedKeys); }); test('retains overscaled loaded children with coveringZoom < maxzoom', () => { const tileManager = createTileManager({maxzoom: 3}); tileManager._source.loadTile = async (tile) => { tile.state = 'errored'; }; const idealTile = new OverscaledTileID(3, 0, 3, 1, 2); tileManager._inViewTiles.setTile(idealTile.key, new Tile(idealTile, undefined)); tileManager._inViewTiles.getTileById(idealTile.key).state = 'errored'; const loadedChildren = [ new OverscaledTileID(4, 0, 3, 1, 2) ]; for (const t of loadedChildren) { tileManager._inViewTiles.setTile(t.key, new Tile(t, undefined)); tileManager._inViewTiles.getTileById(t.key).state = 'loaded'; } const retained = tileManager._updateRetainedTiles([idealTile], 2); expect(Object.keys(retained).sort()).toEqual([idealTile].concat(loadedChildren).map(t => t.key).sort()); }); test('_areDescendentsComplete returns true when descendents fully cover a generation', () => { const tileManager = createTileManager(); const idealTile = new OverscaledTileID(3, 0, 3, 1, 2); const firstGen = idealTile.children(10); expect(tileManager._areDescendentsComplete(firstGen, 4, 3)).toBe(true); const secondGen = idealTile.children(10).flatMap(c => c.children(10)); expect(tileManager._areDescendentsComplete(secondGen, 5, 3)).toBe(true); }); test('_areDescendentsComplete returns false when descendents are incomplete', () => { const tileManager = createTileManager(); const idealTile = new OverscaledTileID(3, 0, 3, 1, 2); const firstGenPartial = idealTile.children(10).slice(0, 3); expect(tileManager._areDescendentsComplete(firstGenPartial, 4, 3)).toBe(false); const secondGenPartial = idealTile.children(10).flatMap(c => c.children(10)).slice(0, 15); expect(tileManager._areDescendentsComplete(secondGenPartial, 5, 3)).toBe(false); }); test('_areDescendentsComplete properly handles overscaled tiles', () => { const tileManager = createTileManager(); const correct = new OverscaledTileID(4, 0, 3, 1, 2); expect(tileManager._areDescendentsComplete([correct], 4, 3)).toBe(true); const wrong = new OverscaledTileID(5, 0, 3, 1, 2); expect(tileManager._areDescendentsComplete([wrong], 4, 3)).toBe(false); }); test('adds parent tile if ideal tile errors and no child tiles are loaded', () => { const stateCache = {}; const tileManager = createTileManager(); tileManager._source.loadTile = async (tile) => { tile.state = stateCache[tile.tileID.key] || 'errored'; }; vi.spyOn(tileManager, '_addTile'); const getTileSpy = vi.spyOn(tileManager, 'getTile'); const idealTiles = [new OverscaledTileID(1, 0, 1, 1, 1), new OverscaledTileID(1, 0, 1, 0, 1)]; stateCache[idealTiles[0].key] = 'loaded'; const retained = tileManager._updateRetainedTiles(idealTiles, 1); expect(getTileSpy.mock.calls.map((c) => { return c[0]; })).toEqual([ // when child tiles aren't found, check and request parent tile new OverscaledTileID(0, 0, 0, 0, 0) ]); // retained tiles include all ideal tiles and any parents that were loaded to cover // non-existant tiles expect(retained).toEqual({