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
JavaScript
/** 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();
}
});
}
}