UNPKG

s2maps-gpu

Version:

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

501 lines (500 loc) 18.8 kB
import { Orthodrome } from 'gis-tools/index.js'; /** * Animator class handles user defined animations of the camera. */ export default class Animator { startTime; startLon; startLat; startZoom; startBearing; startPitch; endLon; endLat; endZoom; endBearing; endPitch; deltaLon; deltaLat; deltaZoom; deltaBearing; deltaPitch; speed = 0; duration = 2.5; velocity = 0; futureOffset = 0; futureTiles = new Map(); // { timeKey: [tileID] } futureKeys = []; ease; #increment; projector; /** * @param projector - projection state * @param directions - Animation directions guide */ constructor(projector, directions = {}) { this.projector = projector; // setup animation paramaters if (directions.easing === 'ease-in') this.ease = easeInExpo; else if (directions.easing === 'ease-out') this.ease = easeOutExpo; else if (directions.easing === 'ease-in-out') this.ease = easeInOutExpo; else this.ease = easeLinear; if (directions.duration !== undefined) this.duration = directions.duration; if (directions.speed !== undefined) this.speed = directions.speed; // pull in varaibles const lon = directions.lon ?? projector.lon; const lat = directions.lat ?? projector.lat; const zoom = directions.zoom ?? projector.zoom; const bearing = directions.bearing ?? projector.bearing; const pitch = directions.pitch ?? projector.pitch; // setup variables this.startLon = projector.lon; this.startLat = projector.lat; this.startZoom = projector.zoom; this.startBearing = projector.bearing; this.startPitch = projector.pitch; this.endLon = lon; this.endLat = lat; this.endZoom = zoom; this.endBearing = bearing; this.endPitch = pitch; this.deltaLon = lon - projector.lon; this.deltaLat = lat - projector.lat; this.deltaZoom = zoom - projector.zoom; this.deltaBearing = bearing - projector.bearing; this.deltaPitch = pitch - projector.pitch; } /** * Updates the position based upon time. returns whether complete or not. * @param time - current time * @returns - true if the animation was built successfully */ increment(time) { const { projector, futureOffset, futureKeys, duration } = this; // corner case: increment was never setup if (this.#increment === undefined) return true; // setup time should it not exist yet if (this.startTime === undefined) this.startTime = time; // if current time is greater than or equal to the latest futureKeys, send off a request to preload tiles if (futureKeys[0] - futureOffset <= time) this.#requestFutureTiles(); // build the new lon, lat, zoom, bearing, pitch const [finished, pos] = this.#increment(time - this.startTime); // grab the positions const [lon, lat, zoom, bearing, pitch] = pos; // update the projector projector.setView({ lon, lat, zoom, bearing, pitch }); // return if time has passed or we are finished if (finished || time - this.startTime >= duration) return true; return false; } /** Handles zoom animations. */ zoomTo() { const { startLon, startLat, startZoom, deltaLon, deltaLat, deltaZoom, endLon, endLat, endZoom, endBearing, endPitch, duration, } = this; /** * @param time - current time * @returns [finished, [lon, lat, zoom, bearing, pitch]] */ this.#increment = (time) => { if (time >= duration) return [true, [endLon, endLat, endZoom, endBearing, endPitch]]; return [ false, [ easeOutExpo(time, startLon, deltaLon, duration), easeOutExpo(time, startLat, deltaLat, duration), easeOutExpo(time, startZoom, deltaZoom, duration), endBearing, endPitch, ], ]; }; // build renderTile list this.#buildFutureTileList(); } /** Handles rotation animations. */ compassTo() { const { startBearing, startPitch, deltaBearing, deltaPitch, endLon, endLat, endZoom, endBearing, endPitch, duration, } = this; /** * @param time - current time * @returns [finished, [lon, lat, zoom, bearing, pitch]] */ this.#increment = (time) => { if (time >= duration) return [true, [endLon, endLat, endZoom, endBearing, endPitch]]; return [ false, [ endLon, endLat, endZoom, easeOutExpo(time, startBearing, deltaBearing, duration), easeOutExpo(time, startPitch, deltaPitch, duration), ], ]; }; // build renderTile list this.#buildFutureTileList(); } /** * Handles swiping animations. * @param movementX - swipe change in the x direction * @param movementY - swipe change in the y direction */ swipeTo(movementX, movementY) { const { projector, duration } = this; const { abs } = Math; /** * @param time - current time * @returns [finished, [lon, lat, zoom, bearing, pitch]] */ this.#increment = (time) => { const newMovementX = easeInExpo(duration - time, 0, movementX, duration); const newMovementY = easeInExpo(duration - time, 0, movementY, duration); if (abs(newMovementX) <= 0.5 && abs(newMovementY) <= 0.5) return [ true, [projector.lon, projector.lat, projector.zoom, projector.bearing, projector.pitch], ]; projector.onMove(newMovementX, newMovementY); return [ false, [projector.lon, projector.lat, projector.zoom, projector.bearing, projector.pitch], ]; }; } /** * Handles easing mechanic * @returns - true if the animation was built successfully */ easeTo() { const { startLon, startLat, startZoom, startBearing, startPitch, deltaZoom, deltaBearing, deltaPitch, endLon, endLat, endZoom, endBearing, endPitch, duration, ease, } = this; if (startLat === endLat && startLon === endLon && startZoom === endZoom && startBearing === endBearing && startPitch === endPitch) return false; // setup orthodrome for lon and lat const orthodrome = new Orthodrome(startLon, startLat, endLon, endLat); // zooming out should have an easeOut while zooming in should have an easeIn const zoomEase = ease ?? (deltaZoom > 0 ? easeInExpo : easeOutExpo); // meanwhile lon, lat should have an easeOut while zooming in and easeIn while zooming out const lonLatEase = ease ?? (deltaZoom > 0 ? easeOutExpo : easeInExpo); // bearing and pitch benefits the most from ease-in-out const bearingPitchEase = ease ?? easeInOutExpo; /** * given a time input in seconds, update the cameras positions * @param time - current time * @returns [finished, [lon, lat, zoom, bearing, pitch]] */ this.#increment = (time) => { if (time >= duration) return [true, [endLon, endLat, endZoom, endBearing, endPitch]]; const { x, y } = orthodrome.intermediatePoint(lonLatEase(time, 0, 1, duration)); return [ false, [ x, y, zoomEase(time, startZoom, deltaZoom, duration), bearingPitchEase(time, startBearing, deltaBearing, duration), bearingPitchEase(time, startPitch, deltaPitch, duration), ], ]; }; // build renderTile list this.#buildFutureTileList(); return true; } /** * Handles flying animation with both panning and zooming combined. * Van Wijk, Jarke J.; Nuij, Wim A. A. “Smooth and efficient zooming and panning.” INFOVIS * ’03. pp. 15–22. <https://www.win.tue.nl/~vanwijk/zoompan.pdf#page=5>. * @returns true if a flyTo animation is built successfully */ flyTo() { const { startLon, startLat, startZoom, startBearing, startPitch, deltaBearing, deltaPitch, endLon, endLat, endZoom, endBearing, endPitch, projector, duration, ease, } = this; if (startLat === endLat && startLon === endLon && startZoom === endZoom && startBearing === endBearing && startPitch === endPitch) return false; const { max, sqrt, log, exp, abs, LN2 } = Math; // setup variables const orthodrome = new Orthodrome(startLon, startLat, endLon, endLat); const rho = 1.42; // curve const scale = projector.zoomScale(endZoom - startZoom); // bearing and pitch benefits the most from ease-in-out const bearingPitchEase = ease ?? easeInOutExpo; // w₀: Initial visible span, measured in pixels at the initial scale. const { x: width, y: height } = projector.aspect; const w0 = max(width, height); // w₁: Final visible span, measured in pixels with respect to the initial scale. const w1 = w0 / scale; // Length of the flight path as projected onto the ground plane, measured in pixels from // the world image origin at the initial scale. // degToRad(45) * projector.radius * 1000 // 360deg = 2_048 (512 * 4) pixels at 0 zoom = 40_030_228.88407185 (2 * Math.PI * 6_371_008.8) (circumference) meters // so 40_030_228.88407185 / 2_048 = 19_546.010197300708meters/pixel const distanceMeters = projector.radius * 1_000 * orthodrome.distanceTo(); const u1 = (distanceMeters / 19_546.010197300708) * projector.zoomScale(startZoom); // ρ² const rho2 = rho * rho; /** * rᵢ: Returns the zoom-out factor at one end of the animation. * i 0 for the ascent or 1 for the descent. * @param i - input ascent * @returns r(i) */ const r = (i) => { const b = (w1 * w1 - w0 * w0 + (i !== 0 ? -1 : 1) * rho2 * rho2 * u1 * u1) / (2 * (i !== 0 ? w1 : w0) * rho2 * u1); return log(sqrt(b * b + 1) - b); }; // r₀: Zoom-out factor during ascent. const r0 = r(0); /** * w(s): Returns the visible span on the ground, measured in pixels with respect to the * initial scale. Assumes an angular field of view of 2 arctan ½ ≈ 53°. * @param s - input scale * @returns w(s) */ let w = (s) => { return cosh(r0) / cosh(r0 + rho * s); }; /** * u(s): Returns the distance along the flight path as projected onto the ground plane, * measured in pixels from the world image origin at the initial scale. * @param s - input scale * @returns u(s) */ let u = (s) => { return (w0 * ((cosh(r0) * tanh(r0 + rho * s) - sinh(r0)) / rho2)) / u1; }; // S: Total length of the flight path, measured in ρ-screenfuls. let S = (r(1) - r0) / rho; // When u₀ = u₁, the optimal path doesn’t require both ascent and descent. if (abs(u1) < 0.000001 || !isFinite(S)) { // Perform a more or less instantaneous transition if the path is too short. if (abs(w0 - w1) < 0.000001) return this.easeTo(); // otherwise adjust S, u & w const k = w1 < w0 ? -1 : 1; S = abs(log(w1 / w0)) / rho; /** * Update state of u for shorter paths * @param _ - unused s * @returns 0 */ u = (_) => { return 0; }; /** * Adjusted state for w(s) * @param s - input scale * @returns w(s) */ w = (s) => { return exp(k * rho * s); }; } // adjust duration if speed is provided if (this.speed !== 0) this.duration = S / this.speed; /** * setup animation function * @param time - time in ms * @returns [finished, [lon, lat, zoom, bearing, pitch]] */ this.#increment = (time) => { if (time >= duration) return [true, [endLon, endLat, endZoom, endBearing, endPitch]]; const s = (time / duration) * S; const uS = u(s); const curScale = 1 / w(s); const { x, y } = orthodrome.intermediatePoint(uS); return [ false, [ x, y, startZoom + log(curScale) / LN2, bearingPitchEase(time, startBearing, deltaBearing, duration), bearingPitchEase(time, startPitch, deltaPitch, duration), ], ]; }; // build renderTile list this.#buildFutureTileList(); return true; } /** Internal function to early request tiles we know we will need */ #buildFutureTileList() { if (this.#increment === undefined) return; const { endLon, endLat, endZoom, endBearing, endPitch, projector, duration } = this; const tileSet = new Set(projector.camera.getTiles().map((tile) => tile.id)); const newTiles = new Map(); let tilesFound; const batch = [ [ 0, duration, duration, projector.getTilesAtPosition(endLon, endLat, endZoom, endBearing, endPitch), ], [ 0, duration, duration / 2, projector.getTilesAtPosition(...this.#increment(duration / 2)[1]), ], ]; // keep diving / spliting while tested tiles are not equal to while (batch.length !== 0) { // reset checker tilesFound = false; // pull in a batch check const tileList = batch.shift(); if (tileList === undefined) continue; const [low, high, pos, tiles] = tileList; // store any new tiles and track if any of them new for (const tile of tiles) { if (!tileSet.has(tile.id)) { tileSet.add(tile.id); if (!newTiles.has(pos)) newTiles.set(pos, [tile]); tilesFound = true; } } // if "tilesFound" than test midpoint positions if (tilesFound && pos !== duration) { const lowPosHalf = (low + pos) / 2; const posHighHalf = (pos + high) / 2; batch.push([low, pos, lowPosHalf, projector.getTilesAtPosition(...this.#increment(lowPosHalf)[1])], [ pos, high, posHighHalf, projector.getTilesAtPosition(...this.#increment(posHighHalf)[1]), ]); } } // store tiles this.futureTiles = newTiles; // store keys this.futureKeys = []; for (const key of Object.keys(newTiles)) this.futureKeys.push(Number(key)); this.futureKeys = this.futureKeys.sort((a, b) => a - b); this.futureOffset = this.futureKeys[0]; // pre-request the first three tile sets this.#requestFutureTiles(); this.#requestFutureTiles(); this.#requestFutureTiles(); } /** Request future tiles */ #requestFutureTiles() { if (this.futureKeys.length === 0) return; const { futureKeys, futureTiles, projector } = this; const { camera } = projector; // get a key const key = futureKeys.shift(); if (key === undefined) return; // pull in the tiles const tiles = futureTiles.get(key); if (tiles === undefined) return; // make a request camera.createFutureTiles(tiles); // cleanup now uneaded tile reference futureTiles.delete(key); } } // setup trig /** * Sin of n * @param n - input * @returns sinh(n) */ function sinh(n) { return (Math.exp(n) - Math.exp(-n)) / 2; } /** * Cosine of n * @param n - input * @returns cosh(n) */ function cosh(n) { return (Math.exp(n) + Math.exp(-n)) / 2; } /** * Tan of n * @param n - input * @returns tanh(n) */ function tanh(n) { return sinh(n) / cosh(n); } // https://spicyyoghurt.com/tools/easing-functions /** * Linear Ease function * @param time - current time * @param start - start time * @param delta - change in time * @param duration - duration * @returns interpolated position */ function easeLinear(time, start, delta, duration) { return (delta * time) / duration + start; } /** * Exponential Ease function * @param time - current time * @param start - start time * @param delta - change in time * @param duration - duration * @returns interpolated position */ function easeInExpo(time, start, delta, duration) { return time === 0 ? start : delta * Math.pow(2, 10 * (time / duration - 1)) + start; } /** * Ease Out function * @param time - current time * @param start - start time * @param delta - change in time * @param duration - duration * @returns interpolated position */ function easeOutExpo(time, start, delta, duration) { return delta * (-Math.pow(2, (-10 * time) / duration) + 1) + start; } /** * Ease In Out function * @param time - current time * @param start - start time * @param delta - change in time * @param duration - duration * @returns interpolated position */ function easeInOutExpo(time, start, delta, duration) { if (time === 0) return start; if ((time /= duration / 2) < 1) return (delta / 2) * Math.pow(2, 10 * (time - 1)) + start; return (delta / 2) * (-Math.pow(2, -10 * --time) + 2) + start; }