From ff6f9ffac130dc38aa035de97fffac0f123e03e7 Mon Sep 17 00:00:00 2001 From: Xiaoji Chen Date: Tue, 4 Feb 2020 10:38:33 -0800 Subject: [PATCH] TileLayer improvements (#4139) --- docs/layers/base-tile-layer.md | 10 +- .../src/tile-layer/utils/viewport-util.js | 4 +- .../src/base-tile-layer/base-tile-layer.js | 88 +++--- .../utils/{tile.js => tile-2d-header.js} | 29 +- .../src/base-tile-layer/utils/tile-cache.js | 147 --------- .../src/base-tile-layer/utils/tileset-2d.js | 291 ++++++++++++++++++ .../geo-layers/tile-layer/tile-layer.spec.js | 4 +- .../base-tile-layer/base-tile-layer.spec.js | 10 +- .../layers/base-tile-layer/tile-cache.spec.js | 150 --------- .../layers/base-tile-layer/tileset-2d.spec.js | 290 +++++++++++++++++ test/modules/layers/index.js | 2 +- 11 files changed, 654 insertions(+), 371 deletions(-) rename modules/layers/src/base-tile-layer/utils/{tile.js => tile-2d-header.js} (56%) delete mode 100644 modules/layers/src/base-tile-layer/utils/tile-cache.js create mode 100644 modules/layers/src/base-tile-layer/utils/tileset-2d.js delete mode 100644 test/modules/layers/base-tile-layer/tile-cache.spec.js create mode 100644 test/modules/layers/base-tile-layer/tileset-2d.spec.js diff --git a/docs/layers/base-tile-layer.md b/docs/layers/base-tile-layer.md index 4cce424be7f..8758319881c 100644 --- a/docs/layers/base-tile-layer.md +++ b/docs/layers/base-tile-layer.md @@ -31,7 +31,6 @@ To use pre-bundled scripts: - ``` ```js @@ -81,6 +80,15 @@ The maximum cache size for a tile layer. If not defined, it is calculated using - Default: `null` +##### `strategy` (Enum, optional) + +How the tile layer determines the visibility of tiles. One of the following: + +* `'best-available'`: If a tile in the current viewport is waiting for its data to load, use cached content from the closest zoom level to fill the empty space. This approach minimizes the visual flashing due to missing content. +* `'no-overlap'`: Avoid showing overlapping tiles when backfilling with cached content. This is usually favorable when tiles do not have opaque backgrounds. + +- Default: `'best-available'` + ### Render Options ##### `onViewportLoad` (Function, optional) diff --git a/modules/geo-layers/src/tile-layer/utils/viewport-util.js b/modules/geo-layers/src/tile-layer/utils/viewport-util.js index 6ed0a65e8f8..ad0e3a83f51 100644 --- a/modules/geo-layers/src/tile-layer/utils/viewport-util.js +++ b/modules/geo-layers/src/tile-layer/utils/viewport-util.js @@ -55,7 +55,6 @@ export function getTileIndices(viewport, maxZoom, minZoom) { expected 2 3 0 1 2 3 */ let [minX, minY] = getTileIndex([bbox[0], bbox[3]], scale); - // eslint-disable-next-line prefer-const let [maxX, maxY] = getTileIndex([bbox[2], bbox[1]], scale); const indices = []; @@ -65,7 +64,8 @@ export function getTileIndices(viewport, maxZoom, minZoom) { */ minX = Math.floor(minX); maxX = Math.min(minX + scale, maxX); // Avoid creating duplicates - minY = Math.floor(minY); + minY = Math.max(0, Math.floor(minY)); + maxY = Math.min(scale, maxY); for (let x = minX; x < maxX; x++) { for (let y = minY; y < maxY; y++) { // Cast to valid x between [0, scale] diff --git a/modules/layers/src/base-tile-layer/base-tile-layer.js b/modules/layers/src/base-tile-layer/base-tile-layer.js index 50de287de23..71f26f259df 100644 --- a/modules/layers/src/base-tile-layer/base-tile-layer.js +++ b/modules/layers/src/base-tile-layer/base-tile-layer.js @@ -1,20 +1,21 @@ import {log} from '@deck.gl/core'; import {CompositeLayer} from '@deck.gl/core'; import GeoJsonLayer from '../geojson-layer/geojson-layer'; -import TileCache from './utils/tile-cache'; +import Tileset2D, {STRATEGY_DEFAULT} from './utils/tileset-2d'; const defaultProps = { renderSubLayers: {type: 'function', value: props => new GeoJsonLayer(props), compare: false}, getTileData: {type: 'function', value: ({x, y, z}) => Promise.resolve(null), compare: false}, - tileToBoundingBox: {type: 'function', value: () => null, compare: false}, - getTileIndices: {type: 'function', value: () => null, compare: false}, + tileToBoundingBox: {type: 'function', value: (x, y, z) => null, compare: false}, + getTileIndices: {type: 'function', value: (viewport, maxZoom, minZoom) => [], compare: false}, // TODO - change to onViewportLoad to align with Tile3DLayer onViewportLoad: {type: 'function', optional: true, value: null, compare: false}, // eslint-disable-next-line onTileError: {type: 'function', value: err => console.error(err), compare: false}, maxZoom: null, minZoom: 0, - maxCacheSize: null + maxCacheSize: null, + strategy: STRATEGY_DEFAULT }; export default class BaseTileLayer extends CompositeLayer { @@ -34,68 +35,74 @@ export default class BaseTileLayer extends CompositeLayer { } updateState({props, oldProps, context, changeFlags}) { - let {tileCache} = this.state; - if ( - !tileCache || + let {tileset} = this.state; + const createTileCache = + !tileset || (changeFlags.updateTriggersChanged && - (changeFlags.updateTriggersChanged.all || changeFlags.updateTriggersChanged.getTileData)) - ) { + (changeFlags.updateTriggersChanged.all || changeFlags.updateTriggersChanged.getTileData)); + + if (createTileCache) { const { getTileData, maxZoom, minZoom, maxCacheSize, + strategy, getTileIndices, tileToBoundingBox } = props; - if (tileCache) { - tileCache.finalize(); + if (tileset) { + tileset.finalize(); } - tileCache = new TileCache({ + tileset = new Tileset2D({ getTileData, getTileIndices, tileToBoundingBox, maxSize: maxCacheSize, maxZoom, minZoom, - onTileLoad: this._onTileLoad.bind(this), + strategy, + onTileLoad: this._updateTileset.bind(this), onTileError: this._onTileError.bind(this) }); - this.setState({tileCache}); + this.setState({tileset}); } else if (changeFlags.propsChanged) { // if any props changed, delete the cached layers - this.state.tileCache.tiles.forEach(tile => { + this.state.tileset.tiles.forEach(tile => { tile.layer = null; }); } - const {viewport} = context; - if (changeFlags.viewportChanged && viewport.id !== 'DEFAULT-INITIAL-VIEWPORT') { - const z = this.getLayerZoomLevel(); - tileCache.update(viewport); - // The tiles that should be displayed at this zoom level - const currTiles = tileCache.tiles.filter(tile => tile.z === z); - this.setState({isLoaded: false, tiles: currTiles}); - this._onTileLoad(); + if (createTileCache || changeFlags.viewportChanged) { + this._updateTileset(); } } - _onTileLoad() { + _updateTileset() { + const {tileset} = this.state; const {onViewportLoad} = this.props; - const currTiles = this.state.tiles; - const allCurrTilesLoaded = currTiles.every(tile => tile.isLoaded); - if (this.state.isLoaded !== allCurrTilesLoaded) { - this.setState({isLoaded: allCurrTilesLoaded}); - if (allCurrTilesLoaded && onViewportLoad) { - onViewportLoad(currTiles.filter(tile => tile._data).map(tile => tile._data)); - } + const frameNumber = tileset.update(this.context.viewport); + const {isLoaded} = tileset; + + const loadingStateChanged = this.state.isLoaded !== isLoaded; + const tilesetChanged = this.state.frameNumber !== frameNumber; + + if (isLoaded && onViewportLoad && (loadingStateChanged || tilesetChanged)) { + onViewportLoad(tileset.selectedTiles.map(tile => tile.data)); } + + if (tilesetChanged) { + // Save the tileset frame number - trigger a rerender + this.setState({frameNumber}); + } + // Save the loaded state - should not trigger a rerender + this.state.isLoaded = isLoaded; } _onTileError(error) { this.props.onTileError(error); // errorred tiles should not block rendering, are considered "loaded" with empty data - this._onTileLoad(); + this._updateTileset(); } getPickingInfo({info, sourceLayer}) { @@ -104,26 +111,13 @@ export default class BaseTileLayer extends CompositeLayer { return info; } - getLayerZoomLevel() { - const z = Math.ceil(this.context.viewport.zoom); - const {maxZoom, minZoom} = this.props; - if (Number.isFinite(maxZoom) && z > maxZoom) { - return Math.floor(maxZoom); - } else if (Number.isFinite(minZoom) && z < minZoom) { - return Math.ceil(minZoom); - } - return z; - } - renderLayers() { const {renderSubLayers, visible} = this.props; - const z = this.getLayerZoomLevel(); - return this.state.tileCache.tiles.map(tile => { + return this.state.tileset.tiles.map(tile => { // For a tile to be visible: // - parent layer must be visible // - tile must be visible in the current viewport - // - if all tiles are loaded, only display the tiles from the current z level - const isVisible = visible && tile.isVisible && (!this.state.isLoaded || tile.z === z); + const isVisible = visible && tile.isVisible; // cache the rendered layer in the tile if (!tile.layer) { tile.layer = renderSubLayers( diff --git a/modules/layers/src/base-tile-layer/utils/tile.js b/modules/layers/src/base-tile-layer/utils/tile-2d-header.js similarity index 56% rename from modules/layers/src/base-tile-layer/utils/tile.js rename to modules/layers/src/base-tile-layer/utils/tile-2d-header.js index 6063ef50e8f..ae76030d729 100644 --- a/modules/layers/src/base-tile-layer/utils/tile.js +++ b/modules/layers/src/base-tile-layer/utils/tile-2d-header.js @@ -1,35 +1,38 @@ -export default class Tile { +export default class Tile2DHeader { constructor({getTileData, x, y, z, onTileLoad, onTileError, tileToBoundingBox}) { this.x = x; this.y = y; this.z = z; this.bbox = tileToBoundingBox(this.x, this.y, this.z); - this.isVisible = true; - this.getTileData = getTileData; - this._data = null; + this.selected = true; + this.parent = null; + this.children = []; + + this.content = null; this._isLoaded = false; - this._loader = this._loadData(); + this._loader = this._loadData(getTileData); + this.onTileLoad = onTileLoad; this.onTileError = onTileError; } get data() { - return this._data || this._loader; + return this.content || this._loader; } get isLoaded() { return this._isLoaded; } - _loadData() { + _loadData(getTileData) { const {x, y, z, bbox} = this; - if (!this.getTileData) { + if (!getTileData) { return null; } - return Promise.resolve(this.getTileData({x, y, z, bbox})) + return Promise.resolve(getTileData({x, y, z, bbox})) .then(buffers => { - this._data = buffers; + this.content = buffers; this._isLoaded = true; this.onTileLoad(this); return buffers; @@ -39,10 +42,4 @@ export default class Tile { this.onTileError(err); }); } - - isOverlapped(tile) { - const {x, y, z} = this; - const m = Math.pow(2, tile.z - z); - return Math.floor(tile.x / m) === x && Math.floor(tile.y / m) === y; - } } diff --git a/modules/layers/src/base-tile-layer/utils/tile-cache.js b/modules/layers/src/base-tile-layer/utils/tile-cache.js deleted file mode 100644 index bc57849a1a8..00000000000 --- a/modules/layers/src/base-tile-layer/utils/tile-cache.js +++ /dev/null @@ -1,147 +0,0 @@ -import Tile from './tile'; - -/** - * Manages loading and purging of tiles data. This class caches recently visited tiles - * and only create new tiles if they are present. - */ - -export default class TileCache { - /** - * Takes in a function that returns tile data, a cache size, and a max and a min zoom level. - * Cache size defaults to 5 * number of tiles in the current viewport - */ - constructor({ - getTileData, - maxSize, - maxZoom, - minZoom, - onTileLoad, - onTileError, - getTileIndices, - tileToBoundingBox - }) { - // TODO: Instead of hardcode size, we should calculate how much memory left - this._getTileData = getTileData; - this._maxSize = maxSize; - this._getTileIndices = getTileIndices; - this._tileToBoundingBox = tileToBoundingBox; - this.onTileError = onTileError; - this.onTileLoad = onTileLoad; - // Maps tile id in string {z}-{x}-{y} to a Tile object - this._cache = new Map(); - this._tiles = []; - - if (Number.isFinite(maxZoom)) { - this._maxZoom = Math.floor(maxZoom); - } - if (Number.isFinite(minZoom)) { - this._minZoom = Math.ceil(minZoom); - } - } - - get tiles() { - return this._tiles; - } - - /** - * Clear the current cache - */ - finalize() { - this._cache.clear(); - } - - /** - * Update the cache with the given viewport and triggers callback onUpdate. - * @param {*} viewport - * @param {*} onUpdate - */ - update(viewport) { - const { - _cache, - _getTileData, - _tileToBoundingBox, - _getTileIndices, - _maxSize, - _maxZoom, - _minZoom - } = this; - this._markOldTiles(); - const tileIndices = _getTileIndices(viewport, _maxZoom, _minZoom); - if (!tileIndices || tileIndices.length === 0) { - return; - } - _cache.forEach(cachedTile => { - if (tileIndices.some(tile => cachedTile.isOverlapped(tile))) { - cachedTile.isVisible = true; - } - }); - - let changed = false; - - for (let i = 0; i < tileIndices.length; i++) { - const tileIndex = tileIndices[i]; - - const {x, y, z} = tileIndex; - let tile = this._getTile(x, y, z); - if (!tile) { - tile = new Tile({ - getTileData: _getTileData, - tileToBoundingBox: _tileToBoundingBox, - x, - y, - z, - onTileLoad: this.onTileLoad, - onTileError: this.onTileError - }); - tile.isVisible = true; - changed = true; - } - const tileId = this._getTileId(x, y, z); - _cache.set(tileId, tile); - } - - if (changed) { - // cache size is either the user defined maxSize or 5 * number of current tiles in the viewport. - const commonZoomRange = 5; - this._resizeCache(_maxSize || commonZoomRange * tileIndices.length); - this._tiles = Array.from(this._cache.values()) - // sort by zoom level so parents tiles don't show up when children tiles are rendered - .sort((t1, t2) => t1.z - t2.z); - } - } - - /** - * Clear tiles that are not visible when the cache is full - */ - _resizeCache(maxSize) { - const {_cache} = this; - if (_cache.size > maxSize) { - const iterator = _cache[Symbol.iterator](); - for (const cachedTile of iterator) { - if (_cache.size <= maxSize) { - break; - } - const tileId = cachedTile[0]; - const tile = cachedTile[1]; - if (!tile.isVisible) { - _cache.delete(tileId); - } - } - } - } - - _markOldTiles() { - this._cache.forEach(cachedTile => { - cachedTile.isVisible = false; - }); - } - - _getTile(x, y, z) { - const tileId = this._getTileId(x, y, z); - return this._cache.get(tileId); - } - - _getTileId(x, y, z) { - return `${z}-${x}-${y}`; - } -} diff --git a/modules/layers/src/base-tile-layer/utils/tileset-2d.js b/modules/layers/src/base-tile-layer/utils/tileset-2d.js new file mode 100644 index 00000000000..ed94ad48c86 --- /dev/null +++ b/modules/layers/src/base-tile-layer/utils/tileset-2d.js @@ -0,0 +1,291 @@ +import Tile2DHeader from './tile-2d-header'; + +const TILE_STATE_UNKNOWN = 0; +const TILE_STATE_VISIBLE = 1; +/* + show cached parent tile if children are loading + +-----------+ +-----+ +-----+-----+ + | | | | | | | + | | | | | | | + | | --> +-----+-----+ -> +-----+-----+ + | | | | | | | + | | | | | | | + +-----------+ +-----+ +-----+-----+ + + show cached children tiles when parent is loading + +-------+---- +------------ + | | | + | | | + | | | + +-------+---- --> | + | | | + */ +const TILE_STATE_PLACEHOLDER = 3; +const TILE_STATE_HIDDEN = 4; +// tiles that should be displayed in the current viewport +const TILE_STATE_SELECTED = 5; + +export const STRATEGY_REPLACE = 'no-overlap'; +export const STRATEGY_DEFAULT = 'best-available'; + +/** + * Manages loading and purging of tiles data. This class caches recently visited tiles + * and only create new tiles if they are present. + */ + +export default class Tileset2D { + /** + * Takes in a function that returns tile data, a cache size, and a max and a min zoom level. + * Cache size defaults to 5 * number of tiles in the current viewport + */ + constructor({ + getTileData, + maxSize, + maxZoom, + minZoom, + strategy = STRATEGY_DEFAULT, + onTileLoad, + onTileError, + getTileIndices, + tileToBoundingBox + }) { + // TODO: Instead of hardcode size, we should calculate how much memory left + this._getTileData = getTileData; + this._maxSize = maxSize; + this._strategy = strategy; + this._getTileIndices = getTileIndices; + this._tileToBoundingBox = tileToBoundingBox; + this.onTileError = onTileError; + this.onTileLoad = onTileLoad; + // Maps tile id in string {z}-{x}-{y} to a Tile object + this._cache = new Map(); + this._tiles = []; + this._dirty = false; + + // Cache the last processed viewport + this._viewport = null; + this._selectedTiles = null; + this._frameNumber = 0; + + if (Number.isFinite(maxZoom)) { + this._maxZoom = Math.floor(maxZoom); + } + if (Number.isFinite(minZoom)) { + this._minZoom = Math.ceil(minZoom); + } + } + + get tiles() { + return this._tiles; + } + + get selectedTiles() { + return this._selectedTiles; + } + + get isLoaded() { + return this._selectedTiles.every(tile => tile.isLoaded); + } + + /** + * Clear the current cache + */ + finalize() { + this._cache.clear(); + } + + /** + * Update the cache with the given viewport and triggers callback onUpdate. + * @param {*} viewport + * @param {*} onUpdate + */ + update(viewport) { + const {_cache, _getTileIndices, _maxSize, _maxZoom, _minZoom} = this; + + let selectedTiles = this._selectedTiles; + + if (viewport !== this._viewport) { + const tileIndices = _getTileIndices(viewport, _maxZoom, _minZoom); + selectedTiles = tileIndices.map(({x, y, z}) => this._getTile(x, y, z, true)); + this._selectedTiles = selectedTiles; + + if (this._dirty) { + // Some new tiles are added + this._rebuildTree(); + } + } + + // Update tile states + this._updateTileStates(selectedTiles); + + let changed = false; + for (const tile of _cache.values()) { + const isVisible = Boolean(tile.state & TILE_STATE_VISIBLE); + if (tile.isVisible !== isVisible) { + changed = true; + tile.isVisible = isVisible; + } + } + + if (this._dirty) { + // cache size is either the user defined maxSize or 5 * number of current tiles in the viewport. + const commonZoomRange = 5; + this._resizeCache(_maxSize || commonZoomRange * selectedTiles.length); + this._dirty = false; + } + + if (changed) { + this._frameNumber++; + } + return this._frameNumber; + } + + // This needs to be called every time some tiles have been added/removed from cache + _rebuildTree() { + const {_cache} = this; + + // Reset states + for (const tile of _cache.values()) { + tile.parent = null; + tile.children.length = 0; + } + + // Rebuild tree + for (const tile of _cache.values()) { + const parent = this._getNearestAncestor(tile.x, tile.y, tile.z); + tile.parent = parent; + if (parent) { + parent.children.push(tile); + } + } + } + + // A selected tile is always visible. + // Never show two overlapping tiles. + // If a selected tile is loading, try showing a cached ancester with the closest z + // If a selected tile is loading, and no ancester is shown - try showing cached + // descendants with the closest z + _updateTileStates(selectedTiles) { + const {_cache} = this; + + // Reset states + for (const tile of _cache.values()) { + tile.state = TILE_STATE_UNKNOWN; + } + + // For all the selected && pending tiles: + // - pick the closest ancestor as placeholder + // - if no ancestor is visible, pick the closest children as placeholder + for (const tile of selectedTiles) { + tile.state = TILE_STATE_SELECTED; + getPlaceholderInAncestors(tile, this._strategy); + } + + // updateAncestorStates(selectedTiles); + + for (const tile of selectedTiles) { + if (needsPlaceholder(tile)) { + getPlaceholderInChildren(tile); + } + } + } + + /** + * Clear tiles that are not visible when the cache is full + */ + _resizeCache(maxSize) { + const {_cache} = this; + if (_cache.size > maxSize) { + for (const [tileId, tile] of _cache) { + if (!tile.isVisible) { + _cache.delete(tileId); + } + if (_cache.size <= maxSize) { + break; + } + } + this._rebuildTree(); + } + this._tiles = Array.from(this._cache.values()) + // sort by zoom level so that smaller tiles are displayed on top + .sort((t1, t2) => t1.z - t2.z); + } + + _getTile(x, y, z, create) { + const tileId = `${z}-${x}-${y}`; + let tile = this._cache.get(tileId); + + if (!tile && create) { + tile = new Tile2DHeader({ + getTileData: this._getTileData, + tileToBoundingBox: this._tileToBoundingBox, + x, + y, + z, + onTileLoad: this.onTileLoad, + onTileError: this.onTileError + }); + this._cache.set(tileId, tile); + this._dirty = true; + } + return tile; + } + + _getNearestAncestor(x, y, z) { + const {_minZoom = 0} = this; + + while (z > _minZoom) { + x = Math.floor(x / 2); + y = Math.floor(y / 2); + z -= 1; + const parent = this._getTile(x, y, z); + if (parent) { + return parent; + } + } + return null; + } +} + +// A selected tile needs placeholder from its children if +// - it is not loaded +// - none of its ancestors is visible and loaded +function needsPlaceholder(tile) { + let t = tile; + while (t) { + if (t.state & (TILE_STATE_VISIBLE === 0)) { + return true; + } + if (t.isLoaded) { + return false; + } + t = t.parent; + } + return true; +} + +function getPlaceholderInAncestors(tile, strategy) { + let parent; + let state = TILE_STATE_PLACEHOLDER; + while ((parent = tile.parent)) { + if (tile.isLoaded) { + // If a tile is loaded, mark all its ancestors as hidden + state = TILE_STATE_HIDDEN; + if (strategy === STRATEGY_DEFAULT) { + return; + } + } + parent.state = Math.max(parent.state, state); + tile = parent; + } +} + +// Recursively set children as placeholder +function getPlaceholderInChildren(tile) { + for (const child of tile.children) { + child.state = Math.max(child.state, TILE_STATE_PLACEHOLDER); + if (!child.isLoaded) { + getPlaceholderInChildren(child); + } + } +} diff --git a/test/modules/geo-layers/tile-layer/tile-layer.spec.js b/test/modules/geo-layers/tile-layer/tile-layer.spec.js index bac675d27a6..e62cb2ff247 100644 --- a/test/modules/geo-layers/tile-layer/tile-layer.spec.js +++ b/test/modules/geo-layers/tile-layer/tile-layer.spec.js @@ -42,12 +42,12 @@ test('TileLayer#updateTriggers', t => { }, onAfterUpdate({layer}) { t.equal( - layer.state.tileCache._tileToBoundingBox, + layer.state.tileset._tileToBoundingBox, tileToBoundingBox, 'Should create a tileCache with correct tileToBoundingBox' ); t.equal( - layer.state.tileCache._getTileIndices, + layer.state.tileset._getTileIndices, getTileIndices, 'Should create a tileCache with correct _getTileIndices' ); diff --git a/test/modules/layers/base-tile-layer/base-tile-layer.spec.js b/test/modules/layers/base-tile-layer/base-tile-layer.spec.js index 0cc8476f0ac..df61ec80ea5 100644 --- a/test/modules/layers/base-tile-layer/base-tile-layer.spec.js +++ b/test/modules/layers/base-tile-layer/base-tile-layer.spec.js @@ -39,7 +39,7 @@ test('BaseTileLayer#updateTriggers', t => { getTileData: 0 }, onAfterUpdate({layer}) { - t.equal(layer.state.tileCache._getTileData, 0, 'Should create a tileCache.'); + t.equal(layer.state.tileset._getTileData, 0, 'Should create a tileset.'); } }, { @@ -48,9 +48,9 @@ test('BaseTileLayer#updateTriggers', t => { }, onAfterUpdate({layer}) { t.equal( - layer.state.tileCache._getTileData, + layer.state.tileset._getTileData, 0, - 'Should not create a tileCache when updateTriggers not changed.' + 'Should not create a tileset when updateTriggers not changed.' ); } }, @@ -63,9 +63,9 @@ test('BaseTileLayer#updateTriggers', t => { }, onAfterUpdate({layer}) { t.equal( - layer.state.tileCache._getTileData, + layer.state.tileset._getTileData, 2, - 'Should create a new tileCache with updated getTileData.' + 'Should create a new tileset with updated getTileData.' ); } } diff --git a/test/modules/layers/base-tile-layer/tile-cache.spec.js b/test/modules/layers/base-tile-layer/tile-cache.spec.js deleted file mode 100644 index 0f6709a4214..00000000000 --- a/test/modules/layers/base-tile-layer/tile-cache.spec.js +++ /dev/null @@ -1,150 +0,0 @@ -import test from 'tape-catch'; -import TileCache from '@deck.gl/layers/base-tile-layer/utils/tile-cache'; -import Tile from '@deck.gl/layers/base-tile-layer/utils/tile'; -import {tileToBoundingBox} from '@deck.gl/geo-layers/tile-layer/utils/tile-util'; -import {getTileIndices} from '@deck.gl/geo-layers/tile-layer/utils/viewport-util'; -import {WebMercatorViewport} from '@deck.gl/core'; - -const testViewState = { - bearing: 0, - pitch: 0, - longitude: -77.06753216318891, - latitude: 38.94628276371387, - zoom: 12, - minZoom: 2, - maxZoom: 14, - height: 1, - width: 1 -}; - -// testViewState should load tile 12-1171-1566 -const testTile = new Tile({x: 1171, y: 1566, z: 12, tileToBoundingBox}); - -const testViewport = new WebMercatorViewport(testViewState); - -const cacheMaxSize = 1; -const maxZoom = 13; -const minZoom = 11; - -const getTileData = () => Promise.resolve(null); -const testTileCacheProps = { - getTileData, - tileToBoundingBox, - getTileIndices, - maxSize: cacheMaxSize, - minZoom, - maxZoom, - onTileLoad: () => {} -}; - -test('TileCache#TileCache#should clear the cache when finalize is called', t => { - const tileCache = new TileCache(testTileCacheProps); - tileCache.update(testViewport); - t.equal(tileCache._cache.size, 1); - tileCache.finalize(); - t.equal(tileCache._cache.size, 0); - t.end(); -}); - -test('TileCache#should call onUpdate with the expected tiles', t => { - const tileCache = new TileCache(testTileCacheProps); - tileCache.update(testViewport); - - t.equal(tileCache.tiles[0].x, testTile.x); - t.equal(tileCache.tiles[0].y, testTile.y); - t.equal(tileCache.tiles[0].z, testTile.z); - - tileCache.finalize(); - t.end(); -}); - -test('TileCache#should clear not visible tiles when cache is full', t => { - const tileCache = new TileCache(testTileCacheProps); - // load a viewport to fill the cache - tileCache.update(testViewport); - // load another viewport. The previous cached tiles shouldn't be visible - tileCache.update( - new WebMercatorViewport( - Object.assign({}, testViewState, { - longitude: -100, - latitude: 80 - }) - ) - ); - - t.equal(tileCache._cache.size, 1); - t.ok(tileCache._cache.get('12-910-459'), 'expected tile is in cache'); - - tileCache.finalize(); - t.end(); -}); - -test('TileCache#should load the cached parent tiles while we are loading the current tiles', t => { - const tileCache = new TileCache(testTileCacheProps); - tileCache.update(testViewport); - - const zoomedInViewport = new WebMercatorViewport( - Object.assign({}, testViewState, { - zoom: maxZoom - }) - ); - tileCache.update(zoomedInViewport); - t.ok( - tileCache.tiles.some( - tile => tile.x === testTile.x && tile.y === testTile.y && tile.z === testTile.z - ), - 'loads cached parent tiles' - ); - - tileCache.finalize(); - t.end(); -}); - -test('TileCache#should try to load the existing zoom levels if we zoom in too far', t => { - const tileCache = new TileCache(testTileCacheProps); - const zoomedInViewport = new WebMercatorViewport( - Object.assign({}, testViewState, { - zoom: 20 - }) - ); - - tileCache.update(zoomedInViewport); - tileCache.tiles.forEach(tile => { - t.equal(tile.z, maxZoom); - }); - - tileCache.finalize(); - t.end(); -}); - -test('TileCache#should not display anything if we zoom out too far', t => { - const tileCache = new TileCache(testTileCacheProps); - const zoomedOutViewport = new WebMercatorViewport( - Object.assign({}, testViewState, { - zoom: 1 - }) - ); - - tileCache.update(zoomedOutViewport); - t.equal(tileCache.tiles.length, 0); - tileCache.finalize(); - t.end(); -}); - -test('TileCache#should set isLoaded to true even when loading the tile throws an error', t => { - const errorTileCache = new TileCache({ - getTileData: () => Promise.reject(null), - onTileError: () => { - t.equal(errorTileCache.tiles[0].isLoaded, true); - errorTileCache.finalize(); - t.end(); - }, - tileToBoundingBox, - getTileIndices, - maxSize: cacheMaxSize, - minZoom, - maxZoom - }); - - errorTileCache.update(testViewport); -}); diff --git a/test/modules/layers/base-tile-layer/tileset-2d.spec.js b/test/modules/layers/base-tile-layer/tileset-2d.spec.js new file mode 100644 index 00000000000..d23d5a1b76f --- /dev/null +++ b/test/modules/layers/base-tile-layer/tileset-2d.spec.js @@ -0,0 +1,290 @@ +import test from 'tape-catch'; +import Tileset2D, { + STRATEGY_REPLACE, + STRATEGY_DEFAULT +} from '@deck.gl/layers/base-tile-layer/utils/tileset-2d'; +import Tile2DHeader from '@deck.gl/layers/base-tile-layer/utils/tile-2d-header'; +import {tileToBoundingBox} from '@deck.gl/geo-layers/tile-layer/utils/tile-util'; +import {getTileIndices} from '@deck.gl/geo-layers/tile-layer/utils/viewport-util'; +import {WebMercatorViewport} from '@deck.gl/core'; + +const testViewState = { + bearing: 0, + pitch: 0, + longitude: -77.06753216318891, + latitude: 38.94628276371387, + zoom: 12, + minZoom: 2, + maxZoom: 14, + height: 1, + width: 1 +}; + +// testViewState should load tile 12-1171-1566 +const testTile = new Tile2DHeader({x: 1171, y: 1566, z: 12, tileToBoundingBox}); + +const testViewport = new WebMercatorViewport(testViewState); + +const cacheMaxSize = 1; +const maxZoom = 13; +const minZoom = 11; + +const getTileData = () => Promise.resolve(null); +const testTileCacheProps = { + getTileData, + tileToBoundingBox, + getTileIndices, + maxSize: cacheMaxSize, + minZoom, + maxZoom, + onTileLoad: () => {} +}; + +test('Tileset2D#Tileset2D#should clear the cache when finalize is called', t => { + const tileset = new Tileset2D(testTileCacheProps); + tileset.update(testViewport); + t.equal(tileset._cache.size, 1); + tileset.finalize(); + t.equal(tileset._cache.size, 0); + t.end(); +}); + +test('Tileset2D#should call onUpdate with the expected tiles', t => { + const tileset = new Tileset2D(testTileCacheProps); + tileset.update(testViewport); + + t.equal(tileset.tiles[0].x, testTile.x); + t.equal(tileset.tiles[0].y, testTile.y); + t.equal(tileset.tiles[0].z, testTile.z); + + tileset.finalize(); + t.end(); +}); + +test('Tileset2D#should clear not visible tiles when cache is full', t => { + const tileset = new Tileset2D(testTileCacheProps); + // load a viewport to fill the cache + tileset.update(testViewport); + // load another viewport. The previous cached tiles shouldn't be visible + tileset.update( + new WebMercatorViewport( + Object.assign({}, testViewState, { + longitude: -100, + latitude: 80 + }) + ) + ); + + t.equal(tileset._cache.size, 1); + t.ok(tileset._cache.get('12-910-459'), 'expected tile is in cache'); + + tileset.finalize(); + t.end(); +}); + +test('Tileset2D#should load the cached parent tiles while we are loading the current tiles', t => { + const tileset = new Tileset2D(testTileCacheProps); + tileset.update(testViewport); + + const zoomedInViewport = new WebMercatorViewport( + Object.assign({}, testViewState, { + zoom: maxZoom + }) + ); + tileset.update(zoomedInViewport); + t.ok( + tileset.tiles.some( + tile => tile.x === testTile.x && tile.y === testTile.y && tile.z === testTile.z + ), + 'loads cached parent tiles' + ); + + tileset.finalize(); + t.end(); +}); + +test('Tileset2D#should try to load the existing zoom levels if we zoom in too far', t => { + const tileset = new Tileset2D(testTileCacheProps); + const zoomedInViewport = new WebMercatorViewport( + Object.assign({}, testViewState, { + zoom: 20 + }) + ); + + tileset.update(zoomedInViewport); + tileset.tiles.forEach(tile => { + t.equal(tile.z, maxZoom); + }); + + tileset.finalize(); + t.end(); +}); + +test('Tileset2D#should not display anything if we zoom out too far', t => { + const tileset = new Tileset2D(testTileCacheProps); + const zoomedOutViewport = new WebMercatorViewport( + Object.assign({}, testViewState, { + zoom: 1 + }) + ); + + tileset.update(zoomedOutViewport); + t.equal(tileset.tiles.length, 0); + tileset.finalize(); + t.end(); +}); + +test('Tileset2D#should set isLoaded to true even when loading the tile throws an error', t => { + const errorTileCache = new Tileset2D({ + getTileData: () => Promise.reject(null), + onTileError: () => { + t.equal(errorTileCache.tiles[0].isLoaded, true); + errorTileCache.finalize(); + t.end(); + }, + tileToBoundingBox, + getTileIndices, + maxSize: cacheMaxSize, + minZoom, + maxZoom + }); + + errorTileCache.update(testViewport); +}); + +test('Tileset2D#traversal', t => { + const tileset = new Tileset2D({ + tileToBoundingBox, + getTileIndices, + getTileData: () => sleep(10), + onTileLoad: () => {}, + onTileError: () => {} + }); + + /* + Test tiles: + +- 2-0-0 (pending) -+- 3-0-0 (pending) -+- 4-0-0 (pending) + +- 2-0-1 (pending) -+- 3-0-2 (loaded) -+- 4-0-4 (pending) + +- 1-0-0 (loaded) -+- 2-1-0 (missing) -+- 3-2-0 (pending) + | +- 2-1-1 (missing) -+- 3-2-2 (loaded) + 0-0-0 (pending) -+ + | +- 2-2-0 (loaded) -+- 3-4-0 (pending) + +- 1-1-0 (pending) -+- 2-2-1 (loaded) -+- 3-4-2 (loaded) + +- 2-3-0 (pending) -+- 3-6-0 (pending) + +- 2-3-1 (pending) -+- 3-6-2 (loaded) + */ + const TEST_CASES = [ + { + selectedTiles: ['0-0-0'], + visibleTiles: ['1-0-0', '2-2-0', '2-2-1', '3-6-2'] + }, + { + selectedTiles: ['1-0-0'], + visibleTiles: ['1-0-0'] + }, + { + selectedTiles: ['1-0-0', '1-1-0'], + visibleTiles: ['1-0-0', '2-2-0', '2-2-1', '3-6-2'] + }, + { + selectedTiles: ['2-0-0', '2-0-1'], + visibleTiles: ['1-0-0'] + }, + { + selectedTiles: ['2-2-0', '2-2-1', '2-3-0', '2-3-1'], + visibleTiles: ['2-2-0', '2-2-1', '3-6-2'] + }, + { + selectedTiles: ['3-0-0', '3-2-0'], + visibleTiles: ['1-0-0'] + }, + { + selectedTiles: ['3-0-0', '3-0-2', '3-2-0', '3-2-2'], + visibleTiles: { + [STRATEGY_DEFAULT]: ['1-0-0', '3-0-2', '3-2-2'], + [STRATEGY_REPLACE]: ['3-0-2', '3-2-2'] + } + }, + { + selectedTiles: ['3-4-0', '3-6-0'], + visibleTiles: ['2-2-0'] + }, + { + selectedTiles: ['3-4-0', '3-4-2', '3-6-0', '3-6-2'], + visibleTiles: ['2-2-0', '3-4-2', '3-6-2'] + }, + { + selectedTiles: ['4-0-0', '4-0-4'], + visibleTiles: { + [STRATEGY_DEFAULT]: ['1-0-0', '3-0-2'], + [STRATEGY_REPLACE]: ['3-0-2'] + } + } + ]; + + const tileMap = tileset._cache; + const strategies = [STRATEGY_DEFAULT, STRATEGY_REPLACE]; + + const validateVisibility = visibleTiles => { + let allMatched = true; + for (const [tileId, tile] of tileMap) { + const expected = visibleTiles.includes(tileId); + const actual = Boolean(tile.state & 1) && tile.isLoaded; + if (expected !== actual) { + t.fail( + `Tile2DHeader ${tileId} has state ${tile.state}, expected ${ + expected ? 'visible' : 'invisible' + }` + ); + allMatched = false; + } + } + t.ok(allMatched, 'Tile2DHeader visibility updated correctly'); + }; + + // Tiles that should be loaded + tileset._getTile(0, 0, 1, true); + tileset._getTile(2, 0, 2, true); + tileset._getTile(2, 1, 2, true); + tileset._getTile(0, 2, 3, true); + tileset._getTile(2, 2, 3, true); + tileset._getTile(4, 2, 3, true); + tileset._getTile(6, 2, 3, true); + + sleep(100).then(() => { + // Tiles that should be pending + tileset._getTile(0, 0, 0, true); + tileset._getTile(1, 0, 1, true); + tileset._getTile(0, 0, 2, true); + tileset._getTile(0, 1, 2, true); + tileset._getTile(3, 0, 2, true); + tileset._getTile(3, 1, 2, true); + tileset._getTile(0, 0, 3, true); + tileset._getTile(2, 0, 3, true); + tileset._getTile(4, 0, 3, true); + tileset._getTile(6, 0, 3, true); + tileset._getTile(0, 0, 4, true); + tileset._getTile(0, 4, 4, true); + + tileset._rebuildTree(); + + for (const testCase of TEST_CASES) { + const selectedTiles = testCase.selectedTiles.map(id => tileMap.get(id)); + + for (const strategy of strategies) { + tileset._strategy = strategy; + tileset._updateTileStates(selectedTiles); + validateVisibility(testCase.visibleTiles[strategy] || testCase.visibleTiles); + } + } + + t.end(); + }); +}); + +function sleep(ms) { + return new Promise(resolve => { + /* global setTimeout */ + setTimeout(resolve, ms); + }); +} diff --git a/test/modules/layers/index.js b/test/modules/layers/index.js index 027a7a8637f..adc726826c0 100644 --- a/test/modules/layers/index.js +++ b/test/modules/layers/index.js @@ -28,7 +28,7 @@ import './geojson-layer.spec'; import './point-cloud-layer.spec'; import './path-layer/path-layer-vertex.spec'; import './icon-manager.spec'; -import './base-tile-layer/tile-cache.spec'; +import './base-tile-layer/tileset-2d.spec'; import './base-tile-layer/base-tile-layer.spec'; import './text-layer/utils.spec'; import './text-layer/lru-cache.spec';