mapbox-gl
Version:
A WebGL interactive maps library
1,147 lines (984 loc) • 45.2 kB
JavaScript
// @flow
import Tile from './tile.js';
import {Event, ErrorEvent, Evented} from '../util/evented.js';
import TileCache from './tile_cache.js';
import {asyncAll, keysDifference, values, clamp} from '../util/util.js';
import Context from '../gl/context.js';
import Point from '@mapbox/point-geometry';
import browser from '../util/browser.js';
import {OverscaledTileID, CanonicalTileID} from './tile_id.js';
import assert from 'assert';
import SourceFeatureState from './source_state.js';
import {mercatorXfromLng} from '../geo/mercator_coordinate.js';
import type {Source} from './source.js';
import type {SourceSpecification} from '../style-spec/types.js';
import type {default as MapboxMap} from '../ui/map.js';
import type Transform from '../geo/transform.js';
import type {TileState} from './tile.js';
import type {Callback} from '../types/callback.js';
import type {FeatureStates} from './source_state.js';
import type {QueryGeometry, TilespaceQueryGeometry} from '../style/query_geometry.js';
import type {Vec3} from 'gl-matrix';
/**
* `SourceCache` is responsible for
*
* - creating an instance of `Source`
* - forwarding events from `Source`
* - caching tiles loaded from an instance of `Source`
* - loading the tiles needed to render a given viewport
* - unloading the cached tiles not needed to render a given viewport
*
* @private
*/
class SourceCache extends Evented {
id: string;
map: MapboxMap;
_source: Source;
_sourceLoaded: boolean;
_sourceErrored: boolean;
_tiles: {[_: string | number]: Tile};
_prevLng: number | void;
_cache: TileCache;
_timers: {[_: any]: TimeoutID};
_cacheTimers: {[_: any]: TimeoutID};
_minTileCacheSize: ?number;
_maxTileCacheSize: ?number;
_paused: boolean;
_isRaster: boolean;
_shouldReloadOnResume: boolean;
_coveredTiles: {[_: number | string]: boolean};
transform: Transform;
used: boolean;
usedForTerrain: boolean;
castsShadows: boolean;
_state: SourceFeatureState;
_loadedParentTiles: {[_: number | string]: ?Tile};
_onlySymbols: ?boolean;
_shadowCasterTiles: {[_: number]: boolean};
static maxUnderzooming: number;
static maxOverzooming: number;
constructor(id: string, source: Source, onlySymbols?: boolean) {
super();
this.id = id;
this._onlySymbols = onlySymbols;
source.on('data', (e) => {
// this._sourceLoaded signifies that the TileJSON is loaded if applicable.
// if the source type does not come with a TileJSON, the flag signifies the
// source data has loaded (in other words, GeoJSON has been tiled on the worker and is ready)
if (e.dataType === 'source' && e.sourceDataType === 'metadata') this._sourceLoaded = true;
// for sources with mutable data, this event fires when the underlying data
// to a source is changed (for example, using [GeoJSONSource#setData](https://docs.mapbox.com/mapbox-gl-js/api/sources/#geojsonsource#setdata) or [ImageSource#setCoordinates](https://docs.mapbox.com/mapbox-gl-js/api/sources/#imagesource#setcoordinates))
if (this._sourceLoaded && !this._paused && e.dataType === "source" && e.sourceDataType === 'content') {
this.reload();
if (this.transform) {
this.update(this.transform);
}
}
});
source.on('error', () => {
this._sourceErrored = true;
});
this._source = source;
this._tiles = {};
// $FlowFixMe[method-unbinding]
this._cache = new TileCache(0, this._unloadTile.bind(this));
this._timers = {};
this._cacheTimers = {};
this._minTileCacheSize = source.minTileCacheSize;
this._maxTileCacheSize = source.maxTileCacheSize;
this._loadedParentTiles = {};
this.castsShadows = false;
this._coveredTiles = {};
this._shadowCasterTiles = {};
this._state = new SourceFeatureState();
this._isRaster =
this._source.type === 'raster' ||
this._source.type === 'raster-dem' ||
// $FlowFixMe[prop-missing]
(this._source.type === 'custom' && this._source._dataType === 'raster');
}
onAdd(map: MapboxMap) {
this.map = map;
this._minTileCacheSize = this._minTileCacheSize === undefined && map ? map._minTileCacheSize : this._minTileCacheSize;
this._maxTileCacheSize = this._maxTileCacheSize === undefined && map ? map._maxTileCacheSize : this._maxTileCacheSize;
}
/**
* Return true if no tile data is pending, tiles will not change unless
* an additional API call is received.
* @private
*/
loaded(): boolean {
if (this._sourceErrored) { return true; }
if (!this._sourceLoaded) { return false; }
if (!this._source.loaded()) { return false; }
for (const t in this._tiles) {
const tile = this._tiles[t];
if (tile.state !== 'errored' && (tile.state !== 'loaded' || !tile.bucketsLoaded()))
return false;
}
return true;
}
getSource(): Source {
return this._source;
}
pause() {
this._paused = true;
}
resume() {
if (!this._paused) return;
const shouldReload = this._shouldReloadOnResume;
this._paused = false;
this._shouldReloadOnResume = false;
if (shouldReload) this.reload();
if (this.transform) this.update(this.transform);
}
_loadTile(tile: Tile, callback: Callback<void>): void {
tile.isSymbolTile = this._onlySymbols;
tile.isExtraShadowCaster = this._shadowCasterTiles[tile.tileID.key];
return this._source.loadTile(tile, callback);
}
_unloadTile(tile: Tile): void {
if (this._source.unloadTile)
return this._source.unloadTile(tile, () => {});
}
_abortTile(tile: Tile): void {
if (this._source.abortTile)
return this._source.abortTile(tile, () => {});
}
serialize(): SourceSpecification {
return this._source.serialize();
}
prepare(context: Context) {
if (this._source.prepare) {
this._source.prepare();
}
this._state.coalesceChanges(this._tiles, this.map ? this.map.painter : null);
for (const i in this._tiles) {
const tile = this._tiles[i];
tile.upload(context);
tile.prepare(this.map.style.imageManager, this.map ? this.map.painter : null, this._source.scope);
}
}
/**
* Return all tile ids ordered with z-order, and cast to numbers
* @private
*/
getIds(): Array<number> {
return values((this._tiles: any)).map((tile: Tile) => tile.tileID).sort(compareTileId).map(id => id.key);
}
getRenderableIds(symbolLayer?: boolean, includeShadowCasters?: boolean): Array<number> {
const renderables: Array<Tile> = [];
for (const id in this._tiles) {
if (this._isIdRenderable(+id, symbolLayer, includeShadowCasters)) renderables.push(this._tiles[id]);
}
if (symbolLayer) {
return renderables.sort((a_: Tile, b_: Tile) => {
const a = a_.tileID;
const b = b_.tileID;
const rotatedA = (new Point(a.canonical.x, a.canonical.y))._rotate(this.transform.angle);
const rotatedB = (new Point(b.canonical.x, b.canonical.y))._rotate(this.transform.angle);
return a.overscaledZ - b.overscaledZ || rotatedB.y - rotatedA.y || rotatedB.x - rotatedA.x;
}).map(tile => tile.tileID.key);
}
return renderables.map(tile => tile.tileID).sort(compareTileId).map(id => id.key);
}
hasRenderableParent(tileID: OverscaledTileID): boolean {
const parentTile = this.findLoadedParent(tileID, 0);
if (parentTile) {
return this._isIdRenderable(parentTile.tileID.key);
}
return false;
}
_isIdRenderable(id: number, symbolLayer?: boolean, includeShadowCasters?: boolean): boolean {
return this._tiles[id] && this._tiles[id].hasData() &&
!this._coveredTiles[id] && (symbolLayer || !this._tiles[id].holdingForFade()) &&
(includeShadowCasters || !this._shadowCasterTiles[id]);
}
reload() {
if (this._paused) {
this._shouldReloadOnResume = true;
return;
}
this._cache.reset();
for (const i in this._tiles) {
if (this._tiles[i].state !== "errored") this._reloadTile(+i, 'reloading');
}
}
_reloadTile(id: number, state: TileState) {
const tile = this._tiles[id];
// this potentially does not address all underlying
// issues https://github.com/mapbox/mapbox-gl-js/issues/4252
// - hard to tell without repro steps
if (!tile) return;
// The difference between "loading" tiles and "reloading" or "expired"
// tiles is that "reloading"/"expired" tiles are "renderable".
// Therefore, a "loading" tile cannot become a "reloading" tile without
// first becoming a "loaded" tile.
if (tile.state !== 'loading') {
tile.state = state;
}
// $FlowFixMe[method-unbinding]
this._loadTile(tile, this._tileLoaded.bind(this, tile, id, state));
}
_tileLoaded(tile: Tile, id: number, previousState: TileState, err: ?Error) {
if (err) {
tile.state = 'errored';
if ((err: any).status !== 404) this._source.fire(new ErrorEvent(err, {tile}));
// If the requested tile is missing, try to load the parent tile
// to use it as an overscaled tile instead of the missing one.
else {
const hasParent = tile.tileID.key in this._loadedParentTiles;
// If there are no parent tiles to load, fire a `data` event to trigger map render
if (!hasParent) {
// We are firing an `error` source type event instead of `content` here because
// the `content` event will reload all tiles and trigger redundant source cache updates
this._source.fire(new Event('data', {dataType: 'source', sourceDataType: 'error', sourceId: this._source.id}));
return;
}
// Otherwise, continue trying to load the parent tile until we find one that loads successfully
const updateForTerrain = this._source.type === 'raster-dem' && this.usedForTerrain;
if (updateForTerrain && this.map.painter.terrain) {
const terrain = this.map.painter.terrain;
this.update(this.transform, terrain.getScaledDemTileSize(), true);
terrain.resetTileLookupCache(this.id);
} else {
this.update(this.transform);
}
}
return;
}
tile.timeAdded = browser.now();
if (previousState === 'expired') tile.refreshedUponExpiration = true;
this._setTileReloadTimer(id, tile);
if (this._source.type === 'raster-dem' && tile.dem) this._backfillDEM(tile);
this._state.initializeTileState(tile, this.map ? this.map.painter : null);
this._source.fire(new Event('data', {dataType: 'source', tile, coord: tile.tileID, 'sourceCacheId': this.id}));
}
/**
* For raster terrain source, backfill DEM to eliminate visible tile boundaries
* @private
*/
_backfillDEM(tile: Tile) {
const renderables = this.getRenderableIds();
for (let i = 0; i < renderables.length; i++) {
const borderId = renderables[i];
if (tile.neighboringTiles && tile.neighboringTiles[borderId]) {
const borderTile = this.getTileByID(borderId);
fillBorder(tile, borderTile);
fillBorder(borderTile, tile);
}
}
function fillBorder(tile: Tile, borderTile: Tile) {
if (!tile.dem || tile.dem.borderReady) return;
tile.needsHillshadePrepare = true;
tile.needsDEMTextureUpload = true;
let dx = borderTile.tileID.canonical.x - tile.tileID.canonical.x;
const dy = borderTile.tileID.canonical.y - tile.tileID.canonical.y;
const dim = Math.pow(2, tile.tileID.canonical.z);
const borderId = borderTile.tileID.key;
if (dx === 0 && dy === 0) return;
if (Math.abs(dy) > 1) {
return;
}
if (Math.abs(dx) > 1) {
// Adjust the delta coordinate for world wraparound.
if (Math.abs(dx + dim) === 1) {
dx += dim;
} else if (Math.abs(dx - dim) === 1) {
dx -= dim;
}
}
if (!borderTile.dem || !tile.dem) return;
tile.dem.backfillBorder(borderTile.dem, dx, dy);
if (tile.neighboringTiles && tile.neighboringTiles[borderId])
tile.neighboringTiles[borderId].backfilled = true;
}
}
/**
* Get a specific tile by TileID
* @private
*/
getTile(tileID: OverscaledTileID): Tile {
return this.getTileByID(tileID.key);
}
/**
* Get a specific tile by id
* @private
*/
getTileByID(id: number): Tile {
return this._tiles[id];
}
/**
* For a given set of tiles, retain children that are loaded and have a zoom
* between `zoom` (exclusive) and `maxCoveringZoom` (inclusive)
* @private
*/
_retainLoadedChildren(
idealTiles: {[number | string]: OverscaledTileID},
zoom: number,
maxCoveringZoom: number,
retain: {[number | string]: OverscaledTileID}
) {
for (const id in this._tiles) {
let tile = this._tiles[id];
// only consider renderable tiles up to maxCoveringZoom
if (retain[id] ||
!tile.hasData() ||
tile.tileID.overscaledZ <= zoom ||
tile.tileID.overscaledZ > maxCoveringZoom
) continue;
// loop through parents and retain the topmost loaded one if found
let topmostLoadedID = tile.tileID;
while (tile && tile.tileID.overscaledZ > zoom + 1) {
const parentID = tile.tileID.scaledTo(tile.tileID.overscaledZ - 1);
tile = this._tiles[parentID.key];
if (tile && tile.hasData()) {
topmostLoadedID = parentID;
}
}
// loop through ancestors of the topmost loaded child to see if there's one that needed it
let tileID = topmostLoadedID;
while (tileID.overscaledZ > zoom) {
tileID = tileID.scaledTo(tileID.overscaledZ - 1);
if (idealTiles[tileID.key]) {
// found a parent that needed a loaded child; retain that child
retain[topmostLoadedID.key] = topmostLoadedID;
break;
}
}
}
}
/**
* Find a loaded parent of the given tile (up to minCoveringZoom)
* @private
*/
findLoadedParent(tileID: OverscaledTileID, minCoveringZoom: number): ?Tile {
if (tileID.key in this._loadedParentTiles) {
const parent = this._loadedParentTiles[tileID.key];
if (parent && parent.tileID.overscaledZ >= minCoveringZoom) {
return parent;
} else {
return null;
}
}
for (let z = tileID.overscaledZ - 1; z >= minCoveringZoom; z--) {
const parentTileID = tileID.scaledTo(z);
const tile = this._getLoadedTile(parentTileID);
if (tile) {
return tile;
}
}
}
_getLoadedTile(tileID: OverscaledTileID): ?Tile {
const tile = this._tiles[tileID.key];
if (tile && tile.hasData()) {
return tile;
}
// TileCache ignores wrap in lookup.
const cachedTile = this._cache.getByKey(this._source.reparseOverscaled ? tileID.wrapped().key : tileID.canonical.key);
return cachedTile;
}
/**
* Resizes the tile cache based on the current viewport's size
* or the minTileCacheSize and maxTileCacheSize options passed during map creation
*
* Larger viewports use more tiles and need larger caches. Larger viewports
* are more likely to be found on devices with more memory and on pages where
* the map is more important.
* @private
*/
updateCacheSize(transform: Transform, tileSize?: number) {
tileSize = tileSize || this._source.tileSize;
const widthInTiles = Math.ceil(transform.width / tileSize) + 1;
const heightInTiles = Math.ceil(transform.height / tileSize) + 1;
const approxTilesInView = widthInTiles * heightInTiles;
const commonZoomRange = 5;
const viewDependentMaxSize = Math.floor(approxTilesInView * commonZoomRange);
const minSize = typeof this._minTileCacheSize === 'number' ? Math.max(this._minTileCacheSize, viewDependentMaxSize) : viewDependentMaxSize;
const maxSize = typeof this._maxTileCacheSize === 'number' ? Math.min(this._maxTileCacheSize, minSize) : minSize;
this._cache.setMaxSize(maxSize);
}
handleWrapJump(lng: number) {
// On top of the regular z/x/y values, TileIDs have a `wrap` value that specify
// which copy of the world the tile belongs to. For example, at `lng: 10` you
// might render z/x/y/0 while at `lng: 370` you would render z/x/y/1.
//
// When lng values get wrapped (going from `lng: 370` to `long: 10`) you expect
// to see the same thing on the screen (370 degrees and 10 degrees is the same
// place in the world) but all the TileIDs will have different wrap values.
//
// In order to make this transition seamless, we calculate the rounded difference of
// "worlds" between the last frame and the current frame. If the map panned by
// a world, then we can assign all the tiles new TileIDs with updated wrap values.
// For example, assign z/x/y/1 a new id: z/x/y/0. It is the same tile, just rendered
// in a different position.
//
// This enables us to reuse the tiles at more ideal locations and prevent flickering.
const prevLng = this._prevLng === undefined ? lng : this._prevLng;
const lngDifference = lng - prevLng;
const worldDifference = lngDifference / 360;
const wrapDelta = Math.round(worldDifference);
this._prevLng = lng;
if (wrapDelta) {
const tiles: {[_: string | number]: Tile} = {};
for (const key in this._tiles) {
const tile = this._tiles[key];
tile.tileID = tile.tileID.unwrapTo(tile.tileID.wrap + wrapDelta);
tiles[tile.tileID.key] = tile;
}
this._tiles = tiles;
// Reset tile reload timers
for (const id in this._timers) {
clearTimeout(this._timers[id]);
delete this._timers[id];
}
for (const id in this._tiles) {
const tile = this._tiles[id];
this._setTileReloadTimer(+id, tile);
}
}
}
/**
* Removes tiles that are outside the viewport and adds new tiles that
* are inside the viewport.
* @private
* @param {boolean} updateForTerrain Signals to update tiles even if the
* source is not used (this.used) by layers: it is used for terrain.
* @param {tileSize} tileSize If needed to get lower resolution ideal cover,
* override source.tileSize used in tile cover calculation.
*/
update(transform: Transform, tileSize?: number, updateForTerrain?: boolean, directionalLight?: Vec3) {
this.transform = transform;
if (!this._sourceLoaded || this._paused || this.transform.freezeTileCoverage) { return; }
assert(!(updateForTerrain && !this.usedForTerrain));
if (this.usedForTerrain && !updateForTerrain) {
// If source is used for both terrain and hillshade, don't update it twice.
return;
}
this.updateCacheSize(transform, tileSize);
if (this.transform.projection.name !== 'globe') {
this.handleWrapJump(this.transform.center.lng);
}
// Tiles acting as shadow casters can be included in the ideal set
// even though they might not be visible on the screen.
this._shadowCasterTiles = {};
// Covered is a list of retained tiles who's areas are fully covered by other,
// better, retained tiles. They are not drawn separately.
this._coveredTiles = {};
let idealTileIDs;
if (!this.used && !this.usedForTerrain) {
idealTileIDs = [];
} else if (this._source.tileID) {
idealTileIDs = transform.getVisibleUnwrappedCoordinates(this._source.tileID)
.map((unwrapped) => new OverscaledTileID(unwrapped.canonical.z, unwrapped.wrap, unwrapped.canonical.z, unwrapped.canonical.x, unwrapped.canonical.y));
} else {
idealTileIDs = transform.coveringTiles({
tileSize: tileSize || this._source.tileSize,
minzoom: this._source.minzoom,
maxzoom: this._source.maxzoom,
roundZoom: this._source.roundZoom && !updateForTerrain,
reparseOverscaled: this._source.reparseOverscaled,
isTerrainDEM: this.usedForTerrain
});
if (this._source.hasTile) {
idealTileIDs = idealTileIDs.filter((coord) => (this._source.hasTile: any)(coord));
}
}
if (idealTileIDs.length > 0 && this.castsShadows &&
directionalLight && this.transform.projection.name !== 'globe' &&
!this.usedForTerrain && !isRasterType(this._source.type)) {
// compute desired max zoom level
const coveringZoom = transform.coveringZoomLevel({
tileSize: tileSize || this._source.tileSize,
roundZoom: this._source.roundZoom && !updateForTerrain
});
const idealZoom = Math.min(coveringZoom, this._source.maxzoom);
// find shadowCasterTiles
const shadowCasterTileIDs = transform.extendTileCoverForShadows(idealTileIDs, directionalLight, idealZoom);
for (const id of shadowCasterTileIDs) {
this._shadowCasterTiles[id.key] = true;
idealTileIDs.push(id);
}
}
// Retain is a list of tiles that we shouldn't delete, even if they are not
// the most ideal tile for the current viewport. This may include tiles like
// parent or child tiles that are *already* loaded.
const retain = this._updateRetainedTiles(idealTileIDs);
if (isRasterType(this._source.type) && idealTileIDs.length !== 0) {
const parentsForFading: {[_: string | number]: OverscaledTileID} = {};
const fadingTiles = {};
const ids = Object.keys(retain);
for (const id of ids) {
const tileID = retain[id];
assert(tileID.key === +id);
const tile = this._tiles[id];
if (!tile || (tile.fadeEndTime && tile.fadeEndTime <= browser.now())) continue;
// if the tile is loaded but still fading in, find parents to cross-fade with it
const parentTile = this.findLoadedParent(tileID, Math.max(tileID.overscaledZ - SourceCache.maxOverzooming, this._source.minzoom));
if (parentTile) {
this._addTile(parentTile.tileID);
parentsForFading[parentTile.tileID.key] = parentTile.tileID;
}
fadingTiles[id] = tileID;
}
// for children tiles with parent tiles still fading in,
// retain the children so the parent can fade on top
const minZoom = idealTileIDs[idealTileIDs.length - 1].overscaledZ;
for (const id in this._tiles) {
const childTile = this._tiles[id];
if (retain[id] || !childTile.hasData()) {
continue;
}
let parentID = childTile.tileID;
while (parentID.overscaledZ > minZoom) {
parentID = parentID.scaledTo(parentID.overscaledZ - 1);
const tile = this._tiles[parentID.key];
if (tile && tile.hasData() && fadingTiles[parentID.key]) {
retain[id] = childTile.tileID;
break;
}
}
}
for (const id in parentsForFading) {
if (!retain[id]) {
// If a tile is only needed for fading, mark it as covered so that it isn't rendered on it's own.
this._coveredTiles[id] = true;
retain[id] = parentsForFading[id];
}
}
}
for (const retainedId in retain) {
// Make sure retained tiles always clear any existing fade holds
// so that if they're removed again their fade timer starts fresh.
this._tiles[retainedId].clearFadeHold();
}
// Remove the tiles we don't need anymore.
const remove = keysDifference((this._tiles: any), (retain: any));
for (const tileID of remove) {
const tile = this._tiles[tileID];
if (tile.hasSymbolBuckets && !tile.holdingForFade()) {
tile.setHoldDuration(this.map._fadeDuration);
} else if (!tile.hasSymbolBuckets || tile.symbolFadeFinished()) {
this._removeTile(+tileID);
}
}
// Construct a cache of loaded parents
this._updateLoadedParentTileCache();
if (this._onlySymbols && this._source.afterUpdate) {
this._source.afterUpdate();
}
}
releaseSymbolFadeTiles() {
for (const id in this._tiles) {
if (this._tiles[id].holdingForFade()) {
this._removeTile(+id);
}
}
}
_updateRetainedTiles(idealTileIDs: Array<OverscaledTileID>): {[_: number | string]: OverscaledTileID} {
const retain: {[_: number | string]: OverscaledTileID} = {};
if (idealTileIDs.length === 0) { return retain; }
const checked: {[_: number | string]: boolean } = {};
const minZoom = idealTileIDs.reduce((min, id) => Math.min(min, id.overscaledZ), Infinity);
const maxZoom = idealTileIDs[0].overscaledZ;
assert(minZoom <= maxZoom);
const minCoveringZoom = Math.max(maxZoom - SourceCache.maxOverzooming, this._source.minzoom);
const maxCoveringZoom = Math.max(maxZoom + SourceCache.maxUnderzooming, this._source.minzoom);
const missingTiles = {};
for (const tileID of idealTileIDs) {
const tile = this._addTile(tileID);
// retain the tile even if it's not loaded because it's an ideal tile.
retain[tileID.key] = tileID;
if (tile.hasData()) continue;
if (minZoom < this._source.maxzoom) {
// save missing tiles that potentially have loaded children
missingTiles[tileID.key] = tileID;
}
}
// retain any loaded children of ideal tiles up to maxCoveringZoom
this._retainLoadedChildren(missingTiles, minZoom, maxCoveringZoom, retain);
for (const tileID of idealTileIDs) {
let tile = this._tiles[tileID.key];
if (tile.hasData()) continue;
// The tile we require is not yet loaded or does not exist;
// Attempt to find children that fully cover it.
if (tileID.canonical.z >= this._source.maxzoom) {
// We're looking for an overzoomed child tile.
const childCoord = tileID.children(this._source.maxzoom)[0];
const childTile = this.getTile(childCoord);
if (!!childTile && childTile.hasData()) {
retain[childCoord.key] = childCoord;
continue; // tile is covered by overzoomed child
}
} else {
// Check if all 4 immediate children are loaded (in other words, the missing ideal tile is covered)
const children = tileID.children(this._source.maxzoom);
if (retain[children[0].key] &&
retain[children[1].key] &&
retain[children[2].key] &&
retain[children[3].key]) continue; // tile is covered by children
}
// We couldn't find child tiles that entirely cover the ideal tile; look for parents now.
// As we ascend up the tile pyramid of the ideal tile, we check whether the parent
// tile has been previously requested (and errored because we only loop over tiles with no data)
// in order to determine if we need to request its parent.
let parentWasRequested = tile.wasRequested();
for (let overscaledZ = tileID.overscaledZ - 1; overscaledZ >= minCoveringZoom; --overscaledZ) {
const parentId = tileID.scaledTo(overscaledZ);
// Break parent tile ascent if this route has been previously checked by another child.
if (checked[parentId.key]) break;
checked[parentId.key] = true;
tile = this.getTile(parentId);
if (!tile && parentWasRequested) {
tile = this._addTile(parentId);
}
if (tile) {
retain[parentId.key] = parentId;
// Save the current values, since they're the parent of the next iteration
// of the parent tile ascent loop.
parentWasRequested = tile.wasRequested();
if (tile.hasData()) break;
}
}
}
return retain;
}
_updateLoadedParentTileCache() {
this._loadedParentTiles = {};
for (const tileKey in this._tiles) {
const path = [];
let parentTile: ?Tile;
let currentId = this._tiles[tileKey].tileID;
// Find the closest loaded ancestor by traversing the tile tree towards the root and
// caching results along the way
while (currentId.overscaledZ > 0) {
// Do we have a cached result from previous traversals?
if (currentId.key in this._loadedParentTiles) {
parentTile = this._loadedParentTiles[currentId.key];
break;
}
path.push(currentId.key);
// Is the parent loaded?
const parentId = currentId.scaledTo(currentId.overscaledZ - 1);
parentTile = this._getLoadedTile(parentId);
if (parentTile) {
break;
}
currentId = parentId;
}
// Cache the result of this traversal to all newly visited tiles
for (const key of path) {
this._loadedParentTiles[key] = parentTile;
}
}
}
/**
* Add a tile, given its coordinate, to the pyramid.
* @private
*/
_addTile(tileID: OverscaledTileID): Tile {
let tile: ?Tile = this._tiles[tileID.key];
const isExtraShadowCaster = !!this._shadowCasterTiles[tileID.key];
if (tile) {
if (tile.isExtraShadowCaster === true && !isExtraShadowCaster) {
// If the tile changed shadow visibility we need to relayout
this._reloadTile(tileID.key, 'reloading');
}
return tile;
}
tile = this._cache.getAndRemove(tileID);
if (tile) {
this._setTileReloadTimer(tileID.key, tile);
// set the tileID because the cached tile could have had a different wrap value
tile.tileID = tileID;
this._state.initializeTileState(tile, this.map ? this.map.painter : null);
if (this._cacheTimers[tileID.key]) {
clearTimeout(this._cacheTimers[tileID.key]);
delete this._cacheTimers[tileID.key];
this._setTileReloadTimer(tileID.key, tile);
}
}
const cached = Boolean(tile);
if (!cached) {
const painter = this.map ? this.map.painter : null;
tile = new Tile(tileID, this._source.tileSize * tileID.overscaleFactor(), this.transform.tileZoom, painter, this._isRaster);
// $FlowFixMe[method-unbinding]
this._loadTile(tile, this._tileLoaded.bind(this, tile, tileID.key, tile.state));
}
// Impossible, but silence flow.
if (!tile) return (null: any);
tile.uses++;
this._tiles[tileID.key] = tile;
if (!cached) this._source.fire(new Event('dataloading', {tile, coord: tile.tileID, dataType: 'source'}));
return tile;
}
_setTileReloadTimer(id: number, tile: Tile) {
if (id in this._timers) {
clearTimeout(this._timers[id]);
delete this._timers[id];
}
const expiryTimeout = tile.getExpiryTimeout();
if (expiryTimeout) {
this._timers[id] = setTimeout(() => {
this._reloadTile(id, 'expired');
delete this._timers[id];
}, expiryTimeout);
}
}
/**
* Remove a tile, given its id, from the pyramid
* @private
*/
_removeTile(id: number) {
const tile = this._tiles[id];
if (!tile)
return;
tile.uses--;
delete this._tiles[id];
if (this._timers[id]) {
clearTimeout(this._timers[id]);
delete this._timers[id];
}
if (tile.uses > 0)
return;
if (tile.hasData() && tile.state !== 'reloading') {
this._cache.add(tile.tileID, tile, tile.getExpiryTimeout());
} else {
tile.aborted = true;
this._abortTile(tile);
this._unloadTile(tile);
}
}
/**
* Remove all tiles from this pyramid.
* @private
*/
clearTiles() {
this._shouldReloadOnResume = false;
this._paused = false;
for (const id in this._tiles)
this._removeTile(+id);
if (this._source._clear) this._source._clear();
this._cache.reset();
if (this.map && this.usedForTerrain && this.map.painter.terrain) {
this.map.painter.terrain.resetTileLookupCache(this.id);
}
}
/**
* Search through our current tiles and attempt to find the tiles that cover the given `queryGeometry`.
*
* @param {QueryGeometry} queryGeometry
* @param {boolean} [visualizeQueryGeometry=false]
* @param {boolean} use3DQuery
* @returns
* @private
*/
tilesIn(queryGeometry: QueryGeometry, use3DQuery: boolean, visualizeQueryGeometry: boolean): TilespaceQueryGeometry[] {
const tileResults = [];
const transform = this.transform;
if (!transform) return tileResults;
const isGlobe = transform.projection.name === 'globe';
const centerX = mercatorXfromLng(transform.center.lng);
for (const tileID in this._tiles) {
const tile = this._tiles[tileID];
if (visualizeQueryGeometry) {
tile.clearQueryDebugViz();
}
if (tile.holdingForFade()) {
// Tiles held for fading are covered by tiles that are closer to ideal
continue;
}
// An array of wrap values for the tile [-1, 0, 1]. The default value is 0 but -1 or 1 wrapping
// might be required in globe view due to globe's surface being continuous.
let tilesToCheck;
if (isGlobe) {
// Compare distances to copies of the tile to see if a wrapped one should be used.
const id = tile.tileID.canonical;
assert(tile.tileID.wrap === 0);
if (id.z === 0) {
// Render the zoom level 0 tile twice as the query polygon might span over the antimeridian
const distances = [
Math.abs(clamp(centerX, ...tileBoundsX(id, -1)) - centerX),
Math.abs(clamp(centerX, ...tileBoundsX(id, 1)) - centerX)
];
tilesToCheck = [0, distances.indexOf(Math.min(...distances)) * 2 - 1];
} else {
const distances = [
Math.abs(clamp(centerX, ...tileBoundsX(id, -1)) - centerX),
Math.abs(clamp(centerX, ...tileBoundsX(id, 0)) - centerX),
Math.abs(clamp(centerX, ...tileBoundsX(id, 1)) - centerX)
];
tilesToCheck = [distances.indexOf(Math.min(...distances)) - 1];
}
} else {
tilesToCheck = [0];
}
for (const wrap of tilesToCheck) {
const tileResult = queryGeometry.containsTile(tile, transform, use3DQuery, wrap);
if (tileResult) {
tileResults.push(tileResult);
}
}
}
return tileResults;
}
getShadowCasterCoordinates(): Array<OverscaledTileID> {
return this._getRenderableCoordinates(false, true);
}
getVisibleCoordinates(symbolLayer?: boolean): Array<OverscaledTileID> {
return this._getRenderableCoordinates(symbolLayer);
}
_getRenderableCoordinates(symbolLayer?: boolean, includeShadowCasters?: boolean): Array<OverscaledTileID> {
const coords = this.getRenderableIds(symbolLayer, includeShadowCasters).map((id) => this._tiles[id].tileID);
const isGlobe = this.transform.projection.name === 'globe';
for (const coord of coords) {
coord.projMatrix = this.transform.calculateProjMatrix(coord.toUnwrapped());
if (isGlobe) {
coord.expandedProjMatrix = this.transform.calculateProjMatrix(coord.toUnwrapped(), false, true);
} else {
coord.expandedProjMatrix = coord.projMatrix;
}
}
return coords;
}
sortCoordinatesByDistance(coords: Array<OverscaledTileID>): Array<OverscaledTileID> {
const sortedCoords = coords.slice();
const camPos = this.transform._camera.position;
const camFwd = this.transform._camera.forward();
const precomputedDistances: {[number]: number} = {};
// Precompute distances of tile center points to the camera plane
for (const id of sortedCoords) {
const invTiles = 1.0 / (1 << id.canonical.z);
const centerX = (id.canonical.x + 0.5) * invTiles + id.wrap;
const centerY = (id.canonical.y + 0.5) * invTiles;
precomputedDistances[id.key] = (centerX - camPos[0]) * camFwd[0] + (centerY - camPos[1]) * camFwd[1] - camPos[2] * camFwd[2];
}
sortedCoords.sort((a, b) => { return precomputedDistances[a.key] - precomputedDistances[b.key]; });
return sortedCoords;
}
hasTransition(): boolean {
if (this._source.hasTransition()) {
return true;
}
if (isRasterType(this._source.type)) {
for (const id in this._tiles) {
const tile = this._tiles[id];
if (tile.fadeEndTime !== undefined && tile.fadeEndTime >= browser.now()) {
return true;
}
}
}
return false;
}
/**
* Set the value of a particular state for a feature
* @private
*/
setFeatureState(sourceLayer?: string, featureId: number | string, state: Object) {
sourceLayer = sourceLayer || '_geojsonTileLayer';
this._state.updateState(sourceLayer, featureId, state);
}
/**
* Resets the value of a particular state key for a feature
* @private
*/
removeFeatureState(sourceLayer?: string, featureId?: number | string, key?: string) {
sourceLayer = sourceLayer || '_geojsonTileLayer';
this._state.removeFeatureState(sourceLayer, featureId, key);
}
/**
* Get the entire state object for a feature
* @private
*/
getFeatureState(sourceLayer?: string, featureId: number | string): FeatureStates {
sourceLayer = sourceLayer || '_geojsonTileLayer';
return this._state.getState(sourceLayer, featureId);
}
/**
* Sets the set of keys that the tile depends on. This allows tiles to
* be reloaded when their dependencies change.
* @private
*/
setDependencies(tileKey: number, namespace: string, dependencies: Array<string>) {
const tile = this._tiles[tileKey];
if (tile) {
tile.setDependencies(namespace, dependencies);
}
}
/**
* Reloads all tiles that depend on the given keys.
* @private
*/
reloadTilesForDependencies(namespaces: Array<string>, keys: Array<string>) {
for (const id in this._tiles) {
const tile = this._tiles[id];
if (tile.hasDependency(namespaces, keys)) {
this._reloadTile(+id, 'reloading');
}
}
this._cache.filter(tile => !tile.hasDependency(namespaces, keys));
}
/**
* Preloads all tiles that will be requested for one or a series of transformations
*
* @private
* @returns {Object} Returns `this` | Promise.
*/
_preloadTiles(transform: Transform | Array<Transform>, callback: Callback<any>) {
if (!this._sourceLoaded) {
const waitUntilSourceLoaded = () => {
if (!this._sourceLoaded) return;
this._source.off('data', waitUntilSourceLoaded);
this._preloadTiles(transform, callback);
};
this._source.on('data', waitUntilSourceLoaded);
return;
}
const coveringTilesIDs: Map<number, OverscaledTileID> = new Map();
const transforms = Array.isArray(transform) ? transform : [transform];
const terrain = this.map.painter.terrain;
const tileSize = this.usedForTerrain && terrain ? terrain.getScaledDemTileSize() : this._source.tileSize;
for (const tr of transforms) {
const tileIDs = tr.coveringTiles({
tileSize,
minzoom: this._source.minzoom,
maxzoom: this._source.maxzoom,
roundZoom: this._source.roundZoom && !this.usedForTerrain,
reparseOverscaled: this._source.reparseOverscaled,
isTerrainDEM: this.usedForTerrain
});
for (const tileID of tileIDs) {
coveringTilesIDs.set(tileID.key, tileID);
}
if (this.usedForTerrain) {
tr.updateElevation(false);
}
}
const tileIDs = Array.from(coveringTilesIDs.values());
asyncAll(tileIDs, (tileID, done) => {
const tile = new Tile(tileID, this._source.tileSize * tileID.overscaleFactor(), this.transform.tileZoom, this.map.painter, this._isRaster);
this._loadTile(tile, (err) => {
if (this._source.type === 'raster-dem' && tile.dem) this._backfillDEM(tile);
done(err, tile);
});
}, callback);
}
}
SourceCache.maxOverzooming = 10;
SourceCache.maxUnderzooming = 3;
function compareTileId(a: OverscaledTileID, b: OverscaledTileID): number {
// Different copies of the world are sorted based on their distance to the center.
// Wrap values are converted to unsigned distances by reserving odd number for copies
// with negative wrap and even numbers for copies with positive wrap.
const aWrap = Math.abs(a.wrap * 2) - +(a.wrap < 0);
const bWrap = Math.abs(b.wrap * 2) - +(b.wrap < 0);
return a.overscaledZ - b.overscaledZ || bWrap - aWrap || b.canonical.y - a.canonical.y || b.canonical.x - a.canonical.x;
}
function isRasterType(type: string): boolean {
return type === 'raster' || type === 'image' || type === 'video' || type === 'custom';
}
function tileBoundsX(id: CanonicalTileID, wrap: number): [number, number] {
const tiles = 1 << id.z;
return [id.x / tiles + wrap, (id.x + 1) / tiles + wrap];
}
export default SourceCache;