UNPKG

mapbox-gl

Version:
619 lines (530 loc) 20.5 kB
'use strict'; const Source = require('./source'); const Tile = require('./tile'); const Evented = require('../util/evented'); const TileCoord = require('./tile_coord'); const Cache = require('../util/lru_cache'); const Coordinate = require('../geo/coordinate'); const util = require('../util/util'); const EXTENT = require('../data/extent'); /** * `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 { constructor(id, options, dispatcher) { super(); this.id = id; this.dispatcher = dispatcher; this.on('data', function(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 (i.e 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. (i.e. GeoJSONSource#setData and ImageSource#serCoordinates) if (this._sourceLoaded && e.dataType === "source" && e.sourceDataType === 'content') { this.reload(); if (this.transform) { this.update(this.transform); } } }); this.on('error', function() { this._sourceErrored = true; }); this._source = Source.create(id, options, dispatcher, this); this._tiles = {}; this._cache = new Cache(0, this.unloadTile.bind(this)); this._timers = {}; this._cacheTimers = {}; this._isIdRenderable = this._isIdRenderable.bind(this); } onAdd(map) { this.map = map; if (this._source && this._source.onAdd) { this._source.onAdd(map); } } onRemove(map) { if (this._source && this._source.onRemove) { this._source.onRemove(map); } } /** * Return true if no tile data is pending, tiles will not change unless * an additional API call is received. * @returns {boolean} * @private */ loaded() { if (this._sourceErrored) { return true; } if (!this._sourceLoaded) { return false; } for (const t in this._tiles) { const tile = this._tiles[t]; if (tile.state !== 'loaded' && tile.state !== 'errored') return false; } return true; } /** * @returns {Source} The underlying source object * @private */ getSource() { return this._source; } loadTile(tile, callback) { return this._source.loadTile(tile, callback); } unloadTile(tile) { if (this._source.unloadTile) return this._source.unloadTile(tile); } abortTile(tile) { if (this._source.abortTile) return this._source.abortTile(tile); } serialize() { return this._source.serialize(); } prepare() { if (this._sourceLoaded && this._source.prepare) return this._source.prepare(); } /** * Return all tile ids ordered with z-order, and cast to numbers * @returns {Array<number>} ids * @private */ getIds() { return Object.keys(this._tiles).map(Number).sort(compareKeyZoom); } getRenderableIds() { return this.getIds().filter(this._isIdRenderable); } _isIdRenderable(id) { return this._tiles[id].hasData() && !this._coveredTiles[id]; } reload() { this._cache.reset(); for (const i in this._tiles) { this.reloadTile(i, 'reloading'); } } reloadTile(id, state) { 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; } this.loadTile(tile, this._tileLoaded.bind(this, tile, id, state)); } _tileLoaded(tile, id, previousState, err) { if (err) { tile.state = 'errored'; if (err.status !== 404) this._source.fire('error', {tile: tile, error: err}); return; } tile.sourceCache = this; tile.timeAdded = new Date().getTime(); if (previousState === 'expired') tile.refreshedUponExpiration = true; this._setTileReloadTimer(id, tile); this._source.fire('data', {dataType: 'source', tile: tile, coord: tile.coord}); // HACK this is necessary to fix https://github.com/mapbox/mapbox-gl-js/issues/2986 if (this.map) this.map.painter.tileExtentVAO.vao = null; } /** * Get a specific tile by TileCoordinate * @param {TileCoordinate} coord * @returns {Object} tile * @private */ getTile(coord) { return this.getTileByID(coord.id); } /** * Get a specific tile by id * @param {number|string} id * @returns {Object} tile * @private */ getTileByID(id) { return this._tiles[id]; } /** * get the zoom level adjusted for the difference in map and source tilesizes * @param {Object} transform * @returns {number} zoom level * @private */ getZoom(transform) { return transform.zoom + transform.scaleZoom(transform.tileSize / this._source.tileSize); } /** * Recursively find children of the given tile (up to maxCoveringZoom) that are already loaded; * adds found tiles to retain object; returns true if any child is found. * * @param {Coordinate} coord * @param {number} maxCoveringZoom * @param {boolean} retain * @returns {boolean} whether the operation was complete * @private */ findLoadedChildren(coord, maxCoveringZoom, retain) { let found = false; for (const id in this._tiles) { let tile = this._tiles[id]; // only consider renderable tiles on higher zoom levels (up to maxCoveringZoom) if (retain[id] || !tile.hasData() || tile.coord.z <= coord.z || tile.coord.z > maxCoveringZoom) continue; // disregard tiles that are not descendants of the given tile coordinate const z2 = Math.pow(2, Math.min(tile.coord.z, this._source.maxzoom) - Math.min(coord.z, this._source.maxzoom)); if (Math.floor(tile.coord.x / z2) !== coord.x || Math.floor(tile.coord.y / z2) !== coord.y) continue; // found loaded child retain[id] = true; found = true; // loop through parents; retain the topmost loaded one if found while (tile && tile.coord.z - 1 > coord.z) { const parentId = tile.coord.parent(this._source.maxzoom).id; tile = this._tiles[parentId]; if (tile && tile.hasData()) { delete retain[id]; retain[parentId] = true; } } } return found; } /** * Find a loaded parent of the given tile (up to minCoveringZoom); * adds the found tile to retain object and returns the tile if found * * @param {Coordinate} coord * @param {number} minCoveringZoom * @param {boolean} retain * @returns {Tile} tile object * @private */ findLoadedParent(coord, minCoveringZoom, retain) { for (let z = coord.z - 1; z >= minCoveringZoom; z--) { coord = coord.parent(this._source.maxzoom); const tile = this._tiles[coord.id]; if (tile && tile.hasData()) { retain[coord.id] = true; return tile; } if (this._cache.has(coord.id)) { retain[coord.id] = true; return this._cache.getWithoutRemoving(coord.id); } } } /** * Resizes the tile cache based on the current viewport's size. * * 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) { const widthInTiles = Math.ceil(transform.width / transform.tileSize) + 1; const heightInTiles = Math.ceil(transform.height / transform.tileSize) + 1; const approxTilesInView = widthInTiles * heightInTiles; const commonZoomRange = 5; this._cache.setMaxSize(Math.floor(approxTilesInView * commonZoomRange)); } /** * Removes tiles that are outside the viewport and adds new tiles that * are inside the viewport. * @private */ update(transform) { this.transform = transform; if (!this._sourceLoaded) { return; } let i; let coord; let tile; let parentTile; this.updateCacheSize(transform); // Determine the overzooming/underzooming amounts. const zoom = (this._source.roundZoom ? Math.round : Math.floor)(this.getZoom(transform)); const minCoveringZoom = Math.max(zoom - SourceCache.maxOverzooming, this._source.minzoom); const maxCoveringZoom = Math.max(zoom + SourceCache.maxUnderzooming, this._source.minzoom); // 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 = {}; // Covered is a list of retained tiles who's areas are full covered by other, // better, retained tiles. They are not drawn separately. this._coveredTiles = {}; let visibleCoords; if (!this.used) { visibleCoords = []; } else if (this._source.coord) { visibleCoords = transform.getVisibleWrappedCoordinates(this._source.coord); } else { visibleCoords = transform.coveringTiles({ tileSize: this._source.tileSize, minzoom: this._source.minzoom, maxzoom: this._source.maxzoom, roundZoom: this._source.roundZoom, reparseOverscaled: this._source.reparseOverscaled }); if (this._source.hasTile) { visibleCoords = visibleCoords.filter((coord) => this._source.hasTile(coord)); } } for (i = 0; i < visibleCoords.length; i++) { coord = visibleCoords[i]; tile = this.addTile(coord); retain[coord.id] = true; if (tile.hasData()) continue; // The tile we require is not yet loaded. // Retain child or parent tiles that cover the same area. if (!this.findLoadedChildren(coord, maxCoveringZoom, retain)) { parentTile = this.findLoadedParent(coord, minCoveringZoom, retain); if (parentTile) { this.addTile(parentTile.coord); } } } const parentsForFading = {}; if (isRasterType(this._source.type)) { const ids = Object.keys(retain); for (let k = 0; k < ids.length; k++) { const id = ids[k]; coord = TileCoord.fromID(id); tile = this._tiles[id]; if (!tile) continue; // If the drawRasterTile has never seen this tile, then // tile.fadeEndTime may be unset. In that case, or if // fadeEndTime is in the future, then this tile is still // fading in. Find tiles to cross-fade with it. if (typeof tile.fadeEndTime === 'undefined' || tile.fadeEndTime >= Date.now()) { if (this.findLoadedChildren(coord, maxCoveringZoom, retain)) { retain[id] = true; } parentTile = this.findLoadedParent(coord, minCoveringZoom, parentsForFading); if (parentTile) { this.addTile(parentTile.coord); } } } } let fadedParent; for (fadedParent in parentsForFading) { if (!retain[fadedParent]) { // If a tile is only needed for fading, mark it as covered so that it isn't rendered on it's own. this._coveredTiles[fadedParent] = true; } } for (fadedParent in parentsForFading) { retain[fadedParent] = true; } // Remove the tiles we don't need anymore. const remove = util.keysDifference(this._tiles, retain); for (i = 0; i < remove.length; i++) { this.removeTile(+remove[i]); } } /** * Add a tile, given its coordinate, to the pyramid. * @param {Coordinate} coord * @returns {Coordinate} the coordinate. * @private */ addTile(coord) { let tile = this._tiles[coord.id]; if (tile) return tile; const wrapped = coord.wrapped(); tile = this._tiles[wrapped.id]; if (!tile) { tile = this._cache.get(wrapped.id); if (tile) { tile.redoPlacement(this._source); if (this._cacheTimers[wrapped.id]) { clearTimeout(this._cacheTimers[wrapped.id]); this._cacheTimers[wrapped.id] = undefined; this._setTileReloadTimer(wrapped.id, tile); } } } const cached = Boolean(tile); if (!cached) { const zoom = coord.z; const overscaling = zoom > this._source.maxzoom ? Math.pow(2, zoom - this._source.maxzoom) : 1; tile = new Tile(wrapped, this._source.tileSize * overscaling, this._source.maxzoom); this.loadTile(tile, this._tileLoaded.bind(this, tile, coord.id, tile.state)); } tile.uses++; this._tiles[coord.id] = tile; if (!cached) this._source.fire('dataloading', {tile: tile, coord: tile.coord, dataType: 'source'}); return tile; } _setTileReloadTimer(id, tile) { const expiryTimeout = tile.getExpiryTimeout(); if (expiryTimeout) { this._timers[id] = setTimeout(() => { this.reloadTile(id, 'expired'); this._timers[id] = undefined; }, expiryTimeout); } } _setCacheInvalidationTimer(id, tile) { const expiryTimeout = tile.getExpiryTimeout(); if (expiryTimeout) { this._cacheTimers[id] = setTimeout(() => { this._cache.remove(id); this._cacheTimers[id] = undefined; }, expiryTimeout); } } /** * Remove a tile, given its id, from the pyramid * @param {string|number} id tile id * @returns {undefined} nothing * @private */ removeTile(id) { const tile = this._tiles[id]; if (!tile) return; tile.uses--; delete this._tiles[id]; if (this._timers[id]) { clearTimeout(this._timers[id]); this._timers[id] = undefined; } if (tile.uses > 0) return; if (tile.hasData()) { const wrappedId = tile.coord.wrapped().id; this._cache.add(wrappedId, tile); this._setCacheInvalidationTimer(wrappedId, tile); } else { tile.aborted = true; this.abortTile(tile); this.unloadTile(tile); } } /** * Remove all tiles from this pyramid * @private */ clearTiles() { for (const id in this._tiles) this.removeTile(id); this._cache.reset(); } /** * Search through our current tiles and attempt to find the tiles that * cover the given bounds. * @param {Array<Coordinate>} queryGeometry coordinates of the corners of bounding rectangle * @returns {Array<Object>} result items have {tile, minX, maxX, minY, maxY}, where min/max bounding values are the given bounds transformed in into the coordinate space of this tile. * @private */ tilesIn(queryGeometry) { const tileResults = {}; const ids = this.getIds(); let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; const z = queryGeometry[0].zoom; for (let k = 0; k < queryGeometry.length; k++) { const p = queryGeometry[k]; minX = Math.min(minX, p.column); minY = Math.min(minY, p.row); maxX = Math.max(maxX, p.column); maxY = Math.max(maxY, p.row); } for (let i = 0; i < ids.length; i++) { const tile = this._tiles[ids[i]]; const coord = TileCoord.fromID(ids[i]); const tileSpaceBounds = [ coordinateToTilePoint(coord, tile.sourceMaxZoom, new Coordinate(minX, minY, z)), coordinateToTilePoint(coord, tile.sourceMaxZoom, new Coordinate(maxX, maxY, z)) ]; if (tileSpaceBounds[0].x < EXTENT && tileSpaceBounds[0].y < EXTENT && tileSpaceBounds[1].x >= 0 && tileSpaceBounds[1].y >= 0) { const tileSpaceQueryGeometry = []; for (let j = 0; j < queryGeometry.length; j++) { tileSpaceQueryGeometry.push(coordinateToTilePoint(coord, tile.sourceMaxZoom, queryGeometry[j])); } let tileResult = tileResults[tile.coord.id]; if (tileResult === undefined) { tileResult = tileResults[tile.coord.id] = { tile: tile, coord: coord, queryGeometry: [], scale: Math.pow(2, this.transform.zoom - tile.coord.z) }; } // Wrapped tiles share one tileResult object but can have multiple queryGeometry parts tileResult.queryGeometry.push(tileSpaceQueryGeometry); } } const results = []; for (const t in tileResults) { results.push(tileResults[t]); } return results; } redoPlacement() { const ids = this.getIds(); for (let i = 0; i < ids.length; i++) { const tile = this.getTileByID(ids[i]); tile.redoPlacement(this._source); } } getVisibleCoordinates() { const coords = this.getRenderableIds().map(TileCoord.fromID); for (const coord of coords) { coord.posMatrix = this.transform.calculatePosMatrix(coord, this._source.maxzoom); } return coords; } } SourceCache.maxOverzooming = 10; SourceCache.maxUnderzooming = 3; /** * Convert a coordinate to a point in a tile's coordinate space. * @param {Coordinate} tileCoord * @param {Coordinate} coord * @returns {Object} position * @private */ function coordinateToTilePoint(tileCoord, sourceMaxZoom, coord) { const zoomedCoord = coord.zoomTo(Math.min(tileCoord.z, sourceMaxZoom)); return { x: (zoomedCoord.column - (tileCoord.x + tileCoord.w * Math.pow(2, tileCoord.z))) * EXTENT, y: (zoomedCoord.row - tileCoord.y) * EXTENT }; } function compareKeyZoom(a, b) { return (a % 32) - (b % 32); } function isRasterType(type) { return type === 'raster' || type === 'image' || type === 'video'; } module.exports = SourceCache;