tangram
Version:
WebGL Maps for Vector Tiles
466 lines (398 loc) • 15.7 kB
JavaScript
import Tile from './tile';
import {TileID} from './tile_id';
import TilePyramid from './tile_pyramid';
import Geo from '../utils/geo';
import mainThreadLabelCollisionPass from '../labels/main_pass';
import log from '../utils/log';
import WorkerBroker from '../utils/worker_broker';
import Task from '../utils/task';
export default class TileManager {
constructor({ scene }) {
this.scene = scene;
this.tiles = {};
this.pyramid = new TilePyramid();
this.visible_coords = {};
this.queued_coords = [];
this.building_tiles = null;
this.renderable_tiles = [];
this.collision = {
tile_keys: null,
mesh_set: null,
zoom: null,
zoom_steps: 3 // divisions per zoom at which labels are re-collided (e.g. 0, 0.33, 0.66)
};
// Provide a hook for this object to be called from worker threads
this.main_thread_target = ['TileManager', this.scene.id].join('_');
WorkerBroker.addTarget(this.main_thread_target, this);
}
destroy() {
this.forEachTile(tile => tile.destroy());
this.tiles = {};
this.pyramid = null;
this.visible_coords = {};
this.queued_coords = [];
this.scene = null;
WorkerBroker.removeTarget(this.main_thread_target);
}
get view () {
return this.scene.view;
}
get style_manager () {
return this.scene.style_manager;
}
keepTile(tile) {
this.tiles[tile.key] = tile;
this.pyramid.addTile(tile);
}
hasTile(key) {
return this.tiles[key] !== undefined;
}
forgetTile(key) {
if (this.hasTile(key)) {
let tile = this.tiles[key];
this.pyramid.removeTile(tile);
}
delete this.tiles[key];
this.tileBuildStop(key);
}
// Remove a single tile
removeTile(key) {
log('trace', `tile unload for ${key}`);
var tile = this.tiles[key];
if (tile != null) {
tile.destroy();
}
this.forgetTile(tile.key);
this.scene.requestRedraw();
}
// Run a function on each tile
forEachTile(func) {
for (let t in this.tiles) {
func(this.tiles[t]);
}
}
// Remove tiles that pass a filter condition
removeTiles(filter) {
let remove_tiles = [];
for (let t in this.tiles) {
let tile = this.tiles[t];
if (filter(tile)) {
remove_tiles.push(t);
}
}
for (let r=0; r < remove_tiles.length; r++) {
let key = remove_tiles[r];
this.removeTile(key);
}
}
updateTilesForView() {
// Find visible tiles and load new ones
this.visible_coords = {};
let tile_coords = this.view.findVisibleTileCoordinates();
for (let c=0; c < tile_coords.length; c++) {
const coords = tile_coords[c];
this.queueCoordinate(coords);
this.visible_coords[coords.key] = coords;
}
this.updateTileStates();
}
updateTileStates () {
this.forEachTile(tile => {
this.updateVisibility(tile);
});
this.loadQueuedCoordinates();
this.updateProxyTiles();
this.view.pruneTilesForView();
this.updateRenderableTiles();
this.style_manager.updateActiveStyles(this.renderable_tiles);
this.style_manager.updateActiveBlendOrders(this.renderable_tiles);
return this.updateLabels();
}
updateLabels () {
if (this.scene.building && !this.scene.building.initial) {
// log('debug', `Skip label layout due to on-going scene rebuild`);
return Promise.resolve({});
}
// get current visible tiles and sort by key for consistency collision order
const tiles = this.renderable_tiles
.filter(t => t.valid)
.filter(t => t.built);
if (tiles.length === 0) {
return Promise.resolve({});
}
// Evaluate labels in order of tile build, to prevent previously visible labels
// from disappearing, e.g. due to a newly loaded repeat label nearby
tiles.sort((a, b) => a.build_id < b.build_id ? -1 : (a.build_id > b.build_id ? 1 : 0));
// check if tile set has changed (in ways that affect collision)
// if not, bail so that the existing collision task can carry on
// if so, carry on and start a new collision task
if (// 1st: check if same zoom level (rounded to a configurable precision)
this.collision.zoom === roundPrecision(this.view.zoom, this.collision.zoom_steps) &&
// 2nd: check if same set of tiles
this.collision.tile_keys === JSON.stringify(tiles.map(t => t.key)) &&
// 3rd: check if same set of meshes
this.collision.mesh_set === meshSetString(tiles)) {
// log('debug', `Skip label layout due to same tile/meshes (zoom ${this.view.zoom.toFixed(2)}, tiles ${this.collision.tile_keys})`);
return Promise.resolve({});
}
// update collision if not already updating
if (!this.collision.task) {
this.collision.zoom = roundPrecision(this.view.zoom, this.collision.zoom_steps);
this.collision.tile_keys = JSON.stringify(tiles.map(t => t.key));
this.collision.mesh_set = meshSetString(tiles);
// log('debug', `Update label collisions (zoom ${this.collision.zoom}, ${this.collision.tile_keys})`);
// make a new collision task
this.collision.task = {
type: 'tileManagerUpdateLabels',
run: async task => {
// Do collision pass, then update view
const results = await mainThreadLabelCollisionPass(tiles, this.collision.zoom, this.isLoadingVisibleTiles());
this.scene.requestRedraw();
// Clear state to allow another collision pass to start
this.collision.task = null;
Task.finish(task, results);
// Check if tiles changed during previous collision pass - will start new pass if so
this.updateTileStates();
},
immediate: true
};
Task.add(this.collision.task);
}
// else {
// log('debug', `Skip label layout due to on-going layout (zoom ${this.view.zoom.toFixed(2)}, tiles ${this.collision.tile_keys})`);
// }
return this.collision.task.promise;
}
updateProxyTiles () {
if (this.view.zoom_direction === 0) {
return;
}
// Clear previous proxies
this.forEachTile(tile => tile.setProxyFor(null));
let proxy = false;
this.forEachTile(tile => {
if (tile.visible && !tile.labeled) {
const parent = this.pyramid.getAncestor(tile);
if (parent) {
parent.setProxyFor(tile);
proxy = true;
} else {
const descendants = this.pyramid.getDescendants(tile);
for (let i=0; i < descendants.length; i++) {
descendants[i].setProxyFor(tile);
proxy = true;
}
}
}
});
if (!proxy) {
this.view.zoom_direction = 0;
}
}
updateVisibility(tile) {
tile.visible = false;
if (tile.style_z === this.view.tile_zoom) {
if (this.visible_coords[tile.coords.key]) {
tile.visible = true;
}
else {
// brute force
for (let key in this.visible_coords) {
if (TileID.isDescendant(tile.coords, this.visible_coords[key])) {
tile.visible = true;
break;
}
}
}
}
}
// Remove tiles that aren't visible, and flag remaining visible ones to be updated (for loading, proxy, etc.)
pruneToVisibleTiles () {
this.removeTiles(tile => !tile.visible);
}
getRenderableTiles () {
return this.renderable_tiles;
}
updateRenderableTiles() {
this.renderable_tiles = [];
for (let t in this.tiles) {
let tile = this.tiles[t];
if (tile.visible && tile.loaded) {
this.renderable_tiles.push(tile);
}
}
return this.renderable_tiles;
}
isLoadingVisibleTiles () {
return Object.keys(this.tiles).some(k => this.tiles[k].visible && !this.tiles[k].built);
}
allVisibleTilesLabeled () {
return this.renderable_tiles.every(t => t.labeled);
}
// Queue a tile for load
queueCoordinate(coords) {
this.queued_coords[this.queued_coords.length] = coords;
}
// Load all queued tiles
loadQueuedCoordinates() {
if (this.queued_coords.length === 0) {
return;
}
// Sort queued tiles from center tile
this.queued_coords.sort((a, b) => {
let center = this.view.center.meters;
let half_span = Geo.metersPerTile(a.z) / 2;
let ac = Geo.metersForTile(a);
ac.x += half_span;
ac.y -= half_span;
let bc = Geo.metersForTile(b);
bc.x += half_span;
bc.y -= half_span;
let ad = Math.abs(center.x - ac.x) + Math.abs(center.y - ac.y);
let bd = Math.abs(center.x - bc.x) + Math.abs(center.y - bc.y);
a.center_dist = ad;
b.center_dist = bd;
return (bd > ad ? -1 : (bd === ad ? 0 : 1));
});
this.queued_coords.forEach(coords => this.loadCoordinate(coords));
this.queued_coords = [];
}
// Load all tiles to cover a given logical tile coordinate
loadCoordinate(coords) {
// Skip if not at current scene zoom
if (coords.z !== this.view.center.tile.z) {
return;
}
// Determine necessary tiles for each source
for (let s in this.scene.sources) {
let source = this.scene.sources[s];
// Check if data source should build this tile
if (!source.builds_geometry_tiles || !source.includesTile(coords, this.view.tile_zoom)) {
continue;
}
let key = TileID.normalizedKey(coords, source, this.view.tile_zoom);
if (key && !this.hasTile(key)) {
log('trace', `load tile ${key}, distance from view center: ${coords.center_dist}`);
let tile = new Tile({
source,
coords,
workers: this.scene.workers,
style_z: this.view.baseZoom(coords.z),
view: this.view
});
this.keepTile(tile);
this.buildTile(tile);
}
}
}
// Start tile build process
buildTile(tile, options) {
this.tileBuildStart(tile.key);
this.updateVisibility(tile);
tile.build(this.scene.generation, options);
}
// Called on main thread when a web worker completes processing for a single tile (initial load, or rebuild)
buildTileStylesCompleted({ tile, progress }) {
// Removed this tile during load?
if (this.tiles[tile.key] == null) {
log('trace', `discarded tile ${tile.key} in TileManager.buildTileStylesCompleted because previously removed`);
Tile.abortBuild(tile);
this.updateTileStates();
}
// Built with an outdated scene configuration?
else if (tile.generation !== this.scene.generation) {
log('trace', `discarded tile ${tile.key} in TileManager.buildTileStylesCompleted because built with ` +
`scene config gen ${tile.generation}, current ${this.scene.generation}`);
Tile.abortBuild(tile);
this.updateTileStates();
}
else {
// Update tile with properties from worker
if (this.tiles[tile.key]) {
// Ignore if from a previously discarded tile
if (tile.id < this.tiles[tile.key].id) {
log('trace', `discarded tile ${tile.key} for id ${tile.id} in TileManager.buildTileStylesCompleted because built for discarded tile id`);
Tile.abortBuild(tile);
return;
}
tile = this.tiles[tile.key].merge(tile);
}
if (progress.done) {
tile.built = true;
}
tile.buildMeshes(this.scene.styles, progress);
this.updateTileStates();
this.scene.requestRedraw();
}
if (progress.done) {
this.tileBuildStop(tile.key);
}
}
// Called on main thread when web worker encounters an error building a tile
buildTileError(tile) {
log('error', `Error building tile ${tile.key}:`, tile.error);
this.forgetTile(tile.key);
Tile.abortBuild(tile);
}
// Track tile build state
tileBuildStart(key) {
this.building_tiles = this.building_tiles || {};
this.building_tiles[key] = true;
log('trace', `tileBuildStart for ${key}: ${Object.keys(this.building_tiles).length}`);
}
tileBuildStop(key) {
// Done building?
if (this.building_tiles) {
log('trace', `tileBuildStop for ${key}: ${Object.keys(this.building_tiles).length}`);
delete this.building_tiles[key];
this.checkBuildQueue();
}
}
// Check status of tile building queue and notify scene when we're done
checkBuildQueue() {
if (!this.building_tiles || Object.keys(this.building_tiles).length === 0) {
this.building_tiles = null;
this.scene.tileManagerBuildDone();
}
}
// Get a debug property across tiles
getDebugProp(prop, filter) {
var vals = [];
for (var t in this.tiles) {
if (this.tiles[t].debug[prop] != null && (typeof filter !== 'function' || filter(this.tiles[t]) === true)) {
vals.push(this.tiles[t].debug[prop]);
}
}
return vals;
}
// Sum of a debug property across tiles
getDebugSum(prop, filter) {
var sum = 0;
for (var t in this.tiles) {
if (this.tiles[t].debug[prop] != null && (typeof filter !== 'function' || filter(this.tiles[t]) === true)) {
sum += this.tiles[t].debug[prop];
}
}
return sum;
}
// Average of a debug property across tiles
getDebugAverage(prop, filter) {
return this.getDebugSum(prop, filter) / Object.keys(this.tiles).length;
}
}
// Round a number to given number of decimal divisions
// e.g. roundPrecision(x, 4) rounds a number to increments of 0.25
function roundPrecision (x, d, places = 2) {
return (Math.floor(x * d) / d).toFixed(places);
}
// Create a string representing the current set of meshes for a given set of tiles,
// based on their created timestamp. Used to determine when tiles should be re-collided.
function meshSetString (tiles) {
return JSON.stringify(
Object.entries(tiles).map(([,t]) => {
return Object.entries(t.meshes).map(([,s]) => {
return s.map(m => m.created_at);
});
})
);
}