spb/satelliteindex
Public
TypeScript 66.5%
Python 30.9%
JavaScript 1.4%
CSS 0.7%
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