/** * CameraController - handles camera movement and focus */ import { useEffect, useRef } from 'react'; import { useFrame, useThree } from '@react-three/fiber'; import { Vector3 } from 'three'; import type { CelestialBody } from '../types'; import { calculateRenderPosition, findParentPlanet } from '../utils/renderPosition'; interface CameraControllerProps { focusTarget: CelestialBody | null; allBodies: CelestialBody[]; onAnimationComplete?: () => void; resetTrigger?: number; defaultPosition?: [number, number, number]; } export function CameraController({ focusTarget, allBodies, onAnimationComplete, resetTrigger = 0, defaultPosition = [10, 8, 10] // Default closer to inner solar system }: CameraControllerProps) { const { camera } = useThree(); const targetPosition = useRef(new Vector3()); const isAnimating = useRef(false); const isResetting = useRef(false); const animationProgress = useRef(0); const startPosition = useRef(new Vector3()); const lastResetTrigger = useRef(0); // Handle manual reset trigger useEffect(() => { if (resetTrigger !== lastResetTrigger.current) { lastResetTrigger.current = resetTrigger; // Force reset targetPosition.current.set(...defaultPosition); startPosition.current.copy(camera.position); isAnimating.current = true; isResetting.current = true; animationProgress.current = 0; } }, [resetTrigger, camera, defaultPosition]); // Only run when resetTrigger changes // Handle focus target changes useEffect(() => { if (focusTarget) { isResetting.current = false; // Cancel reset if target selected // Focus on target - use smart rendered position const renderPos = calculateRenderPosition(focusTarget, allBodies); const currentTargetPos = new Vector3(renderPos.x, renderPos.z, renderPos.y); // Calculate ideal camera position based on target const pos = focusTarget.positions[0]; const distance = Math.sqrt(pos.x ** 2 + pos.y ** 2 + pos.z ** 2); const parentInfo = findParentPlanet(focusTarget, allBodies); let offset: number; let heightMultiplier = 1; let sideMultiplier = 1; if (focusTarget.type === 'planet') { offset = 4; heightMultiplier = 1.5; sideMultiplier = 1; } else if (focusTarget.type === 'probe') { if (parentInfo) { offset = 3; heightMultiplier = 0.8; sideMultiplier = 1.2; } else if (distance < 10) { offset = 5; heightMultiplier = 0.6; sideMultiplier = 1.5; } else if (distance > 50) { offset = 4; heightMultiplier = 0.8; sideMultiplier = 1; } else { offset = 6; heightMultiplier = 0.8; sideMultiplier = 1.2; } } else { offset = 10; heightMultiplier = 1; sideMultiplier = 1; } targetPosition.current.set( currentTargetPos.x + (offset * sideMultiplier), currentTargetPos.y + (offset * heightMultiplier), currentTargetPos.z + offset ); // Start animation to target startPosition.current.copy(camera.position); 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; } } }, [focusTarget, allBodies, camera]); useFrame((_, delta) => { 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); // Look at target - use smart rendered position (only during animation) if (focusTarget) { const renderPos = calculateRenderPosition(focusTarget, allBodies); camera.lookAt(renderPos.x, renderPos.z, renderPos.y); } else { camera.lookAt(0, 0, 0); } } // After animation completes, OrbitControls will take over }); return null; }