@mcmhomes/panorama-viewer
Version:
Provides React components to render panoramas.
354 lines (311 loc) • 12.4 kB
JSX
/*eslint-disable react-compiler/react-compiler*/
import {MathUtils} from 'three';
import {useThree} from '@react-three/fiber';
import equals from 'fast-deep-equal';
import {FLOAT_LAX, FLOAT_LAX_ANY, ISSET} from '../utils/PanoramaUtils.jsx';
import {memo, useCallback, useEffect, useEffectAnimationFrameInterval, useMemo, useRef} from '../utils/PanoramaUtilsReact.jsx';
export const PanoramaControls = memo(({minFov, maxFov, calculateFov, onFovChanged, initialCameraRotation, onCameraRotationChanged, lookSpeed:givenLookSpeed, lookSpeedX:givenLookSpeedX, lookSpeedY:givenLookSpeedY, zoomSpeed:givenZoomSpeed}) =>
{
const CUBEMAP_YAW_OFFSET = 90;
const ROTATION_SPEED = 0.0012;
const ROTATION_TOUCH_SPEED_MULTIPLIER = 2.0;
const ROTATION_SLIDING_DISTANCE = 60;
const ROTATION_DRAG_WHEN_SLIDING = 0.95393;
const ROTATION_DRAG_WHEN_DRAGGING = 0.9999999995;
const FOV_SCROLL_SPEED = 0.05;
const FOV_TOUCH_SPEED_MULTIPLIER = 600;
const ROTATION_ANIMATION_SPEED = 2;
const lookSpeed = useRef();
const lookSpeedX = useRef();
const lookSpeedY = useRef();
const zoomSpeed = useRef();
const newLookSpeed = FLOAT_LAX_ANY(givenLookSpeed, 1);
const newLookSpeedX = FLOAT_LAX_ANY(givenLookSpeedX, 1);
const newLookSpeedY = FLOAT_LAX_ANY(givenLookSpeedY, 1);
const newZoomSpeed = FLOAT_LAX_ANY(givenZoomSpeed, 1);
lookSpeed.current = newLookSpeed;
lookSpeedX.current = newLookSpeedX;
lookSpeedY.current = newLookSpeedY;
zoomSpeed.current = newZoomSpeed;
const cameraMinFov = useRef();
const cameraMaxFov = useRef();
cameraMinFov.current = FLOAT_LAX_ANY(minFov, 1);
cameraMaxFov.current = FLOAT_LAX_ANY(maxFov, 179);
const clampFov = (fov) => Math.min(cameraMaxFov.current, Math.max(cameraMinFov.current, FLOAT_LAX_ANY(fov, 90)));
const onCameraRotationChangedRef = useRef();
onCameraRotationChangedRef.current = onCameraRotationChanged;
const {gl, camera, invalidate} = useThree();
const isDragging = useRef(false);
const lastMousePosition = useRef({x:0, y:0});
const startMousePosition = useRef({x:0, y:0});
const startCameraRotation = useRef();
const cameraRotation = useRef({yaw:0, pitch:0});
const hasSetInitialCameraRotation = useRef(false);
const cameraRotationGoal = useRef();
const lastCameraRotationCallbackParams = useRef();
const cameraRotationSpeed = useRef({yaw:0, pitch:0});
const lastCalculatedFovAspectRatio = useRef(null);
const lastCalculatedFov = useRef(null);
if(lastCalculatedFov.current === null)
{
lastCalculatedFov.current = FLOAT_LAX(calculateFov(1));
}
const cameraFov = useRef(clampFov(lastCalculatedFov.current));
const lastCameraFovCallbackParams = useRef();
const lastTouchDistance = useRef(null);
const cameraFovRotationSpeedMultiplier = () => (cameraFov.current / 90);
// noinspection com.intellij.reactbuddy.ExhaustiveDepsInspection
useMemo(() =>
{
startMousePosition.current = lastMousePosition.current;
startCameraRotation.current = cameraRotation.current;
// when the look speed changes, we need to reset the start position (of the mouse dragging functionality)
//eslint-disable-next-line react-hooks/exhaustive-deps
}, [newLookSpeed, newLookSpeedX, newLookSpeedY]);
const setInitialCameraRotation = useCallback((rotation, instant) =>
{
const newRotation = {yaw:MathUtils.degToRad(CUBEMAP_YAW_OFFSET - FLOAT_LAX_ANY(rotation?.yaw, 0)), pitch:MathUtils.clamp(MathUtils.degToRad(FLOAT_LAX_ANY(rotation?.pitch, 0)), -Math.PI / 2, Math.PI / 2)};
if(instant)
{
cameraRotation.current = newRotation;
cameraRotationGoal.current = null;
}
else
{
cameraRotationGoal.current = newRotation;
}
isDragging.current = false;
cameraRotationSpeed.current = {yaw:0, pitch:0};
}, []);
useEffect(() =>
{
if(!hasSetInitialCameraRotation.current)
{
setInitialCameraRotation(initialCameraRotation, true);
hasSetInitialCameraRotation.current = true;
}
else if(ISSET(initialCameraRotation))
{
setInitialCameraRotation(initialCameraRotation);
}
}, [setInitialCameraRotation, initialCameraRotation]);
const handleMouseScroll = useCallback((event) =>
{
event.stopPropagation?.();
event.preventDefault?.();
cameraFov.current = clampFov(cameraFov.current + (FLOAT_LAX_ANY(event.deltaY, 0) * FOV_SCROLL_SPEED * zoomSpeed.current));
}, []);
useEffectAnimationFrameInterval(() =>
{
if(!gl?.domElement?.width || !gl?.domElement?.height)
{
return;
}
const aspectRatio = gl.domElement.width / gl.domElement.height;
if(aspectRatio === lastCalculatedFovAspectRatio.current)
{
return;
}
lastCalculatedFovAspectRatio.current = aspectRatio;
const lastCalculatedFovValue = lastCalculatedFov.current;
lastCalculatedFov.current = FLOAT_LAX(calculateFov(aspectRatio));
if(lastCalculatedFovValue === lastCalculatedFov.current)
{
return;
}
cameraFov.current = clampFov(cameraFov.current * (lastCalculatedFov.current / lastCalculatedFovValue));
}, [gl?.domElement, calculateFov]);
useEffect(() =>
{
lastCalculatedFovAspectRatio.current = null; // force the FOV to be recalculated
}, [calculateFov]);
const handleMouseDown = useCallback((event) =>
{
event.stopPropagation?.();
event.preventDefault?.();
if(cameraRotationGoal.current)
{
return;
}
if(event.touches)
{
if(event.touches.length > 1)
{
return;
}
if(event.touches.length <= 0)
{
isDragging.current = false;
lastTouchDistance.current = null;
return;
}
event = event.touches[0];
}
isDragging.current = true;
lastMousePosition.current = {x:event.clientX, y:event.clientY};
startMousePosition.current = {x:event.clientX, y:event.clientY};
startCameraRotation.current = {yaw:cameraRotation.current.yaw, pitch:cameraRotation.current.pitch};
}, []);
const handleMouseMove = useCallback((event) =>
{
if(!isDragging.current)
{
return;
}
const isTouchEvent = !!event.touches;
if(event.touches)
{
const lastTouchDistanceValue = lastTouchDistance.current;
lastTouchDistance.current = null;
if(event.touches.length > 1)
{
if(event.touches.length === 2)
{
lastTouchDistance.current = FLOAT_LAX(Math.hypot(event.touches[0].clientX - event.touches[1].clientX, event.touches[0].clientY - event.touches[1].clientY));
if(lastTouchDistanceValue !== null)
{
handleMouseScroll({deltaY:((lastTouchDistanceValue - lastTouchDistance.current) / lastTouchDistanceValue) * FOV_TOUCH_SPEED_MULTIPLIER});
}
}
return;
}
if(event.touches.length <= 0)
{
isDragging.current = false;
lastTouchDistance.current = null;
return;
}
event = event.touches[0];
}
lastMousePosition.current = {x:event.clientX, y:event.clientY};
const deltaX = event.clientX - startMousePosition.current.x;
const deltaY = event.clientY - startMousePosition.current.y;
const newCameraRotation = {
yaw: startCameraRotation.current.yaw + (deltaX * ROTATION_SPEED * (isTouchEvent ? ROTATION_TOUCH_SPEED_MULTIPLIER : 1) * lookSpeed.current * lookSpeedX.current * cameraFovRotationSpeedMultiplier()),
pitch:MathUtils.clamp(startCameraRotation.current.pitch + (deltaY * ROTATION_SPEED * (isTouchEvent ? ROTATION_TOUCH_SPEED_MULTIPLIER : 1) * lookSpeed.current * lookSpeedY.current * cameraFovRotationSpeedMultiplier()), -Math.PI / 2, Math.PI / 2),
};
cameraRotationSpeed.current = {
yaw: (newCameraRotation.yaw - cameraRotation.current.yaw) * ROTATION_SLIDING_DISTANCE,
pitch:(newCameraRotation.pitch - cameraRotation.current.pitch) * ROTATION_SLIDING_DISTANCE,
};
cameraRotation.current = newCameraRotation;
}, [handleMouseScroll]);
const handleMouseUp = useCallback((event) =>
{
isDragging.current = false;
lastTouchDistance.current = null;
if(event && event.touches && (event.touches.length === 1))
{
handleMouseDown(event);
}
}, [handleMouseDown]);
useEffectAnimationFrameInterval(deltaTime =>
{
deltaTime = Math.min(0.33, deltaTime); // at least 3 FPS - this is to prevent odd behavior (like when the tab is inactive for a long time)
if(cameraRotationGoal.current)
{
let yawDiff = (MathUtils.radToDeg(cameraRotationGoal.current.yaw) - MathUtils.radToDeg(cameraRotation.current.yaw) + 360000) % 360;
if(yawDiff > 180)
{
yawDiff -= 360;
}
yawDiff = MathUtils.degToRad(yawDiff);
let pitchDiff = (cameraRotationGoal.current.pitch - cameraRotation.current.pitch);
const distance = Math.sqrt((yawDiff * yawDiff) + (pitchDiff * pitchDiff));
const applyDistance = Math.max(distance, 0.0001) * deltaTime * ROTATION_ANIMATION_SPEED;
if(applyDistance >= (distance - 0.8))
{
cameraRotationSpeed.current = {yaw:yawDiff * 3, pitch:pitchDiff * 3};
cameraRotationGoal.current = null;
}
else
{
const applyDistanceMultiplier = applyDistance / distance;
cameraRotation.current = {
yaw: cameraRotation.current.yaw + (yawDiff * applyDistanceMultiplier),
pitch:cameraRotation.current.pitch + (pitchDiff * applyDistanceMultiplier),
};
}
}
if(!isDragging.current)
{
cameraRotation.current = {
yaw: cameraRotation.current.yaw + (cameraRotationSpeed.current.yaw * deltaTime),
pitch:MathUtils.clamp(cameraRotation.current.pitch + (cameraRotationSpeed.current.pitch * deltaTime), -Math.PI / 2, Math.PI / 2),
};
const drag = Math.pow(1 - ROTATION_DRAG_WHEN_SLIDING, deltaTime);
cameraRotationSpeed.current.yaw *= drag;
cameraRotationSpeed.current.pitch *= drag;
}
else
{
const drag = Math.pow(1 - ROTATION_DRAG_WHEN_DRAGGING, deltaTime);
cameraRotationSpeed.current.yaw *= drag;
cameraRotationSpeed.current.pitch *= drag;
}
if((cameraRotation.current.yaw !== camera.rotation.y) || (cameraRotation.current.pitch !== camera.rotation.x))
{
camera.rotation.order = 'YXZ'; // yaw first, then pitch
camera.rotation.y = cameraRotation.current.yaw;
camera.rotation.x = cameraRotation.current.pitch;
invalidate();
if(onCameraRotationChangedRef.current)
{
let yaw = MathUtils.radToDeg(cameraRotation.current.yaw);
let pitch = MathUtils.radToDeg(cameraRotation.current.pitch);
yaw = CUBEMAP_YAW_OFFSET - yaw;
while(yaw < 0)
{
yaw += 360;
}
yaw %= 360;
yaw = Math.round(yaw * 1000) / 1000;
pitch = Math.round(pitch * 1000) / 1000;
const roundedRotation = {yaw, pitch};
if(!equals(lastCameraRotationCallbackParams.current, roundedRotation))
{
lastCameraRotationCallbackParams.current = roundedRotation;
onCameraRotationChangedRef.current?.({yaw, pitch});
}
}
}
if(('fov' in camera) && (cameraFov.current !== camera.fov))
{
camera.fov = FLOAT_LAX_ANY(cameraFov.current, 90);
camera.updateProjectionMatrix();
invalidate();
const roundedFov = Math.round(cameraFov.current * 10) / 10;
if(!equals(lastCameraFovCallbackParams.current, roundedFov))
{
lastCameraFovCallbackParams.current = roundedFov;
onFovChanged?.(roundedFov);
}
}
}, [camera, invalidate]);
useEffect(() =>
{
const domElement = gl.domElement;
domElement.addEventListener('mousedown', handleMouseDown);
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
//domElement.addEventListener('mouseleave', handleMouseUp); // stop dragging if the mouse leaves the canvas
domElement.addEventListener('touchstart', handleMouseDown);
document.addEventListener('touchmove', handleMouseMove);
document.addEventListener('touchend', handleMouseUp);
document.addEventListener('touchcancel', handleMouseUp);
domElement.addEventListener('wheel', handleMouseScroll);
return () =>
{
domElement.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
//domElement.removeEventListener('mouseleave', handleMouseUp);
domElement.removeEventListener('touchstart', handleMouseDown);
document.removeEventListener('touchmove', handleMouseMove);
document.removeEventListener('touchend', handleMouseUp);
document.removeEventListener('touchcancel', handleMouseUp);
domElement.removeEventListener('wheel', handleMouseScroll);
};
}, [gl.domElement, handleMouseDown, handleMouseMove, handleMouseUp, handleMouseScroll]);
return null;
});