UNPKG

mapbox-gl

Version:
673 lines (565 loc) 19.7 kB
'use strict'; var util = require('../util/util'); var interpolate = require('../util/interpolate'); var browser = require('../util/browser'); var LatLng = require('../geo/lat_lng'); var LatLngBounds = require('../geo/lat_lng_bounds'); var Point = require('point-geometry'); /** * Options common to Map#jumpTo, Map#easeTo, and Map#flyTo, controlling the destination * location, zoom level, bearing and pitch. All properties are options; unspecified * options will default to the current value for that property. * * @typedef {Object} CameraOptions * @property {Array} center Latitude and longitude (passed as `[lat, lng]`) * @property {number} zoom Map zoom level * @property {number} bearing Map rotation bearing in degrees counter-clockwise from north * @property {number} pitch The angle at which the camera is looking at the ground */ /** * Options common to map movement methods that involve animation, such as Map#panBy and * Map#easeTo, controlling the duration of the animation and easing function. All properties * are optional. * * @typedef {Object} AnimationOptions * @property {number} duration Number in milliseconds * @property {Function} easing * @property {Array} offset point, origin of movement relative to map center * @property {boolean} animate When set to false, no animation happens */ var Camera = module.exports = function() {}; util.extend(Camera.prototype, /** @lends Map.prototype */{ /** * Get the current view geographical point. * @returns {LatLng} */ getCenter: function() { return this.transform.center; }, /** * Sets a map location. Equivalent to `jumpTo({center: center})`. * * @param {Array} center Latitude and longitude (passed as `[lat, lng]`) * @fires movestart * @fires moveend * @returns {Map} `this` * @example * map.setCenter([-74, 38]); */ setCenter: function(center) { this.jumpTo({center: center}); return this; }, /** * Pan by a certain number of pixels * * @param {Array} offset [x, y] * @param {AnimationOptions} [options] * @fires movestart * @fires moveend * @returns {Map} `this` */ panBy: function(offset, options) { this.panTo(this.transform.center, util.extend({offset: Point.convert(offset).mult(-1)}, options)); return this; }, /** * Pan to a certain location with easing * * @param {Object} latlng a `LatLng` object * @param {AnimationOptions} [options] * @fires movestart * @fires moveend * @returns {Map} `this` */ panTo: function(latlng, options) { this.stop(); latlng = LatLng.convert(latlng); options = util.extend({ duration: 500, easing: util.ease, offset: [0, 0] }, options); var tr = this.transform, offset = Point.convert(options.offset).rotate(-tr.angle), from = tr.point, to = tr.project(latlng).sub(offset); if (!options.noMoveStart) { this.fire('movestart'); } this._ease(function(k) { tr.center = tr.unproject(from.add(to.sub(from).mult(k))); this.fire('move'); }, function() { this.fire('moveend'); }, options); return this; }, /** * Get the current zoom * @returns {number} */ getZoom: function() { return this.transform.zoom; }, /** * Sets a map zoom. Equivalent to `jumpTo({zoom: zoom})`. * * @param {number} zoom Map zoom level * @fires movestart * @fires moveend * @returns {Map} `this` * @example * // zoom the map to 5 * map.setZoom(5); */ setZoom: function(zoom) { this.jumpTo({zoom: zoom}); return this; }, /** * Zooms to a certain zoom level with easing. * * @param {number} zoom * @param {AnimationOptions} [options] * @fires movestart * @fires moveend * @returns {Map} `this` */ zoomTo: function(zoom, options) { this.stop(); options = util.extend({ duration: 500 }, options); options.easing = this._updateEasing(options.duration, zoom, options.easing); var tr = this.transform, around = tr.center, startZoom = tr.zoom; if (options.around) { around = LatLng.convert(options.around); } else if (options.offset) { around = tr.pointLocation(tr.centerPoint.add(Point.convert(options.offset))); } if (options.animate === false) options.duration = 0; if (!this.zooming) { this.zooming = true; this.fire('movestart'); } this._ease(function(k) { tr.setZoomAround(interpolate(startZoom, zoom, k), around); this.fire('move').fire('zoom'); }, function() { this.ease = null; if (options.duration >= 200) { this.zooming = false; this.fire('moveend'); } }, options); if (options.duration < 200) { clearTimeout(this._onZoomEnd); this._onZoomEnd = setTimeout(function() { this.zooming = false; this.fire('moveend'); }.bind(this), 200); } return this; }, /** * Zoom in by 1 level * * @param {AnimationOptions} [options] * @fires movestart * @fires moveend * @returns {Map} `this` */ zoomIn: function(options) { this.zoomTo(this.getZoom() + 1, options); return this; }, /** * Zoom out by 1 level * * @param {AnimationOptions} [options] * @fires movestart * @fires moveend * @returns {Map} `this` */ zoomOut: function(options) { this.zoomTo(this.getZoom() - 1, options); return this; }, /** * Get the current bearing in degrees * @returns {number} */ getBearing: function() { return this.transform.bearing; }, /** * Sets a map rotation. Equivalent to `jumpTo({bearing: bearing})`. * * @param {number} bearing Map rotation bearing in degrees counter-clockwise from north * @fires movestart * @fires moveend * @returns {Map} `this` * @example * // rotate the map to 90 degrees * map.setBearing(90); */ setBearing: function(bearing) { this.jumpTo({bearing: bearing}); return this; }, /** * Rotate bearing by a certain number of degrees with easing * * @param {number} bearing * @param {AnimationOptions} [options] * @fires movestart * @fires moveend * @returns {Map} `this` */ rotateTo: function(bearing, options) { this.stop(); options = util.extend({ duration: 500, easing: util.ease }, options); var tr = this.transform, start = this.getBearing(), around = tr.center; if (options.around) { around = LatLng.convert(options.around); } else if (options.offset) { around = tr.pointLocation(tr.centerPoint.add(Point.convert(options.offset))); } bearing = this._normalizeBearing(bearing, start); this.rotating = true; this.fire('movestart'); this._ease(function(k) { tr.setBearingAround(interpolate(start, bearing, k), around); this.fire('move').fire('rotate'); }, function() { this.rotating = false; this.fire('moveend'); }, options); return this; }, /** * Sets map bearing to 0 (north) with easing * * @param {AnimationOptions} [options] * @fires movestart * @fires moveend * @returns {Map} `this` */ resetNorth: function(options) { this.rotateTo(0, util.extend({duration: 1000}, options)); return this; }, /** * Get the current angle in degrees * @returns {number} */ getPitch: function() { return this.transform.pitch; }, /** * Sets a map angle. Equivalent to `jumpTo({pitch: pitch})`. * * @param {number} pitch The angle at which the camera is looking at the ground * @fires movestart * @fires moveend * @returns {Map} `this` */ setPitch: function(pitch) { this.jumpTo({pitch: pitch}); return this; }, /** * Zoom to contain certain geographical bounds * * @param {Array} bounds [[minLat, minLng], [maxLat, maxLng]] * @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 {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 * @fires movestart * @fires moveend * @returns {Map} `this` */ fitBounds: function(bounds, options) { options = util.extend({ padding: 0, offset: [0, 0], maxZoom: Infinity }, options); bounds = LatLngBounds.convert(bounds); var offset = Point.convert(options.offset), tr = this.transform, nw = tr.project(bounds.getNorthWest()), se = tr.project(bounds.getSouthEast()), size = se.sub(nw), scaleX = (tr.width - options.padding * 2 - Math.abs(offset.x) * 2) / size.x, scaleY = (tr.height - options.padding * 2 - Math.abs(offset.y) * 2) / size.y; options.center = tr.unproject(nw.add(se).div(2)); options.zoom = Math.min(tr.scaleZoom(tr.scale * Math.min(scaleX, scaleY)), options.maxZoom); options.bearing = 0; return options.linear ? this.easeTo(options) : this.flyTo(options); }, /** * Change any combination of center, zoom, bearing, and pitch, without * a transition. The map will retain the current values for any options * not included in `options`. * * @param {CameraOptions} options map view options * @fires movestart * @fires moveend * @returns {Map} `this` */ jumpTo: function(options) { this.stop(); var tr = this.transform, zoomChanged = false, bearingChanged = false, pitchChanged = false; if ('center' in options) { tr.center = LatLng.convert(options.center); } if ('zoom' in options && tr.zoom !== +options.zoom) { zoomChanged = true; tr.zoom = +options.zoom; } if ('bearing' in options && tr.bearing !== +options.bearing) { bearingChanged = true; tr.bearing = +options.bearing; } if ('pitch' in options && tr.pitch !== +options.pitch) { pitchChanged = true; tr.pitch = +options.pitch; } this.fire('movestart') .fire('move'); if (zoomChanged) { this.fire('zoom'); } if (bearingChanged) { this.fire('rotate'); } if (pitchChanged) { this.fire('pitch'); } return this.fire('moveend'); }, /** * Easing animation to a specified location/zoom/bearing * * @param {CameraOptions~AnimationOptions} options map view and animation options * @fires movestart * @fires moveend * @returns {Map} `this` */ easeTo: function(options) { this.stop(); options = util.extend({ offset: [0, 0], duration: 500, easing: util.ease }, options); var tr = this.transform, offset = Point.convert(options.offset).rotate(-tr.angle), from = tr.point, startZoom = this.getZoom(), startBearing = this.getBearing(), startPitch = this.getPitch(), zoom = 'zoom' in options ? +options.zoom : startZoom, bearing = 'bearing' in options ? this._normalizeBearing(options.bearing, startBearing) : startBearing, pitch = 'pitch' in options ? +options.pitch : startPitch, scale = tr.zoomScale(zoom - startZoom), to = 'center' in options ? tr.project(LatLng.convert(options.center)).sub(offset.div(scale)) : from, around = LatLng.convert(options.around); if (zoom !== startZoom) { this.zooming = true; } if (startBearing !== bearing) { this.rotating = true; } if (this.zooming && !around) { around = tr.pointLocation(tr.centerPoint.add(to.sub(from).div(1 - 1 / scale))); } this.fire('movestart'); this._ease(function (k) { if (this.zooming) { tr.setZoomAround(interpolate(startZoom, zoom, k), around); } else { tr.center = tr.unproject(from.add(to.sub(from).mult(k))); } if (this.rotating) { tr.bearing = interpolate(startBearing, bearing, k); } if (pitch !== startPitch) { tr.pitch = interpolate(startPitch, pitch, k); } this.fire('move'); if (this.zooming) { this.fire('zoom'); } if (this.rotating) { this.fire('rotate'); } }, function() { this.zooming = false; this.rotating = false; this.fire('moveend'); }, options); return this; }, /** * Flying animation to a specified location/zoom/bearing with automatic curve * * @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] * @fires movestart * @fires moveend * @returns {this} * @example * // fly with default options to null island * map.flyTo({center: [0, 0], zoom: 9}); * // using flyTo options * map.flyTo({ * center: [0, 0], * zoom: 9, * speed: 0.2, * curve: 1, * easing: function(t) { * return t; * } * }); */ flyTo: function(options) { this.stop(); options = util.extend({ offset: [0, 0], speed: 1.2, curve: 1.42, easing: util.ease }, options); var tr = this.transform, offset = Point.convert(options.offset), startZoom = this.getZoom(), startBearing = this.getBearing(); var center = 'center' in options ? LatLng.convert(options.center) : this.getCenter(); var zoom = 'zoom' in options ? +options.zoom : startZoom; var bearing = 'bearing' in options ? this._normalizeBearing(options.bearing, startBearing) : startBearing; var scale = tr.zoomScale(zoom - startZoom), from = tr.point, to = tr.project(center).sub(offset.div(scale)); var startWorldSize = tr.worldSize, rho = options.curve, V = options.speed, w0 = Math.max(tr.width, tr.height), w1 = w0 / scale, u1 = to.sub(from).mag(), rho2 = rho * rho; 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); } function sinh(n) { return (Math.exp(n) - Math.exp(-n)) / 2; } function cosh(n) { return (Math.exp(n) + Math.exp(-n)) / 2; } function tanh(n) { return sinh(n) / cosh(n); } var r0 = r(0), w = function (s) { return (cosh(r0) / cosh(r0 + rho * s)); }, u = function (s) { return w0 * ((cosh(r0) * tanh(r0 + rho * s) - sinh(r0)) / rho2) / u1; }, S = (r(1) - r0) / rho; if (Math.abs(u1) < 0.000001) { if (Math.abs(w0 - w1) < 0.000001) return this; var k = w1 < w0 ? -1 : 1; S = Math.abs(Math.log(w1 / w0)) / rho; u = function() { return 0; }; w = function(s) { return Math.exp(k * rho * s); }; } options.duration = 1000 * S / V; this.zooming = true; if (startBearing !== bearing) this.rotating = true; this.fire('movestart'); this._ease(function (k) { var s = k * S, us = u(s); tr.zoom = startZoom + tr.scaleZoom(1 / w(s)); tr.center = tr.unproject(from.add(to.sub(from).mult(us)), startWorldSize); if (bearing !== startBearing) { tr.bearing = interpolate(startBearing, bearing, k); } this.fire('move').fire('zoom'); if (bearing !== startBearing) { this.fire('rotate'); } }, function() { this.zooming = false; this.rotating = false; this.fire('moveend'); }, options); return this; }, isEasing: function() { return !!this._abortFn; }, /** * Stop current animation * * @returns {Map} `this` */ stop: function() { if (this._abortFn) { this._abortFn.call(this); this._finishEase(); } return this; }, _ease: function(frame, finish, options) { this._finishFn = finish; this._abortFn = browser.timed(function (t) { frame.call(this, options.easing(t)); if (t === 1) { this._finishEase(); } }, options.animate === false ? 0 : options.duration, this); }, _finishEase: function() { delete this._abortFn; // The finish function might emit events which trigger new eases, which // set a new _finishFn. Ensure we don't delete it unintentionally. var finish = this._finishFn; delete this._finishFn; finish.call(this); }, // convert bearing so that it's numerically close to the current one so that it interpolates properly _normalizeBearing: function(bearing, currentBearing) { bearing = util.wrap(bearing, -180, 180); var diff = Math.abs(bearing - currentBearing); if (Math.abs(bearing - 360 - currentBearing) < diff) bearing -= 360; if (Math.abs(bearing + 360 - currentBearing) < diff) bearing += 360; return bearing; }, _updateEasing: function(duration, zoom, bezier) { var easing; if (this.ease) { var ease = this.ease, t = (Date.now() - ease.start) / ease.duration, speed = ease.easing(t + 0.01) - ease.easing(t), // Quick hack to make new bezier that is continuous with last x = 0.27 / Math.sqrt(speed * speed + 0.0001) * 0.01, y = Math.sqrt(0.27 * 0.27 - x * x); easing = util.bezier(x, y, 0.25, 1); } else { easing = bezier ? util.bezier.apply(util, bezier) : util.ease; } // store information on current easing this.ease = { start: (new Date()).getTime(), to: Math.pow(2, zoom), duration: duration, easing: easing }; return easing; } });