import * as turf from '@turf/turf'; import * as d3 from 'd3'; import { Feature, GeoJsonProperties, Geometry, LineString } from 'geojson'; import { MercatorCoordinate } from 'mapbox-gl'; import { action, computed, makeObservable, observable, runInAction } from 'mobx'; import { MapRef } from 'react-map-gl/mapbox'; export type Position = [number, number]; 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[] = []; @observable private PATH?: Feature = undefined; // turf.helpers.Feature = undefined; private PATH_DISTANCE: number = 0; private FLY_IN_START_PITCH = 40; private FIRST_LNG_LAT: { lng: number; lat: number } = { lng: 0, lat: 0 }; private START_ALTITUDE = 3_000_000; private MAP_REF: MapRef | null = null; @observable private isStreetViewAnimation: boolean = false; @observable private animationSpeed: AnimationSpeed = AnimationSpeed.MEDIUM; @observable private previousLngLat: { lng: number; lat: number }; private previousAltitude: number | null = null; private previousPitch: number | null = null; private currentStreetViewBearing: number = 0; private terrainDisplayed: boolean; @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 currentAnimationAltitude(): number { if (!this.isStreetViewAnimation) return 20_000; if (!this.terrainDisplayed) return 50; const coords: mapboxgl.LngLatLike = [this.previousLngLat.lng, this.previousLngLat.lat]; // console.log('MAP REF: ', this.MAP_REF) // console.log("current elevation: ", this.MAP_REF?.queryTerrainElevation(coords)); let altitude = this.MAP_REF ? (this.MAP_REF.queryTerrainElevation(coords) ?? 0) : 0; if (altitude === 0) { altitude += 50; } if (this.previousAltitude) { return this.lerp(altitude, this.previousAltitude, 0.02); } return altitude; } @computed get flyInStartBearing() { return Math.max(0, Math.min(this.flyInEndBearing + 20, 360)); // between 0 and 360 } @computed get flyInEndAltitude() { // return this.isStreetViewAnimation ? (this.currentAnimationAltitude + 70 ): 10_000; return this.currentAnimationAltitude; } @computed get currentPitch(): number { if (!this.isStreetViewAnimation) return 50; if (!this.terrainDisplayed) return 80; // const groundElevation = 0; const heightAboveGround = this.currentAnimationAltitude; const horizontalDistance = 500; let pitch; if (heightAboveGround >= 0) { pitch = 90 - Math.atan(heightAboveGround / horizontalDistance) * (180 / Math.PI); } else { pitch = 80; } console.log(Math.max(50, Math.min(pitch, 85))); if (this.previousPitch) { return this.lerp(Math.max(50, Math.min(pitch, 85)), this.previousPitch, 0.02); } return Math.max(50, Math.min(pitch, 85)); } @computed get flyInEndPitch() { return this.currentPitch; } @computed get flyToDuration() { switch (this.animationSpeed) { case AnimationSpeed.SLOW: return 4_000; case AnimationSpeed.MEDIUM: return 2_500; case AnimationSpeed.FAST: return 1_250; default: return 2_500; } } @computed get animationDuration(): number { let scalingFactor: number; const MIN_DISTANCE = 0; const MAX_DISTANCE = 3_000; // anything greater than 3000 is just set to 1 when normalized const MAX_DURATION = this.isStreetViewAnimation ? 120_000 : 60_000; const normalizedDistance = Math.min(1, (this.PATH_DISTANCE - MIN_DISTANCE) / (MAX_DISTANCE - MIN_DISTANCE)); const easedDistance = d3.easeExpOut(Math.min(normalizedDistance, 1)); switch (this.animationSpeed) { case AnimationSpeed.SLOW: scalingFactor = 250; break; case AnimationSpeed.MEDIUM: scalingFactor = 150; break; case AnimationSpeed.FAST: scalingFactor = 85; break; default: scalingFactor = 150; break; } const duration = Math.min(MAX_DURATION, easedDistance * MAX_DISTANCE * (this.isStreetViewAnimation ? scalingFactor * 1.12 : scalingFactor)); return duration; } @action public updateAnimationSpeed(speed: AnimationSpeed) { // calculate new flyToDuration and animationDuration this.animationSpeed = speed; } @action public updateIsStreetViewAnimation(isStreetViewAnimation: boolean) { this.isStreetViewAnimation = isStreetViewAnimation; } @action public setPath = (path: Feature) => { // turf.helpers.Feature) => { this.PATH = path; }; constructor(firstLngLat: { lng: number; lat: number }, routeCoordinates: Position[], isStreetViewAnimation: boolean, animationSpeed: AnimationSpeed, terrainDisplayed: boolean, mapRef: MapRef | null) { makeObservable(this); this.FIRST_LNG_LAT = firstLngLat; this.previousLngLat = firstLngLat; this.isStreetViewAnimation = isStreetViewAnimation; this.MAP_REF = mapRef; this.ROUTE_COORDINATES = routeCoordinates; this.PATH = turf.lineString(routeCoordinates); this.PATH_DISTANCE = turf.length(this.PATH as Feature); this.terrainDisplayed = terrainDisplayed; const bearing = this.calculateBearing( { lng: routeCoordinates[0][0], lat: routeCoordinates[0][1], }, { lng: routeCoordinates[1][0], lat: routeCoordinates[1][1], } ); this.currentStreetViewBearing = bearing; this.animationSpeed = animationSpeed; } 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; }) => // eslint-disable-next-line no-async-promise-executor new Promise(async resolve => { 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; } if (!this.PATH) return; // calculate the distance along the path based on the animationPhase const alongPath = turf.along(this.PATH as Feature, this.PATH_DISTANCE * 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.032); 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; } runInAction(() => { this.previousLngLat = lngLat; }); updateAnimationPhase(animationPhase); // compute corrected camera ground position, so that he leading edge of the path is in view const correctedPosition = this.computeCameraPosition( this.isStreetViewAnimation, this.currentPitch, bearing, lngLat, this.currentAnimationAltitude, true // smooth ); // set the pitch and bearing of the camera const camera = map.getFreeCameraOptions(); camera.setPitchBearing(this.currentPitch, bearing); // set the position and altitude of the camera camera.position = MercatorCoordinate.fromLngLat(correctedPosition, this.currentAnimationAltitude); // apply the new camera options map.setFreeCameraOptions(camera); this.previousAltitude = this.currentAnimationAltitude; // this.previousPitch = this.previousPitch; // 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 }) => // eslint-disable-next-line no-async-promise-executor new Promise<{ bearing: number; altitude: number }>(async resolve => { let start: number | null; let currentAltitude; let currentBearing; let 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 const 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) => (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; } }