/** * 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; } export function CameraController({ focusTarget, allBodies, onAnimationComplete }: CameraControllerProps) { const { camera } = useThree(); const targetPosition = useRef(new Vector3()); const isAnimating = useRef(false); const animationProgress = useRef(0); const startPosition = useRef(new Vector3()); // Track previous focus to detect changes vs updates const lastFocusId = useRef(null); const lastTargetPos = useRef(null); useEffect(() => { if (focusTarget) { // Focus on target - use smart rendered position const renderPos = calculateRenderPosition(focusTarget, allBodies); const currentTargetPos = new Vector3(renderPos.x, renderPos.z, renderPos.y); // Note: Y/Z swap in rendering // Check if we are just updating positions for the same body if (lastFocusId.current === focusTarget.id && lastTargetPos.current) { // Same body, position changed (timeline playing) // Move camera by the same delta to maintain relative view const delta = new Vector3().subVectors(currentTargetPos, lastTargetPos.current); camera.position.add(delta); // Update tracking ref lastTargetPos.current.copy(currentTargetPos); return; // Skip animation and reset logic } // New target or first load: Calculate ideal camera position const pos = focusTarget.positions[0]; const distance = Math.sqrt(pos.x ** 2 + pos.y ** 2 + pos.z ** 2); const parentInfo = findParentPlanet(focusTarget, allBodies); // Calculate camera position based on target type and context let offset: number; let heightMultiplier = 1; // For adjusting vertical position let sideMultiplier = 1; // For adjusting horizontal offset if (focusTarget.type === 'planet') { // For planets, use closer view from above offset = 4; heightMultiplier = 1.5; sideMultiplier = 1; } else if (focusTarget.type === 'probe') { // For probes, determine view based on context if (parentInfo) { // Probe near a planet - use closer view to see both probe and planet offset = 3; // Closer view (was 5) heightMultiplier = 0.8; sideMultiplier = 1.2; } else if (distance < 10) { // Inner solar system probe (not near a planet) offset = 5; // Closer view (was 8) heightMultiplier = 0.6; sideMultiplier = 1.5; } else if (distance > 50) { // Far probes (Voyagers, New Horizons) - need even closer view since they're so far offset = 4; // Much closer (was 12) heightMultiplier = 0.8; sideMultiplier = 1; } else { // Medium distance probes offset = 6; // Closer view (was 10) heightMultiplier = 0.8; sideMultiplier = 1.2; } } else { offset = 10; heightMultiplier = 1; sideMultiplier = 1; } targetPosition.current.set( currentTargetPos.x + (offset * sideMultiplier), currentTargetPos.y + (offset * heightMultiplier), // already swapped in currentTargetPos? No, currentTargetPos is X, Z, Y world coords. // Wait, let's look at the original code logic carefully. // Original: scaledPos.x + offset, scaledPos.z + offset, scaledPos.y + offset // currentTargetPos is created as (renderPos.x, renderPos.z, renderPos.y). // In Three.js scene: x=x, y=z(up), z=y(depth) usually? // Let's verify Scene coordinate system. // Scene.tsx: controlsTarget = [scaledPos.x, scaledPos.z, scaledPos.y] // So Y is up in Threejs, but Z is Up in data? // renderPos (from calculateRenderPosition) usually returns data coords. // let's stick to the exact mapping used in the original code. // Original: // targetPosition.current.set( // scaledPos.x + (offset * sideMultiplier), // scaledPos.z + (offset * heightMultiplier), // scaledPos.y + offset // ); // So: X_cam = X_data + off // Y_cam = Z_data + off <-- Y is up in ThreeJS, so Z_data is mapped to Y // Z_cam = Y_data + off currentTargetPos.y + (offset * heightMultiplier), // Z_data is in .y of currentTargetPos because we did new Vector3(x, z, y) currentTargetPos.z + offset // Y_data is in .z of currentTargetPos ); // Wait, my `currentTargetPos` construction `new Vector3(renderPos.x, renderPos.z, renderPos.y)` // maps Data X->X, Data Z->Y, Data Y->Z. // So currentTargetPos.x = Data X // currentTargetPos.y = Data Z (Up) // currentTargetPos.z = Data Y (Depth) // This matches the Scene.tsx controlsTarget `[scaledPos.x, scaledPos.z, scaledPos.y]`. // So the calculation should be relative to currentTargetPos: targetPosition.current.set( currentTargetPos.x + (offset * sideMultiplier), currentTargetPos.y + (offset * heightMultiplier), currentTargetPos.z + offset ); // Update tracking refs lastFocusId.current = focusTarget.id; if (!lastTargetPos.current) { lastTargetPos.current = new Vector3(); } lastTargetPos.current.copy(currentTargetPos); // Start animation startPosition.current.copy(camera.position); isAnimating.current = true; animationProgress.current = 0; } else { // No target - stop any ongoing animation and reset tracking refs. // Do NOT force camera to return to solar system overview. // OrbitControls will maintain the current camera position. if (isAnimating.current) { // If an animation was in progress, stop it. isAnimating.current = false; animationProgress.current = 0; } lastFocusId.current = null; lastTargetPos.current = null; } }, [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; 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; }