UNPKG

s2maps-gpu

Version:

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

567 lines (566 loc) 22 kB
import * as mat4 from './mat4.js'; import { EARTH_RADIUS, EARTH_RADIUS_EQUATORIAL, EARTH_RADIUS_POLAR, degToRad, mercatorLatScale, pointFromLonLatGL, pointMulScalar, pointNormalize, pxToLL, } from 'gis-tools/index.js'; import { cursorToLonLatS2, cursorToLonLatWM } from './cursorToLonLat.js'; import { getTilesInViewS2, getTilesInViewWM } from './getTiles/index.js'; export * from './getTiles/index.js'; /** * # Projector * * Maintain state of the camera, view, zoom, and other parameters that control how we see the map. * Also used as a tool to find the tiles that are currently visible. * @see {@link Camera} */ export class Projector { camera; projection = 'S2'; webworker = false; noClamp = false; // radius is the radius of the earth in kilometers radius = EARTH_RADIUS / 1_000; // radii is the radius of the earth in meters for each axis radii = [EARTH_RADIUS_EQUATORIAL, EARTH_RADIUS_POLAR, EARTH_RADIUS_EQUATORIAL]; zTranslateStart = 5; zTranslateEnd = 1.001; zoomEnd = 5; positionalZoom = true; // [zoom, lon, lat, bearing, pitch, time, aspectX, aspectY, mouseX, mouseY, deltaMouseX, deltaMouseY, featureState, currFeature] view = new Float32Array(14); aspect = { x: 400, y: 300 }; // default canvas width x height matrices = {}; eye = { x: 0, y: 0, z: 0 }; // [x, y, z] only z should change for visual effects constrainZoomToFill = true; duplicateHorizontally = true; minLatPosition = 70; maxLatPosition = 89.99999; // deg prevZoom = 0; zoom = -1; minzoom = 0; maxzoom = 20; zoomOffset = 0; lon = -1; lat = -1; bearing = 0; pitch = 0; zNear = 0.5; // static; just for draw calls zFar = 100_000_000; // static; just for draw calls tileSize = 768; multiplier = 1; dirty = true; /** * @param config - Map Options * @param camera - Camera */ constructor(config, camera) { const { canvasMultiplier, positionalZoom, noClamp, style } = config; if (typeof style === 'object' && style.projection === 'WM') this.projection = 'WM'; if (canvasMultiplier !== undefined) this.multiplier = canvasMultiplier; if (positionalZoom === false) this.positionalZoom = false; this.webworker = camera.webworker; if (noClamp === true) this.noClamp = true; this.camera = camera; // setup deltaMouse positions to middle of 0 and 2^32 this.view[10] = 2 ** 11; this.view[11] = 2 ** 11; } /* API */ /** Reset the projector. This forces a re-calculation of it's internal data like matrices before rendering */ reset() { if (!this.dirty) { this.dirty = true; this.matrices = {}; } } /** * Set the mouse position on the canvas for potential interactions * Input is the pixel position (0->width, 0->height). Convert to -1->1 for the GPU * @param x - x mouse position * @param y - y mouse position */ setMousePosition(x, y) { const { x: width, y: height } = this.aspect; this.view[8] = (x / width) * 2 - 1; this.view[9] = (y / height) * -2 + 1; } /** * Set the state of the current feature * @param state - 0: none, 1: hover, 2: click */ setFeatureState(state) { this.view[12] = state; this.dirty = true; } /** * Set the current feature that's under the mouse * @param id - the id of the feature */ setCurrentFeature(id) { this.view[13] = id; this.dirty = true; } /** * Set the style parameters * @param style - user defined style params * @param ignorePosition - if set, do not update the view */ setStyleParameters(style, ignorePosition) { const { min, max } = Math; const { constrainZoomToFill, duplicateHorizontally, noClamp, minLatPosition, maxLatPosition, zoomOffset, zNear, zFar, view, } = style; const { lon, lat, zoom, bearing, pitch } = view ?? {}; const maxzoom = style.maxzoom ?? this.maxzoom; const minzoom = style.minzoom ?? this.minzoom; // setup wm properties if needed if (constrainZoomToFill !== undefined) this.constrainZoomToFill = constrainZoomToFill; if (duplicateHorizontally !== undefined) this.duplicateHorizontally = duplicateHorizontally; if (!this.constrainZoomToFill && this.duplicateHorizontally) { console.warn('duplicateHorizontally may only be used if constrainZoomToFill is true. Setting duplicateHorizontally to false.'); this.duplicateHorizontally = false; } // clamp values and ensure minzoom is less than maxzoom this.minzoom = minzoom < -2 ? -2 : minzoom > maxzoom ? maxzoom - 1 : minzoom > 19 ? 19 : minzoom; this.maxzoom = maxzoom > 20 ? 20 : maxzoom < this.minzoom ? this.minzoom + 1 : maxzoom; if (zoomOffset !== undefined) this.zoomOffset = zoomOffset; if (maxLatPosition !== undefined) this.maxLatPosition = min(maxLatPosition, this.maxLatPosition); if (minLatPosition !== undefined) this.minLatPosition = max(minLatPosition, this.minLatPosition); if (noClamp === true) this.noClamp = true; if (zNear !== undefined) this.zNear = zNear; if (zFar !== undefined) this.zFar = zFar; // set position if (!ignorePosition) this.setView({ lon, lat, zoom, bearing, pitch }); } /** * Update the view * @param view - the new view */ setView(view) { const { zoom, lon, lat, bearing, pitch } = view; this.#setView({ zoom: zoom ?? this.zoom, lon: lon ?? this.lon, lat: lat ?? this.lat, bearing: bearing ?? this.bearing, pitch: pitch ?? this.pitch, }); } /** @returns the amount the zoom has changed since the last update */ zoomChange() { const { zoom, prevZoom } = this; const { floor } = Math; return floor(zoom) - floor(prevZoom); } /** * Get a zoom scale, if no zoom is provided, use the current zoom * @param zoom - the zoom level * @returns the zoom scale */ zoomScale(zoom = this.zoom) { return Math.pow(2, zoom); } /** * Resize the canvas. So we need to update our view's aspect * @param width - new width * @param height - new height */ resize(width, height) { this.view[6] = this.aspect.x = width; this.view[7] = this.aspect.y = height; // update view this.setView({}); // cleanup this.reset(); } /** * The user has scrolled or two finger pinched * @param zoomInput - the amount the user scrolled * @param canvasX - the x position on the canvas * @param canvasY - the y position on the canvas */ onZoom(zoomInput, canvasX, canvasY) { const { positionalZoom, multiplier, aspect } = this; // set zoom this.setView({ zoom: this.zoom - 0.003 * zoomInput }); if (this.prevZoom === this.zoom) return; // if positionalZoom, we adjust the lon and lat according to the mouse position. // consider the distance between the lon-lat of our current "center" position and // the lon-lat of the cursor position PRE-zoom adjustment. After zooming, we // want to readjust our lon-lat position to compensate for that delta. if (positionalZoom) { // STEP 1: Get the distance from the center in pixels (up is +y, right is +x) // this value is considered our "previous" distance metric. const { x: width, y: height } = aspect; const posX = canvasX - width / multiplier / 2; const posY = height / multiplier / 2 - canvasY; // STEP 2: find the distance POST-zoom adjustment. In other words, // multiply the previous position by the scale change const zoomAdjust = 1 + (this.zoom - this.prevZoom); const posDeltaX = posX * zoomAdjust - posX; const posDeltaY = posY * zoomAdjust - posY; // STEP 3: The deltas need to be converted to deg change if (this.projection === 'S2') this.onMove(-posDeltaX, posDeltaY, 3072, 1536); else this.onMove(-posDeltaX, posDeltaY, 1, 1); } } /** * User mouse/touch input (or swipe animation) * @param movementX - the change in x position * @param movementY - the change in y position * @param multiplierX - the multiplier for the x axis * @param multiplierY - the multiplier for the y axis */ onMove(movementX = 0, movementY = 0, multiplierX, multiplierY) { this.#setMove(movementX, movementY); const { lon, lat, tileSize, projection, multiplier } = this; let { bearing } = this; const { abs, max, min, PI, sin, cos } = Math; const zScale = max(this.zoomScale(), 1); const tileScale = tileSize / 512; const isS2 = projection === 'S2'; // setup multipliers if (multiplierX === undefined) multiplierX = multiplier * (isS2 ? 6.5 * 360 : 0.75); if (multiplierY === undefined) multiplierY = multiplier * (isS2 ? 6.5 * 180 : 0.75); if (!isS2) { multiplierX *= tileScale; multiplierY *= tileScale; } // adjust movement vector if bearing if (bearing !== 0) { bearing = degToRad(bearing); // adjust to radians const tmpY = movementX * sin(bearing) + movementY * cos(bearing); movementX = movementX * cos(bearing) - movementY * sin(bearing); movementY = tmpY; } // set the new lon-lat if (isS2) { // https://math.stackexchange.com/questions/377445/given-a-latitude-how-many-miles-is-the-corresponding-longitude const lonMultiplier = min(30, 1 / cos((abs(lat) * PI) / 180)); this.setView({ lon: lon - (movementX / (multiplierX * zScale)) * 360 * lonMultiplier, lat: lat + (movementY / (multiplierY * zScale)) * 180, }); } else { this.setView({ lon: lon - movementX / (multiplierX * zScale), lat: lat + movementY / (multiplierY * zScale * mercatorLatScale(lat)), }); } } /** * Get the lon-lat based on the mouse position * x and y are the distances from the center of the screen * @param xOffset - x offset * @param yOffset - y offset * @returns longitude and latitude at the mouse position */ cursorToLonLat(xOffset, yOffset) { const { projection, lon, lat, zoom, tileSize, multiplier } = this; if (projection === 'S2') return cursorToLonLatS2(lon, lat, xOffset, yOffset, (tileSize * Math.pow(2, zoom)) / 2); return cursorToLonLatWM(lon, lat, xOffset, yOffset, zoom, tileSize / multiplier); } /** * Get the matrix for either a global state (S2) or a specific tile (WM) * - S2 -> type of meters or kilometers * - WM -> scale and offset * @param typeOrScale - type or scale * @param offset - offset in pixels * @returns the matrix */ getMatrix(typeOrScale, offset = { x: 0, y: 0 }) { if (typeof typeOrScale === 'number') { // WM case const matrix = this.#getMatrixWM(typeOrScale, offset); return mat4.clone(matrix); } // S2 let matrix = this.matrices[typeOrScale]; if (matrix !== undefined) return mat4.clone(matrix); // updated matrix matrix = this.matrices[typeOrScale] = this.#getMatrixS2(typeOrScale); return mat4.clone(matrix); } /** @returns the tiles in this projector's current view */ getTilesInView() { const { projection, radius, zoom, zoomOffset, lon, lat } = this; if (projection === 'S2') { const matrix = this.getMatrix('m'); return getTilesInViewS2(zoom + zoomOffset, lon, lat, matrix, radius); } return getTilesInViewWM(zoom + zoomOffset, lon, lat, this); } /** * Get the tiles at a specific position * @param lon - longitude * @param lat - latitude * @param zoom - zoom * @param bearing - bearing * @param pitch - pitch * @returns the tiles in view */ getTilesAtPosition(lon, lat, zoom, bearing, pitch) { const { projection, radius, zoomOffset } = this; if (projection === 'S2') { const matrix = this.#getMatrixS2('m', false, lon, lat, zoom, bearing, pitch); return getTilesInViewS2(zoom + zoomOffset, lon, lat, matrix, radius); } // TODO: bearing and pitch without editing the projection? return getTilesInViewWM(zoom + zoomOffset, lon, lat, this); } /* INTERNAL FUNCTIONS */ /** * Handles moving the camera. Update state for the GPU * @param movementX - the change in x position * @param movementY - the change in y position */ #setMove(movementX, movementY) { const { view, aspect } = this; const maxValue = 2 ** 11; const midValue = 2 ** 10; view[10] -= movementX / aspect.x; view[11] += movementY / aspect.y; // if we ever hit the min-max values, we reset to the middle if (view[10] < 0 || view[10] > maxValue) view[10] = midValue; if (view[11] < 0 || view[11] > maxValue) view[11] = midValue; } /** * Update the view * @param view - the new view */ #setView(view) { // clamp the view based upon the current settings this.#clampView(view); // update if any changes found: const { zoom, lon, lat, bearing, pitch } = view; const bearingPitchChange = this.bearing !== bearing || this.pitch !== pitch; if ( // zoom change? this.zoom !== zoom || this.prevZoom !== zoom || // lon-lat change? this.lon !== lon || this.lat !== lat || // bearing or pitch change? bearingPitchChange) { // keep track of the old zoom and adjust the zoom this.prevZoom = this.zoom; this.zoom = zoom; // adjust the lon-lat this.lon = lon; this.lat = lat; // adjust the bearing and pitch this.bearing = bearing; this.pitch = pitch; // update view this.view[0] = this.zoom; this.view[1] = this.lon; this.view[2] = this.lat; this.view[3] = this.bearing; this.view[4] = this.pitch; // if bearing or pitch change we let the map know if (bearingPitchChange) this.camera._updateCompass(this.bearing, this.pitch); // cleanup for next render this.reset(); } } /** * Clamp the view * @param view - the new view */ #clampView(view) { const { noClamp, constrainZoomToFill, projection } = this; // adjust zoom this.#clampZoom(view); // adjust lon-lat if (!noClamp) { view.lon = this.#clampDeg(view.lon); this.#clampLat(view); } // adjust bearing view.bearing = this.#clampDeg(view.bearing); // adjust view if constrained to fill if (projection === 'WM' && constrainZoomToFill) this.#clampConstraint(view); } /** * Clamp the zoom * @param view - the new view */ #clampZoom(view) { const { minzoom, maxzoom } = this; view.zoom = Math.max(Math.min(view.zoom, maxzoom), minzoom); } /** * Clamp the latitude * @param view - the new view */ #clampLat(view) { const { maxLatPosition, minLatPosition, zoom } = this; const { min, max } = Math; // prep current boundaries const latPosDiff = maxLatPosition - minLatPosition; const curMaxLat = min(minLatPosition + min(latPosDiff, (latPosDiff / 3) * zoom), maxLatPosition); // clamp view.lat = max(min(curMaxLat, view.lat), -curMaxLat); } /** * Clamp the view by the constraints provided by the projection and/or user style settings * @param view - the new view */ #clampConstraint(view) { const { aspect, tileSize } = this; // if tileSize relative to zoom is smaller than aspect, we adjust zoom if (tileSize * Math.pow(2, view.zoom) < aspect.y) view.zoom = Math.log2(aspect.y / tileSize); // now that we have the min zoom, we can adjust the latitude to ensure the view is within bounds const worldSize = tileSize * Math.pow(2, view.zoom); const center = worldSize / 2; const worldMinusAspectHalfed = (worldSize - aspect.y) / 2; const { y: maxLat } = pxToLL({ x: 0, y: center - worldMinusAspectHalfed }, view.zoom, tileSize); const { y: minLat } = pxToLL({ x: 0, y: center + worldMinusAspectHalfed }, view.zoom, tileSize); view.lat = Math.min(maxLat, Math.max(minLat, view.lat)); } /** * Clamp longitude and bearing between [-180,180] * @param input - the longitude * @returns clamped longitude */ #clampDeg(input) { while (input >= 180) { input -= 360; } while (input < -180) { input += 360; } return input; } /* S2 */ /** * Get the matrix for the S2 projection * @param type - the matrix type (meters or kilometers) * @param updateEye - whether to update the eye * @param lon - longitude * @param lat - latitude * @param zoom - zoom * @param bearing - bearing * @param _pitch - pitch * @returns S2 matrix */ #getMatrixS2(type, updateEye = true, lon = this.lon, lat = this.lat, zoom = this.zoom, bearing = this.bearing, _pitch = this.pitch) { // update eye const eye = this.#updateEyeS2(lon, lat, zoom, updateEye); // get projection matrix let matrix = this.#getProjectionMatrixS2(type, zoom); // create view matrix const view = mat4.lookAt(eye, { x: 0, y: lat > 90 || lat < -90 ? -1 : 1, z: 0 }); // adjust by bearing if (bearing !== 0) mat4.rotateZ(matrix, degToRad(bearing)); // if km we "remove" the eye if (type === 'km') { view[12] = 0; view[13] = 0; view[14] = 0; } // multiply projection matrix by view matrix matrix = mat4.multiply(matrix, view); return matrix; } /** * Update the eye position given a longitude, latitude and zoom * @param lon - longitude * @param lat - latitude * @param zoom - zoom * @param update - whether to update the eye. If false, we only needed the resultant eye for other computations * @returns new eye */ #updateEyeS2(lon, lat, zoom, update = true) { const { radius, zTranslateEnd, zTranslateStart, zoomEnd } = this; // find radial distance from core of ellipsoid const radialMultiplier = Math.max(((zTranslateEnd - zTranslateStart) / zoomEnd) * zoom + zTranslateStart, zTranslateEnd) * radius; // create xyz point for eye const eye = pointMulScalar(pointNormalize(pointFromLonLatGL({ x: lon, y: lat })), radialMultiplier); if (update) this.eye = eye; return eye; } /** * Get the S2 projection matrix * @param type - the matrix type (meters or kilometers) * @param zoom - zoom * @returns S2 projection matrix */ #getProjectionMatrixS2(type, zoom = this.zoom) { const { aspect, tileSize, multiplier } = this; let radius = this.radius; // prep a matrix const matrix = mat4.create(); // BLEND LOOKS A BIT DIFF const multpl = -radius / multiplier / (tileSize * scale * radius * 5) if (type === 'km') radius *= 1000; const multpl = radius / multiplier / (tileSize * Math.pow(2, zoom)); // create projection mat4.ortho(matrix, aspect.x * multpl, aspect.y * multpl, 100_000); return matrix; } /* WM */ /** * Get the matrix for the WM projection * @param scale - scale shift * @param offset - offset * @param bearing - bearing * @param _pitch - pitch * @returns WM matrix */ #getMatrixWM(scale, offset, bearing = this.bearing, _pitch = this.pitch) { const { x: offsetX, y: offsetY } = offset; // get projection matrix let matrix = this.#getProjectionMatrixWM(scale); // create view matrix const view = mat4.lookAt({ x: 0, y: 0, z: -1 }, { x: 0, y: -1, z: 0 }); // adjust by bearing if (bearing !== 0) mat4.rotateZ(matrix, degToRad(bearing)); // multiply projection matrix by view matrix matrix = mat4.multiply(matrix, view); // adjust by position mat4.translate(matrix, [-offsetX, -offsetY, 0]); return matrix; } /** * Get the WM projection matrix * @param scale - scale shift * @returns WM projection matrix */ #getProjectionMatrixWM(scale) { const { aspect, tileSize, multiplier } = this; // prep a matrix const matrix = mat4.create(); // adjust aspect ratio by zoom const multpl = 1 / multiplier / (tileSize * scale); // create projection mat4.ortho(matrix, aspect.x * multpl, aspect.y * multpl, 1_000); return matrix; } }