cosmo/frontend/src/components/CameraController.tsx

161 lines
6.2 KiB
TypeScript
Raw Normal View History

/**
* CameraController - handles camera movement and focus
*/
2026-07-21 12:09:51 +00:00
import { useEffect, useRef, type RefObject } from 'react';
import { useFrame, useThree } from '@react-three/fiber';
import { Vector3 } from 'three';
import type { CelestialBody } from '../types';
2025-11-29 15:10:00 +00:00
import { calculateRenderPosition, findParentPlanet } from '../utils/renderPosition';
2026-07-21 12:09:51 +00:00
import { getCelestialSize } from '../config/celestialSizes';
interface OrbitControlsLike {
target: Vector3;
update: () => void;
}
type CelestialTypeConfigs = Record<string, { ratio?: number; default?: number }> | null;
interface CameraControllerProps {
focusTarget: CelestialBody | null;
2025-11-29 15:10:00 +00:00
allBodies: CelestialBody[];
onAnimationComplete?: () => void;
2025-11-30 02:43:47 +00:00
resetTrigger?: number;
defaultPosition?: [number, number, number];
2026-07-21 12:09:51 +00:00
typeConfigs?: CelestialTypeConfigs;
controlsRef: RefObject<OrbitControlsLike | null>;
}
export function CameraController({
focusTarget,
allBodies,
onAnimationComplete,
resetTrigger = 0,
2026-07-21 12:09:51 +00:00
defaultPosition = [10, 8, 10],
typeConfigs,
controlsRef,
}: CameraControllerProps) {
const { camera } = useThree();
const targetPosition = useRef(new Vector3());
2026-07-21 12:09:51 +00:00
const targetLookPosition = useRef(new Vector3());
const isAnimating = useRef(false);
const isResetting = useRef(false);
const animationProgress = useRef(0);
const startPosition = useRef(new Vector3());
2026-07-21 12:09:51 +00:00
const startLookPosition = useRef(new Vector3());
const trackedLookPosition = useRef(new Vector3());
2025-11-30 02:43:47 +00:00
const lastResetTrigger = useRef(0);
2026-07-21 12:09:51 +00:00
// Keep the latest bodies list without making it a trigger for the focus effect.
// `allBodies` gets a new array reference on every timeline/historical frame, and we
// don't want that to re-fire the fly-to animation while the user is watching playback.
const allBodiesRef = useRef(allBodies);
allBodiesRef.current = allBodies;
2025-11-30 02:43:47 +00:00
// Handle manual reset trigger
useEffect(() => {
if (resetTrigger !== lastResetTrigger.current) {
lastResetTrigger.current = resetTrigger;
// Force reset
targetPosition.current.set(...defaultPosition);
2026-07-21 12:09:51 +00:00
targetLookPosition.current.set(0, 0, 0);
2025-11-30 02:43:47 +00:00
startPosition.current.copy(camera.position);
2026-07-21 12:09:51 +00:00
startLookPosition.current.copy(controlsRef.current?.target ?? new Vector3());
2025-11-30 02:43:47 +00:00
isAnimating.current = true;
isResetting.current = true;
2025-11-30 02:43:47 +00:00
animationProgress.current = 0;
}
2026-07-21 12:09:51 +00:00
}, [resetTrigger, camera, defaultPosition, controlsRef]); // Only run when resetTrigger changes
2025-11-30 02:43:47 +00:00
// Handle focus target changes
2026-07-21 12:09:51 +00:00
// Trigger only on the SELECTED body changing (by id), not on every new bodies array.
useEffect(() => {
2026-07-21 12:09:51 +00:00
const bodies = allBodiesRef.current;
if (focusTarget) {
isResetting.current = false; // Cancel reset if target selected
2026-07-21 12:09:51 +00:00
2025-11-29 15:10:00 +00:00
// Focus on target - use smart rendered position
2026-07-21 12:09:51 +00:00
const latestBody = bodies.find((body) => body.id === focusTarget.id) ?? focusTarget;
const renderPos = calculateRenderPosition(latestBody, bodies, typeConfigs);
2025-11-30 02:43:47 +00:00
const currentTargetPos = new Vector3(renderPos.x, renderPos.z, renderPos.y);
2026-07-21 12:09:51 +00:00
const bodySize = getCelestialSize(latestBody, undefined, typeConfigs);
const parentInfo = findParentPlanet(latestBody, bodies);
const focusDistance = latestBody.type === 'star'
? Math.max(5, bodySize * 9)
: latestBody.type === 'planet'
? Math.max(4, bodySize * 7)
: latestBody.type === 'dwarf_planet'
? Math.max(2.4, bodySize * 8)
: parentInfo
? Math.max(1.4, bodySize * 8)
: Math.max(1.6, bodySize * 9);
const previousLook = controlsRef.current?.target ?? new Vector3();
const viewDirection = camera.position.clone().sub(previousLook);
if (viewDirection.lengthSq() < 0.0001) viewDirection.set(1, 0.65, 1);
viewDirection.normalize();
targetLookPosition.current.copy(currentTargetPos);
targetPosition.current.copy(currentTargetPos)
.addScaledVector(viewDirection, focusDistance)
.add(new Vector3(0, focusDistance * 0.12, 0));
2025-11-30 02:43:47 +00:00
// Start animation to target
startPosition.current.copy(camera.position);
2026-07-21 12:09:51 +00:00
startLookPosition.current.copy(previousLook);
trackedLookPosition.current.copy(currentTargetPos);
isAnimating.current = true;
animationProgress.current = 0;
2025-11-30 02:43:47 +00:00
} else {
2025-11-30 02:43:47 +00:00
// Target became null (e.g. info window closed)
// Only stop animation if we are NOT in the middle of a reset
if (!isResetting.current && isAnimating.current) {
2025-11-29 16:58:58 +00:00
isAnimating.current = false;
animationProgress.current = 0;
}
}
2026-07-21 12:09:51 +00:00
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focusTarget?.id, camera, controlsRef, typeConfigs]);
useFrame((_, delta) => {
2026-07-21 12:09:51 +00:00
const orbitControls = controlsRef.current;
if (isAnimating.current) {
// Smooth animation using easing
animationProgress.current += delta * 0.8; // Animation speed
if (animationProgress.current >= 1) {
animationProgress.current = 1;
isAnimating.current = false;
isResetting.current = false; // Animation done
if (onAnimationComplete) onAnimationComplete();
}
// Easing function (ease-in-out)
const t = animationProgress.current;
const eased = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
// Interpolate camera position
camera.position.lerpVectors(startPosition.current, targetPosition.current, eased);
2026-07-21 12:09:51 +00:00
if (orbitControls) {
orbitControls.target.lerpVectors(startLookPosition.current, targetLookPosition.current, eased);
orbitControls.update();
}
camera.lookAt(targetLookPosition.current);
} else if (focusTarget && orbitControls) {
const bodies = allBodiesRef.current;
const latestBody = bodies.find((body) => body.id === focusTarget.id) ?? focusTarget;
const renderPos = calculateRenderPosition(latestBody, bodies, typeConfigs);
const nextLook = new Vector3(renderPos.x, renderPos.z, renderPos.y);
const movement = nextLook.sub(trackedLookPosition.current);
if (movement.lengthSq() > 0.00000001) {
camera.position.add(movement);
orbitControls.target.add(movement);
trackedLookPosition.current.add(movement);
orbitControls.update();
}
}
});
return null;
}