diff options
Diffstat (limited to 'src/client/views/nodes/MapBox/AnimationUtility.ts')
-rw-r--r-- | src/client/views/nodes/MapBox/AnimationUtility.ts | 429 |
1 files changed, 429 insertions, 0 deletions
diff --git a/src/client/views/nodes/MapBox/AnimationUtility.ts b/src/client/views/nodes/MapBox/AnimationUtility.ts new file mode 100644 index 000000000..256acbf13 --- /dev/null +++ b/src/client/views/nodes/MapBox/AnimationUtility.ts @@ -0,0 +1,429 @@ +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', +} + +@observer +export class AnimationUtility { + private SMOOTH_FACTOR = 0.95 + private ROUTE_COORDINATES: Position[] = []; + private PATH: turf.helpers.Feature<turf.helpers.LineString, turf.helpers.Properties>; + 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<turf.helpers.LineString, turf.helpers.Properties>, + // startBearing: number, + // startAltitude: number, + // pitch: number, + currentAnimationPhase: number, + updateAnimationPhase: ( + newAnimationPhase: number, + ) => void, + updateFrameId: (newFrameId: number) => void; + + }) => { + return new Promise<void>(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<Geometry, GeoJsonProperties>=> { + const coords = { + latitude: center[1], + longitude: center[0], + }; + const km = radiusInKm; + const ret = []; + const distanceX = km / (111.320 * 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; + } + + +}
\ No newline at end of file |