From 12be7f9f31e0cc03dc0f8498c3d7292860803d2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Minh=20Nguy=E1=BB=85n?= Date: Mon, 21 Dec 2015 22:19:12 -0800 Subject: [PATCH] More intuitive flyTo options Added flyTo() options that are based on observable properties, so that suitable values can be chosen without guessing and checking. Also documented local variables in flyTo(), based on the paper that describes this algorithm, because the names are non-obvious. Fixes #1854. --- js/ui/camera.js | 103 +++++++++++++++++++++++++++++++++----- test/js/ui/camera.test.js | 37 ++++++++++---- 2 files changed, 119 insertions(+), 21 deletions(-) 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();