'use client'; /** * All satellites as ONE THREE.Points draw call. Positions are lerped every frame between the two SGP4 endpoints of * the snapshot (t0 → t1) in Cartesian space; visibility/size/alpha are per-vertex attributes so filtering never * rebuilds geometry. Picking is done on the CPU in screen space against the same buffer (with globe occlusion). */ import { useFrame, useThree } from '@react-three/fiber'; import { useEffect, useMemo, useRef, type MutableRefObject } from 'react'; import * as THREE from 'three'; import { token } from './geo'; import type { GlobeData } from './use-positions'; const VERT = /* glsl */ ` uniform float uScale; attribute float aSize; attribute float aAlpha; attribute float aFlag; varying vec3 vColor; varying float vAlpha; void main() { vColor = color; vAlpha = aAlpha; vec4 mv = modelViewMatrix * vec4(position, 1.0); gl_Position = projectionMatrix * mv; gl_PointSize = aSize * uScale / max(-mv.z, 0.2); if (aFlag < 0.5) { gl_PointSize = 0.0; gl_Position = vec4(2.0, 2.0, 2.0, 1.0); } } `; const FRAG = /* glsl */ ` varying vec3 vColor; varying float vAlpha; void main() { float d = length(gl_PointCoord - 0.5); float a = smoothstep(0.5, 0.18, d) * vAlpha; if (a < 0.02) discard; gl_FragColor = vec4(vColor, a); } `; export interface PickResult { index: number; norad: number; } export interface PointsProps { data: GlobeData; /** 0/1 per rendered object (length ≥ data.n). */ flags: Float32Array; /** Bumped whenever `flags` changes so the attribute uploads. */ flagsVersion: number; highlight: number | null; // index into data /** Written every frame with the highlighted object's current position (for the camera rig / track). */ highlightPos: MutableRefObject; onPick?: (r: PickResult | null) => void; pointScale?: number; } /** Interpolation factor from the wall clock; allowed to extrapolate a little when a refresh is late. */ export function lerpFactor(d: GlobeData, now: number): number { const span = Math.max(1, d.t1 - d.t0); return Math.min(3, Math.max(0, (now - d.t0) / span)); } export function SatellitePoints({ data, flags, flagsVersion, highlight, highlightPos, onPick, pointScale = 1 }: PointsProps) { const { gl, camera, size } = useThree(); const pointsRef = useRef(null); const ringRef = useRef(null); const posAttr = useRef(null); const cur = useRef(new Float32Array(0)); const { geometry, material } = useMemo(() => { const n = data.n; const positions = new Float32Array(n * 3); positions.set(data.p0); cur.current = positions; const colors = new Float32Array(n * 3); const sizes = new Float32Array(n); const alphas = new Float32Array(n); const palette = ['--leo', '--meo', '--geo', '--heo', '--other'].map((t) => new THREE.Color(token(t as '--leo'))); const stationIdx = data.legend.mission.indexOf('station'); for (let i = 0; i < n; i++) { const c = palette[data.cls[i] ?? 4] ?? palette[4]!; const active = data.active[i] === 1; const isLeo = (data.cls[i] ?? 0) === 0; const isStation = stationIdx >= 0 && data.mission[i] === stationIdx; colors[i * 3] = c.r; colors[i * 3 + 1] = c.g; colors[i * 3 + 2] = c.b; sizes[i] = (isStation ? 4.2 : isLeo ? 2.1 : 2.9) * (active ? 1 : 0.8); alphas[i] = active ? (isLeo ? 0.85 : 0.95) : 0.35; } const g = new THREE.BufferGeometry(); const pa = new THREE.BufferAttribute(positions, 3); pa.setUsage(THREE.DynamicDrawUsage); posAttr.current = pa; g.setAttribute('position', pa); g.setAttribute('color', new THREE.BufferAttribute(colors, 3)); g.setAttribute('aSize', new THREE.BufferAttribute(sizes, 1)); g.setAttribute('aAlpha', new THREE.BufferAttribute(alphas, 1)); g.setAttribute('aFlag', new THREE.BufferAttribute(new Float32Array(n).fill(1), 1)); g.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 2.2); const m = new THREE.ShaderMaterial({ vertexShader: VERT, fragmentShader: FRAG, uniforms: { uScale: { value: 1 } }, vertexColors: true, transparent: true, depthWrite: false, depthTest: true, }); return { geometry: g, material: m }; }, [data]); useEffect(() => () => { geometry.dispose(); material.dispose(); }, [geometry, material]); // Upload visibility flags when filters change. useEffect(() => { const attr = geometry.getAttribute('aFlag') as THREE.BufferAttribute; (attr.array as Float32Array).set(flags.subarray(0, data.n)); attr.needsUpdate = true; }, [flags, flagsVersion, geometry, data.n]); useFrame(() => { const d = data; const f = lerpFactor(d, Date.now()); const p = cur.current; const { p0, p1 } = d; for (let i = 0; i < p.length; i++) p[i] = p0[i]! + (p1[i]! - p0[i]!) * f; if (posAttr.current) posAttr.current.needsUpdate = true; material.uniforms.uScale!.value = 4.6 * Math.min(gl.getPixelRatio(), 2) * pointScale; if (highlight !== null && highlight >= 0 && highlight < d.n) { const i3 = highlight * 3; highlightPos.current.set(p[i3]!, p[i3 + 1]!, p[i3 + 2]!); if (ringRef.current) { ringRef.current.visible = true; ringRef.current.position.copy(highlightPos.current); ringRef.current.quaternion.copy(camera.quaternion); const dist = camera.position.distanceTo(highlightPos.current); const s = 0.012 * dist * (1 + 0.15 * Math.sin(Date.now() / 250)); ringRef.current.scale.setScalar(s); } } else if (ringRef.current) ringRef.current.visible = false; }); // CPU picking in screen space (click/tap without drag), occluded by the globe. Listeners attach once per canvas; // everything else is read through a ref so re-renders (e.g. OrbitControls onStart) never drop a pointerdown. const latest = useRef({ data, flags, camera, size, onPick }); latest.current = { data, flags, camera, size, onPick }; useEffect(() => { const el = gl.domElement; let sx = 0; let sy = 0; let st = 0; const v = new THREE.Vector3(); const down = (e: PointerEvent) => { sx = e.clientX; sy = e.clientY; st = performance.now(); }; const up = (e: PointerEvent) => { const { data: d, flags: fl, camera: cam3, size: sz, onPick: pick } = latest.current; if (!pick) return; if (Math.hypot(e.clientX - sx, e.clientY - sy) > 6 || performance.now() - st > 600) return; const rect = el.getBoundingClientRect(); const px = e.clientX - rect.left; const py = e.clientY - rect.top; const tol = (e.pointerType === 'touch' ? 22 : 12) ** 2; const p = cur.current; const cam = cam3.position; const camLen2 = cam.lengthSq(); let best = -1; let bestD = Infinity; let bestScore = Infinity; for (let i = 0; i < d.n; i++) { if ((fl[i] ?? 1) < 0.5) continue; v.set(p[i * 3]!, p[i * 3 + 1]!, p[i * 3 + 2]!); // Occlusion: closest approach of the camera→point line to the origin, restricted to the segment. const dx = v.x - cam.x; const dy = v.y - cam.y; const dz = v.z - cam.z; const len2 = dx * dx + dy * dy + dz * dz; const t = -(cam.x * dx + cam.y * dy + cam.z * dz) / len2; if (t > 0 && t < 1) { const cx = cam.x + dx * t; const cy = cam.y + dy * t; const cz = cam.z + dz * t; if (cx * cx + cy * cy + cz * cz < 1) continue; } v.project(cam3); if (v.z > 1) continue; const x = ((v.x + 1) / 2) * sz.width; const y = ((1 - v.y) / 2) * sz.height; const d2 = (x - px) ** 2 + (y - py) ** 2; if (d2 > tol) continue; // Prefer close-to-cursor, then nearer to camera. const score = d2 + (len2 / camLen2) * 4; if (score < bestScore) { bestScore = score; bestD = d2; best = i; } } if (best >= 0 && bestD <= tol) pick({ index: best, norad: d.norad[best] ?? 0 }); else pick(null); }; el.addEventListener('pointerdown', down); el.addEventListener('pointerup', up); return () => { el.removeEventListener('pointerdown', down); el.removeEventListener('pointerup', up); }; }, [gl]); const ringColor = useMemo(() => new THREE.Color(token('--accent')), []); return ( ); }