├── LICENSE ├── .gitignore ├── example └── index.html ├── README.md └── src └── maplibre-preload.js /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Abel Vázquez Montoro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | MapLibre-preload 1.1.0 7 | 8 | 9 | 10 | 11 | 24 | 25 | 26 | 27 | 44 |
45 |
46 | 47 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # maplibre-preload 2 | 3 | A tiny (1 kB gziped) almost-zero-configuration plugin for preloading tiles and smoothen the experience when using targeted movements in [MapLibreGL JS](https://maplibre.org/). 4 | 5 | ## Demo 6 | 7 | [https://abelvm.github.io/maplibre-preload/example/](https://abelvm.github.io/maplibre-preload/example/) 8 | 9 | You may want to check the `network` tab at develover console to check the cache hits. 10 | 11 | ## Motivation 12 | 13 | It started [here](https://github.com/maplibre/maplibre-gl-js/issues/116), a conversation about the need of precaching tiles when the user start a movement, and as one the reference mentioned there was [MapWorkBox](https://github.com/AbelVM/mapworkbox), a little PoC I built for testing preemptive tiles caching using `Service Workers`. 14 | 15 | So, the idea is to smooth the rendering of the animation frames of the camera and final scenario when using (animated) targeted movements map methods (`panTo`, `zoomTo`, `easeTo` and `flyTo`). 16 | 17 | The MapLibre GL JS requests new tiles as the camera view changes during the animation, and maybe batch-preloading the tiles of the final scenario, the final animation transition might look way better. 18 | 19 | ## Features 20 | 21 | As of today, this plugin offers the next features: 22 | 23 | * It takes advantage of MapLibre internal tiles life-cycle management 24 | * **custom protocols ([addProtocol](https://maplibre.org/maplibre-gl-js/docs/API/functions/addProtocol/)) supported!** (when `useTile = true`) 25 | * It hijacks the old methods and adds the pre-load functionality in a transparent way. 26 | * It automatically detects the tiled sources and apply the preload to all of them 27 | * Full final scenario preload 28 | * Full in-between animation scenarios preload 29 | * Pitch & bearing management 30 | * Limit the amount of server requests, lowering the priority of tiles at the viewport borders if needed (those tiles won't be cached and will be requested by MapLibre as it needs them) 31 | * Cancelling pending requests if the movement has ended or new interactions are detected 32 | 33 | ## How to 34 | 35 | First, just add the dependency `after` MapLibreGL JS. 36 | 37 | Then, just load the plugin after instantiating your map and it will manage everything in a transparent way 38 | 39 | ```javascript 40 | new MaplibrePreload(map, options); 41 | 42 | ``` 43 | 44 | Available options on instantiating the plugin: 45 | 46 | | Parameter | Type | Default | Description | 47 | |---|---|---|---| 48 | | progressCallback | function({ loaded, total, failed }) | null | Callback function to be called per tile preload, mainly for debugging purposes. If `useTile` is set to `true`, this parameter is ignored | 49 | | async | boolean | true | Tells the plugin whether to wait for the full preload before triggering the movement| 50 | | burstLimit | integer | 200 | Soft limit for the number of tiles preloaded during the animation | 51 | | useTile | boolean | true | To use internal MapLibre tiles management, or fallback to `fetch` | 52 | 53 | Example: 54 | 55 | ```javascript 56 | const map = new maplibregl.Map(mapOptions); 57 | 58 | new MaplibrePreload(map, { 59 | progressCallback: ({ loaded, total, failed }) => { 60 | console.log(`Preloading tiles: ${loaded}/${total} loaded, ${failed} failed`); 61 | }, 62 | async: true, 63 | burstLimit: 250, 64 | useTile: true 65 | }); 66 | 67 | ``` 68 | 69 | Then, you can call `panTo`, `zoomTo`, `easeTo` or `flyTo` in the old way, and the tiles will be pre preloaded without further coding. 70 | 71 | Some notes on the side effects of the common options of those functions: 72 | 73 | | Parameter | Effect | 74 | |---|---| 75 | | animate | If set to `false`, no preload is performed | 76 | | duration | The preload time limit is set to `5 * duration` for each run | 77 | 78 | ### Changelog 79 | 80 | * **v 1.1.0** 81 | * [Feature] `Tile` class hijacked! We can choose whether use this or classic `fetch` (thanks to hack by [wayofthefuture](https://github.com/wayofthefuture)) 82 | * [Fix] Missing path for `zoomTo` 83 | * **v 1.0.0** 84 | * [Feature] Fully rewritten 85 | * [Feature] Full support for in-between animation frames scenarios 86 | * [Feature] Full support of actual `flyTo` camera path during animation 87 | * [Feature] Pitch and bearing are taken into account for each frame 88 | * [Feature] The higher the pitch, the lower priority is given to far tiles 89 | * [Feature] If the amount of tiles in a given frame is bigger than `burstLimit`, the tiles far from the center are dropped till the limit is met 90 | * [Feature] Pending requests are canceled if the movement has ended, the time limit is met or a new movement is started 91 | * **v 0.0.1** 92 | * [Feature] Upgraded to work with MapLibre 5.6.0 93 | * [Feature] Updated dependencies 94 | * [Feature] Working example as gh-page 95 | * **v 0.0.0b** 96 | * [Fix] Return the map object in the `cached__To` methods to keep the original output 97 | * [Fix] Use the [Bresenham algorithm](https://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm) to preload only the tiles in the start -> end path instead of all the tiles in the bounding box defined by those two points 98 | * [Feature] Add a `run` flag to allow preloading with/without running the actual `___To` map methods. Intended to enable the preload of the movement when we can expect the animation later (v.g.: preload when hovering a button, flyTo when clicking) 99 | * [Feature] Hide log messages behind a `debug` flag (boolean, default `false`) 100 | * **v 0.0.0a** 101 | * Initial release 102 | 103 | ### To Do 104 | 105 | * ~~Because of [this issue](https://github.com/maplibre/maplibre-gl-js/issues/6041), there is no way to take advantage of the own MapLibre GL JS tiles and cache management, so I needed to fall back to `fetch` logic and rely on the browser cache.~~ 106 | * Check whether TMS schema needs extra work 107 | -------------------------------------------------------------------------------- /src/maplibre-preload.js: -------------------------------------------------------------------------------- 1 | class MaplibrePreload { 2 | 3 | constructor(map, options = {}) { 4 | this.map = map; 5 | this.progressCallback = options.progressCallback || null; 6 | this.burstLimit = options.burstLimit || 200; 7 | this.async = (options.hasOwnProperty('async') && !options.async) ? false : true; 8 | this.useTile = (options.hasOwnProperty('useTile') && !options.useTile) ? false : true; 9 | this.controller = {}; 10 | this._patchMoveMethods(); 11 | this.map._captureTileClass= e => { 12 | if (e.tile && e.tile.tileID) { 13 | e.target.Tile = e.tile.constructor; 14 | e.target.OverscaledTileID = e.tile.tileID.constructor; 15 | e.target.off('sourcedata', e.target._captureTileClass); 16 | } 17 | } 18 | if (this.useTile) this.map.on('sourcedata', this.map._captureTileClass); 19 | } 20 | 21 | 22 | _patchMoveMethods() { 23 | const methods = ['flyTo', 'panTo', 'easeTo', 'zoomTo']; 24 | methods.forEach(method => { 25 | const original = this.map[method].bind(this.map); 26 | this.map[method] = async (options) => { 27 | Object.keys(this.controller).forEach(a => { 28 | this.controller[a].abort('cancelling due to new movement'); 29 | delete this.controller[a]; 30 | }); 31 | if (this.async) { 32 | await this._preloadTilesForMove(method, options); 33 | } else { 34 | this._preloadTilesForMove(method, options); 35 | } 36 | return original(options); 37 | }; 38 | }); 39 | } 40 | 41 | async _preloadTilesForMove(method, options) { 42 | if (options.hasOwnProperty('animate') && !options.animate) return true; 43 | this.duration = options.duration || 1000; 44 | this.padding = options.padding || 0; 45 | this.fps = options.fps || 60; 46 | this.rho = options.curve || 1.42; 47 | const 48 | start = { 49 | 'center': this.map.getCenter(), 50 | 'zoom': this.map.getZoom(), 51 | 'bearing': this.map.getBearing(), 52 | 'pitch': this.map.getPitch() 53 | }, 54 | tc = options.center || start.center, 55 | endCenter = (!!tc.lng) ? tc : { 'lng': tc[0], 'lat': tc[1] }, 56 | end = { 57 | 'center': endCenter, 58 | 'zoom': options.zoom !== undefined ? options.zoom : start.zoom, 59 | 'bearing': options.bearing !== undefined ? options.bearing : start.bearing, 60 | 'pitch': options.pitch !== undefined ? options.pitch : start.pitch 61 | }; 62 | 63 | let samples; 64 | if (method === 'flyTo') { 65 | samples = this._sampleFlyToPath(start, end, options); 66 | } else if (method === 'panTo') { 67 | samples = this._samplePanToPath(start, end, options); 68 | } else if (method === 'easeTo') { 69 | samples = this._sampleEaseToPath(start, end, options); 70 | } else if (method === 'zoomTo') { 71 | samples = this._sampleZoomToPath(start, end, options); 72 | } else { 73 | samples = [end]; 74 | } 75 | 76 | const endRequests = {}; 77 | const perSource = this._getVisibleTilesPerSource(end, 0); 78 | for (const [sourceId, tiles] of Object.entries(perSource)) { 79 | if (!endRequests[sourceId]) endRequests[sourceId] = new Set(); 80 | tiles.forEach(t => endRequests[sourceId].add(t)); 81 | } 82 | await this._preloadTilesInternal(endRequests); 83 | 84 | const tileRequests = {}; 85 | for (const s of samples) { 86 | let 87 | f = 0, 88 | size = 0, 89 | perSource = this._getVisibleTilesPerSource(s); 90 | for (const [sourceId, tiles] of Object.entries(perSource)) { 91 | size = Math.max(size, tiles.length); 92 | } 93 | while (size > 1.1 * this.burstLimit) { 94 | f++; 95 | size = 0; 96 | perSource = this._getVisibleTilesPerSource(s, f / 20); 97 | for (const [sourceId, tiles] of Object.entries(perSource)) { 98 | size = Math.max(size, tiles.length); 99 | } 100 | } 101 | for (const [sourceId, tiles] of Object.entries(perSource)) { 102 | if (!tileRequests[sourceId]) tileRequests[sourceId] = new Set(); 103 | tiles.forEach(t => tileRequests[sourceId].add(t)); 104 | } 105 | } 106 | await this._preloadTilesInternal(tileRequests); 107 | } 108 | 109 | _sampleFlyToPath(start, end, options) { 110 | return this._flyToFrames(options); 111 | } 112 | 113 | _samplePanToPath(start, end, options) { 114 | const 115 | totalFrames = Math.ceil((this.duration / 1000) * this.fps), 116 | samples = [end]; 117 | for (let i = 1; i < totalFrames; i++) { 118 | const t = i / totalFrames; 119 | samples.push({ 120 | 'center': this._interpolateLngLatLinear(start.center, end.center, t), 121 | 'zoom': start.zoom, 122 | 'bearing': start.bearing, 123 | 'pitch': start.pitch 124 | }); 125 | } 126 | return samples; 127 | } 128 | 129 | _sampleEaseToPath(start, end, options) { 130 | const 131 | totalFrames = Math.ceil((this.duration / 1000) * this.fps), 132 | samples = [end]; 133 | for (let i = 1; i < totalFrames; i++) { 134 | const t = i / totalFrames; 135 | samples.push({ 136 | 'center': this._interpolateLngLatLinear(start.center, end.center, t), 137 | 'zoom': this._interpolateLinear(start.zoom, end.zoom, t), 138 | 'bearing': this._interpolateLinear(start.bearing, end.bearing, t), 139 | 'pitch': this._interpolateLinear(start.pitch, end.pitch, t) 140 | }); 141 | } 142 | return samples; 143 | } 144 | 145 | _sampleZoomToPath(start, end, options) { 146 | const 147 | totalFrames = Math.ceil((this.duration / 1000) * this.fps), 148 | samples = [end]; 149 | for (let i = 1; i < totalFrames; i++) { 150 | const t = i / totalFrames; 151 | samples.push({ 152 | 'center': start.center, 153 | 'zoom': this._interpolateLinear(start.zoom, end.zoom, t), 154 | 'bearing': start.bearing, 155 | 'pitch': start.pitch 156 | }); 157 | } 158 | return samples; 159 | } 160 | 161 | _getVisibleTilesPerSource({ center, zoom, bearing, pitch }, factor = 0) { 162 | const perSource = {}; 163 | for (const sourceId in this.map.style.sourceCaches) { 164 | const sourceCache = this.map.style.sourceCaches[sourceId]; 165 | if (!sourceCache.used) continue; 166 | perSource[sourceId] = this._getVisibleTileRange(this.map.getSource(sourceId), { center, zoom, bearing, pitch }, factor).map(t => `${t[0]}|${t[1]}|${t[2]}`); 167 | } 168 | return perSource; 169 | } 170 | 171 | _getVisibleTileRange(source, { center, zoom, bearing, pitch }, factor) { 172 | 173 | function lngLatToTile(lng, lat, zoom) { 174 | const z2 = Math.pow(2, zoom); 175 | const x = z2 * ((lng + 180) / 360); 176 | const y = z2 * (1 - (Math.log(Math.tan(Math.PI / 4 + lat * Math.PI / 360)) / Math.PI)) / 2; 177 | return [x, y]; 178 | } 179 | 180 | const 181 | tr = this.map.transform, 182 | width = tr.width, 183 | height = tr.height, 184 | pitchLimit = pitch / 150, 185 | corner_points = [ 186 | [width * factor, height * (factor + pitchLimit)], 187 | [width * (1 - factor), height * (factor + pitchLimit)], 188 | [width * (1 - factor), height * (1 - factor)], 189 | [width * factor, height * (1 - factor)] 190 | ], 191 | corner_lnglat = corner_points.map(p => this.map.transform.screenPointToLocation({ x: p[0], y: p[1] })), 192 | tileCoords = corner_lnglat.map(c => lngLatToTile(c.lng, c.lat, Math.floor(zoom))), 193 | xs = tileCoords.map(([x, _]) => x), 194 | ys = tileCoords.map(([_, y]) => y), 195 | minX = Math.floor(Math.min(...xs)), 196 | maxX = Math.ceil(Math.max(...xs)), 197 | minY = Math.floor(Math.min(...ys)), 198 | maxY = Math.ceil(Math.max(...ys)), 199 | tiles = []; 200 | 201 | for (let x = minX; x < maxX; x++) { 202 | for (let y = minY; y < maxY; y++) { 203 | if (source.scheme != 'xyz') y = Math.pow(2, zoom) - y - 1; 204 | tiles.push([Math.floor(zoom), x, y]); 205 | } 206 | } 207 | 208 | return tiles; 209 | } 210 | 211 | async _preloadTilesInternal(tileRequests) { 212 | 213 | if(this.useTile){ 214 | const tileArray = []; 215 | for (const [sourceId, tileSet] of Object.entries(tileRequests)) { 216 | const 217 | source = this.map.getSource(sourceId), 218 | tileSize = source.tileSize; 219 | for (const t of [...tileSet]) { 220 | const 221 | [z, x, y] = t.split('|'), 222 | tileID = new this.map.OverscaledTileID(z, 0, z, x, y), 223 | tile = new this.map.Tile(tileID, tileSize); 224 | tileArray.push(source.loadTile(tile)); 225 | } 226 | } 227 | return Promise.allSettled(tileArray); 228 | }else{ 229 | return new Promise(async (resolve, reject) => { 230 | const 231 | uuid = this._uuid(), 232 | timeoutId = setTimeout(() => { 233 | this.controller[uuid].abort('timeout'); 234 | cleanup(); 235 | resolve(); 236 | }, this.duration * 5), 237 | cleanup = () => { 238 | delete this.controller[uuid]; 239 | clearTimeout(timeoutId); 240 | }, 241 | fetchArray = []; 242 | let 243 | loaded = 0, 244 | failed = 0; 245 | this.controller[uuid] = new AbortController(); 246 | 247 | for (const [sourceId, tileSet] of Object.entries(tileRequests)) { 248 | const source = this.map.getSource(sourceId); 249 | for (const tile of [...tileSet]) { 250 | const 251 | [z, x, y] = tile.split('|'), 252 | url = source.tiles[0].replace('{z}', z).replace('{x}', x).replace('{y}', y); 253 | try { 254 | fetchArray.push(fetch(url, { 'signal': this.controller[uuid].signal })); 255 | } catch (e) { 256 | console.log(e); 257 | } 258 | 259 | } 260 | } 261 | try { 262 | const response = await Promise.allSettled(fetchArray); 263 | response.forEach(r => { 264 | if (!r.ok) { 265 | failed++; 266 | } else { 267 | loaded++; 268 | } 269 | if (this.progressCallback) { 270 | this.progressCallback({ loaded, total: fetchArray.length, failed }); 271 | } 272 | }); 273 | cleanup(); 274 | resolve(); 275 | } catch (e) { 276 | console.log(e); 277 | cleanup(); 278 | resolve(); 279 | } 280 | }); 281 | } 282 | } 283 | 284 | _flyToFrames(options) { 285 | // ported from https://github.com/maplibre/maplibre-gl-js/blob/b7cf56df3605c4ce6f68df216ea1c6d69790c385/src/ui/camera.ts_L1379 286 | const 287 | totalFrames = Math.ceil((this.duration / 1000) * this.fps), 288 | tr = this.map._getTransformForUpdate(), 289 | startCenter = tr.center, 290 | startZoom = tr.zoom, 291 | startBearing = tr.bearing, 292 | startPitch = tr.pitch, 293 | startRoll = tr.roll, 294 | startPadding = tr.padding, 295 | center = (!!options.center.lng) ? options.center : { lng: options.center[0], lat: options.center[1] }, 296 | bearing = 'bearing' in options ? this.map._normalizeBearing(options.bearing, startBearing) : startBearing, 297 | pitch = 'pitch' in options ? + options.pitch : startPitch, 298 | roll = 'roll' in options ? this.map._normalizeBearing(options.roll, startRoll) : startRoll, 299 | padding = 'padding' in options ? options.padding : startPadding, 300 | flyToHandler = this.map.cameraHelper.handleFlyTo(tr, { 301 | bearing, 302 | pitch, 303 | roll, 304 | padding, 305 | locationAtOffset: tr.center, 306 | offsetAsPoint: { 'x': 0, 'y': 0 }, 307 | center: options.center, 308 | minZoom: options.minZoom || 0, 309 | zoom: options.zoom, 310 | }), 311 | w0 = Math.max(tr.width, tr.height), 312 | w1 = w0 / flyToHandler.scaleOfZoom, 313 | u1 = flyToHandler.pixelPathLength; 314 | let rho = options.curve || 1.42; 315 | if (typeof flyToHandler.scaleOfMinZoom === 'number') { 316 | const wMax = w0 / flyToHandler.scaleOfMinZoom; 317 | rho = Math.sqrt(wMax / u1 * 2); 318 | } 319 | const rho2 = rho * rho; 320 | function zoomOutFactor(descent) { 321 | const b = (w1 * w1 - w0 * w0 + (descent ? -1 : 1) * rho2 * rho2 * u1 * u1) / (2 * (descent ? w1 : w0) * rho2 * u1); 322 | return Math.log(Math.sqrt(b * b + 1) - b); 323 | } 324 | function sinh(n) { return (Math.exp(n) - Math.exp(-n)) / 2; } 325 | function cosh(n) { return (Math.exp(n) + Math.exp(-n)) / 2; } 326 | function tanh(n) { return sinh(n) / cosh(n); } 327 | const r0 = zoomOutFactor(false); 328 | function w(s) { return (cosh(r0) / cosh(r0 + rho * s)); }; 329 | function u(s) { return w0 * ((cosh(r0) * tanh(r0 + rho * s) - sinh(r0)) / rho2) / u1; }; 330 | let S = (zoomOutFactor(true) - r0) / rho; 331 | if (Math.abs(u1) < 0.000002 || !isFinite(S)) { 332 | const k = w1 < w0 ? -1 : 1; 333 | S = Math.abs(Math.log(w1 / w0)) / rho; 334 | u = () => 0; 335 | w = (s) => Math.exp(k * rho * s); 336 | } 337 | const frames = []; 338 | for (let i = 0; i <= totalFrames; i++) { 339 | const k = i / totalFrames; 340 | const s = k * S; 341 | const scale = 1 / w(s); 342 | frames.push({ 343 | 'center': this._interpolateLngLatLinear(startCenter, center, k), 344 | 'zoom': startZoom + Math.log2(scale), 345 | 'bearing': bearing + (bearing - startBearing) * k, 346 | 'pitch': pitch + (pitch - startPitch) * k 347 | }) 348 | } 349 | frames.push({ 350 | 'center': center, 351 | 'zoom': options.zoom, 352 | 'bearing': bearing, 353 | 'pitch': pitch 354 | }); 355 | return frames; 356 | 357 | } 358 | 359 | _interpolateLinear(a, b, t) { return a + (b - a) * t; } 360 | 361 | _interpolateLngLatLinear(a, b, t) { 362 | return { lng: this._interpolateLinear(a.lng, b.lng, t), lat: this._interpolateLinear(a.lat, b.lat, t) }; 363 | } 364 | 365 | _uuid() { 366 | const 367 | lut = [], 368 | d0 = Math.random() * 0xffffffff | 0, 369 | d1 = Math.random() * 0xffffffff | 0, 370 | d2 = Math.random() * 0xffffffff | 0, 371 | d3 = Math.random() * 0xffffffff | 0; 372 | for (var i = 0; i < 256; i++) { 373 | lut[i] = (i < 16 ? '0' : '') + (i).toString(16); 374 | } 375 | return lut[d0 & 0xff] + lut[d0 >> 8 & 0xff] + lut[d0 >> 16 & 0xff] + lut[d0 >> 24 & 0xff] + '-' + 376 | lut[d1 & 0xff] + lut[d1 >> 8 & 0xff] + '-' + lut[d1 >> 16 & 0x0f | 0x40] + lut[d1 >> 24 & 0xff] + '-' + 377 | lut[d2 & 0x3f | 0x80] + lut[d2 >> 8 & 0xff] + '-' + lut[d2 >> 16 & 0xff] + lut[d2 >> 24 & 0xff] + 378 | lut[d3 & 0xff] + lut[d3 >> 8 & 0xff] + lut[d3 >> 16 & 0xff] + lut[d3 >> 24 & 0xff]; 379 | } 380 | 381 | } 382 | --------------------------------------------------------------------------------