/** * CameraController - handles camera movement and focus */ import { useEffect, useRef, type RefObject } from 'react'; import { useFrame, useThree } from '@react-three/fiber'; import { Vector3 } from 'three'; import type { CelestialBody } from '../types'; import { calculateRenderPosition, findParentPlanet } from '../utils/renderPosition'; import { getCelestialSize } from '../config/celestialSizes'; interface OrbitControlsLike { target: Vector3; update: () => void; } type CelestialTypeConfigs = Record | null; interface CameraControllerProps { focusTarget: CelestialBody | null; allBodies: CelestialBody[]; onAnimationComplete?: () => void; resetTrigger?: number; defaultPosition?: [number, number, number]; typeConfigs?: CelestialTypeConfigs; controlsRef: RefObject; } export function CameraController({ focusTarget, allBodies, onAnimationComplete, resetTrigger = 0, defaultPosition = [10, 8, 10], typeConfigs, controlsRef, }: CameraControllerProps) { const { camera } = useThree(); const targetPosition = useRef(new Vector3()); const targetLookPosition = useRef(new Vector3()); const isAnimating = useRef(false); const isResetting = useRef(false); const animationProgress = useRef(0); const startPosition = useRef(new Vector3()); const startLookPosition = useRef(new Vector3()); const trackedLookPosition = useRef(new Vector3()); const lastResetTrigger = useRef(0); // 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; // Handle manual reset trigger useEffect(() => { if (resetTrigger !== lastResetTrigger.current) { lastResetTrigger.current = resetTrigger; // Force reset targetPosition.current.set(...defaultPosition); targetLookPosition.current.set(0, 0, 0); startPosition.current.copy(camera.position); startLookPosition.current.copy(controlsRef.current?.target ?? new Vector3()); isAnimating.current = true; isResetting.current = true; animationProgress.current = 0; } }, [resetTrigger, camera, defaultPosition, controlsRef]); // Only run when resetTrigger changes // Handle focus target changes // Trigger only on the SELECTED body changing (by id), not on every new bodies array. useEffect(() => { const bodies = allBodiesRef.current; if (focusTarget) { isResetting.current = false; // Cancel reset if target selected // Focus on target - use smart rendered position const latestBody = bodies.find((body) => body.id === focusTarget.id) ?? focusTarget; const renderPos = calculateRenderPosition(latestBody, bodies, typeConfigs); const currentTargetPos = new Vector3(renderPos.x, renderPos.z, renderPos.y); 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)); // Start animation to target startPosition.current.copy(camera.position); startLookPosition.current.copy(previousLook); trackedLookPosition.current.copy(currentTargetPos); isAnimating.current = true; animationProgress.current = 0; } else { // 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) { isAnimating.current = false; animationProgress.current = 0; } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [focusTarget?.id, camera, controlsRef, typeConfigs]); useFrame((_, delta) => { 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); 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; }