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.7 KB · 230 lines tsx
Raw Blame History
1'use client';2/**3 * All satellites as ONE THREE.Points draw call. Positions are lerped every frame between the two SGP4 endpoints of4 * the snapshot (t0 → t1) in Cartesian space; visibility/size/alpha are per-vertex attributes so filtering never5 * rebuilds geometry. Picking is done on the CPU in screen space against the same buffer (with globe occlusion).6 */7import { useFrame, useThree } from '@react-three/fiber';8import { useEffect, useMemo, useRef, type MutableRefObject } from 'react';9import * as THREE from 'three';10import { token } from './geo';11import type { GlobeData } from './use-positions';1213const VERT = /* glsl */ `14  uniform float uScale;15  attribute float aSize;16  attribute float aAlpha;17  attribute float aFlag;18  varying vec3 vColor;19  varying float vAlpha;20  void main() {21    vColor = color;22    vAlpha = aAlpha;23    vec4 mv = modelViewMatrix * vec4(position, 1.0);24    gl_Position = projectionMatrix * mv;25    gl_PointSize = aSize * uScale / max(-mv.z, 0.2);26    if (aFlag < 0.5) { gl_PointSize = 0.0; gl_Position = vec4(2.0, 2.0, 2.0, 1.0); }27  }28`;29const FRAG = /* glsl */ `30  varying vec3 vColor;31  varying float vAlpha;32  void main() {33    float d = length(gl_PointCoord - 0.5);34    float a = smoothstep(0.5, 0.18, d) * vAlpha;35    if (a < 0.02) discard;36    gl_FragColor = vec4(vColor, a);37  }38`;3940export interface PickResult {41  index: number;42  norad: number;43}4445export interface PointsProps {46  data: GlobeData;47  /** 0/1 per rendered object (length ≥ data.n). */48  flags: Float32Array;49  /** Bumped whenever `flags` changes so the attribute uploads. */50  flagsVersion: number;51  highlight: number | null; // index into data52  /** Written every frame with the highlighted object's current position (for the camera rig / track). */53  highlightPos: MutableRefObject<THREE.Vector3>;54  onPick?: (r: PickResult | null) => void;55  pointScale?: number;56}5758/** Interpolation factor from the wall clock; allowed to extrapolate a little when a refresh is late. */59export function lerpFactor(d: GlobeData, now: number): number {60  const span = Math.max(1, d.t1 - d.t0);61  return Math.min(3, Math.max(0, (now - d.t0) / span));62}6364export function SatellitePoints({ data, flags, flagsVersion, highlight, highlightPos, onPick, pointScale = 1 }: PointsProps) {65  const { gl, camera, size } = useThree();66  const pointsRef = useRef<THREE.Points>(null);67  const ringRef = useRef<THREE.Mesh>(null);68  const posAttr = useRef<THREE.BufferAttribute | null>(null);69  const cur = useRef<Float32Array>(new Float32Array(0));7071  const { geometry, material } = useMemo(() => {72    const n = data.n;73    const positions = new Float32Array(n * 3);74    positions.set(data.p0);75    cur.current = positions;76    const colors = new Float32Array(n * 3);77    const sizes = new Float32Array(n);78    const alphas = new Float32Array(n);79    const palette = ['--leo', '--meo', '--geo', '--heo', '--other'].map((t) => new THREE.Color(token(t as '--leo')));80    const stationIdx = data.legend.mission.indexOf('station');81    for (let i = 0; i < n; i++) {82      const c = palette[data.cls[i] ?? 4] ?? palette[4]!;83      const active = data.active[i] === 1;84      const isLeo = (data.cls[i] ?? 0) === 0;85      const isStation = stationIdx >= 0 && data.mission[i] === stationIdx;86      colors[i * 3] = c.r;87      colors[i * 3 + 1] = c.g;88      colors[i * 3 + 2] = c.b;89      sizes[i] = (isStation ? 4.2 : isLeo ? 2.1 : 2.9) * (active ? 1 : 0.8);90      alphas[i] = active ? (isLeo ? 0.85 : 0.95) : 0.35;91    }92    const g = new THREE.BufferGeometry();93    const pa = new THREE.BufferAttribute(positions, 3);94    pa.setUsage(THREE.DynamicDrawUsage);95    posAttr.current = pa;96    g.setAttribute('position', pa);97    g.setAttribute('color', new THREE.BufferAttribute(colors, 3));98    g.setAttribute('aSize', new THREE.BufferAttribute(sizes, 1));99    g.setAttribute('aAlpha', new THREE.BufferAttribute(alphas, 1));100    g.setAttribute('aFlag', new THREE.BufferAttribute(new Float32Array(n).fill(1), 1));101    g.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 2.2);102    const m = new THREE.ShaderMaterial({103      vertexShader: VERT,104      fragmentShader: FRAG,105      uniforms: { uScale: { value: 1 } },106      vertexColors: true,107      transparent: true,108      depthWrite: false,109      depthTest: true,110    });111    return { geometry: g, material: m };112  }, [data]);113114  useEffect(() => () => {115    geometry.dispose();116    material.dispose();117  }, [geometry, material]);118119  // Upload visibility flags when filters change.120  useEffect(() => {121    const attr = geometry.getAttribute('aFlag') as THREE.BufferAttribute;122    (attr.array as Float32Array).set(flags.subarray(0, data.n));123    attr.needsUpdate = true;124  }, [flags, flagsVersion, geometry, data.n]);125126  useFrame(() => {127    const d = data;128    const f = lerpFactor(d, Date.now());129    const p = cur.current;130    const { p0, p1 } = d;131    for (let i = 0; i < p.length; i++) p[i] = p0[i]! + (p1[i]! - p0[i]!) * f;132    if (posAttr.current) posAttr.current.needsUpdate = true;133    material.uniforms.uScale!.value = 4.6 * Math.min(gl.getPixelRatio(), 2) * pointScale;134135    if (highlight !== null && highlight >= 0 && highlight < d.n) {136      const i3 = highlight * 3;137      highlightPos.current.set(p[i3]!, p[i3 + 1]!, p[i3 + 2]!);138      if (ringRef.current) {139        ringRef.current.visible = true;140        ringRef.current.position.copy(highlightPos.current);141        ringRef.current.quaternion.copy(camera.quaternion);142        const dist = camera.position.distanceTo(highlightPos.current);143        const s = 0.012 * dist * (1 + 0.15 * Math.sin(Date.now() / 250));144        ringRef.current.scale.setScalar(s);145      }146    } else if (ringRef.current) ringRef.current.visible = false;147  });148149  // CPU picking in screen space (click/tap without drag), occluded by the globe. Listeners attach once per canvas;150  // everything else is read through a ref so re-renders (e.g. OrbitControls onStart) never drop a pointerdown.151  const latest = useRef({ data, flags, camera, size, onPick });152  latest.current = { data, flags, camera, size, onPick };153  useEffect(() => {154    const el = gl.domElement;155    let sx = 0;156    let sy = 0;157    let st = 0;158    const v = new THREE.Vector3();159    const down = (e: PointerEvent) => {160      sx = e.clientX;161      sy = e.clientY;162      st = performance.now();163    };164    const up = (e: PointerEvent) => {165      const { data: d, flags: fl, camera: cam3, size: sz, onPick: pick } = latest.current;166      if (!pick) return;167      if (Math.hypot(e.clientX - sx, e.clientY - sy) > 6 || performance.now() - st > 600) return;168      const rect = el.getBoundingClientRect();169      const px = e.clientX - rect.left;170      const py = e.clientY - rect.top;171      const tol = (e.pointerType === 'touch' ? 22 : 12) ** 2;172      const p = cur.current;173      const cam = cam3.position;174      const camLen2 = cam.lengthSq();175      let best = -1;176      let bestD = Infinity;177      let bestScore = Infinity;178      for (let i = 0; i < d.n; i++) {179        if ((fl[i] ?? 1) < 0.5) continue;180        v.set(p[i * 3]!, p[i * 3 + 1]!, p[i * 3 + 2]!);181        // Occlusion: closest approach of the camera→point line to the origin, restricted to the segment.182        const dx = v.x - cam.x;183        const dy = v.y - cam.y;184        const dz = v.z - cam.z;185        const len2 = dx * dx + dy * dy + dz * dz;186        const t = -(cam.x * dx + cam.y * dy + cam.z * dz) / len2;187        if (t > 0 && t < 1) {188          const cx = cam.x + dx * t;189          const cy = cam.y + dy * t;190          const cz = cam.z + dz * t;191          if (cx * cx + cy * cy + cz * cz < 1) continue;192        }193        v.project(cam3);194        if (v.z > 1) continue;195        const x = ((v.x + 1) / 2) * sz.width;196        const y = ((1 - v.y) / 2) * sz.height;197        const d2 = (x - px) ** 2 + (y - py) ** 2;198        if (d2 > tol) continue;199        // Prefer close-to-cursor, then nearer to camera.200        const score = d2 + (len2 / camLen2) * 4;201        if (score < bestScore) {202          bestScore = score;203          bestD = d2;204          best = i;205        }206      }207      if (best >= 0 && bestD <= tol) pick({ index: best, norad: d.norad[best] ?? 0 });208      else pick(null);209    };210    el.addEventListener('pointerdown', down);211    el.addEventListener('pointerup', up);212    return () => {213      el.removeEventListener('pointerdown', down);214      el.removeEventListener('pointerup', up);215    };216  }, [gl]);217218  const ringColor = useMemo(() => new THREE.Color(token('--accent')), []);219220  return (221    <group>222      <points ref={pointsRef} geometry={geometry} material={material} frustumCulled={false} renderOrder={5} />223      <mesh ref={ringRef} visible={false} renderOrder={6}>224        <ringGeometry args={[0.7, 1, 40]} />225        <meshBasicMaterial color={ringColor} transparent opacity={0.95} depthTest={false} side={THREE.DoubleSide} />226      </mesh>227    </group>228  );229}230