import mapboxgl from 'mapbox-gl'; import { MercatorCoordinate } from 'mapbox-gl'; import { MapRef } from 'react-map-gl'; import * as React from 'react'; import * as d3 from 'd3'; import * as turf from '@turf/turf'; import { Position } from '@turf/turf'; import { Feature, FeatureCollection, GeoJsonProperties, Geometry } from 'geojson'; import { observer } from 'mobx-react'; import { action, computed, observable } from 'mobx'; export enum AnimationStatus { START = 'start', RESUME = 'resume', RESTART = 'restart', } export enum AnimationSpeed { SLOW = '1x', MEDIUM = '2x', FAST = '3x', } export class AnimationUtility { private SMOOTH_FACTOR = 0.95; private ROUTE_COORDINATES: Position[] = []; private PATH: turf.helpers.Feature; private FLY_IN_START_PITCH = 40; private FIRST_LNG_LAT: { lng: number; lat: number }; private START_ALTITUDE = 3_000_000; @observable private isStreetViewAnimation: boolean; @observable private animationSpeed: AnimationSpeed; private previousLngLat: { lng: number; lat: number }; private currentStreetViewBearing: number = 0; @computed get flyInEndBearing() { return this.isStreetViewAnimation ? this.calculateBearing( { lng: this.ROUTE_COORDINATES[0][0], lat: this.ROUTE_COORDINATES[0][1], }, { lng: this.ROUTE_COORDINATES[1][0], lat: this.ROUTE_COORDINATES[1][1], } ) : -20; } @computed get flyInStartBearing() { return Math.max(0, Math.min(this.flyInEndBearing + 20, 360)); // between 0 and 360 } @computed get flyInEndAltitude() { return this.isStreetViewAnimation ? 70 : 10000; } @computed get flyInEndPitch() { return this.isStreetViewAnimation ? 80 : 50; } @computed get flyToDuration() { switch (this.animationSpeed) { case AnimationSpeed.SLOW: return 4000; case AnimationSpeed.MEDIUM: return 2500; case AnimationSpeed.FAST: return 1250; default: return 2500; } } @computed get animationDuration(): number { return 20_000; // compute path distance for a standard speed // get animation speed // get isStreetViewAnimation (should be slower if so) } @action public updateAnimationSpeed(speed: AnimationSpeed) { // calculate new flyToDuration and animationDuration this.animationSpeed = speed; } @action public updateIsStreetViewAnimation(isStreetViewAnimation: boolean) { this.isStreetViewAnimation = isStreetViewAnimation; } constructor(firstLngLat: { lng: number; lat: number }, routeCoordinates: Position[], isStreetViewAnimation: boolean, animationSpeed: AnimationSpeed) { this.FIRST_LNG_LAT = firstLngLat; this.previousLngLat = firstLngLat; this.ROUTE_COORDINATES = routeCoordinates; this.PATH = turf.lineString(routeCoordinates); const bearing = this.calculateBearing( { lng: routeCoordinates[0][0], lat: routeCoordinates[0][1], }, { lng: routeCoordinates[1][0], lat: routeCoordinates[1][1], } ); this.currentStreetViewBearing = bearing; // if (isStreetViewAnimation){ // this.flyInEndBearing = bearing; // } this.isStreetViewAnimation = isStreetViewAnimation; this.animationSpeed = animationSpeed; // calculate animation duration based on speed // this.animationDuration = animationDuration; } public animatePath = async ({ map, // path, // startBearing, // startAltitude, // pitch, currentAnimationPhase, updateAnimationPhase, updateFrameId, }: { map: MapRef; // path: turf.helpers.Feature, // startBearing: number, // startAltitude: number, // pitch: number, currentAnimationPhase: number; updateAnimationPhase: (newAnimationPhase: number) => void; updateFrameId: (newFrameId: number) => void; }) => { return new Promise(async resolve => { const pathDistance = turf.lineDistance(this.PATH); console.log('PATH DISTANCE: ', pathDistance); let startTime: number | null = null; const frame = async (currentTime: number) => { if (!startTime) startTime = currentTime; const elapsedSinceLastFrame = currentTime - startTime; const phaseIncrement = elapsedSinceLastFrame / this.animationDuration; const animationPhase = currentAnimationPhase + phaseIncrement; // when the duration is complete, resolve the promise and stop iterating if (animationPhase > 1) { resolve(); return; } // calculate the distance along the path based on the animationPhase const alongPath = turf.along(this.PATH, pathDistance * animationPhase).geometry.coordinates; const lngLat = { lng: alongPath[0], lat: alongPath[1], }; let bearing: number; if (this.isStreetViewAnimation) { bearing = this.lerp( this.currentStreetViewBearing, this.calculateBearing(this.previousLngLat, lngLat), 0.028 // Adjust the transition speed as needed ); this.currentStreetViewBearing = bearing; // bearing = this.calculateBearing(this.previousLngLat, lngLat); // TODO: Calculate actual bearing } else { // slowly rotate the map at a constant rate bearing = this.flyInEndBearing - animationPhase * 200.0; // bearing = startBearing - animationPhase * 200.0; } this.previousLngLat = lngLat; updateAnimationPhase(animationPhase); // compute corrected camera ground position, so that he leading edge of the path is in view var correctedPosition = this.computeCameraPosition( this.isStreetViewAnimation, this.flyInEndPitch, bearing, lngLat, this.flyInEndAltitude, true // smooth ); // set the pitch and bearing of the camera const camera = map.getFreeCameraOptions(); camera.setPitchBearing(this.flyInEndPitch, bearing); console.log('Corrected pos: ', correctedPosition); console.log('Start altitude: ', this.flyInEndAltitude); // set the position and altitude of the camera camera.position = MercatorCoordinate.fromLngLat(correctedPosition, this.flyInEndAltitude); // apply the new camera options map.setFreeCameraOptions(camera); // repeat! const innerFrameId = await window.requestAnimationFrame(frame); updateFrameId(innerFrameId); }; const outerFrameId = await window.requestAnimationFrame(frame); updateFrameId(outerFrameId); }); }; public flyInAndRotate = async ({ map, updateFrameId }: { map: MapRef; updateFrameId: (newFrameId: number) => void }) => { return new Promise<{ bearing: number; altitude: number }>(async resolve => { let start: number | null; var currentAltitude; var currentBearing; var currentPitch; // the animation frame will run as many times as necessary until the duration has been reached const frame = async (time: number) => { if (!start) { start = time; } // otherwise, use the current time to determine how far along in the duration we are let animationPhase = (time - start) / this.flyToDuration; // because the phase calculation is imprecise, the final zoom can vary // if it ended up greater than 1, set it to 1 so that we get the exact endAltitude that was requested if (animationPhase > 1) { animationPhase = 1; } currentAltitude = this.START_ALTITUDE + (this.flyInEndAltitude - this.START_ALTITUDE) * d3.easeCubicOut(animationPhase); // rotate the camera between startBearing and endBearing currentBearing = this.flyInStartBearing + (this.flyInEndBearing - this.flyInStartBearing) * d3.easeCubicOut(animationPhase); currentPitch = this.FLY_IN_START_PITCH + (this.flyInEndPitch - this.FLY_IN_START_PITCH) * d3.easeCubicOut(animationPhase); // compute corrected camera ground position, so the start of the path is always in view var correctedPosition = this.computeCameraPosition(false, currentPitch, currentBearing, this.FIRST_LNG_LAT, currentAltitude); // set the pitch and bearing of the camera const camera = map.getFreeCameraOptions(); camera.setPitchBearing(currentPitch, currentBearing); // set the position and altitude of the camera camera.position = MercatorCoordinate.fromLngLat(correctedPosition, currentAltitude); // apply the new camera options map.setFreeCameraOptions(camera); // when the animationPhase is done, resolve the promise so the parent function can move on to the next step in the sequence if (animationPhase === 1) { resolve({ bearing: currentBearing, altitude: currentAltitude, }); // return so there are no further iterations of this frame return; } const innerFrameId = await window.requestAnimationFrame(frame); updateFrameId(innerFrameId); }; const outerFrameId = await window.requestAnimationFrame(frame); updateFrameId(outerFrameId); }); }; previousCameraPosition: { lng: number; lat: number } | null = null; lerp = (start: number, end: number, amt: number) => { return (1 - amt) * start + amt * end; }; computeCameraPosition = (isStreetViewAnimation: boolean, pitch: number, bearing: number, targetPosition: { lng: number; lat: number }, altitude: number, smooth = false) => { const bearingInRadian = (bearing * Math.PI) / 180; const pitchInRadian = ((90 - pitch) * Math.PI) / 180; let correctedLng = targetPosition.lng; let correctedLat = targetPosition.lat; if (!isStreetViewAnimation) { const lngDiff = ((altitude / Math.tan(pitchInRadian)) * Math.sin(-bearingInRadian)) / 70000; // ~70km/degree longitude const latDiff = ((altitude / Math.tan(pitchInRadian)) * Math.cos(-bearingInRadian)) / 110000; // 110km/degree latitude correctedLng = targetPosition.lng + lngDiff; correctedLat = targetPosition.lat - latDiff; } const newCameraPosition = { lng: correctedLng, lat: correctedLat, }; if (smooth) { if (this.previousCameraPosition) { newCameraPosition.lng = this.lerp(newCameraPosition.lng, this.previousCameraPosition.lng, this.SMOOTH_FACTOR); newCameraPosition.lat = this.lerp(newCameraPosition.lat, this.previousCameraPosition.lat, this.SMOOTH_FACTOR); } } this.previousCameraPosition = newCameraPosition; return newCameraPosition; }; public static createGeoJSONCircle = (center: number[], radiusInKm: number, points = 64): Feature => { const coords = { latitude: center[1], longitude: center[0], }; const km = radiusInKm; const ret = []; const distanceX = km / (111.32 * Math.cos((coords.latitude * Math.PI) / 180)); const distanceY = km / 110.574; let theta; let x; let y; for (let i = 0; i < points; i += 1) { theta = (i / points) * (2 * Math.PI); x = distanceX * Math.cos(theta); y = distanceY * Math.sin(theta); ret.push([coords.longitude + x, coords.latitude + y]); } ret.push(ret[0]); return { type: 'Feature', geometry: { type: 'Polygon', coordinates: [ret], }, properties: {}, }; }; private calculateBearing(from: { lng: number; lat: number }, to: { lng: number; lat: number }): number { const lon1 = from.lng; const lat1 = from.lat; const lon2 = to.lng; const lat2 = to.lat; const lon1Rad = (lon1 * Math.PI) / 180; const lon2Rad = (lon2 * Math.PI) / 180; const lat1Rad = (lat1 * Math.PI) / 180; const lat2Rad = (lat2 * Math.PI) / 180; const y = Math.sin(lon2Rad - lon1Rad) * Math.cos(lat2Rad); const x = Math.cos(lat1Rad) * Math.sin(lat2Rad) - Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(lon2Rad - lon1Rad); let bearing = Math.atan2(y, x); // Convert bearing from radians to degrees bearing = (bearing * 180) / Math.PI; // Ensure the bearing is within [0, 360) if (bearing < 0) { bearing += 360; } return bearing; } }