Skip to content

Commit

Permalink
More intuitive flyTo options
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
1ec5 committed Dec 22, 2015
1 parent e47961a commit 4140e0b
Show file tree
Hide file tree
Showing 2 changed files with 119 additions and 21 deletions.
103 changes: 91 additions & 12 deletions js/ui/camera.js
Original file line number Diff line number Diff line change
Expand Up @@ -336,11 +336,14 @@ util.extend(Camera.prototype, /** @lends Map.prototype */{
*
* @param {LngLatBounds|Array<Array<number>>} 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`
Expand Down Expand Up @@ -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}
Expand All @@ -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.
// <https://www.win.tue.nl/~vanwijk/zoompan.pdf#page=5>.
//
// Where applicable, local variable documentation begins with the
// associated variable or function in van Wijk (2003).

this.stop();

options = util.extend({
Expand Down Expand Up @@ -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);
// w<sub>m</sub>: 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);
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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);

Expand Down
37 changes: 28 additions & 9 deletions test/js/ui/camera.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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();
Expand Down

0 comments on commit 4140e0b

Please sign in to comment.