'use client'; /** * The R3F scene: camera + controls (auto-rotate until first interaction, damping, touch), Earth, the satellite * point cloud, the optional ground/orbit track of the focused object and the camera "fly-to" rig. */ import { OrbitControls } from '@react-three/drei'; import { Canvas, useFrame, useThree } from '@react-three/fiber'; import { useEffect, useMemo, useRef, useState, type ComponentRef, type MutableRefObject } from 'react'; import * as THREE from 'three'; type OrbitControlsImpl = ComponentRef; import { clientApi } from '@/lib/client-api'; import type { Track } from '@/lib/types'; import { Earth } from './earth'; import { altToRadius, isLowPower, llaToXyz, prefersReducedMotion, token } from './geo'; import { SatellitePoints, type PickResult } from './satellite-points'; import type { GlobeData } from './use-positions'; export interface SceneProps { data: GlobeData | null; flags: Float32Array; flagsVersion: number; highlight: number | null; /** Bumped to request a fly-to on the highlighted object. */ focusRequest: number; focusNorad: number | null; onPick: (r: PickResult | null) => void; variant: 'hero' | 'full'; onInteract?: () => void; } function CameraRig({ highlightPos, focusRequest, controls, variant }: { highlightPos: MutableRefObject; focusRequest: number; controls: MutableRefObject; variant: 'hero' | 'full' }) { const { camera } = useThree(); const flying = useRef(false); const start = useRef(0); const from = useRef(new THREE.Vector3()); const reduced = prefersReducedMotion(); const lastReq = useRef(0); const pending = useRef(false); useFrame(() => { if (focusRequest !== lastReq.current && focusRequest > 0) { lastReq.current = focusRequest; pending.current = true; if (controls.current) controls.current.autoRotate = false; } // The point cloud writes `highlightPos` in its own frame callback; start the flight once it is available. if (pending.current && highlightPos.current.lengthSq() > 0.5) { pending.current = false; flying.current = true; start.current = performance.now(); from.current.copy(camera.position); } if (!flying.current) return; const dist = Math.max(variant === 'hero' ? 2.6 : 2.2, Math.min(from.current.length(), 3.6)); const target = highlightPos.current.clone().normalize().multiplyScalar(dist); const dur = reduced ? 1 : 1100; const t = Math.min(1, (performance.now() - start.current) / dur); const k = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; // easeInOutCubic // Spherical interpolation keeps the camera on the sphere of radius `dist` (no dive through the globe). const a = from.current.clone().normalize(); const b = target.clone().normalize(); const ang = a.angleTo(b); let dir: THREE.Vector3; if (ang < 1e-4) dir = b; else { const q = new THREE.Quaternion().setFromUnitVectors(a, b); const qk = new THREE.Quaternion().slerp(q, k); dir = a.clone().applyQuaternion(qk); } const r = THREE.MathUtils.lerp(from.current.length(), dist, k); camera.position.copy(dir.multiplyScalar(r)); camera.lookAt(0, 0, 0); controls.current?.update(); if (t >= 1) flying.current = false; }); return null; } function TrackLine({ norad, color }: { norad: number | null; color: THREE.Color }) { const [track, setTrack] = useState(null); useEffect(() => { setTrack(null); if (norad === null) return; const ctrl = new AbortController(); clientApi .track(String(norad), ctrl.signal) .then((r) => setTrack(r.data)) .catch(() => undefined); return () => ctrl.abort(); }, [norad]); const objects = useMemo(() => { if (!track || track.points.length < 2) return null; const build = (pts: Track['points'], opacity: number) => { const arr = new Float32Array(pts.length * 3); pts.forEach((p, i) => llaToXyz(p.lat, p.lon, altToRadius(p.alt), arr, i * 3)); const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.BufferAttribute(arr, 3)); const m = new THREE.LineBasicMaterial({ color, transparent: true, opacity, depthWrite: false }); const line = new THREE.Line(g, m); line.renderOrder = 4; return line; }; const past = track.points.filter((p) => !p.future); const futureStart = Math.max(0, past.length - 1); const future = track.points.slice(futureStart); return { past: past.length > 1 ? build(past, 0.35) : null, future: future.length > 1 ? build(future, 0.85) : null }; }, [track, color]); useEffect( () => () => { objects?.past?.geometry.dispose(); objects?.future?.geometry.dispose(); }, [objects], ); if (!objects) return null; return ( {objects.past && } {objects.future && } ); } /** Frame the globe for the viewport aspect until the user takes over: portrait phones fit the LEO shell horizontally. */ function FitCamera({ variant, interacted }: { variant: 'hero' | 'full'; interacted: boolean }) { const { camera, size } = useThree(); useEffect(() => { if (interacted || !(camera instanceof THREE.PerspectiveCamera)) return; const aspect = size.width / Math.max(1, size.height); const vHalf = (camera.fov / 2) * (Math.PI / 180); const hHalf = Math.atan(Math.tan(vHalf) * aspect); const half = Math.min(vHalf, hHalf); const radius = aspect < 0.9 ? 1.32 : variant === 'hero' ? 1.22 : 1.12; const dist = Math.min(6, Math.max(1.8, radius / Math.sin(half))); camera.position.setLength(dist); camera.updateProjectionMatrix(); }, [camera, size, variant, interacted]); return null; } function TouchAction({ mode }: { mode: string }) { const { gl } = useThree(); useEffect(() => { // OrbitControls forces `touch-action: none`; on the homepage hero we keep vertical page scrolling. const el = gl.domElement; const t = setTimeout(() => { el.style.touchAction = mode; }, 0); return () => clearTimeout(t); }, [gl, mode]); return null; } export function GlobeScene({ data, flags, flagsVersion, highlight, focusRequest, focusNorad, onPick, variant, onInteract }: SceneProps) { const controls = useRef(null); const highlightPos = useRef(new THREE.Vector3()); const reduced = prefersReducedMotion(); const low = isLowPower(); const trackColor = useMemo(() => new THREE.Color(token('--accent')), []); const [interacted, setInteracted] = useState(false); const camPos: [number, number, number] = variant === 'hero' ? [1.6, 0.9, 2.6] : [1.4, 0.8, 2.6]; return ( undefined} aria-label="3D globe of tracked satellites" role="img" > {data && } {variant === 'full' && } { if (!interacted) setInteracted(true); onInteract?.(); }} /> ); }