// @flow import { bindAll, extend, warnOnce, clamp, wrap, ease as defaultEasing, pick } from '../util/util'; import {number as interpolate} from '../style-spec/util/interpolate'; import browser from '../util/browser'; import LngLat from '../geo/lng_lat'; import LngLatBounds from '../geo/lng_lat_bounds'; import Point from '@mapbox/point-geometry'; import {Event, Evented} from '../util/evented'; import assert from 'assert'; import {Debug} from '../util/debug'; import type Transform from '../geo/transform'; import type {LngLatLike} from '../geo/lng_lat'; import type {LngLatBoundsLike} from '../geo/lng_lat_bounds'; import type {TaskID} from '../util/task_queue'; import type {PointLike} from '@mapbox/point-geometry'; import type {PaddingOptions} from '../geo/edge_insets'; /** * Options common to {@link Map#jumpTo}, {@link Map#easeTo}, and {@link Map#flyTo}, controlling the desired location, * zoom, bearing, and pitch of the camera. All properties are optional, and when a property is omitted, the current * camera value for that property will remain unchanged. * * @typedef {Object} CameraOptions * @property {LngLatLike} center The desired center. * @property {number} zoom The desired zoom level. * @property {number} bearing The desired bearing in degrees. The bearing is the compass direction that * is "up". For example, `bearing: 90` orients the map so that east is up. * @property {number} pitch The desired pitch in degrees. The pitch is the angle towards the horizon * measured in degrees with a range between 0 and 60 degrees. For example, pitch: 0 provides the appearance * of looking straight down at the map, while pitch: 60 tilts the user's perspective towards the horizon. * Increasing the pitch value is often used to display 3D objects. * @property {LngLatLike} around If `zoom` is specified, `around` determines the point around which the zoom is centered. * @property {PaddingOptions} padding Dimensions in pixels applied on each side of the viewport for shifting the vanishing point. * @example * // set the map's initial perspective with CameraOptions * var map = new mapboxgl.Map({ * container: 'map', * style: 'mapbox://styles/mapbox/streets-v11', * center: [-73.5804, 45.53483], * pitch: 60, * bearing: -60, * zoom: 10 * }); * @see [Set pitch and bearing](https://docs.mapbox.com/mapbox-gl-js/example/set-perspective/) * @see [Jump to a series of locations](https://docs.mapbox.com/mapbox-gl-js/example/jump-to/) * @see [Fly to a location](https://docs.mapbox.com/mapbox-gl-js/example/flyto/) * @see [Display buildings in 3D](https://docs.mapbox.com/mapbox-gl-js/example/3d-buildings/) */ export type CameraOptions = { center?: LngLatLike, zoom?: number, bearing?: number, pitch?: number, around?: LngLatLike, padding?: PaddingOptions }; /** * Options common to map movement methods that involve animation, such as {@link Map#panBy} and * {@link Map#easeTo}, controlling the duration and easing function of the animation. All properties * are optional. * * @typedef {Object} AnimationOptions * @property {number} duration The animation's duration, measured in milliseconds. * @property {Function} easing A function taking a time in the range 0..1 and returning a number where 0 is * the initial state and 1 is the final state. * @property {PointLike} offset of the target center relative to real map container center at the end of animation. * @property {boolean} animate If `false`, no animation will occur. * @property {boolean} essential If `true`, then the animation is considered essential and will not be affected by * [`prefers-reduced-motion`](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion). */ export type AnimationOptions = { duration?: number, easing?: (_: number) => number, offset?: PointLike, animate?: boolean, essential?: boolean }; /** * Options for setting padding on calls to methods such as {@link Map#fitBounds}, {@link Map#fitScreenCoordinates}, and {@link Map#setPadding}. Adjust these options to set the amount of padding in pixels added to the edges of the canvas. Set a uniform padding on all edges or individual values for each edge. All properties of this object must be * non-negative integers. * * @typedef {Object} PaddingOptions * @property {number} top Padding in pixels from the top of the map canvas. * @property {number} bottom Padding in pixels from the bottom of the map canvas. * @property {number} left Padding in pixels from the left of the map canvas. * @property {number} right Padding in pixels from the right of the map canvas. * * @example * var bbox = [[-79, 43], [-73, 45]]; * map.fitBounds(bbox, { * padding: {top: 10, bottom:25, left: 15, right: 5} * }); * * @example * var bbox = [[-79, 43], [-73, 45]]; * map.fitBounds(bbox, { * padding: 20 * }); * @see [Fit to the bounds of a LineString](https://docs.mapbox.com/mapbox-gl-js/example/zoomto-linestring/) * @see [Fit a map to a bounding box](https://docs.mapbox.com/mapbox-gl-js/example/fitbounds/) */ class Camera extends Evented { transform: Transform; _moving: boolean; _zooming: boolean; _rotating: boolean; _pitching: boolean; _padding: boolean; _bearingSnap: number; _easeStart: number; _easeOptions: {duration: number, easing: (_: number) => number}; _easeId: string | void; _onEaseFrame: (_: number) => void; _onEaseEnd: (easeId?: string) => void; _easeFrameId: ?TaskID; +_requestRenderFrame: (() => void) => TaskID; +_cancelRenderFrame: (_: TaskID) => void; constructor(transform: Transform, options: {bearingSnap: number}) { super(); this._moving = false; this._zooming = false; this.transform = transform; this._bearingSnap = options.bearingSnap; bindAll(['_renderFrameCallback'], this); //addAssertions(this); } /** * Returns the map's geographical centerpoint. * * @memberof Map# * @returns The map's geographical centerpoint. * @example * // return a LngLat object such as {lng: 0, lat: 0} * var center = map.getCenter(); * // access longitude and latitude values directly * var {longitude, latitude} = map.getCenter(); * @see Tutorial: [Use Mapbox GL JS in a React app](https://docs.mapbox.com/help/tutorials/use-mapbox-gl-js-with-react/#store-the-new-coordinates) */ getCenter(): LngLat { return new LngLat(this.transform.center.lng, this.transform.center.lat); } /** * Sets the map's geographical centerpoint. Equivalent to `jumpTo({center: center})`. * * @memberof Map# * @param center The centerpoint to set. * @param eventData Additional properties to be added to event objects of events triggered by this method. * @fires movestart * @fires moveend * @returns {Map} `this` * @example * map.setCenter([-74, 38]); */ setCenter(center: LngLatLike, eventData?: Object) { return this.jumpTo({center}, eventData); } /** * Pans the map by the specified offset. * * @memberof Map# * @param offset `x` and `y` coordinates by which to pan the map. * @param options Options object * @param eventData Additional properties to be added to event objects of events triggered by this method. * @fires movestart * @fires moveend * @returns {Map} `this` * @see [Navigate the map with game-like controls](https://www.mapbox.com/mapbox-gl-js/example/game-controls/) */ panBy(offset: PointLike, options?: AnimationOptions, eventData?: Object) { offset = Point.convert(offset).mult(-1); return this.panTo(this.transform.center, extend({offset}, options), eventData); } /** * Pans the map to the specified location with an animated transition. * * @memberof Map# * @param lnglat The location to pan the map to. * @param options Options describing the destination and animation of the transition. * @param eventData Additional properties to be added to event objects of events triggered by this method. * @fires movestart * @fires moveend * @returns {Map} `this` * @example * map.panTo([-74, 38]); * @example * // Specify that the panTo animation should last 5000 milliseconds. * map.panTo([-74, 38], {duration: 5000}); * @see [Update a feature in realtime](https://docs.mapbox.com/mapbox-gl-js/example/live-update-feature/) */ panTo(lnglat: LngLatLike, options?: AnimationOptions, eventData?: Object) { return this.easeTo(extend({ center: lnglat }, options), eventData); } /** * Returns the map's current zoom level. * * @memberof Map# * @returns The map's current zoom level. * @example * map.getZoom(); */ getZoom(): number { return this.transform.zoom; } /** * Sets the map's zoom level. Equivalent to `jumpTo({zoom: zoom})`. * * @memberof Map# * @param zoom The zoom level to set (0-20). * @param eventData Additional properties to be added to event objects of events triggered by this method. * @fires movestart * @fires zoomstart * @fires move * @fires zoom * @fires moveend * @fires zoomend * @returns {Map} `this` * @example * // Zoom to the zoom level 5 without an animated transition * map.setZoom(5); */ setZoom(zoom: number, eventData?: Object) { this.jumpTo({zoom}, eventData); return this; } /** * Zooms the map to the specified zoom level, with an animated transition. * * @memberof Map# * @param zoom The zoom level to transition to. * @param options Options object * @param eventData Additional properties to be added to event objects of events triggered by this method. * @fires movestart * @fires zoomstart * @fires move * @fires zoom * @fires moveend * @fires zoomend * @returns {Map} `this` * @example * // Zoom to the zoom level 5 without an animated transition * map.zoomTo(5); * // Zoom to the zoom level 8 with an animated transition * map.zoomTo(8, { * duration: 2000, * offset: [100, 50] * }); */ zoomTo(zoom: number, options: ? AnimationOptions, eventData?: Object) { return this.easeTo(extend({ zoom }, options), eventData); } /** * Increases the map's zoom level by 1. * * @memberof Map# * @param options Options object * @param eventData Additional properties to be added to event objects of events triggered by this method. * @fires movestart * @fires zoomstart * @fires move * @fires zoom * @fires moveend * @fires zoomend * @returns {Map} `this` * @example * // zoom the map in one level with a custom animation duration * map.zoomIn({duration: 1000}); */ zoomIn(options?: AnimationOptions, eventData?: Object) { this.zoomTo(this.getZoom() + 1, options, eventData); return this; } /** * Decreases the map's zoom level by 1. * * @memberof Map# * @param options Options object * @param eventData Additional properties to be added to event objects of events triggered by this method. * @fires movestart * @fires zoomstart * @fires move * @fires zoom * @fires moveend * @fires zoomend * @returns {Map} `this` * @example * // zoom the map out one level with a custom animation offset * map.zoomOut({offset: [80, 60]}); */ zoomOut(options?: AnimationOptions, eventData?: Object) { this.zoomTo(this.getZoom() - 1, options, eventData); return this; } /** * Returns the map's current bearing. The bearing is the compass direction that is "up"; for example, a bearing * of 90° orients the map so that east is up. * * @memberof Map# * @returns The map's current bearing. * @see [Navigate the map with game-like controls](https://www.mapbox.com/mapbox-gl-js/example/game-controls/) */ getBearing(): number { return this.transform.bearing; } /** * Sets the map's bearing (rotation). The bearing is the compass direction that is "up"; for example, a bearing * of 90° orients the map so that east is up. * * Equivalent to `jumpTo({bearing: bearing})`. * * @memberof Map# * @param bearing The desired bearing. * @param eventData Additional properties to be added to event objects of events triggered by this method. * @fires movestart * @fires moveend * @returns {Map} `this` * @example * // rotate the map to 90 degrees * map.setBearing(90); */ setBearing(bearing: number, eventData?: Object) { this.jumpTo({bearing}, eventData); return this; } /** * Returns the current padding applied around the map viewport. * * @memberof Map# * @returns The current padding around the map viewport. */ getPadding(): PaddingOptions { return this.transform.padding; } /** * Sets the padding in pixels around the viewport. * * Equivalent to `jumpTo({padding: padding})`. * * @memberof Map# * @param padding The desired padding. Format: { left: number, right: number, top: number, bottom: number } * @param eventData Additional properties to be added to event objects of events triggered by this method. * @fires movestart * @fires moveend * @returns {Map} `this` * @example * // Sets a left padding of 300px, and a top padding of 50px * map.setPadding({ left: 300, top: 50 }); */ setPadding(padding: PaddingOptions, eventData?: Object) { this.jumpTo({padding}, eventData); return this; } /** * Rotates the map to the specified bearing, with an animated transition. The bearing is the compass direction * that is \"up\"; for example, a bearing of 90° orients the map so that east is up. * * @memberof Map# * @param bearing The desired bearing. * @param options Options object * @param eventData Additional properties to be added to event objects of events triggered by this method. * @fires movestart * @fires moveend * @returns {Map} `this` */ rotateTo(bearing: number, options?: AnimationOptions, eventData?: Object) { return this.easeTo(extend({ bearing }, options), eventData); } /** * Rotates the map so that north is up (0° bearing), with an animated transition. * * @memberof Map# * @param options Options object * @param eventData Additional properties to be added to event objects of events triggered by this method. * @fires movestart * @fires moveend * @returns {Map} `this` */ resetNorth(options?: AnimationOptions, eventData?: Object) { this.rotateTo(0, extend({duration: 1000}, options), eventData); return this; } /** * Rotates and pitches the map so that north is up (0° bearing) and pitch is 0°, with an animated transition. * * @memberof Map# * @param options Options object * @param eventData Additional properties to be added to event objects of events triggered by this method. * @fires movestart * @fires moveend * @returns {Map} `this` */ resetNorthPitch(options?: AnimationOptions, eventData?: Object) { this.easeTo(extend({ bearing: 0, pitch: 0, duration: 1000 }, options), eventData); return this; } /** * Snaps the map so that north is up (0° bearing), if the current bearing is close enough to it (i.e. within the * `bearingSnap` threshold). * * @memberof Map# * @param options Options object * @param eventData Additional properties to be added to event objects of events triggered by this method. * @fires movestart * @fires moveend * @returns {Map} `this` */ snapToNorth(options?: AnimationOptions, eventData?: Object) { if (Math.abs(this.getBearing()) < this._bearingSnap) { return this.resetNorth(options, eventData); } return this; } /** * Returns the map's current pitch (tilt). * * @memberof Map# * @returns The map's current pitch, measured in degrees away from the plane of the screen. */ getPitch(): number { return this.transform.pitch; } /** * Sets the map's pitch (tilt). Equivalent to `jumpTo({pitch: pitch})`. * * @memberof Map# * @param pitch The pitch to set, measured in degrees away from the plane of the screen (0-60). * @param eventData Additional properties to be added to event objects of events triggered by this method. * @fires pitchstart * @fires movestart * @fires moveend * @returns {Map} `this` */ setPitch(pitch: number, eventData?: Object) { this.jumpTo({pitch}, eventData); return this; } /** * @memberof Map# * @param {LngLatBoundsLike} bounds Calculate the center for these bounds in the viewport and use * the highest zoom level up to and including `Map#getMaxZoom()` that fits * in the viewport. LngLatBounds represent a box that is always axis-aligned with bearing 0. * @param options Options object * @param {number | PaddingOptions} [options.padding] The amount of padding in pixels to add to the given bounds. * @param {number} [options.bearing=0] Desired map bearing at end of animation, in degrees. * @param {PointLike} [options.offset=[0, 0]] The center of the given bounds relative to the map's center, measured in pixels. * @param {number} [options.maxZoom] The maximum zoom level to allow when the camera would transition to the specified bounds. * @returns {CameraOptions | void} If map is able to fit to provided bounds, returns `CameraOptions` with * `center`, `zoom`, and `bearing`. If map is unable to fit, method will warn and return undefined. * @example * var bbox = [[-79, 43], [-73, 45]]; * var newCameraTransform = map.cameraForBounds(bbox, { * padding: {top: 10, bottom:25, left: 15, right: 5} * }); */ cameraForBounds(bounds: LngLatBoundsLike, options?: CameraOptions): void | CameraOptions & AnimationOptions { bounds = LngLatBounds.convert(bounds); const bearing = options && options.bearing || 0; return this._cameraForBoxAndBearing(bounds.getNorthWest(), bounds.getSouthEast(), bearing, options); } /** * Calculate the center of these two points in the viewport and use * the highest zoom level up to and including `Map#getMaxZoom()` that fits * the points in the viewport at the specified bearing. * @memberof Map# * @param {LngLatLike} p0 First point * @param {LngLatLike} p1 Second point * @param bearing Desired map bearing at end of animation, in degrees * @param options * @param {number | PaddingOptions} [options.padding] The amount of padding in pixels to add to the given bounds. * @param {PointLike} [options.offset=[0, 0]] The center of the given bounds relative to the map's center, measured in pixels. * @param {number} [options.maxZoom] The maximum zoom level to allow when the camera would transition to the specified bounds. * @returns {CameraOptions | void} If map is able to fit to provided bounds, returns `CameraOptions` with * `center`, `zoom`, and `bearing`. If map is unable to fit, method will warn and return undefined. * @private * @example * var p0 = [-79, 43]; * var p1 = [-73, 45]; * var bearing = 90; * var newCameraTransform = map._cameraForBoxAndBearing(p0, p1, bearing, { * padding: {top: 10, bottom:25, left: 15, right: 5} * }); */ _cameraForBoxAndBearing(p0: LngLatLike, p1: LngLatLike, bearing: number, options?: CameraOptions): void | CameraOptions & AnimationOptions { const defaultPadding = { top: 0, bottom: 0, right: 0, left: 0 }; options = extend({ padding: defaultPadding, offset: [0, 0], maxZoom: this.transform.maxZoom }, options); if (typeof options.padding === 'number') { const p = options.padding; options.padding = { top: p, bottom: p, right: p, left: p }; } options.padding = extend(defaultPadding, options.padding); const tr = this.transform; const edgePadding = tr.padding; // We want to calculate the upper right and lower left of the box defined by p0 and p1 // in a coordinate system rotate to match the destination bearing. const p0world = tr.project(LngLat.convert(p0)); const p1world = tr.project(LngLat.convert(p1)); const p0rotated = p0world.rotate(-bearing * Math.PI / 180); const p1rotated = p1world.rotate(-bearing * Math.PI / 180); const upperRight = new Point(Math.max(p0rotated.x, p1rotated.x), Math.max(p0rotated.y, p1rotated.y)); const lowerLeft = new Point(Math.min(p0rotated.x, p1rotated.x), Math.min(p0rotated.y, p1rotated.y)); // Calculate zoom: consider the original bbox and padding. const size = upperRight.sub(lowerLeft); const scaleX = (tr.width - (edgePadding.left + edgePadding.right + options.padding.left + options.padding.right)) / size.x; const scaleY = (tr.height - (edgePadding.top + edgePadding.bottom + options.padding.top + options.padding.bottom)) / size.y; if (scaleY < 0 || scaleX < 0) { warnOnce( 'Map cannot fit within canvas with the given bounds, padding, and/or offset.' ); return; } const zoom = Math.min(tr.scaleZoom(tr.scale * Math.min(scaleX, scaleY)), options.maxZoom); // Calculate center: apply the zoom, the configured offset, as well as offset that exists as a result of padding. const offset = (typeof options.offset.x === 'number') ? new Point(options.offset.x, options.offset.y) : Point.convert(options.offset); const paddingOffsetX = (options.padding.left - options.padding.right) / 2; const paddingOffsetY = (options.padding.top - options.padding.bottom) / 2; const paddingOffset = new Point(paddingOffsetX, paddingOffsetY); const rotatedPaddingOffset = paddingOffset.rotate(bearing * Math.PI / 180); const offsetAtInitialZoom = offset.add(rotatedPaddingOffset); const offsetAtFinalZoom = offsetAtInitialZoom.mult(tr.scale / tr.zoomScale(zoom)); const center = tr.unproject(p0world.add(p1world).div(2).sub(offsetAtFinalZoom)); return { center, zoom, bearing }; } /** * Pans and zooms the map to contain its visible area within the specified geographical bounds. * This function will also reset the map's bearing to 0 if bearing is nonzero. * * @memberof Map# * @param bounds Center these bounds in the viewport and use the highest * zoom level up to and including `Map#getMaxZoom()` that fits them in the viewport. * @param {Object} [options] Options supports all properties from {@link AnimationOptions} and {@link CameraOptions} in addition to the fields below. * @param {number | PaddingOptions} [options.padding] The amount of padding in pixels to add to the given bounds. * @param {boolean} [options.linear=false] If `true`, the map transitions using * {@link Map#easeTo}. If `false`, the map transitions using {@link Map#flyTo}. See * those functions and {@link AnimationOptions} for information about options available. * @param {Function} [options.easing] An easing function for the animated transition. See {@link AnimationOptions}. * @param {PointLike} [options.offset=[0, 0]] The center of the given bounds relative to the map's center, measured in pixels. * @param {number} [options.maxZoom] The maximum zoom level to allow when the map view transitions to the specified bounds. * @param {Object} [eventData] Additional properties to be added to event objects of events triggered by this method. * @fires movestart * @fires moveend * @returns {Map} `this` * @example * var bbox = [[-79, 43], [-73, 45]]; * map.fitBounds(bbox, { * padding: {top: 10, bottom:25, left: 15, right: 5} * }); * @see [Fit a map to a bounding box](https://www.mapbox.com/mapbox-gl-js/example/fitbounds/) */ fitBounds(bounds: LngLatBoundsLike, options?: AnimationOptions & CameraOptions, eventData?: Object) { return this._fitInternal( this.cameraForBounds(bounds, options), options, eventData); } /** * Pans, rotates and zooms the map to to fit the box made by points p0 and p1 * once the map is rotated to the specified bearing. To zoom without rotating, * pass in the current map bearing. * * @memberof Map# * @param p0 First point on screen, in pixel coordinates * @param p1 Second point on screen, in pixel coordinates * @param bearing Desired map bearing at end of animation, in degrees * @param options Options object * @param {number | PaddingOptions} [options.padding] The amount of padding in pixels to add to the given bounds. * @param {boolean} [options.linear=false] If `true`, the map transitions using * {@link Map#easeTo}. If `false`, the map transitions using {@link Map#flyTo}. See * those functions and {@link AnimationOptions} for information about options available. * @param {Function} [options.easing] An easing function for the animated transition. See {@link AnimationOptions}. * @param {PointLike} [options.offset=[0, 0]] The center of the given bounds relative to the map's center, measured in pixels. * @param {number} [options.maxZoom] The maximum zoom level to allow when the map view transitions to the specified bounds. * @param eventData Additional properties to be added to event objects of events triggered by this method. * @fires movestart * @fires moveend * @returns {Map} `this` * @example * var p0 = [220, 400]; * var p1 = [500, 900]; * map.fitScreenCoordinates(p0, p1, map.getBearing(), { * padding: {top: 10, bottom:25, left: 15, right: 5} * }); * @see Used by {@link BoxZoomHandler} */ fitScreenCoordinates(p0: PointLike, p1: PointLike, bearing: number, options?: AnimationOptions & CameraOptions, eventData?: Object) { return this._fitInternal( this._cameraForBoxAndBearing( this.transform.pointLocation(Point.convert(p0)), this.transform.pointLocation(Point.convert(p1)), bearing, options), options, eventData); } _fitInternal(calculatedOptions?: CameraOptions & AnimationOptions, options?: AnimationOptions & CameraOptions, eventData?: Object) { // cameraForBounds warns + returns undefined if unable to fit: if (!calculatedOptions) return this; options = extend(calculatedOptions, options); // Explictly remove the padding field because, calculatedOptions already accounts for padding by setting zoom and center accordingly. delete options.padding; return options.linear ? this.easeTo(options, eventData) : this.flyTo(options, eventData); } /** * Changes any combination of center, zoom, bearing, and pitch, without * an animated transition. The map will retain its current values for any * details not specified in `options`. * * @memberof Map# * @param options Options object * @param eventData Additional properties to be added to event objects of events triggered by this method. * @fires movestart * @fires zoomstart * @fires pitchstart * @fires rotate * @fires move * @fires zoom * @fires pitch * @fires moveend * @fires zoomend * @fires pitchend * @returns {Map} `this` * @example * // jump to coordinates at current zoom * map.jumpTo({center: [0, 0]}); * // jump with zoom, pitch, and bearing options * map.jumpTo({ * center: [0, 0], * zoom: 8, * pitch: 45, * bearing: 90 * }); * @see [Jump to a series of locations](https://docs.mapbox.com/mapbox-gl-js/example/jump-to/) * @see [Update a feature in realtime](https://docs.mapbox.com/mapbox-gl-js/example/live-update-feature/) */ jumpTo(options: CameraOptions, eventData?: Object) { this.stop(); const tr = this.transform; let zoomChanged = false, bearingChanged = false, pitchChanged = false; if ('zoom' in options && tr.zoom !== +options.zoom) { zoomChanged = true; tr.zoom = +options.zoom; } if (options.center !== undefined) { tr.center = LngLat.convert(options.center); } 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; } if (options.padding != null && !tr.isPaddingEqual(options.padding)) { tr.padding = options.padding; } this.fire(new Event('movestart', eventData)) .fire(new Event('move', eventData)); if (zoomChanged) { this.fire(new Event('zoomstart', eventData)) .fire(new Event('zoom', eventData)) .fire(new Event('zoomend', eventData)); } if (bearingChanged) { this.fire(new Event('rotatestart', eventData)) .fire(new Event('rotate', eventData)) .fire(new Event('rotateend', eventData)); } if (pitchChanged) { this.fire(new Event('pitchstart', eventData)) .fire(new Event('pitch', eventData)) .fire(new Event('pitchend', eventData)); } return this.fire(new Event('moveend', eventData)); } /** * Changes any combination of `center`, `zoom`, `bearing`, `pitch`, and `padding` with an animated transition * between old and new values. The map will retain its current values for any * details not specified in `options`. * * Note: The transition will happen instantly if the user has enabled * the `reduced motion` accesibility feature enabled in their operating system, * unless `options` includes `essential: true`. * * @memberof Map# * @param options Options describing the destination and animation of the transition. * Accepts {@link CameraOptions} and {@link AnimationOptions}. * @param eventData Additional properties to be added to event objects of events triggered by this method. * @fires movestart * @fires zoomstart * @fires pitchstart * @fires rotate * @fires move * @fires zoom * @fires pitch * @fires moveend * @fires zoomend * @fires pitchend * @returns {Map} `this` * @see [Navigate the map with game-like controls](https://www.mapbox.com/mapbox-gl-js/example/game-controls/) */ easeTo(options: CameraOptions & AnimationOptions & {easeId?: string}, eventData?: Object) { this._stop(false, options.easeId); options = extend({ offset: [0, 0], duration: 500, easing: defaultEasing }, options); if (options.animate === false || (!options.essential && browser.prefersReducedMotion)) options.duration = 0; const tr = this.transform, startZoom = this.getZoom(), startBearing = this.getBearing(), startPitch = this.getPitch(), startPadding = this.getPadding(), zoom = 'zoom' in options ? +options.zoom : startZoom, bearing = 'bearing' in options ? this._normalizeBearing(options.bearing, startBearing) : startBearing, pitch = 'pitch' in options ? +options.pitch : startPitch, padding = 'padding' in options ? options.padding : tr.padding; const offsetAsPoint = Point.convert(options.offset); let pointAtOffset = tr.centerPoint.add(offsetAsPoint); const locationAtOffset = tr.pointLocation(pointAtOffset); const center = LngLat.convert(options.center || locationAtOffset); this._normalizeCenter(center); const from = tr.project(locationAtOffset); const delta = tr.project(center).sub(from); const finalScale = tr.zoomScale(zoom - startZoom); let around, aroundPoint; if (options.around) { around = LngLat.convert(options.around); aroundPoint = tr.locationPoint(around); } const currently = { moving: this._moving, zooming: this._zooming, rotating: this._rotating, pitching: this._pitching }; this._zooming = this._zooming || (zoom !== startZoom); this._rotating = this._rotating || (startBearing !== bearing); this._pitching = this._pitching || (pitch !== startPitch); this._padding = !tr.isPaddingEqual(padding); this._easeId = options.easeId; this._prepareEase(eventData, options.noMoveStart, currently); this._ease((k) => { if (this._zooming) { tr.zoom = interpolate(startZoom, zoom, k); } if (this._rotating) { tr.bearing = interpolate(startBearing, bearing, k); } if (this._pitching) { tr.pitch = interpolate(startPitch, pitch, k); } if (this._padding) { tr.interpolatePadding(startPadding, padding, k); // When padding is being applied, Transform#centerPoint is changing continously, // thus we need to recalculate offsetPoint every fra,e pointAtOffset = tr.centerPoint.add(offsetAsPoint); } if (around) { tr.setLocationAtPoint(around, aroundPoint); } else { const scale = tr.zoomScale(tr.zoom - startZoom); const base = zoom > startZoom ? Math.min(2, finalScale) : Math.max(0.5, finalScale); const speedup = Math.pow(base, 1 - k); const newCenter = tr.unproject(from.add(delta.mult(k * speedup)).mult(scale)); tr.setLocationAtPoint(tr.renderWorldCopies ? newCenter.wrap() : newCenter, pointAtOffset); } this._fireMoveEvents(eventData); }, (interruptingEaseId?: string) => { this._afterEase(eventData, interruptingEaseId); }, options); return this; } _prepareEase(eventData?: Object, noMoveStart: boolean, currently: Object = {}) { this._moving = true; if (!noMoveStart && !currently.moving) { this.fire(new Event('movestart', eventData)); } if (this._zooming && !currently.zooming) { this.fire(new Event('zoomstart', eventData)); } if (this._rotating && !currently.rotating) { this.fire(new Event('rotatestart', eventData)); } if (this._pitching && !currently.pitching) { this.fire(new Event('pitchstart', eventData)); } } _fireMoveEvents(eventData?: Object) { this.fire(new Event('move', eventData)); if (this._zooming) { this.fire(new Event('zoom', eventData)); } if (this._rotating) { this.fire(new Event('rotate', eventData)); } if (this._pitching) { this.fire(new Event('pitch', eventData)); } } _afterEase(eventData?: Object, easeId?: string) { // if this easing is being stopped to start another easing with // the same id then don't fire any events to avoid extra start/stop events if (this._easeId && easeId && this._easeId === easeId) { return; } delete this._easeId; const wasZooming = this._zooming; const wasRotating = this._rotating; const wasPitching = this._pitching; this._moving = false; this._zooming = false; this._rotating = false; this._pitching = false; this._padding = false; if (wasZooming) { this.fire(new Event('zoomend', eventData)); } if (wasRotating) { this.fire(new Event('rotateend', eventData)); } if (wasPitching) { this.fire(new Event('pitchend', eventData)); } this.fire(new Event('moveend', eventData)); } /** * Changes any combination of center, zoom, bearing, and pitch, animating the transition along a curve that * evokes flight. The animation seamlessly incorporates zooming and panning to help * the user maintain her bearings even after traversing a great distance. * * Note: The animation will be skipped, and this will behave equivalently to `jumpTo` * if the user has the `reduced motion` accesibility feature enabled in their operating system, * unless 'options' includes `essential: true`. * * @memberof Map# * @param {Object} options Options describing the destination and animation of the transition. * Accepts {@link CameraOptions}, {@link AnimationOptions}, * and the following additional options. * @param {number} [options.curve=1.42] The zooming "curve" that will occur along the * flight path. A high value maximizes zooming for an exaggerated animation, while a low * value minimizes zooming for an effect closer to {@link Map#easeTo}. 1.42 is the average * value selected by participants in the user study discussed 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] The 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] The average speed of the animation defined in relation to * `options.curve`. A speed of 1.2 means that the map appears to move along the flight path * by 1.2 times `options.curve` screenfuls every second. A _screenful_ is the map's visible span. * It does not correspond to a fixed physical distance, but varies by zoom level. * @param {number} [options.screenSpeed] The 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 {number} [options.maxDuration] The animation's maximum duration, measured in milliseconds. * If duration exceeds maximum duration, it resets to 0. * @param eventData Additional properties to be added to event objects of events triggered by this method. * @fires movestart * @fires zoomstart * @fires pitchstart * @fires move * @fires zoom * @fires rotate * @fires pitch * @fires moveend * @fires zoomend * @fires pitchend * @returns {Map} `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(t) { * return t; * } * }); * @see [Fly to a location](https://www.mapbox.com/mapbox-gl-js/example/flyto/) * @see [Slowly fly to a location](https://www.mapbox.com/mapbox-gl-js/example/flyto-options/) * @see [Fly to a location based on scroll position](https://www.mapbox.com/mapbox-gl-js/example/scroll-fly-to/) */ flyTo(options: Object, eventData?: Object) { // Fall through to jumpTo if user has set prefers-reduced-motion if (!options.essential && browser.prefersReducedMotion) { const coercedOptions = (pick(options, ['center', 'zoom', 'bearing', 'pitch', 'around']): CameraOptions); return this.jumpTo(coercedOptions, eventData); } // 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 = extend({ offset: [0, 0], speed: 1.2, curve: 1.42, easing: defaultEasing }, options); const tr = this.transform, startZoom = this.getZoom(), startBearing = this.getBearing(), startPitch = this.getPitch(), startPadding = this.getPadding(); const zoom = 'zoom' in options ? clamp(+options.zoom, tr.minZoom, tr.maxZoom) : startZoom; const bearing = 'bearing' in options ? this._normalizeBearing(options.bearing, startBearing) : startBearing; const pitch = 'pitch' in options ? +options.pitch : startPitch; const padding = 'padding' in options ? options.padding : tr.padding; const scale = tr.zoomScale(zoom - startZoom); const offsetAsPoint = Point.convert(options.offset); let pointAtOffset = tr.centerPoint.add(offsetAsPoint); const locationAtOffset = tr.pointLocation(pointAtOffset); const center = LngLat.convert(options.center || locationAtOffset); this._normalizeCenter(center); const from = tr.project(locationAtOffset); const delta = tr.project(center).sub(from); let rho = options.curve; // w₀: Initial visible span, measured in pixels at the initial scale. const w0 = Math.max(tr.width, tr.height), // w₁: Final visible span, measured in pixels with respect to the initial scale. w1 = w0 / scale, // Length of the flight path as projected onto the ground plane, measured in pixels from // the world image origin at the initial scale. u1 = delta.mag(); if ('minZoom' in options) { const minZoom = clamp(Math.min(options.minZoom, startZoom, zoom), tr.minZoom, tr.maxZoom); // wm: Maximum visible span, measured in pixels with respect to the initial // scale. const wMax = w0 / tr.zoomScale(minZoom - startZoom); rho = Math.sqrt(wMax / u1 * 2); } // ρ² const 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. * @private */ function r(i) { const 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); } // r₀: Zoom-out factor during ascent. const 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°. let w: (_: number) => number = 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. let u: (_: number) => number = function (s) { return w0 * ((cosh(r0) * tanh(r0 + rho * s) - sinh(r0)) / rho2) / u1; }; // S: Total length of the flight path, measured in ρ-screenfuls. let S = (r(1) - r0) / rho; // When u₀ = u₁, the optimal path doesn’t require both ascent and descent. if (Math.abs(u1) < 0.000001 || !isFinite(S)) { // Perform a more or less instantaneous transition if the path is too short. if (Math.abs(w0 - w1) < 0.000001) return this.easeTo(options, eventData); const 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); }; } if ('duration' in options) { options.duration = +options.duration; } else { const V = 'screenSpeed' in options ? +options.screenSpeed / rho : +options.speed; options.duration = 1000 * S / V; } if (options.maxDuration && options.duration > options.maxDuration) { options.duration = 0; } this._zooming = true; this._rotating = (startBearing !== bearing); this._pitching = (pitch !== startPitch); this._padding = !tr.isPaddingEqual(padding); this._prepareEase(eventData, false); this._ease((k) => { // s: The distance traveled along the flight path, measured in ρ-screenfuls. const s = k * S; const scale = 1 / w(s); tr.zoom = k === 1 ? zoom : startZoom + tr.scaleZoom(scale); if (this._rotating) { tr.bearing = interpolate(startBearing, bearing, k); } if (this._pitching) { tr.pitch = interpolate(startPitch, pitch, k); } if (this._padding) { tr.interpolatePadding(startPadding, padding, k); // When padding is being applied, Transform#centerPoint is changing continously, // thus we need to recalculate offsetPoint every frame pointAtOffset = tr.centerPoint.add(offsetAsPoint); } const newCenter = k === 1 ? center : tr.unproject(from.add(delta.mult(u(s))).mult(scale)); tr.setLocationAtPoint(tr.renderWorldCopies ? newCenter.wrap() : newCenter, pointAtOffset); this._fireMoveEvents(eventData); }, () => this._afterEase(eventData), options); return this; } isEasing() { return !!this._easeFrameId; } /** * Stops any animated transition underway. * * @memberof Map# * @returns {Map} `this` */ stop(): this { return this._stop(); } _stop(allowGestures?: boolean, easeId?: string): this { if (this._easeFrameId) { this._cancelRenderFrame(this._easeFrameId); delete this._easeFrameId; delete this._onEaseFrame; } if (this._onEaseEnd) { // The _onEaseEnd function might emit events which trigger new // animation, which sets a new _onEaseEnd. Ensure we don't delete // it unintentionally. const onEaseEnd = this._onEaseEnd; delete this._onEaseEnd; onEaseEnd.call(this, easeId); } if (!allowGestures) { const handlers = (this: any).handlers; if (handlers) handlers.stop(false); } return this; } _ease(frame: (_: number) => void, finish: () => void, options: {animate: boolean, duration: number, easing: (_: number) => number}) { if (options.animate === false || options.duration === 0) { frame(1); finish(); } else { this._easeStart = browser.now(); this._easeOptions = options; this._onEaseFrame = frame; this._onEaseEnd = finish; this._easeFrameId = this._requestRenderFrame(this._renderFrameCallback); } } // Callback for map._requestRenderFrame _renderFrameCallback() { const t = Math.min((browser.now() - this._easeStart) / this._easeOptions.duration, 1); this._onEaseFrame(this._easeOptions.easing(t)); if (t < 1) { this._easeFrameId = this._requestRenderFrame(this._renderFrameCallback); } else { this.stop(); } } // convert bearing so that it's numerically close to the current one so that it interpolates properly _normalizeBearing(bearing: number, currentBearing: number) { bearing = wrap(bearing, -180, 180); const 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; } // If a path crossing the antimeridian would be shorter, extend the final coordinate so that // interpolating between the two endpoints will cross it. _normalizeCenter(center: LngLat) { const tr = this.transform; if (!tr.renderWorldCopies || tr.lngRange) return; const delta = center.lng - tr.center.lng; center.lng += delta > 180 ? -360 : delta < -180 ? 360 : 0; } } // In debug builds, check that camera change events are fired in the correct order. // - ___start events needs to be fired before ___ and ___end events // - another ___start event can't be fired before a ___end event has been fired for the previous one function addAssertions(camera: Camera) { //eslint-disable-line Debug.run(() => { const inProgress = {}; ['drag', 'zoom', 'rotate', 'pitch', 'move'].forEach(name => { inProgress[name] = false; camera.on(`${name}start`, () => { assert(!inProgress[name], `"${name}start" fired twice without a "${name}end"`); inProgress[name] = true; assert(inProgress.move); }); camera.on(name, () => { assert(inProgress[name]); assert(inProgress.move); }); camera.on(`${name}end`, () => { assert(inProgress.move); assert(inProgress[name]); inProgress[name] = false; }); }); // Canary used to test whether this function is stripped in prod build canary = 'canary debug run'; }); } let canary; //eslint-disable-line export default Camera;