'use client'; /** * Positions snapshot → GPU-ready buffers. Fetches `/orbit/positions` (SGP4 at t0 and t1 = t0 + step_s) every 30 s, * converts both endpoints to xyz on the compressed-altitude sphere so the render loop can lerp in Cartesian space * (no longitude wrap-around issues), and precomputes per-object class/mission/active flags for filtering. */ import { useEffect, useRef, useState } from 'react'; import { clientApi } from '@/lib/client-api'; import type { PositionsSnapshot } from '@/lib/types'; import { altToRadius, isLowPower, llaToXyz } from './geo'; export const REFRESH_MS = 30_000; export const MOBILE_CAP = 6_000; export interface GlobeData { /** Number of rendered objects (≤ snapshot.count when capped). */ n: number; /** Total objects in the snapshot (real count, before any device cap). */ total: number; t0: number; t1: number; t0Iso: string; p0: Float32Array; // xyz at t0 (3n) p1: Float32Array; // xyz at t1 (3n) norad: Int32Array; cls: Uint8Array; mission: Uint8Array; active: Uint8Array; alt0: Float32Array; alt1: Float32Array; vel: Float32Array; legend: PositionsSnapshot['legend']; /** Counts over the full snapshot (not the capped subset). */ counts: { cls: number[]; mission: number[]; active: number; inactive: number }; capped: boolean; fetchedAt: number; } function priority(s: PositionsSnapshot, i: number): number { // Active first, then non-LEO (so the GEO/MEO rings stay legible on a capped device), then stations. const cls = s.legend.cls[s.cls[i] ?? 0] ?? 'LEO'; const mission = s.legend.mission[s.mission[i] ?? 0] ?? ''; return (s.active[i] ? 4 : 0) + (cls !== 'LEO' ? 2 : 0) + (mission === 'station' ? 1 : 0); } export function prepare(s: PositionsSnapshot, cap: number | null): GlobeData { const total = s.count; let order: number[] | null = null; if (cap !== null && total > cap) { order = Array.from({ length: total }, (_, i) => i); const pr = order.map((i) => priority(s, i)); order.sort((a, b) => pr[b]! - pr[a]! || a - b); order.length = cap; } const n = order ? order.length : total; const p0 = new Float32Array(n * 3); const p1 = new Float32Array(n * 3); const norad = new Int32Array(n); const cls = new Uint8Array(n); const mission = new Uint8Array(n); const active = new Uint8Array(n); const alt0 = new Float32Array(n); const alt1 = new Float32Array(n); const vel = new Float32Array(n); for (let k = 0; k < n; k++) { const i = order ? order[k]! : k; const b = i * 6; const a0 = s.pos[b + 2] ?? 0; const a1 = s.pos[b + 5] ?? 0; llaToXyz(s.pos[b] ?? 0, s.pos[b + 1] ?? 0, altToRadius(a0), p0, k * 3); llaToXyz(s.pos[b + 3] ?? 0, s.pos[b + 4] ?? 0, altToRadius(a1), p1, k * 3); norad[k] = s.norad[i] ?? 0; cls[k] = s.cls[i] ?? 0; mission[k] = s.mission[i] ?? 0; active[k] = s.active[i] ?? 0; alt0[k] = a0; alt1[k] = a1; vel[k] = s.vel?.[i] ?? 0; } const counts = { cls: s.legend.cls.map(() => 0), mission: s.legend.mission.map(() => 0), active: 0, inactive: 0 }; for (let i = 0; i < total; i++) { counts.cls[s.cls[i] ?? 0] = (counts.cls[s.cls[i] ?? 0] ?? 0) + 1; counts.mission[s.mission[i] ?? 0] = (counts.mission[s.mission[i] ?? 0] ?? 0) + 1; if (s.active[i]) counts.active++; else counts.inactive++; } return { n, total, t0: Date.parse(s.t0), t1: Date.parse(s.t1), t0Iso: s.t0, p0, p1, norad, cls, mission, active, alt0, alt1, vel, legend: s.legend, counts, capped: n < total, fetchedAt: Date.now(), }; } export interface PositionsState { data: GlobeData | null; error: string | null; loading: boolean; } export function usePositions(enabled = true): PositionsState { const [state, setState] = useState({ data: null, error: null, loading: true }); const capRef = useRef(null); useEffect(() => { if (!enabled) return; capRef.current = isLowPower() ? MOBILE_CAP : null; let ctrl: AbortController | null = null; let timer: ReturnType | null = null; let disposed = false; const tick = async () => { ctrl?.abort(); ctrl = new AbortController(); try { const res = await clientApi.positions(ctrl.signal); if (disposed) return; setState({ data: prepare(res.data, capRef.current), error: null, loading: false }); } catch (e) { if (disposed || (e as Error).name === 'AbortError') return; setState((s) => ({ data: s.data, error: s.data ? null : 'Live positions unavailable', loading: false })); } finally { if (!disposed) timer = setTimeout(tick, REFRESH_MS); } }; const onVis = () => { if (document.visibilityState === 'visible') { if (timer) clearTimeout(timer); void tick(); } }; void tick(); document.addEventListener('visibilitychange', onVis); return () => { disposed = true; ctrl?.abort(); if (timer) clearTimeout(timer); document.removeEventListener('visibilitychange', onVis); }; }, [enabled]); return state; }