spb/satelliteindex
Public
TypeScript 66.5%
Python 30.9%
JavaScript 1.4%
CSS 0.7%
1'use client';2/**3 * The R3F scene: camera + controls (auto-rotate until first interaction, damping, touch), Earth, the satellite4 * point cloud, the optional ground/orbit track of the focused object and the camera "fly-to" rig.5 */6import { OrbitControls } from '@react-three/drei';7import { Canvas, useFrame, useThree } from '@react-three/fiber';8import { useEffect, useMemo, useRef, useState, type ComponentRef, type MutableRefObject } from 'react';9import * as THREE from 'three';1011type OrbitControlsImpl = ComponentRef<typeof OrbitControls>;12import { clientApi } from '@/lib/client-api';13import type { Track } from '@/lib/types';14import { Earth } from './earth';15import { altToRadius, isLowPower, llaToXyz, prefersReducedMotion, token } from './geo';16import { SatellitePoints, type PickResult } from './satellite-points';17import type { GlobeData } from './use-positions';1819export interface SceneProps {20 data: GlobeData | null;21 flags: Float32Array;22 flagsVersion: number;23 highlight: number | null;24 /** Bumped to request a fly-to on the highlighted object. */25 focusRequest: number;26 focusNorad: number | null;27 onPick: (r: PickResult | null) => void;28 variant: 'hero' | 'full';29 onInteract?: () => void;30}3132function CameraRig({ highlightPos, focusRequest, controls, variant }: { highlightPos: MutableRefObject<THREE.Vector3>; focusRequest: number; controls: MutableRefObject<OrbitControlsImpl | null>; variant: 'hero' | 'full' }) {33 const { camera } = useThree();34 const flying = useRef(false);35 const start = useRef(0);36 const from = useRef(new THREE.Vector3());37 const reduced = prefersReducedMotion();38 const lastReq = useRef(0);39 const pending = useRef(false);4041 useFrame(() => {42 if (focusRequest !== lastReq.current && focusRequest > 0) {43 lastReq.current = focusRequest;44 pending.current = true;45 if (controls.current) controls.current.autoRotate = false;46 }47 // The point cloud writes `highlightPos` in its own frame callback; start the flight once it is available.48 if (pending.current && highlightPos.current.lengthSq() > 0.5) {49 pending.current = false;50 flying.current = true;51 start.current = performance.now();52 from.current.copy(camera.position);53 }54 if (!flying.current) return;55 const dist = Math.max(variant === 'hero' ? 2.6 : 2.2, Math.min(from.current.length(), 3.6));56 const target = highlightPos.current.clone().normalize().multiplyScalar(dist);57 const dur = reduced ? 1 : 1100;58 const t = Math.min(1, (performance.now() - start.current) / dur);59 const k = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; // easeInOutCubic60 // Spherical interpolation keeps the camera on the sphere of radius `dist` (no dive through the globe).61 const a = from.current.clone().normalize();62 const b = target.clone().normalize();63 const ang = a.angleTo(b);64 let dir: THREE.Vector3;65 if (ang < 1e-4) dir = b;66 else {67 const q = new THREE.Quaternion().setFromUnitVectors(a, b);68 const qk = new THREE.Quaternion().slerp(q, k);69 dir = a.clone().applyQuaternion(qk);70 }71 const r = THREE.MathUtils.lerp(from.current.length(), dist, k);72 camera.position.copy(dir.multiplyScalar(r));73 camera.lookAt(0, 0, 0);74 controls.current?.update();75 if (t >= 1) flying.current = false;76 });77 return null;78}7980function TrackLine({ norad, color }: { norad: number | null; color: THREE.Color }) {81 const [track, setTrack] = useState<Track | null>(null);82 useEffect(() => {83 setTrack(null);84 if (norad === null) return;85 const ctrl = new AbortController();86 clientApi87 .track(String(norad), ctrl.signal)88 .then((r) => setTrack(r.data))89 .catch(() => undefined);90 return () => ctrl.abort();91 }, [norad]);9293 const objects = useMemo(() => {94 if (!track || track.points.length < 2) return null;95 const build = (pts: Track['points'], opacity: number) => {96 const arr = new Float32Array(pts.length * 3);97 pts.forEach((p, i) => llaToXyz(p.lat, p.lon, altToRadius(p.alt), arr, i * 3));98 const g = new THREE.BufferGeometry();99 g.setAttribute('position', new THREE.BufferAttribute(arr, 3));100 const m = new THREE.LineBasicMaterial({ color, transparent: true, opacity, depthWrite: false });101 const line = new THREE.Line(g, m);102 line.renderOrder = 4;103 return line;104 };105 const past = track.points.filter((p) => !p.future);106 const futureStart = Math.max(0, past.length - 1);107 const future = track.points.slice(futureStart);108 return { past: past.length > 1 ? build(past, 0.35) : null, future: future.length > 1 ? build(future, 0.85) : null };109 }, [track, color]);110111 useEffect(112 () => () => {113 objects?.past?.geometry.dispose();114 objects?.future?.geometry.dispose();115 },116 [objects],117 );118119 if (!objects) return null;120 return (121 <group>122 {objects.past && <primitive object={objects.past} />}123 {objects.future && <primitive object={objects.future} />}124 </group>125 );126}127128/** Frame the globe for the viewport aspect until the user takes over: portrait phones fit the LEO shell horizontally. */129function FitCamera({ variant, interacted }: { variant: 'hero' | 'full'; interacted: boolean }) {130 const { camera, size } = useThree();131 useEffect(() => {132 if (interacted || !(camera instanceof THREE.PerspectiveCamera)) return;133 const aspect = size.width / Math.max(1, size.height);134 const vHalf = (camera.fov / 2) * (Math.PI / 180);135 const hHalf = Math.atan(Math.tan(vHalf) * aspect);136 const half = Math.min(vHalf, hHalf);137 const radius = aspect < 0.9 ? 1.32 : variant === 'hero' ? 1.22 : 1.12;138 const dist = Math.min(6, Math.max(1.8, radius / Math.sin(half)));139 camera.position.setLength(dist);140 camera.updateProjectionMatrix();141 }, [camera, size, variant, interacted]);142 return null;143}144145function TouchAction({ mode }: { mode: string }) {146 const { gl } = useThree();147 useEffect(() => {148 // OrbitControls forces `touch-action: none`; on the homepage hero we keep vertical page scrolling.149 const el = gl.domElement;150 const t = setTimeout(() => {151 el.style.touchAction = mode;152 }, 0);153 return () => clearTimeout(t);154 }, [gl, mode]);155 return null;156}157158export function GlobeScene({ data, flags, flagsVersion, highlight, focusRequest, focusNorad, onPick, variant, onInteract }: SceneProps) {159 const controls = useRef<OrbitControlsImpl | null>(null);160 const highlightPos = useRef(new THREE.Vector3());161 const reduced = prefersReducedMotion();162 const low = isLowPower();163 const trackColor = useMemo(() => new THREE.Color(token('--accent')), []);164 const [interacted, setInteracted] = useState(false);165166 const camPos: [number, number, number] = variant === 'hero' ? [1.6, 0.9, 2.6] : [1.4, 0.8, 2.6];167168 return (169 <Canvas170 dpr={low ? [1, 1.5] : [1, 2]}171 camera={{ position: camPos, fov: variant === 'hero' ? 38 : 42, near: 0.05, far: 50 }}172 gl={{ antialias: !low, alpha: true, powerPreference: low ? 'low-power' : 'high-performance' }}173 style={{ background: 'transparent', touchAction: variant === 'hero' ? 'pan-y' : 'none' }}174 frameloop="always"175 onPointerMissed={() => undefined}176 aria-label="3D globe of tracked satellites"177 role="img"178 >179 <TouchAction mode={variant === 'hero' ? 'pan-y' : 'none'} />180 <FitCamera variant={variant} interacted={interacted} />181 <Earth quality={low ? 'low' : 'high'} />182 {data && <SatellitePoints data={data} flags={flags} flagsVersion={flagsVersion} highlight={highlight} highlightPos={highlightPos} onPick={onPick} pointScale={variant === 'hero' ? 0.9 : 1} />}183 {variant === 'full' && <TrackLine norad={focusNorad} color={trackColor} />}184 <CameraRig highlightPos={highlightPos} focusRequest={focusRequest} controls={controls} variant={variant} />185 <OrbitControls186 ref={controls}187 enablePan={false}188 enableZoom={variant === 'full'}189 minDistance={1.35}190 maxDistance={6}191 enableDamping192 dampingFactor={0.08}193 rotateSpeed={0.55}194 zoomSpeed={0.7}195 autoRotate={!reduced && !interacted}196 autoRotateSpeed={variant === 'hero' ? 0.45 : 0.3}197 makeDefault198 onStart={() => {199 if (!interacted) setInteracted(true);200 onInteract?.();201 }}202 />203 </Canvas>204 );205}206