UNPKG

tangram

Version:
1,247 lines (1,062 loc) 55.5 kB
import log from '../utils/log'; import Utils from '../utils/utils'; import debugSettings from '../utils/debug_settings'; import * as URLs from '../utils/urls'; import WorkerBroker from '../utils/worker_broker'; import Task from '../utils/task'; import subscribeMixin from '../utils/subscribe'; import sliceObject from '../utils/slice'; import Context from '../gl/context'; import Texture from '../gl/texture'; import ShaderProgram from '../gl/shader_program'; import VertexArrayObject from '../gl/vao'; import {StyleManager} from '../styles/style_manager'; import {Style} from '../styles/style'; import StyleParser from '../styles/style_parser'; import SceneLoader from './scene_loader'; import View from './view'; import Light from '../lights/light'; import TileManager from '../tile/tile_manager'; import DataSource from '../sources/data_source'; import '../sources/sources'; import FeatureSelection from '../selection/selection'; import RenderStateManager from '../gl/render_state'; import TextCanvas from '../styles/text/text_canvas'; import FontManager from '../styles/text/font_manager'; import MediaCapture from '../utils/media_capture'; import setupSceneDebug from './scene_debug'; // Load scene definition: pass an object directly, or a URL as string to load remotely export default class Scene { constructor(config_source, options) { options = options || {}; subscribeMixin(this); this.id = Scene.id++; this.initialized = false; this.initializing = null; // will be a promise that resolves when scene is loaded this.sources = {}; this.view = new View(this, options); this.tile_manager = new TileManager({ scene: this }); this.num_workers = options.numWorkers || 2; if (options.disableVertexArrayObjects === true) { VertexArrayObject.disabled = true; } Utils.use_high_density_display = options.highDensityDisplay !== undefined ? options.highDensityDisplay : true; Utils.updateDevicePixelRatio(); this.config = null; this.config_source = config_source; this.config_bundle = null; this.last_valid_config_source = null; this.styles = null; this.style_manager = new StyleManager(); this.building = null; // tracks current scene building state (tiles being built, etc.) this.dirty = true; // request a redraw if (options.preUpdate){ // optional pre-render loop hook this.subscribe({'pre_update': options.preUpdate}); } if (options.postUpdate){ // optional post-render loop hook this.subscribe({'post_update': options.postUpdate}); } this.render_loop = !options.disableRenderLoop; // disable render loop - app will have to manually call Scene.render() per frame this.render_loop_active = false; this.render_loop_stop = false; this.render_count = 0; this.last_render_count = 0; this.render_count_changed = false; this.frame = 0; this.last_main_render = -1; // frame counter for last main render pass this.last_selection_render = -1; // frame counter for last selection render pass this.media_capture = new MediaCapture(); this.selection = null; this.selection_feature_count = 0; this.fetching_selection_map = null; this.prev_textures = null; // textures from previously loaded scene (used for cleanup) this.introspection = (options.introspection === true) ? true : false; this.times = {}; // internal time logs (mostly for dev/profiling) this.resetTime(); this.container = options.container; this.canvas = null; this.contextOptions = options.webGLContextOptions; this.lights = null; this.background = null; this.createListeners(); this.updating = 0; this.generation = Scene.generation; // an id that is incremented each time the scene config is invalidated this.last_complete_generation = Scene.generation; // last generation id with a complete view setupSceneDebug(this); this.log_level = options.logLevel || 'warn'; log.setLevel(this.log_level); log.reset(); } static create (config, options = {}) { return new Scene(config, options); } // Load scene (or reload existing scene if no new source specified) // Options: // `base_path`: base URL against which scene resources should be resolved (useful for Play) (default nulll) // `blocking`: should rendering block on scene load completion (default true) load (config_source = null, options = {}) { if (this.initializing) { return this.initializing; } log.reset(); this.updating++; this.initialized = false; this.view_complete = false; // track if a view complete event has been triggered yet this.times.frame = null; // clear first frame time this.times.build = null; // clear first scene build time // Backwards compatibilty for passing `base_path` string as second argument // (since transitioned to using options argument to accept more parameters) options = (typeof options === 'string') ? { base_path: options } : options; // Should rendering block on load (not desirable for initial load, often desired for live style-switching) options.blocking = (options.blocking !== undefined) ? options.blocking : true; if (this.render_loop !== false) { this.setupRenderLoop(); } // Load scene definition (sources, styles, etc.), then create styles & workers this.createCanvas(); this.prev_textures = this.config && Object.keys(this.config.textures); // save textures from last scene this.initializing = this.loadScene(config_source, options) .then(async ({ texture_nodes }) => { await this.createWorkers(); // Clean up resources from prior scene this.destroyFeatureSelection(); WorkerBroker.postMessage(this.workers, 'self.clearFunctionStringCache'); // Scene loaded from a JS object, or modified by a `load` event, may contain compiled JS functions // which need to be serialized, while one loaded only from a URL does not. const serialize_funcs = ((typeof this.config_source === 'object') || this.hasSubscribersFor('load')); const updating = this.updateConfig({ texture_nodes, serialize_funcs, normalize: false, loading: true, fade_in: true }); if (options.blocking === true) { await updating; } this.freePreviousTextures(); this.updating--; this.initializing = null; this.initialized = true; this.last_valid_config_source = this.config_source; this.last_valid_options = { base_path: options.base_path, file_type: options.file_type }; this.requestRedraw(); }).catch(error => { this.initializing = null; this.updating = 0; // Report and revert to last valid config if available let type, message; if (error.name === 'YAMLException') { type = 'yaml'; message = 'Error parsing scene YAML'; } else { // TODO: more error types message = 'Error initializing scene'; } this.trigger('error', { type, message, error, url: this.config_source }); message = `Scene.load() failed to load ${JSON.stringify(this.config_source)}: ${error.message}`; if (this.last_valid_config_source) { log('warn', message, error); log('info', 'Scene.load() reverting to last valid configuration'); return this.load(this.last_valid_config_source, this.last_valid_base_path); } log('error', message, error); throw error; }); return this.initializing; } destroy() { this.initialized = false; this.render_loop_stop = true; // schedule render loop to stop this.destroyListeners(); this.destroyFeatureSelection(); if (this.canvas && this.canvas.parentNode) { this.canvas.parentNode.removeChild(this.canvas); this.canvas = null; } this.container = null; if (this.gl) { Texture.destroy(this.gl); this.style_manager.destroy(this.gl); this.styles = {}; ShaderProgram.reset(); // Force context loss let ext = this.gl.getExtension('WEBGL_lose_context'); if (ext) { ext.loseContext(); } this.gl = null; } this.sources = {}; this.destroyWorkers(); this.tile_manager.destroy(); this.tile_manager = null; log.reset(); } createCanvas() { if (this.canvas) { return; } this.container = this.container || document.body; this.canvas = document.createElement('canvas'); this.canvas.style.position = 'absolute'; this.canvas.style.top = 0; this.canvas.style.left = 0; // Force tangram canvas underneath all leaflet layers, and set background to transparent this.container.style.backgroundColor = 'transparent'; this.container.appendChild(this.canvas); try { this.gl = Context.getContext(this.canvas, Object.assign({ alpha: true, premultipliedAlpha: true, stencil: true, device_pixel_ratio: Utils.device_pixel_ratio, powerPreference: 'high-performance' }, this.contextOptions)); } catch(e) { throw new Error( 'Couldn\'t create WebGL context. ' + 'Your browser may not support WebGL, or it\'s turned off? ' + 'Visit http://webglreport.com/ for more info.' ); } this.resizeMap(this.container.clientWidth, this.container.clientHeight); VertexArrayObject.init(this.gl); this.render_states = new RenderStateManager(this.gl); this.media_capture.setCanvas(this.canvas, this.gl); } // Update list of any custom scripts (either at scene-level or data-source-level) updateExternalScripts () { let prev_scripts = [...(this.external_scripts||[])]; // save list of previously loaded scripts let scripts = []; // scene-level scripts if (this.config.scene.scripts) { for (let f in this.config.scene.scripts) { if (scripts.indexOf(this.config.scene.scripts[f]) === -1) { scripts.push(this.config.scene.scripts[f]); } } } // data-source-level scripts for (let s in this.config.sources) { let source = this.config.sources[s]; if (source.scripts) { for (let f in source.scripts) { if (scripts.indexOf(source.scripts[f]) === -1) { scripts.push(source.scripts[f]); } } } } this.external_scripts = scripts; // Scripts changed? return !(this.external_scripts.length === prev_scripts.length && this.external_scripts.every((v, i) => v === prev_scripts[i])); } // Web workers handle heavy duty tile construction: networking, geometry processing, etc. createWorkers() { // Reset old workers (if any) if we need to re-instantiate with new external scripts if (this.updateExternalScripts()) { this.destroyWorkers(); } if (!this.workers) { return this.makeWorkers(); } return Promise.resolve(); } // Instantiate workers from URL, init event handlers makeWorkers() { // Let VertexElements know if 32 bit indices for element arrays are available let has_element_index_uint = this.gl.getExtension('OES_element_index_uint') ? true : false; let queue = []; this.workers = []; for (let id=0; id < this.num_workers; id++) { let worker = new Worker(Tangram.workerURL); // eslint-disable-line no-undef this.workers[id] = worker; WorkerBroker.addWorker(worker); log('debug', `Scene.makeWorkers: initializing worker ${id}`); let _id = id; queue.push(WorkerBroker.postMessage(worker, 'self.init', this.id, id, this.num_workers, this.log_level, Utils.device_pixel_ratio, has_element_index_uint, this.external_scripts).then( (id) => { log('debug', `Scene.makeWorkers: initialized worker ${id}`); return id; }, (error) => { log('error', `Scene.makeWorkers: failed to initialize worker ${_id}:`, error); return Promise.reject(error); }) ); } this.next_worker = 0; return Promise.all(queue).then(() => { log.setWorkers(this.workers); }); } destroyWorkers() { this.selection = null; // selection needs to be re-initialized when workers are if (Array.isArray(this.workers)) { log.setWorkers(null); this.workers.forEach((worker) => { worker.terminate(); }); this.workers = null; } } // Scene is ready for rendering ready() { if (!this.view.ready() || Object.keys(this.sources).length === 0) { return false; } return true; } // Resize the map when device pixel ratio changes, e.g. when switching between displays updateDevicePixelRatio () { if (Utils.updateDevicePixelRatio()) { WorkerBroker.postMessage(this.workers, 'self.updateDevicePixelRatio', Utils.device_pixel_ratio) .then(() => this.rebuild()) .then(() => this.resizeMap(this.view.size.css.width, this.view.size.css.height)); } } resizeMap(width, height) { if (width === 0 && height === 0) { return; } this.dirty = true; this.view.setViewportSize(width, height); if (this.gl) { Context.resize(this.gl, width, height, Utils.device_pixel_ratio); } } // Request scene be redrawn at next animation loop requestRedraw() { this.dirty = true; } // Redraw scene immediately - don't wait for animation loop // Use sparingly, but for cases where you need the closest possible sync with other UI elements, // such as other, non-WebGL map layers (e.g. Leaflet raster layers, markers, etc.) immediateRedraw() { this.dirty = true; this.update(); } renderLoop () { this.render_loop_active = true; // only let the render loop instantiate once // Update and render the scene this.update(); // Pending background tasks Task.setState({ user_moving_view: this.view.user_input_active }); Task.processAll(); // Request the next frame if not scheduled to stop if (!this.render_loop_stop) { window.requestAnimationFrame(this.renderLoop.bind(this)); } else { this.render_loop_stop = false; this.render_loop_active = false; } } // Setup the render loop setupRenderLoop() { if (!this.render_loop_active) { setTimeout(() => { this.renderLoop(); }, 0); // delay start by one tick } } update() { // Determine which passes (if any) to render let main = this.dirty; let selection = this.selection ? this.selection.hasPendingRequests() : false; var will_render = !( (main === false && selection === false) || this.initialized === false || this.updating > 0 || this.ready() === false ); // Pre-render loop hook this.trigger('pre_update', will_render); // Update view (needs to update user input timer even if no render will occur) this.view.update(); // Bail if no need to render if (!will_render) { return false; } this.dirty = false; // subclasses can set this back to true when animation is needed // Render the scene this.updateDevicePixelRatio(); this.render({ main, selection }); this.updateViewComplete(); // fires event when rendered tile set or style changes this.media_capture.completeScreenshot(); // completes screenshot capture if requested // Post-render loop hook this.trigger('post_update', will_render); // Redraw every frame if animating if (this.animated === true || this.view.isAnimating()) { this.dirty = true; } this.frame++; log('trace', 'Scene.render()'); return true; } // Accepts flags indicating which render passes should be made render({ main, selection }) { var gl = this.gl; this.updateBackground(); Object.keys(this.lights).forEach(i => this.lights[i].update()); // Render main pass this.render_count_changed = false; if (main) { this.render_count = this.renderPass(); this.last_main_render = this.frame; // Update feature selection map if necessary if (this.render_count !== this.last_render_count) { this.render_count_changed = true; this.logFirstFrame(); this.getFeatureSelectionMapSize().then(size => { this.selection_feature_count = size; log('info', `Scene: rendered ${this.render_count} primitives (${size} features in selection map)`); }); } this.last_render_count = this.render_count; } // Render selection pass (if needed) if (selection) { if (this.view.panning || this.view.user_input_active) { this.selection.clearPendingRequests(); return; } // Only re-render if selection buffer is out of date (relative to main render buffer) // and not locked (e.g. no tiles are actively building) if (!this.selection.locked && this.last_selection_render < this.last_main_render) { this.selection.bind(); // switch to FBO this.renderPass( 'selection_program', // render w/alternate program { allow_blend: false }); // Reset to screen buffer gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, this.canvas.width, this.canvas.height); gl.clearColor(...this.background.computed_color); // restore scene background color this.last_selection_render = this.frame; } this.selection.read(); // process any pending results from selection buffer } return true; } // Render all active styles, grouped by blend/depth type (opaque, overlay, etc.) and by program (style) // Called both for main render pass, and for secondary passes like selection buffer renderPass(program_key = 'program', { allow_blend } = {}) { // optionally force alpha off (e.g. for selection pass) allow_blend = (allow_blend == null) ? true : allow_blend; this.clearFrame(); let count = 0; // how many primitives were rendered let last_blend; // blend mode active in last render pass // Get sorted list of current blend orders, with accompanying list of styles to render for each const blend_orders = this.style_manager.getActiveBlendOrders(); for (const { blend_order, styles } of blend_orders) { // Render each style for (let s=0; s < styles.length; s++) { let style = this.styles[styles[s]]; if (style == null) { continue; } // Only update render state when blend mode changes if (style.blend !== last_blend) { let state = Object.assign({}, Style.render_states[style.blend], // render state for blend mode { blend: (allow_blend && style.blend) } // enable/disable blending (e.g. no blend for selection) ); this.setRenderState(state); } const blend = allow_blend && style.blend; if (blend === 'translucent') { // Depth pre-pass for translucency this.gl.colorMask(false, false, false, false); this.renderStyle(style.name, program_key, blend_order); this.gl.colorMask(true, true, true, true); this.gl.depthFunc(this.gl.EQUAL); // Stencil buffer mask prevents overlap/flicker from compounding alpha of overlapping polys this.gl.enable(this.gl.STENCIL_TEST); this.gl.clearStencil(0); this.gl.clear(this.gl.STENCIL_BUFFER_BIT); this.gl.stencilFunc(this.gl.EQUAL, this.gl.ZERO, 0xFF); this.gl.stencilOp(this.gl.KEEP, this.gl.KEEP, this.gl.INCR); // Main render pass count += this.renderStyle(style.name, program_key, blend_order); // Disable translucency-specific settings this.gl.disable(this.gl.STENCIL_TEST); this.gl.depthFunc(this.gl.LESS); } else if (blend !== 'opaque' && style.stencil_proxy_tiles === true) { // Mask proxy tiles to with stencil buffer to avoid overlap/flicker from compounding alpha // Find unique levels of proxy tiles to render for this style const proxy_levels = this.tile_manager.getRenderableTiles() .filter(t => t.meshes[style.name]) // must have meshes for this style .map(t => t.proxy_level) // get the proxy depth .reduce((levels, level) => { // count unique proxy depths levels.indexOf(level) > -1 || levels.push(level); return levels; }, []) .sort(); // sort by lower depth first if (proxy_levels.length > 1) { // When there are multiple "levels" of tiles to render (e.g. non-proxy and one or more proxy // tile levels, or multiple proxy tile levels but no non-proxy tiles, etc.): // Render each proxy tile level to stencil buffer, masking each level such that it will not // render over any pixel rendered by a previous proxy tile level. this.gl.enable(this.gl.STENCIL_TEST); this.gl.clearStencil(0); this.gl.clear(this.gl.STENCIL_BUFFER_BIT); this.gl.stencilOp(this.gl.KEEP, this.gl.KEEP, this.gl.REPLACE); for (let i = 0; i < proxy_levels.length; i++) { // stencil test passes either for zero (not-yet-rendered), // or for other pixels at this proxy level (but not previous proxy levels) this.gl.stencilFunc(this.gl.GEQUAL, proxy_levels.length - i, 0xFF); count += this.renderStyle(style.name, program_key, blend_order, proxy_levels[i]); } this.gl.disable(this.gl.STENCIL_TEST); } else { // No special render handling needed when there are no proxy tiles, // or if there is ONLY a single proxy tile level (e.g. with no non-proxy tiles) count += this.renderStyle(style.name, program_key, blend_order); } } else { // Regular render pass (no special blend handling, or selection buffer pass) count += this.renderStyle(style.name, program_key, blend_order); } last_blend = style.blend; } } return count; } renderStyle(style_name, program_key, blend_order, proxy_level = null) { let style = this.styles[style_name]; let first_for_style = true; // TODO: allow this state to be passed in (for multilpe blend orders, stencil tests, etc) let render_count = 0; let program; // Render tile GL geometries let renderable_tiles = this.tile_manager.getRenderableTiles(); // For each tile, only include meshes for the blend order currently being rendered // Builds an array tiles and their associated meshes, each as a [tile, meshes] 2-element array let tile_meshes = renderable_tiles .filter(t => typeof proxy_level !== 'number' || t.proxy_level === proxy_level) // optional filter by proxy level .map(t => { if (t.meshes[style_name]) { return [t, t.meshes[style_name].filter(m => m.variant.blend_order === blend_order)]; } }) .filter(x => x); // skip tiles with no meshes for this blend order // Mesh variants must be rendered in requested order across tiles, to prevent labels that cross // tile boundaries from rendering over adjacent tile features meant to be underneath let max_mesh_order = Math.max(...tile_meshes.map(([, meshes]) => { return Math.max(...meshes.map(m => m.variant.mesh_order)); })); // One pass per mesh variant order (loop goes to max value +1 because 0 is a valid order value) for (let mo=0; mo < max_mesh_order + 1; mo++) { // Loop over tiles, with meshes pre-filtered by current blend order for (let [tile, meshes] of tile_meshes) { let first_for_tile = true; // Skip proxy tiles if new tiles have finished loading this style if (!tile.shouldProxyForStyle(style_name)) { // log('trace', `Scene.renderStyle(): Skip proxy tile for style '${style_name}' `, tile, tile.proxy_for); continue; } // Filter meshes further by current variant order const order_meshes = meshes.filter(m => m.variant.mesh_order === mo); if (order_meshes.length === 0) { continue; } // Style-specific state // Only setup style if rendering for first time this frame // (lazy init, not all styles will be used in all screen views; some styles might be defined but never used) if (first_for_style === true) { first_for_style = false; program = this.setupStyle(style, program_key); if (!program) { // no program found, e.g. happens when rendering selection pass, but style doesn't support selection return 0; } } // Render each mesh (for current variant order) order_meshes.forEach(mesh => { // Tile-specific state if (first_for_tile === true) { first_for_tile = false; this.view.setupTile(tile, program); } // Render this mesh variant if (style.render(mesh)) { this.requestRedraw(); } render_count += mesh.geometry_count; }); } } return render_count; } setupStyle(style, program_key) { // Get shader program from style, lazily compiling if necessary let program; try { program = style.getProgram(program_key); if (!program) { return; } } catch(error) { this.trigger('warning', { type: 'styles', message: `Error compiling style ${style.name}`, style, shader_errors: style.program && style.program.shader_errors }); return; } program.use(); style.setup(); program.uniform('1f', 'u_time', this.animated ? (((+new Date()) - this.start_time) / 1000) : 0); this.view.setupProgram(program); for (let i in this.lights) { this.lights[i].setupProgram(program); } return program; } clearFrame() { if (!this.initialized) { return; } this.render_states.depth_write.set({ depth_write: true }); this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT | this.gl.STENCIL_BUFFER_BIT); } setRenderState({ depth_test, depth_write, cull_face, blend } = {}) { if (!this.initialized) { return; } // Defaults // TODO: when we abstract out support for multiple render passes, these can be per-pass config options let render_states = this.render_states; depth_test = (depth_test === false) ? false : render_states.defaults.depth_test; // default true depth_write = (depth_write === false) ? false : render_states.defaults.depth_write; // default true cull_face = (cull_face === false) ? false : render_states.defaults.culling; // default true blend = (blend != null) ? blend : render_states.defaults.blending; // default false // Reset frame state let gl = this.gl; render_states.depth_test.set({ depth_test }); render_states.depth_write.set({ depth_write }); render_states.culling.set({ cull: cull_face, face: render_states.defaults.culling_face }); // Blending of alpha channel is modified to account for WebGL alpha behavior, see: // http://webglfundamentals.org/webgl/lessons/webgl-and-alpha.html // http://stackoverflow.com/a/11533416 if (blend) { // Opaque: all source, no destination if (blend === 'opaque') { render_states.blending.set({ blend: false }); } // Traditional alpha blending else if (blend === 'overlay' || blend === 'inlay' || blend === 'translucent') { render_states.blending.set({ blend: true, src: gl.SRC_ALPHA, dst: gl.ONE_MINUS_SRC_ALPHA, src_alpha: gl.ONE, dst_alpha: gl.ONE_MINUS_SRC_ALPHA }); } // Additive blending else if (blend === 'add') { render_states.blending.set({ blend: true, src: gl.ONE, dst: gl.ONE }); } // Multiplicative blending else if (blend === 'multiply') { render_states.blending.set({ blend: true, src: gl.ZERO, dst: gl.SRC_COLOR }); } } else { render_states.blending.set({ blend: false }); } } // Request feature selection at given pixel. Runs async and returns results via a promise. getFeatureAt(pixel, { radius } = {}) { if (!this.initialized) { log('debug', 'Scene.getFeatureAt() called before scene was initialized'); return Promise.resolve(); } // skip selection if no interactive features if (this.selection_feature_count === 0) { return Promise.resolve(); } // only instantiate feature selection on-demand if (!this.selection) { this.resetFeatureSelection(); } // Scale point and radius to [0..1] range let point = { x: pixel.x / this.view.size.css.width, y: pixel.y / this.view.size.css.height }; if (radius > 0) { radius = { x: radius / this.view.size.css.width, y: radius / this.view.size.css.height }; } else { radius = null; } return this.selection.getFeatureAt(point, { radius }). then(selection => Object.assign(selection, { pixel })). catch(error => Promise.resolve({ error })); } // Query features within visible tiles, with optional filter conditions async queryFeatures({ filter, unique = true, group_by = null, visible = null, geometry = false } = {}) { if (!this.initialized) { return []; } filter = Utils.serializeWithFunctions(filter); // Optional uniqueify criteria // Valid values: true, false/null, single property name, or array of property names unique = (typeof unique === 'string') ? [unique] : unique; const uniqueify_on_id = (unique === true || (Array.isArray(unique) && unique.indexOf('$id') > -1)); const uniqueify = unique && (obj => { const properties = Array.isArray(unique) ? sliceObject(obj.properties, unique) : obj.properties; const id = uniqueify_on_id ? obj.id : null; if (geometry) { // when `geometry` flag is set, we need to uniqueify based on *both* feature properties and geometry return JSON.stringify({ geometry: obj.geometry, properties, id }); } return JSON.stringify({ properties, id }); }); // Optional grouping criteria // Valid values: false/null, single property name, or array of property names group_by = (typeof group_by === 'string' || Array.isArray(group_by)) && group_by; const group = group_by && (obj => { return Array.isArray(group_by) ? JSON.stringify(sliceObject(obj, group_by)) : obj[group_by]; }); const tile_keys = this.tile_manager.getRenderableTiles().map(t => t.key); const results = await WorkerBroker.postMessage(this.workers, 'self.queryFeatures', { filter, visible, geometry, tile_keys }); const features = []; const keys = {}; const groups = {}; results.forEach(r => r.forEach(feature => { if (uniqueify) { const str = uniqueify(feature); if (keys[str]) { return; } keys[str] = true; } if (group) { const str = group(feature.properties); groups[str] = groups[str] || []; groups[str].push(feature); } else { features.push(feature); } })); return group ? groups : features; // returned grouped results, or all results } // Rebuild all tiles, without re-parsing the config or re-compiling styles // sync: boolean of whether to sync the config object to the worker // sources: optional array of data sources to selectively rebuild (by default all our rebuilt) rebuild({ initial = false, new_generation = true, sources = null, serialize_funcs, profile = false, fade_in = false } = {}) { return new Promise((resolve, reject) => { // Skip rebuild if already in progress if (this.building) { // Queue up to one rebuild call at a time, only save last request if (this.building.queued && this.building.queued.reject) { // notify previous request that it did not complete log('debug', 'Scene.rebuild: request superceded by a newer call'); this.building.queued.resolve(false); // false flag indicates rebuild request was superceded } // Save queued request let options = { initial, new_generation, sources, serialize_funcs, profile, fade_in }; this.building.queued = { resolve, reject, options }; log('trace', 'Scene.rebuild(): queuing request'); return; } // Track tile build state this.building = { resolve, reject, initial }; // Profiling if (profile) { this.debug.profile('Scene.rebuild'); } // Increment generation to ensure style/tile building stay in sync // (skipped if calling function already incremented) if (new_generation) { this.generation = ++Scene.generation; for (let style in this.styles) { this.styles[style].setGeneration(this.generation); } } // Update config (in case JS objects were manipulated directly) this.syncConfigToWorker({ serialize_funcs }); this.resetWorkerFeatureSelection(sources); this.resetTime(); // Rebuild visible tiles this.tile_manager.pruneToVisibleTiles(); this.tile_manager.forEachTile(tile => { if (!sources || sources.indexOf(tile.source.name) > -1) { this.tile_manager.buildTile(tile, { fade_in }); } }); this.tile_manager.updateTilesForView(); // picks up additional tiles for any new/changed data sources this.tile_manager.checkBuildQueue(); // resolve immediately if no tiles to build }).then(() => { // Profiling if (profile) { this.debug.profileEnd('Scene.rebuild'); } }); } // Tile manager finished building tiles // TODO move to tile manager tileManagerBuildDone() { TextCanvas.pruneTextCache(); if (this.building) { log('info', 'Scene: build geometry finished'); if (this.building.resolve) { this.logFirstBuild(); this.building.resolve(true); } // Another rebuild queued? var queued = this.building.queued; this.building = null; if (queued) { log('debug', 'Scene: starting queued rebuild() request'); this.rebuild(queued.options).then(queued.resolve, queued.reject); } else { this.tile_manager.updateLabels(); // refresh label if nothing to rebuild } } } /** Load (or reload) the scene config @return {Promise} */ async loadScene(config_source = null, { base_path, file_type } = {}) { this.config_source = config_source || this.config_source; if (typeof this.config_source === 'string') { this.base_path = URLs.pathForURL(base_path || this.config_source); } else { this.base_path = URLs.pathForURL(base_path); } // backwards compatibility for accessing base path under previous name // TODO: schedule for deprecation this.config_path = this.base_path; const { config, bundle, texture_nodes } = await SceneLoader.loadScene( this.config_source, { path: this.base_path, type: file_type }); this.config = config; this.config_bundle = bundle; return { texture_nodes }; // pass along texture nodes for resolution after global property subtistution } // Add source to a scene, arguments `name` and `config` need to be provided: // - If the name doesn't match a sources it will create it // - the `config` obj follow the YAML scene spec, ex: ```{type: 'TopoJSON', url: "//tile.mapzen.com/mapzen/vector/v1/all/{z}/{x}/{y}.topojson"]}``` // that looks like: // // scene.setDataSource("osm", {type: 'TopoJSON', url: "//tile.mapzen.com/mapzen/vector/v1/all/{z}/{x}/{y}.topojson" }); // // - also can be pass a ```data``` obj: ```{type: 'GeoJSON', data: JSObj ]}``` // // var geojson_data = {}; // ... // scene.setDataSource("dynamic_data", {type: 'GeoJSON', data: geojson_data }); // setDataSource (name, config) { if (!name || !config || !config.type || (!config.url && !config.data)) { log('error', 'No name provided or not a valid config:', name, config); return; } let load = (this.config.sources[name] == null); let source = this.config.sources[name] = Object.assign({}, config); // Convert raw data into blob URL if (source.data && typeof source.data === 'object') { source.url = URLs.createObjectURL(new Blob([JSON.stringify(source.data)], { type: 'application/json' })); delete source.data; } if (load) { return this.updateConfig({ rebuild: { sources: [name] } }); } else { return this.rebuild({ sources: [name] }); } } // (Re-)create all data sources. Re-layout view and rebuild tiles when either: // 1) all tiles if `rebuild_all` parameter is specified (used when loading a new scene) // 2) the data source has changed in a way that affects tile layout (e.g. tile size, max_zoom, etc.) createDataSources(rebuild_all = false) { const reset = []; // sources to reset const prev_source_names = Object.keys(this.sources); let source_id = 0; for (var name in this.config.sources) { const source = this.config.sources[name]; const prev_source = this.sources[name]; try { const config = { ...source, name, id: source_id++ }; this.sources[name] = DataSource.create(config, this.sources); if (!this.sources[name]) { throw {}; } } catch(e) { delete this.sources[name]; const message = `Could not create data source: ${e.message}`; log('warn', `Scene: ${message}`, source); this.trigger('warning', { type: 'sources', source, message }); } // Data source changed in a way that affects tile layout? // If so, we'll re-calculate the tiles in view for this source and rebuild them if (rebuild_all || DataSource.tileLayoutChanged(this.sources[name], prev_source)) { reset.push(name); } } // Sources that were removed prev_source_names.forEach(s => { if (!this.config.sources[s]) { delete this.sources[s]; // TODO: remove from workers too? reset.push(s); } }); // Remove tiles from sources that have changed if (reset.length > 0) { this.tile_manager.removeTiles(tile => { return (reset.indexOf(tile.source.name) > -1); }); } // Mark sources that will generate geometry tiles (any that are referenced in scene layers) for (let ln in this.config.layers) { let layer = this.config.layers[ln]; if (layer.enabled !== false && layer.data && this.sources[layer.data.source]) { this.sources[layer.data.source].builds_geometry_tiles = true; } } } // Load all textures in the scene definition loadTextures() { return Texture.createFromObject(this.gl, this.config.textures) .then(() => Texture.createDefault(this.gl)); // create a 'default' texture for placeholders } // Free textures from previously loaded scene freePreviousTextures() { if (!this.prev_textures) { return; } this.prev_textures.forEach(t => { // free textures that aren't in the new scene, but are still in the global texture set if (!this.config.textures[t] && Texture.textures[t]) { Texture.textures[t].destroy(); } }); this.prev_textures = null; } // Called (currently manually) after styles are updated in stylesheet updateStyles() { if (!this.initialized && !this.initializing) { throw new Error('Scene.updateStyles() called before scene was initialized'); } // (Re)build styles from config this.styles = this.style_manager.build(this.config.styles); this.style_manager.initStyles(this); // Optionally set GL context (used when initializing or re-initializing GL resources) for (let style in this.styles) { this.styles[style].setGL(this.gl); } this.dirty = true; } // Is scene currently animating? get animated () { // Disable animation is scene flag requests it, otherwise enable animation if any animated styles are in view return (this.config.scene.animated === false ? false : this.style_manager.getActiveStyles().some(s => this.styles[s].animated)); } // Get active camera - for public API getActiveCamera() { return this.view.getActiveCamera(); } // Set active camera - for public API setActiveCamera(name) { return this.view.setActiveCamera(name); } // Create lighting createLights() { this.lights = {}; if (debugSettings.wireframe) { Light.enabled = false; // disable lighting for wireframe mode } for (let i in this.config.lights) { if (!this.config.lights[i] || typeof this.config.lights[i] !== 'object') { continue; } let light = this.config.lights[i]; light.name = i.replace('-', '_'); // light names are injected in shaders, can't have hyphens light.visible = (light.visible === false) ? false : true; if (light.visible) { this.lights[light.name] = Light.create(this.view, light); } } Light.inject(this.lights); } // Set background color from scene config setBackground() { const bg = this.config.scene.background; this.background = {}; if (bg && bg.color) { this.background.color = StyleParser.createColorPropertyCache(bg.color); } if (!this.background.color) { this.background.color = StyleParser.createColorPropertyCache([0, 0, 0, 0]); // default background TODO: vary w/scene alpha } } // Update background color each frame as needed (e.g. may be zoom-interpolated) updateBackground () { const last_color = this.background.computed_color; const color = this.background.computed_color = StyleParser.evalCachedColorProperty(this.background.color, { zoom: this.view.tile_zoom }); // update GL/canvas if color has changed if (!last_color || color.some((v, i) => last_color[i] !== v)) { // if background is fully opaque, set canvas background to match if (color[3] === 1) { this.canvas.style.backgroundColor = `rgba(${color.map(c => Math.floor(c * 255)).join(', ')})`; } else { this.canvas.style.backgroundColor = 'transparent'; } this.gl.clearColor(...color); } } // Turn introspection mode on/off setIntrospection (val) { if (val !== this.introspection) { this.introspection = (val != null) ? val : false; this.updating++; return this.updateConfig({ normalize: false }).then(() => this.updating--); } return Promise.resolve(); } // Update scene config, and optionally rebuild geometry // rebuild can be boolean, or an object containing rebuild options to passthrough updateConfig({ loading = false, rebuild = true, serialize_funcs, texture_nodes = {}, normalize = true, fade_in = false } = {}) { this.generation = ++Scene.generation; this.updating++; // Apply globals, finalize textures and other resource paths if needed this.config = SceneLoader.applyGlobalProperties(this.config); if (normalize) { // normalize whole scene if requested - usually when user is making run-time updates to scene SceneLoader.normalize(this.config, this.config_bundle, texture_nodes); } SceneLoader.hoistTextureNodes(this.config, this.config_bundle, texture_nodes); this.trigger(loading ? 'load' : 'update', { config: this.config }); this.style_manager.init(); this.view.reset(); this.createLights(); this.createDataSources(loading); this.