aboutsummaryrefslogtreecommitdiff
path: root/src/client/views/nodes/MapBox/AnimationUtility.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/views/nodes/MapBox/AnimationUtility.ts')
-rw-r--r--src/client/views/nodes/MapBox/AnimationUtility.ts429
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