UNPKG

s2maps-gpu

Version:

S2 Maps GPU - An open source, high-performance, and GPU-accelerated map engine for rendering large-scale, interactive maps.

438 lines (437 loc) 15.2 kB
/** CAMERA */ import Camera from './camera/index.js'; /** SOURCES */ import Animator from './camera/animator.js'; /** * # S2 Map UI * * Internal wrapper for the Camera. * Manages user APIs and inputs for user interactions to the map */ export default class S2MapUI extends Camera { renderNextFrame = false; injectionQueue = []; /* API */ /** Delete all tile cache, painter, and draw method */ delete() { // delete all tiles this.tileCache.deleteAll(); // to ensure no more draws, set the draw method to a noop /** empty the draw method */ this._draw = () => { /* noop */ }; // tell the painter to cleanup this.painter.delete(); } /** * Jump to a specific location and zoom level * @param view - View to jump to */ jumpTo(view) { // update the projectors position and render the update this.projector.setView(view); this.render(); } /** * Animate the map to a specific location and zoom level * @param type - Type of animation to perform * @param directions - Directions for the animation */ animateTo(type, directions) { // build animator const animator = new Animator(this.projector, directions); const render = type === 'flyTo' ? animator.flyTo() : animator.easeTo(); if (!render) return; /** * set an animation function * @param now - Current time in milliseconds from start */ this.currAnimFunction = (now) => { this._animate(animator, now * 0.001); }; // render it out this.render(); } /** * Set the style of the map * @param style - Style to set * @param ignorePosition - Ignore the current position of the map */ async setStyle(style, ignorePosition) { await this._setStyle(style, ignorePosition); } /** * Update the style of the map * 1) updateStyle from the style object. return a list of "from->to" for tiles and "layerIDs" for webworkers * 2) remove tiles from tileCache not in view * 3) update the tileCache tiles using "from->to" * 4) if a layer "source", "layer", or "filter" change it will be in "webworkers". Tell webworkers to rebuild * @param _style - Style to update to */ updateStyle(_style) { // // build style for the map, painter, and webworkers // Style.updateStyle(style) // remove any tiles outside of view // this._resetTileCache([], false, true) // // update tileCache // this.tileCache.forEach(tile => { tile.updateStyle(Style) }) // // inject minzoom and maxzoom // this._setStyle(style, true) // // render our first pass // this.render() } /** * Clear the source data from all tiles * @param sourceNames - Names of sources to clear */ clearSource(sourceNames) { // delete source data from all tiles this.tileCache.forEach((tile) => { tile.deleteSources(sourceNames); }); // let the renderer know the painter is "dirty" this.painter.dirty = true; // rerender this.render(); } /** * Reset the source data for all tiles * @param sources - Array of [sourceName, href] pairs * @param keepCache - Whether to keep the tile cache * @param awaitReplace - Whether to await the replacement of the source data or clear the cache immediately */ resetSource(sources, keepCache = false, awaitReplace = false) { const { id: mapID, painter, webworker, parent } = this; const tileRequests = this._resetTileCache(sources.map((s) => s[0]), keepCache, awaitReplace); // Send off the tile request (by including sourceNames we are letting the // source worker know we only need to update THIS source) if (tileRequests.length > 0) { const msg = { mapID, type: 'tilerequest', tiles: tileRequests, sources }; if (webworker) postMessage(msg); else parent?.onMessage({ data: msg }); } // let the renderer know the painter is "dirty" painter.dirty = true; // rerender this.render(); } /** * Add a new style layer to the map * @param _layer - the style layer to add * @param _nameIndex - the index position to add the layer */ addLayer(_layer, _nameIndex) { // TODO // // remove all tiles outside of view // const tileRequests = this._resetTileCache([], false, true) // // style needs to be updated on the change // Style.addLayer(layer, nameIndex, tileRequests) // // rerender // this.render() } /** * Delete a style layer from the map * @param _nameIndex - the index position to delete the layer */ deleteLayer(_nameIndex) { // TODO // // style needs to be updated on the change // const index = Style.deleteLayer(nameIndex) // // remove all instances of the layer in each tile // this.tileCache.forEach(tile => { tile.deleteLayer(index) }) // // rerender // this.render() } /** * Reorder style layers on the map * @param _layerChanges - the layer changes to make, their starting and end positions { [start]: end } */ reorderLayers(_layerChanges) { // TODO // // style needs to updated on the change // this.style.reorderLayers(layerChanges) // // update every tile // this.tileCache.forEach(tile => { tile.reorderLayers(layerChanges) }) // // rerender // this.render() } /** * Update a style layer on the map * @param _layer - the layer to update * @param _nameIndex - the index position to update the layer * @param _fullUpdate - whether to update the layer completely or just the style */ updateLayer(_layer, _nameIndex, _fullUpdate = false) { // TODO } /** * Set the move state (if true user can edit move, otherwise current move is locked in) * @param state - new state */ setMoveState(state) { this.canMove = state; } /** * Set the zoom state (if true user can edit zoom, otherwise current zoom is locked in) * @param state - new state */ setZoomState(state) { this.canZoom = state; } /** * Handle on zoom case * @param deltaZ - The change in zoom level * @param deltaX - The change in x position * @param deltaY - The change in y position */ onZoom(deltaZ, deltaX = 0, deltaY = 0) { this.dragPan.clear(); if (!this.canZoom) return; // remove any prexisting animations this.currAnimFunction = undefined; // update projector this.projector.onZoom(deltaZ, deltaX, deltaY); // render this.render(); } /** * Update bearing and/or pitch from compass change * @param bearing - the bearing avlue to update * @param pitch - the pitch value to update */ updateCompass(bearing, pitch) { const { projector } = this; this.currAnimFunction = undefined; projector.setView({ bearing: projector.bearing + bearing, pitch: projector.pitch + pitch, }); this.render(); } /** Handle compass on mouseup. Snap to north or south if close enough */ mouseupCompass() { const { projector } = this; const { bearing } = projector; if (bearing === 0) return; const newBearing = bearing >= -10 && bearing <= 10 ? 0 : bearing <= -167.5 ? -180 : bearing >= 167.5 ? 180 : undefined; if (newBearing !== undefined) { const animator = new Animator(projector, { duration: 1, bearing: newBearing }); animator.compassTo(); /** * Set the current animation function * @param now - current time since start */ this.currAnimFunction = (now) => { this._animate(animator, now * 0.001); }; this.render(); } } /** Reset the compass to north */ resetCompass() { const { projector } = this; const { bearing, pitch } = projector; // create the animator const duration = bearing !== 0 ? (bearing > 90 ? 1.75 : 1) : 1; const animator = new Animator(projector, { duration, bearing: 0, pitch: bearing !== 0 ? bearing : pitch, }); animator.compassTo(); /** * Set the current animation function * @param now - current time since start */ this.currAnimFunction = (now) => { this._animate(animator, now * 0.001); }; // send off a render this.render(); } /** * Resize the canvas * @param width - new width * @param height - new height */ resize(width, height) { this.resizeQueued = { width, height }; this.render(); } /** Takes a screenshot and ships the uint8 buffer back to the parent */ screenshot() { const { id: mapID, painter, parent, webworker } = this; requestAnimationFrame(() => { if (this.#fullyRenderedScreen()) { // assuming the screen is ready for a screen shot we ask for a draw void painter.getScreen().then((data) => { const screen = data.buffer; const msg = { mapID, type: 'screenshot', screen }; if (webworker) postMessage(msg, [screen]); else parent?.onMessage({ data: msg }); }); } else { this.screenshot(); } }); } /** * Call this function to wait until the screen is fully rendered. A "rendered" message will be * sent back to the parent when the screen is fully rendered */ awaitFullyRendered() { const { id: mapID, parent, webworker } = this; requestAnimationFrame(() => { if (this.#fullyRenderedScreen()) { const msg = { mapID, type: 'rendered' }; if (webworker) postMessage({ type: 'rendered' }); parent?.onMessage({ data: msg }); } else { this.awaitFullyRendered(); } }); } /** * Called when the screen is fully rendered with all source/layer data * @returns true if the screen is fully rendered */ #fullyRenderedScreen() { // check tiles const tiles = this.getTiles(); let fullyRendered = true; for (const tile of tiles) { if (tile.state === 'loading') { fullyRendered = false; break; } } // if the painter has a skybox, check it fullyRendered = this.painter.workflows.skybox?.ready ?? fullyRendered; return fullyRendered; } /** * some cases we can just do the work immediately, otherwise we do one job per frame * to improve performance. Data is stored in the injection queue while it waits for it's frame. * @param data - data to inject. Tile data resuls or a flush command (useful for clearing parent data or inverted fills, etc.) */ injectData(data) { if (data.type === 'flush') this._injectData(data); else this.injectionQueue.push(data); this.render(); } /* INPUT EVENTS */ /** * set the colorblind mode * @param mode - colorblind mode */ colorMode(mode) { this.painter.setColorMode(mode); // force a re-render this.render(); } /** * for interaction with features on the screen * @param x - x mouse position * @param y - y mouse position */ onCanvasMouseMove(x, y) { if (!this._interactive) return; this._setMousePosition(x, y); this.mouseMoved = true; this.render(); } /** * action when the user touches the screen * @param touches - collection of touch events */ onTouchStart(touches) { this.dragPan.onTouchStart(touches); if (!this._interactive || touches.length > 1) return; const { x, y } = touches[0]; this._setMousePosition(x, y); this.mouseMoved = true; this.render(); } /** * builtin navigation controller inputs * @param ctrl - 'zoomIn' | 'zoomOut' * @param lon - optional longitude * @param lat - optional latitude */ navEvent(ctrl, lon, lat) { this._navEvent(ctrl, lon, lat); } /* DRAW */ /** * we don't want to over request rendering, so we render with a limiter to * safely call render as many times as we like */ render() { if (!this._canDraw) return; if (this.renderNextFrame) return; this.renderNextFrame = true; requestAnimationFrame((now) => { this.renderNextFrame = false; // if timeCache exists, run animation function this.timeCache?.animate(now, this._updatePainter.bind(this)); // if animation currently exists, run it this.currAnimFunction?.(now); // if resize has been queued, we do so now if (this.resizeQueued !== undefined) this._resize(); // if there is data to 'inject', we make sure to render another frame later if (this.injectionQueue.length > 0) { // pull out the latest data we received (think about it, the newest data is the most constructive) const data = this.injectionQueue.pop(); // tell the camera to inject data if (data !== undefined) this._injectData(data); // setup another render queue this.render(); } // get state of scene const projectorDirty = this.projector.dirty; // if the projector was dirty (zoom or movement) we run render again just incase if (projectorDirty) { this.render(); this._onViewUpdate(); } // run a draw, it will repaint framebuffers as necessary try { this._draw(); } catch (e) { this._canDraw = false; throw e; } // if mouse movement, check feature at position if (this.mouseMoved && !projectorDirty) { this.mouseMoved = false; void this._onCanvasMouseMove(); } }); } }