s2maps-gpu
Version:
S2 Maps GPU - An open source, high-performance, and GPU-accelerated map engine for rendering large-scale, interactive maps.
680 lines (679 loc) • 27.2 kB
JavaScript
/** STYLE */
import Style from 'style/index.js';
/** GEOMETRY / PROJECTIONS */
import { idParent } from 'gis-tools/index.js';
import { Projector, mod } from './projector/index.js';
/** SOURCES */
import Animator from './animator.js';
import Cache from './cache.js';
import DragPan from './dragPan.js';
import TimeCache from './timeCache.js';
import { createTile } from 'source/index.js';
/**
* # Camera
*
* The camera of the map. Maintains local cache, manages the painter, projector, and handles
* the rendering of the map.
*
* The Camera also handles user interactions, map states, each frame,
* along with any animations that might be in progress.
*
* Any updates that are required are sent to the Style container and any data that shipped here is
* forwarded to the Painter.
*/
export default class Camera {
parent;
id;
#canvas;
_canDraw = false; // let the render sequence know if the painter is ready to paint
_interactive = false; // allow the user to make visual changes to the map, whether that be zooming, panning, or dragging
#scrollZoom; // allow the user to scroll over the canvas and cause a zoom change
style;
projector;
painter;
tileCache = new Cache();
timeCache;
tilesInView = []; // S2CellIDs of the tiles
lastTileViewState = [];
requestQueue = [];
wasDirtyLastFrame = false;
/** Denote this mapUI is running in a separate thread */
webworker;
canMove = true;
canZoom = true;
dragPan = new DragPan();
mouseMoved = true;
mousePosition = { x: 0, y: 0 };
currAnimFunction;
resizeQueued;
currFeatures = new Map();
/**
* Initialize the mapUI
* @param options - Map options
* @param canvas - Canvas element we are rendering to
* @param id - Unique identifier for the mapUI
* @param parent - Parent mapUI means this is running on the main thread so we can make direct calls to the parent
*/
constructor(options, canvas, id, parent) {
this.#canvas = canvas;
// setup options
const { style, interactive, scrollZoom, canMove, canZoom } = options;
// assign webworker if applicable
this.webworker = parent === undefined;
// check if we can interact with the camera
this._interactive = interactive ?? true;
this.#scrollZoom = scrollZoom ?? true;
this.canMove = canMove ?? true;
this.canZoom = canZoom ?? true;
// create style
this.style = new Style(this, options);
// setup projector
this.projector = new Projector(options, this);
this.id = id;
this.parent = parent;
// build the painter and style
void this.#buildPaint(options, style);
}
/**
* Locally called but managed by parent class S2MapsUI
* @param _deltaZ - change in zoom
* @param _deltaX - change in x
* @param _deltaY - change in y
*/
onZoom(_deltaZ, _deltaX, _deltaY) {
/* NOOP */
}
/** Locally called but managed by parent class S2MapsUI */
render() {
/* NOOP */
}
/**
* Given an user defined time series, build the time cache
* @param timeSeries - the time series to build the cache for
*/
buildTimeCache(timeSeries) {
const { webworker, painter } = this;
this.timeCache = new TimeCache(this, webworker, timeSeries);
painter.injectTimeCache(this.timeCache);
}
/**
* Setup funciton to prepare the painter for rendering. Notify the main thread that the painter is ready.
* @param options - the map options to use for the painter
* @param style - the style to use for the painter
*/
async #buildPaint(options, style) {
const isBuilt = await this.#createPainter(options);
if (!isBuilt)
throw new Error('Could not build painter');
// now we setup canvas interaction
this.#setupCanvas();
// setup the style - this goes AFTER creation of the painter, because the
// style will tell the painter what programs/pipelines it will be using
await this._setStyle(style, false);
// explain we are ready to paint
const msg = { type: 'ready', mapID: this.id };
if (this.webworker)
postMessage(msg);
else
this.parent?.onMessage({ data: msg });
}
/**
* Set the style for the map
* @param style - the style to use for the painter
* @param ignorePosition - whether to ignore the position set in the style (keep the map where it is)
*/
async _setStyle(style, ignorePosition) {
// ensure we don't draw for a sec
this._canDraw = false;
// incase style was imported, clear cache
this.tileCache.deleteAll();
// build style for the map, painter, and webworkers
this._canDraw = await this.style.buildStyle(style, ignorePosition);
// render our first pass
this.render();
}
/** Setup the canvas for the map adding listeners and the size of the canvas */
#setupCanvas() {
const { _interactive, dragPan } = this;
// setup listeners
this.#canvas.addEventListener('webglcontextlost', this.#contextLost.bind(this));
this.#canvas.addEventListener('webglcontextrestored', this.#contextRestored.bind(this));
// if we allow the user to interact with map, we add events
if (_interactive) {
// let dragPan know if we can zoom
if (this.#scrollZoom)
dragPan.zoomActive = true;
// listen to dragPans updates
dragPan.addEventListener('move', this.#onMovement.bind(this));
dragPan.addEventListener('swipe', this.#onSwipe.bind(this));
dragPan.addEventListener('zoom', () => {
this.onZoom(dragPan.zoom);
});
dragPan.addEventListener('click', ((e) => {
this.#onClick(e);
}));
dragPan.addEventListener('doubleClick', ((e) => {
this.#onDoubleClick(e);
}));
}
// setup camera
this.#resizeCamera(this.#canvas.width, this.#canvas.height);
}
/**
* Notification that the context was lost
* @param _event - Event information about the context loss
*/
#contextLost(_event) {
console.warn('context lost');
}
/** Notification that the context was restored */
#contextRestored() {
console.info('context restored');
}
/**
* Create the painter is the first step in building the map.
* We figure out which context we can use before pulling in GL or GPU.
* After we have the appropriate context, we build the painter and then the
* @param options - map options
* @returns whether or not the painter was built
*/
async #createPainter(options) {
const { contextType } = options;
let context = null;
// first try webGPU
if (contextType === 3) {
context = this.#canvas.getContext('webgpu'); // GPUCanvasContext
if (context === null)
return false;
const Painter = await import('gpu/index.js').then((m) => m.Painter);
this.painter = new Painter(context, options);
await this.painter.prepare();
}
else {
let type = 1;
// prep webgl style options
const webglOptions = {
antialias: false,
premultipliedAlpha: true,
preserveDrawingBuffer: true,
alpha: true,
stencil: true,
};
// than try webgl2
if (contextType === 2) {
context = this.#canvas.getContext('webgl2', webglOptions);
type = 2;
}
if (context === null) {
// last effort, webgl1
webglOptions.premultipliedAlpha = true;
context = this.#canvas.getContext('webgl', webglOptions);
}
if (context === null)
return false;
const Painter = await import('gl/index.js').then((m) => m.Painter);
this.painter = new Painter(context, type, options);
}
return true;
}
/**
* Update the compass position if camera changes were made internally, like an animation or functional update
* @param bearing - The bearing angle in degrees.
* @param pitch - The pitch angle in degrees.
*/
_updateCompass(bearing, pitch) {
if (this.webworker)
postMessage({ type: 'updateCompass', bearing, pitch });
else
this.parent?._updateCompass(bearing, pitch);
}
/**
* Resize the camera and update the projector and painter to the change
* @param width - The new width of the camera.
* @param height - The new height of the camera.
*/
#resizeCamera(width, height) {
// ensure minimum is 1px for both
width = Math.max(width, 1);
height = Math.max(height, 1);
// update the projector and painter
this.projector.resize(width, height);
this.painter.resize(width, height);
}
/**
* Reset the tile cache for the given sources.
* @param sourceNames - The names of the sources to reset the tile cache for.
* @param keepCache - Whether to keep the cache or not. don't delete any tiles, request replacements for all (for s2json since it's locally cached and fast)
* @param awaitReplace - Whether to await the replacement of tiles or not. to avoid flickering (i.e. adding/removing markers), we can wait for an update (from source+tile workers) on how the tile should look
* @returns An array of tile requests that need to be processed to complete the reset for the tile cache for the given sources.
*/
_resetTileCache(sourceNames, keepCache, awaitReplace) {
// TODO:
// get tiles in view, prep request for said tiles
const tilesInView = this.getTiles();
const tileIDs = tilesInView.map((tile) => tile.id);
const tileRequests = [];
// delete all tiles not in view, add to tileRequests for those that are,
// and delete source data from tile
this.tileCache.forEach((tile, key) => {
if (!keepCache && !tileIDs.includes(key)) {
// just remove the tile for simplicity
this.tileCache.delete(key);
}
else {
// add to tileRequests
const { id, face, zoom, i, j, bbox, type, division } = tile;
tileRequests.push({ id, face, zoom, i, j, bbox, type, division });
if (!awaitReplace)
tile.deleteSources(sourceNames);
}
});
return tileRequests;
}
/** Resize the camera and canvas, cleaning up animations */
_resize() {
const { resizeQueued } = this;
if (resizeQueued !== undefined) {
// remove any prexisting animations
this.currAnimFunction = undefined;
// grab width and height
const { width, height } = resizeQueued;
this.#canvas.width = width;
this.#canvas.height = height;
this.#resizeCamera(width, height);
this.resizeQueued = undefined;
}
}
/**
* Set the new mouse position
* @param posX - The x-coordinate of the mouse position
* @param posY - The y-coordinate of the mouse position
*/
_setMousePosition(posX, posY) {
this.mousePosition = { x: posX, y: posY };
this.projector.setMousePosition(posX, posY);
// NOTE: Sometimes mouse positions update before the painter is ready, so discard them
if (this._canDraw)
this.painter.dirty = true;
}
/**
* Process a click event
* @param event - The click event
*/
#onClick(event) {
const { id: mapID, projector, currFeatures, parent, webworker } = this;
// get lon lat of cursor
const { posX, posY } = event.detail;
const lonLat = projector.cursorToLonLat(posX, posY);
if (lonLat === undefined)
return;
const { x: lon, y: lat } = lonLat;
// send off the information
const msg = {
type: 'click',
mapID,
features: [...currFeatures.values()],
lon,
lat,
};
if (webworker)
postMessage(msg);
else
parent?.onMessage({ data: msg });
}
/**
* Process a double click event
* @param event - The double click event
*/
#onDoubleClick(event) {
const { posX, posY } = event.detail;
const lonLat = this.projector.cursorToLonLat(posX, posY);
if (lonLat === undefined)
return;
const { x: lon, y: lat } = lonLat;
this._navEvent('zoomIn', lon, lat);
}
/**
* Handle a navigation event
* @param ctrl - The navigation control
* @param lon - The longitude change if provided
* @param lat - The latitude change if provided
*/
_navEvent(ctrl, lon, lat) {
const { projector } = this;
const startZoom = projector.zoom;
const endZoom = startZoom + (ctrl === 'zoomIn' ? 1 : -1);
// build animation
const animator = new Animator(projector, { duration: 1.5, zoom: endZoom, lon, lat });
animator.zoomTo();
/**
* Set a new animation function
* @param now - The current time in milliseconds since the animation started
*/
this.currAnimFunction = (now) => {
this._animate(animator, now * 0.001);
};
// render
this.render();
}
/** Handle a view change */
_onViewUpdate() {
const { id: mapID, projector, webworker, parent } = this;
const { zoom, lon, lat, bearing, pitch } = projector;
const msg = { type: 'view', mapID, view: { zoom, lon, lat, bearing, pitch } };
if (webworker)
postMessage(msg);
else
parent?.onMessage({ data: msg });
}
/** Handle a mouse move event that occurs on the canvas */
async _onCanvasMouseMove() {
const { style, mousePosition: { x, y }, painter, currFeatures, tileCache, } = this;
if (!style.interactive)
return;
const foundObjects = new Map();
const featureIDs = await painter.context.getFeatureAtMousePosition(x, y);
// if we found an ID and said feature is not the same as the current, we dive down
for (const featureID of featureIDs) {
// first check if we already have the feature
const hasFeature = currFeatures.get(featureID);
if (hasFeature !== undefined) {
foundObjects.set(featureID, hasFeature);
continue;
}
// otherwise, we check the tiles in our store
for (const tile of tileCache.getAll()) {
const feature = tile.getInteractiveFeature(featureID);
if (feature !== undefined) {
foundObjects.set(featureID, feature);
break;
}
}
}
this.#handleFeatureChange(foundObjects);
}
/**
* Handle a change in interactive features.
* @param foundFeatures - the features that are under the cursor
*/
#handleFeatureChange(foundFeatures) {
const previousFrameFeatures = this.currFeatures;
// ensure currFeature is up-to-date
this.currFeatures = foundFeatures;
const currentFeatures = [...foundFeatures.values()];
// find all the new features found this frame compared to the previous frame
const newFeatures = [];
for (const [id, feature] of foundFeatures) {
if (!previousFrameFeatures.has(id))
newFeatures.push(feature);
}
this.#submitFeatureChanges('mouseenter', newFeatures, currentFeatures);
// find all the old features found in the previous frame compared to the current frame
const oldFeatures = [];
for (const [id, feature] of previousFrameFeatures) {
if (!foundFeatures.has(id))
oldFeatures.push(feature);
}
this.#submitFeatureChanges('mouseleave', oldFeatures, currentFeatures);
// due to a potential change in feature draw properties (change in color/size/etc.) we draw again
this.render();
}
/**
* Submit to the main thread a mouse enter/leave event for a set of features
* @param type - The event type
* @param features - The "new" feature states that need updating
* @param currentFeatures - The "current" features states whose state has changed
*/
#submitFeatureChanges(type, features, currentFeatures) {
if (features.length === 0)
return;
const { id: mapID, webworker, parent } = this;
const msg = { type, mapID, features, currentFeatures };
if (webworker)
postMessage(msg);
else
parent?.onMessage({ data: msg });
}
/** When the map has moved, this handles the current frames updates pre-render */
#onMovement() {
const { projector, dragPan, canMove } = this;
if (!canMove)
return;
const { movementX, movementY } = dragPan;
// update projector
projector.onMove(movementX, movementY);
this.render();
}
/** Handle a swipe event's impact on the map frame */
#onSwipe() {
const { projector, dragPan, canMove } = this;
const { movementX, movementY } = dragPan;
if (!canMove)
return;
// build animation
const animator = new Animator(projector, { duration: 1.75 });
animator.swipeTo(movementX, movementY);
/**
* Set the current animation function
* @param now - The current time in milliseconds since the animation started
*/
this.currAnimFunction = (now) => {
this._animate(animator, now * 0.001);
};
// render
this.render();
}
/**
* Animation function for a frame
* @param animator - The animator we pull the current position from
* @param curTime - The current time
*/
_animate(animator, curTime) {
// ensure new render is queued
this.render();
// tell the animator to increment frame
const done = animator.increment(curTime);
// continue animation if not done and no mouse/touch events
if (done || this.dragPan.mouseActive)
this.currAnimFunction = undefined;
}
/** Signal to the painter that it needs to be updated */
_updatePainter() {
const { painter } = this;
painter.dirty = true;
this.render();
}
/**
* Inject data into the painter
* @param data - The data to inject
*/
_injectData(data) {
const { painter, tileCache } = this;
const { type } = data;
if (type === 'interactive')
this.#injectInteractiveData(data.tileID, data.interactiveGuideBuffer, data.interactiveDataBuffer);
else if (type === 'flush')
this.#injectFlush(data);
else if (type === 'glyphimages')
painter.injectGlyphImages(data.maxHeight, data.images, tileCache.getAll());
else if (type === 'spriteimage')
painter.injectSpriteImage(data, tileCache.getAll());
else if (type === 'timesource')
this._addTimeSource(data.sourceName, data.interval);
else {
// 1) grab the tile
const tile = tileCache.get(data.tileID);
if (tile === undefined)
return;
// 2) Build features via the painter. Said workflow will add to the tile's feature list
painter.buildFeatureData(tile, data);
}
// new 'paint', so painter is dirty
painter.dirty = true;
this.render();
}
/**
* Inject a flush command into a tile
* @param data - the flush command (either a tile flush or source flush)
*/
#injectFlush(data) {
const { tileID } = data;
const tile = this.tileCache.get(tileID);
tile?.flush(data);
}
/**
* Add a time series source
* @param sourceName - the name of the temporal source
* @param interval - the interval position in the source
*/
_addTimeSource(sourceName, interval) {
this.timeCache?.addSource(sourceName, interval);
}
/**
* Inject interactive data to their respective tiles
* @param tileID - the id of the tile to inject features properties into
* @param interactiveGuideBuffer - the guide buffer for decoding the feature's properties
* @param interactiveDataBuffer - the raw data to pull the properties from
*/
#injectInteractiveData(tileID, interactiveGuideBuffer, interactiveDataBuffer) {
const { tileCache } = this;
if (tileCache.has(tileID)) {
const tile = tileCache.get(tileID);
if (tile === undefined)
return;
tile.injectInteractiveData(new Uint32Array(interactiveGuideBuffer), new Uint8Array(interactiveDataBuffer));
}
}
/**
* Get a tile from the cache given an S2CellId
* @param tileID - the id of the tile
* @returns the tile if the cache has it
*/
getTile(tileID) {
return this.tileCache.get(tileID);
}
/** @returns the tiles in the current view */
getTiles() {
const { tileCache, projector, painter, style } = this;
if (projector.dirty) {
painter.dirty = true; // to avoid re-requesting getTiles (which is expensive), we set painter.dirty to true
let tilesInView = [];
// no matter what we need to update what's in view
const newTiles = [];
// update tiles in view
tilesInView = projector.getTilesInView();
// check if any of the tiles don't exist in the cache. If they don't create a new tile
for (const foundTile of tilesInView) {
if (!tileCache.has(foundTile.id)) {
// tile not found, so we create it
const createdTiles = this.#createTiles(foundTile);
// store reference for the style to request from webworker(s)
newTiles.push(...createdTiles);
}
}
// if new tiles exist, ensture the worker and painter are updated
// do not request out of bounds tiles because they just reference
// the "wrapped" real world tiles
const newTilesWithoutOutofBounds = newTiles.filter((tile) => tile.wrappedID === undefined);
if (newTilesWithoutOutofBounds.length > 0)
style.requestTiles(newTilesWithoutOutofBounds);
// given the S2CellID, find them in cache and return them
this.tilesInView = tileCache.getBatch(tilesInView.map((tile) => tile.id));
}
return this.tilesInView;
}
/**
* Given a list of S2CellIDs, create the tiles necessary to render those IDs for future requests
* @param tileIDs - the list of S2CellIDs
*/
createFutureTiles(tileIDs) {
const { tileCache, painter, style } = this;
const newTiles = [];
// create the tiles
for (const tile of tileIDs) {
if (!tileCache.has(tile.id)) {
const createdTiles = this.#createTiles(tile);
newTiles.push(...createdTiles);
}
}
// tell the style to make the requests
painter.dirty = true;
style.requestTiles(newTiles);
}
/**
* Given an S2CellID, create the tiles necessary to render that ID.
* Although steriotypical, we only create a single tile from the S2CellID or WMID,
* if the tile is out of bounds, we may need to create a second tile that
* it references (the "wrapped" tile). Often times the tile already exists
* @param tInView - information on tile in view
* @returns an array of tiles for the given S2CellID
*/
#createTiles(tInView) {
const res = [];
const { style, painter, tileCache, projector } = this;
// create tile
const tile = createTile(projector.projection, painter.context, tInView);
res.push(tile);
// should our style have mask layers, let's add them
style.injectMaskLayers(tile);
// inject parent should one exist
const s2ID = tile.wrappedID ?? tile.id;
if (tInView.zoom > 0) {
// get closest parent S2CellID. If actively zooming, the parent tile will pass along
// it's parent tile (and so forth) if its own data has not been processed yet.
const pID = idParent(s2ID);
// check if parent tile exists, if so inject
const parent = tileCache.get(pID);
if (parent !== undefined)
tile.injectParentTile(parent, style.layers);
}
if (tile.wrappedID !== undefined) {
// This is a WM only case. Inject "wrapped" tile's featureGuides as a reference
if (!tileCache.has(tile.wrappedID)) {
const size = 1 << tile.zoom;
res.push(...this.#createTiles({
id: tile.wrappedID,
face: tile.face,
x: mod(tile.i, size),
y: mod(tile.j, size),
zoom: tile.zoom,
}));
}
const wrappedTile = tileCache.get(tile.wrappedID);
if (wrappedTile !== undefined)
tile.injectWrappedTile(wrappedTile);
}
// store the tile, if an out of bounds tile, store it's own WMID
tileCache.set(tInView.id, tile);
return res;
}
/**
* Internal Draw handler.
* - Get the tiles needed for the current frame
* - If any state changes happened since last frame, update the style, painter, and projector as needed
* - Paint the scene
* - If there was movement/zoom change, compute the interactive elements
* - cleanup for the next frame
*/
_draw() {
const { style, painter, projector } = this;
// prep tiles
const tiles = this.getTiles();
// if any changes, we paint new scene
if (style.dirty || painter.dirty || projector.dirty) {
// store for future draw that it was a "dirty" frame
this.wasDirtyLastFrame = true;
// paint scene
painter.paint(projector, tiles);
}
// draw the interactive elements if there was no movement/zoom change
if (style.interactive && !projector.dirty && this.wasDirtyLastFrame) {
this.wasDirtyLastFrame = false;
painter.computeInteractive(tiles);
}
// cleanup
painter.dirty = false;
style.dirty = false;
projector.dirty = false;
}
}