diff --git a/js/ui/camera.js b/js/ui/camera.js index 11052efa5cf..6aa5d8106fa 100644 --- a/js/ui/camera.js +++ b/js/ui/camera.js @@ -336,11 +336,14 @@ util.extend(Camera.prototype, /** @lends Map.prototype */{ * * @param {LngLatBounds|Array>} bounds [[minLng, minLat], [maxLng, maxLat]] * @param {Object} options - * @param {number} [options.speed=1.2] How fast animation occurs - * @param {number} [options.curve=1.42] How much zooming out occurs during animation + * @param {boolean} [options.linear] When true, the map transitions to the + * new camera using {@link #Map.easeTo}. When false, the map transitions + * using {@link #Map.flyTo}. See {@link #Map.flyTo} for information on + * options specific to that animation transition. * @param {Function} options.easing * @param {number} options.padding how much padding there is around the given bounds on each side in pixels - * @param {number} options.maxZoom + * @param {number} options.maxZoom The resulting zoom level will be at most + * this value. * @fires movestart * @fires moveend * @returns {Map} `this` @@ -516,12 +519,34 @@ util.extend(Camera.prototype, /** @lends Map.prototype */{ }, /** - * Flying animation to a specified location/zoom/bearing with automatic curve + * Change any combination of center, zoom, bearing, and pitch, animated + * along a curve that evokes flight. The transition animation seamlessly + * incorporates zooming and panning to help the user find his or her + * bearings even after traversing a great distance. * - * @param {CameraOptions} options map view options - * @param {number} [options.speed=1.2] How fast animation occurs - * @param {number} [options.curve=1.42] How much zooming out occurs during animation - * @param {Function} [options.easing] + * @param {CameraOptions|AnimationOptions} options map view and animation + * options + * @param {number} [options.curve=1.42] Relative amount of zooming that + * takes place along the flight path. A high value maximizes zooming for + * an exaggerated animation, while a low value minimizes zooming for + * something closer to {@link #Map.easeTo}. 1.42 is the average value + * selected by participants in the user study in + * [van Wijk (2003)](https://www.win.tue.nl/~vanwijk/zoompan.pdf). A + * value of `Math.pow(6, 0.25)` would be equivalent to the root mean + * squared average velocity. A value of 1 would produce a circular + * motion. + * @param {number} [options.minZoom] Zero-based zoom level at the peak of + * the flight path. If `options.curve` is specified, this option is + * ignored. + * @param {number} [options.speed=1.2] Average speed of the animation. A + * speed of 1.2 means that the map appears to move along the flight path + * by `options.curve` screenfuls every second. A _screenful_ is the + * visible span in pixels. It does not correspond to a fixed physical + * distance but rather varies by zoom level. + * @param {number} [options.screenSpeed] Average speed of the animation, + * measured in screenfuls per second, assuming a linear timing curve. If + * `options.speed` is specified, this option is ignored. + * @param {Function} [options.easing] Transition timing curve * @fires movestart * @fires moveend * @returns {this} @@ -540,6 +565,15 @@ util.extend(Camera.prototype, /** @lends Map.prototype */{ * }); */ flyTo: function(options) { + // This method implements an “optimal path” animation, as detailed in: + // + // Van Wijk, Jarke J.; Nuij, Wim A. A. “Smooth and efficient zooming and + // panning.” INFOVIS ’03. pp. 15–22. + // . + // + // Where applicable, local variable documentation begins with the + // associated variable or function in van Wijk (2003). + this.stop(); options = util.extend({ @@ -577,13 +611,34 @@ util.extend(Camera.prototype, /** @lends Map.prototype */{ var startWorldSize = tr.worldSize, rho = options.curve, - V = options.speed, + // w₀: Initial visible span, measured in pixels at the initial + // scale. w0 = Math.max(tr.width, tr.height), + // w₁: Final visible span, measured in pixels with respect to the + // initial scale. w1 = w0 / scale, - u1 = to.sub(from).mag(), - rho2 = rho * rho; + // Length of the flight path as projected onto the ground plane, + // measured in pixels from the world image origin at the initial + // scale. + u1 = to.sub(from).mag(); + + if ('minZoom' in options) { + var minZoom = util.clamp(Math.min(options.minZoom, startZoom, zoom), tr.minZoom, tr.maxZoom); + // wm: Maximum visible span, measured in pixels with + // respect to the initial scale. + var wMax = w0 / tr.zoomScale(minZoom - startZoom); + rho = Math.sqrt(wMax / u1 * 2); + } + // ρ² + var rho2 = rho * rho; + + /** + * rᵢ: Returns the zoom-out factor at one end of the animation. + * + * @param i 0 for the ascent or 1 for the descent. + */ function r(i) { var b = (w1 * w1 - w0 * w0 + (i ? -1 : 1) * rho2 * rho2 * u1 * u1) / (2 * (i ? w1 : w0) * rho2 * u1); return Math.log(Math.sqrt(b * b + 1) - b); @@ -593,12 +648,29 @@ util.extend(Camera.prototype, /** @lends Map.prototype */{ function cosh(n) { return (Math.exp(n) + Math.exp(-n)) / 2; } function tanh(n) { return sinh(n) / cosh(n); } + // r₀: Zoom-out factor during ascent. var 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°. + */ w = function (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. + */ u = function (s) { return w0 * ((cosh(r0) * tanh(r0 + rho * s) - sinh(r0)) / rho2) / u1; }, + // S: Total length of the flight path, measured in ρ-screenfuls. S = (r(1) - r0) / rho; + // When u₀ = u₁, the optimal path doesn’t require both ascent and + // descent. if (Math.abs(u1) < 0.000001) { + // Perform a more or less instantaneous transition if the path is + // too short. if (Math.abs(w0 - w1) < 0.000001) return this.easeTo(options); var k = w1 < w0 ? -1 : 1; @@ -608,7 +680,12 @@ util.extend(Camera.prototype, /** @lends Map.prototype */{ w = function(s) { return Math.exp(k * rho * s); }; } - options.duration = 1000 * S / V; + if ('duration' in options) { + options.duration = +options.duration; + } else { + var V = 'screenSpeed' in options ? +options.screenSpeed / rho : +options.speed; + options.duration = 1000 * S / V; + } this.zooming = true; if (startBearing !== bearing) this.rotating = true; @@ -617,6 +694,8 @@ util.extend(Camera.prototype, /** @lends Map.prototype */{ this.fire('movestart'); this._ease(function (k) { + // s: The distance traveled along the flight path, measured in + // ρ-screenfuls. var s = k * S, us = u(s); diff --git a/test/js/ui/camera.test.js b/test/js/ui/camera.test.js index d8db9f1a743..9f0c2f9eba1 100644 --- a/test/js/ui/camera.test.js +++ b/test/js/ui/camera.test.js @@ -761,19 +761,19 @@ test('camera', function(t) { t.test('stops existing ease', function(t) { var camera = createCamera(); - camera.flyTo({ center: [200, 0] }); - camera.flyTo({ center: [100, 0], animate: false }); + camera.flyTo({ center: [200, 0], duration: 100 }); + camera.flyTo({ center: [100, 0], duration: 0 }); t.deepEqual(fixedLngLat(camera.getCenter()), { lng: 100, lat: 0 }); t.end(); }); t.test('can be called from within a moveend event handler', function(t) { var camera = createCamera(); - camera.flyTo({ center: [100, 0] }); + camera.flyTo({ center: [100, 0], duration: 10 }); camera.once('moveend', function() { - camera.flyTo({ center: [200, 0] }); + camera.flyTo({ center: [200, 0], duration: 10 }); camera.once('moveend', function() { - camera.flyTo({ center: [300, 0] }); + camera.flyTo({ center: [300, 0], duration: 10 }); camera.once('moveend', function() { t.end(); }); @@ -816,7 +816,7 @@ test('camera', function(t) { t.end(); }); - camera.flyTo({ center: [10, 0] }); + camera.flyTo({ center: [10, 0], duration: 10 }); }); t.test('pans westward across the prime meridian', function(t) { @@ -835,7 +835,7 @@ test('camera', function(t) { t.end(); }); - camera.flyTo({ center: [-10, 0] }); + camera.flyTo({ center: [-10, 0], duration: 10 }); }); t.test('pans eastward across the antimeridian', function(t) { @@ -854,7 +854,7 @@ test('camera', function(t) { t.end(); }); - camera.flyTo({ center: [-170, 0] }); + camera.flyTo({ center: [-170, 0], duration: 10 }); }); t.test('pans westward across the antimeridian', function(t) { @@ -873,7 +873,26 @@ test('camera', function(t) { t.end(); }); - camera.flyTo({ center: [170, 0] }); + camera.flyTo({ center: [170, 0], duration: 10 }); + }); + + t.test('peaks at the specified zoom level', function(t) { + var camera = createCamera(); + camera.setZoom(20); + var minZoom = Infinity; + + camera.on('zoom', function() { + if (camera.getZoom() < minZoom) { + minZoom = camera.getZoom(); + } + }); + + camera.on('moveend', function() { + t.equal(fixedNum(minZoom, 2), 1); + t.end(); + }); + + camera.flyTo({ center: [1, 0], zoom: 20, minZoom: 1 }); }); t.end();