mapbox-gl
Version:
A WebGL interactive maps library
346 lines (296 loc) • 12.1 kB
JavaScript
// @flow
const util = require('../util/util');
const Bucket = require('../data/bucket');
const FeatureIndex = require('../data/feature_index');
const vt = require('vector-tile');
const Protobuf = require('pbf');
const GeoJSONFeature = require('../util/vectortile_to_geojson');
const featureFilter = require('../style-spec/feature_filter');
const CollisionTile = require('../symbol/collision_tile');
const CollisionBoxArray = require('../symbol/collision_box');
const Throttler = require('../util/throttler');
const CLOCK_SKEW_RETRY_TIMEOUT = 30000;
import type TileCoord from './tile_coord';
import type {WorkerTileResult} from './worker_source';
/**
* A tile object is the combination of a Coordinate, which defines
* its place, as well as a unique ID and data tracking for its content
*
* @private
*/
class Tile {
coord: TileCoord;
uid: number;
uses: number;
tileSize: number;
sourceMaxZoom: number;
buckets: {[string]: Bucket};
expirationTime: any;
expiredRequestCount: number;
state: 'loading' // Tile data is in the process of loading.
| 'loaded' // Tile data has been loaded. Tile can be rendered.
| 'reloading' // Tile data has been loaded and is being updated. Tile can be rendered.
| 'unloaded' // Tile data has been deleted.
| 'errored' // Tile data was not loaded because of an error.
| 'expired'; /* Tile data was previously loaded, but has expired per its
* HTTP headers and is in the process of refreshing. */
placementThrottler: any;
timeAdded: any;
fadeEndTime: any;
rawTileData: ArrayBuffer;
collisionBoxArray: ?CollisionBoxArray;
collisionTile: ?CollisionTile;
featureIndex: ?FeatureIndex;
redoWhenDone: boolean;
angle: number;
pitch: number;
cameraToCenterDistance: number;
cameraToTileDistance: number;
showCollisionBoxes: boolean;
placementSource: any;
workerID: number;
vtLayers: {[string]: VectorTileLayer};
aborted: ?boolean;
boundsBuffer: any;
boundsVAO: any;
request: any;
texture: any;
sourceCache: any;
refreshedUponExpiration: boolean;
reloadCallback: any;
/**
* @param {TileCoord} coord
* @param size
* @param sourceMaxZoom
*/
constructor(coord: any, size: number, sourceMaxZoom: number) {
this.coord = coord;
this.uid = util.uniqueId();
this.uses = 0;
this.tileSize = size;
this.sourceMaxZoom = sourceMaxZoom;
this.buckets = {};
this.expirationTime = null;
// Counts the number of times a response was already expired when
// received. We're using this to add a delay when making a new request
// so we don't have to keep retrying immediately in case of a server
// serving expired tiles.
this.expiredRequestCount = 0;
this.state = 'loading';
this.placementThrottler = new Throttler(300, this._immediateRedoPlacement.bind(this));
}
registerFadeDuration(animationLoop: any, duration: number) {
const fadeEndTime = duration + this.timeAdded;
if (fadeEndTime < Date.now()) return;
if (this.fadeEndTime && fadeEndTime < this.fadeEndTime) return;
this.fadeEndTime = fadeEndTime;
animationLoop.set(this.fadeEndTime - Date.now());
}
/**
* Given a data object with a 'buffers' property, load it into
* this tile's elementGroups and buffers properties and set loaded
* to true. If the data is null, like in the case of an empty
* GeoJSON tile, no-op but still set loaded to true.
* @param {Object} data
* @param painter
* @returns {undefined}
* @private
*/
loadVectorData(data: WorkerTileResult, painter: any) {
if (this.hasData()) {
this.unloadVectorData();
}
this.state = 'loaded';
// empty GeoJSON tile
if (!data) return;
// If we are redoing placement for the same tile, we will not recieve
// a new "rawTileData" object. If we are loading a new tile, we will
// recieve a new "rawTileData" object.
if (data.rawTileData) {
this.rawTileData = data.rawTileData;
}
this.collisionBoxArray = new CollisionBoxArray(data.collisionBoxArray);
this.collisionTile = CollisionTile.deserialize(data.collisionTile, this.collisionBoxArray);
this.featureIndex = FeatureIndex.deserialize(data.featureIndex, this.rawTileData, this.collisionTile);
this.buckets = Bucket.deserialize(data.buckets, painter.style);
}
/**
* Replace this tile's symbol buckets with fresh data.
* @param {Object} data
* @param {Style} style
* @returns {undefined}
* @private
*/
reloadSymbolData(data: WorkerTileResult, style: any) {
if (this.state === 'unloaded') return;
this.collisionTile = CollisionTile.deserialize(data.collisionTile, this.collisionBoxArray);
if (this.featureIndex) {
this.featureIndex.setCollisionTile(this.collisionTile);
}
for (const id in this.buckets) {
const bucket = this.buckets[id];
if (bucket.layers[0].type === 'symbol') {
bucket.destroy();
delete this.buckets[id];
}
}
// Add new symbol buckets
util.extend(this.buckets, Bucket.deserialize(data.buckets, style));
}
/**
* Release any data or WebGL resources referenced by this tile.
* @returns {undefined}
* @private
*/
unloadVectorData() {
for (const id in this.buckets) {
this.buckets[id].destroy();
}
this.buckets = {};
this.collisionBoxArray = null;
this.collisionTile = null;
this.featureIndex = null;
this.state = 'unloaded';
}
redoPlacement(source: any) {
if (source.type !== 'vector' && source.type !== 'geojson') {
return;
}
if (this.state !== 'loaded') {
this.redoWhenDone = true;
return;
}
if (!this.collisionTile) { // empty tile
return;
}
const cameraToTileDistance = source.map.transform.cameraToTileDistance(this);
if (this.angle === source.map.transform.angle &&
this.pitch === source.map.transform.pitch &&
this.cameraToCenterDistance === source.map.transform.cameraToCenterDistance &&
this.showCollisionBoxes === source.map.showCollisionBoxes) {
if (this.cameraToTileDistance === cameraToTileDistance) {
return;
} else if (this.pitch < 25) {
// At low pitch tile distance doesn't affect placement very
// much, so we skip the cost of redoPlacement
// However, we might as well store the latest value of
// cameraToTileDistance in case a redoPlacement request
// is already queued.
this.cameraToTileDistance = cameraToTileDistance;
return;
}
}
this.angle = source.map.transform.angle;
this.pitch = source.map.transform.pitch;
this.cameraToCenterDistance = source.map.transform.cameraToCenterDistance;
this.cameraToTileDistance = cameraToTileDistance;
this.showCollisionBoxes = source.map.showCollisionBoxes;
this.placementSource = source;
this.state = 'reloading';
this.placementThrottler.invoke();
}
_immediateRedoPlacement() {
this.placementSource.dispatcher.send('redoPlacement', {
type: this.placementSource.type,
uid: this.uid,
source: this.placementSource.id,
angle: this.angle,
pitch: this.pitch,
cameraToCenterDistance: this.cameraToCenterDistance,
cameraToTileDistance: this.cameraToTileDistance,
showCollisionBoxes: this.showCollisionBoxes
}, (_, data) => {
this.state = 'loaded';
this.reloadSymbolData(data, this.placementSource.map.style);
this.placementSource.fire('data', {tile: this, coord: this.coord, dataType: 'source'});
// HACK this is nescessary to fix https://github.com/mapbox/mapbox-gl-js/issues/2986
if (this.placementSource.map) this.placementSource.map.painter.tileExtentVAO.vao = null;
if (this.redoWhenDone) {
this.redoWhenDone = false;
this._immediateRedoPlacement();
}
}, this.workerID);
}
getBucket(layer: any) {
return this.buckets[layer.id];
}
querySourceFeatures(result: any, params: any) {
if (!this.rawTileData) return;
if (!this.vtLayers) {
this.vtLayers = new vt.VectorTile(new Protobuf(this.rawTileData)).layers;
}
const sourceLayer = params ? params.sourceLayer : '';
const layer = this.vtLayers._geojsonTileLayer || this.vtLayers[sourceLayer];
if (!layer) return;
const filter = featureFilter(params && params.filter);
const coord = { z: this.coord.z, x: this.coord.x, y: this.coord.y };
for (let i = 0; i < layer.length; i++) {
const feature = layer.feature(i);
if (filter(feature)) {
const geojsonFeature = new GeoJSONFeature(feature, this.coord.z, this.coord.x, this.coord.y);
geojsonFeature.tile = coord;
result.push(geojsonFeature);
}
}
}
hasData() {
return this.state === 'loaded' || this.state === 'reloading' || this.state === 'expired';
}
setExpiryData(data: any) {
const prior = this.expirationTime;
if (data.cacheControl) {
const parsedCC = util.parseCacheControl(data.cacheControl);
if (parsedCC['max-age']) this.expirationTime = Date.now() + parsedCC['max-age'] * 1000;
} else if (data.expires) {
this.expirationTime = new Date(data.expires).getTime();
}
if (this.expirationTime) {
const now = Date.now();
let isExpired = false;
if (this.expirationTime > now) {
isExpired = false;
} else if (!prior) {
isExpired = true;
} else if (this.expirationTime < prior) {
// Expiring date is going backwards:
// fall back to exponential backoff
isExpired = true;
} else {
const delta = this.expirationTime - prior;
if (!delta) {
// Server is serving the same expired resource over and over: fall
// back to exponential backoff.
isExpired = true;
} else {
// Assume that either the client or the server clock is wrong and
// try to interpolate a valid expiration date (from the client POV)
// observing a minimum timeout.
this.expirationTime = now + Math.max(delta, CLOCK_SKEW_RETRY_TIMEOUT);
}
}
if (isExpired) {
this.expiredRequestCount++;
this.state = 'expired';
} else {
this.expiredRequestCount = 0;
}
}
}
getExpiryTimeout() {
if (this.expirationTime) {
if (this.expiredRequestCount) {
return 1000 * (1 << Math.min(this.expiredRequestCount - 1, 31));
} else {
// Max value for `setTimeout` implementations is a 32 bit integer; cap this accordingly
return Math.min(this.expirationTime - new Date().getTime(), Math.pow(2, 31) - 1);
}
}
}
stopPlacementThrottler() {
this.placementThrottler.stop();
if (this.state === 'reloading') {
this.state = 'loaded';
}
}
}
module.exports = Tile;