SPB Git forge
3commits 1branches 0releases
417.0 KBsize
maindefault branch
10 days agolast push
TypeScript 66.5% Python 30.9% JavaScript 1.4% CSS 0.7%
8.2 KB · 206 lines tsx
Raw Blame History
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