spb/satelliteindex
Public
TypeScript 66.5%
Python 30.9%
JavaScript 1.4%
CSS 0.7%
1'use client';2/**3 * Positions snapshot → GPU-ready buffers. Fetches `/orbit/positions` (SGP4 at t0 and t1 = t0 + step_s) every 30 s,4 * converts both endpoints to xyz on the compressed-altitude sphere so the render loop can lerp in Cartesian space5 * (no longitude wrap-around issues), and precomputes per-object class/mission/active flags for filtering.6 */7import { useEffect, useRef, useState } from 'react';8import { clientApi } from '@/lib/client-api';9import type { PositionsSnapshot } from '@/lib/types';10import { altToRadius, isLowPower, llaToXyz } from './geo';1112export const REFRESH_MS = 30_000;13export const MOBILE_CAP = 6_000;1415export interface GlobeData {16 /** Number of rendered objects (≤ snapshot.count when capped). */17 n: number;18 /** Total objects in the snapshot (real count, before any device cap). */19 total: number;20 t0: number;21 t1: number;22 t0Iso: string;23 p0: Float32Array; // xyz at t0 (3n)24 p1: Float32Array; // xyz at t1 (3n)25 norad: Int32Array;26 cls: Uint8Array;27 mission: Uint8Array;28 active: Uint8Array;29 alt0: Float32Array;30 alt1: Float32Array;31 vel: Float32Array;32 legend: PositionsSnapshot['legend'];33 /** Counts over the full snapshot (not the capped subset). */34 counts: { cls: number[]; mission: number[]; active: number; inactive: number };35 capped: boolean;36 fetchedAt: number;37}3839function priority(s: PositionsSnapshot, i: number): number {40 // Active first, then non-LEO (so the GEO/MEO rings stay legible on a capped device), then stations.41 const cls = s.legend.cls[s.cls[i] ?? 0] ?? 'LEO';42 const mission = s.legend.mission[s.mission[i] ?? 0] ?? '';43 return (s.active[i] ? 4 : 0) + (cls !== 'LEO' ? 2 : 0) + (mission === 'station' ? 1 : 0);44}4546export function prepare(s: PositionsSnapshot, cap: number | null): GlobeData {47 const total = s.count;48 let order: number[] | null = null;49 if (cap !== null && total > cap) {50 order = Array.from({ length: total }, (_, i) => i);51 const pr = order.map((i) => priority(s, i));52 order.sort((a, b) => pr[b]! - pr[a]! || a - b);53 order.length = cap;54 }55 const n = order ? order.length : total;56 const p0 = new Float32Array(n * 3);57 const p1 = new Float32Array(n * 3);58 const norad = new Int32Array(n);59 const cls = new Uint8Array(n);60 const mission = new Uint8Array(n);61 const active = new Uint8Array(n);62 const alt0 = new Float32Array(n);63 const alt1 = new Float32Array(n);64 const vel = new Float32Array(n);65 for (let k = 0; k < n; k++) {66 const i = order ? order[k]! : k;67 const b = i * 6;68 const a0 = s.pos[b + 2] ?? 0;69 const a1 = s.pos[b + 5] ?? 0;70 llaToXyz(s.pos[b] ?? 0, s.pos[b + 1] ?? 0, altToRadius(a0), p0, k * 3);71 llaToXyz(s.pos[b + 3] ?? 0, s.pos[b + 4] ?? 0, altToRadius(a1), p1, k * 3);72 norad[k] = s.norad[i] ?? 0;73 cls[k] = s.cls[i] ?? 0;74 mission[k] = s.mission[i] ?? 0;75 active[k] = s.active[i] ?? 0;76 alt0[k] = a0;77 alt1[k] = a1;78 vel[k] = s.vel?.[i] ?? 0;79 }80 const counts = { cls: s.legend.cls.map(() => 0), mission: s.legend.mission.map(() => 0), active: 0, inactive: 0 };81 for (let i = 0; i < total; i++) {82 counts.cls[s.cls[i] ?? 0] = (counts.cls[s.cls[i] ?? 0] ?? 0) + 1;83 counts.mission[s.mission[i] ?? 0] = (counts.mission[s.mission[i] ?? 0] ?? 0) + 1;84 if (s.active[i]) counts.active++;85 else counts.inactive++;86 }87 return {88 n,89 total,90 t0: Date.parse(s.t0),91 t1: Date.parse(s.t1),92 t0Iso: s.t0,93 p0,94 p1,95 norad,96 cls,97 mission,98 active,99 alt0,100 alt1,101 vel,102 legend: s.legend,103 counts,104 capped: n < total,105 fetchedAt: Date.now(),106 };107}108109export interface PositionsState {110 data: GlobeData | null;111 error: string | null;112 loading: boolean;113}114115export function usePositions(enabled = true): PositionsState {116 const [state, setState] = useState<PositionsState>({ data: null, error: null, loading: true });117 const capRef = useRef<number | null>(null);118119 useEffect(() => {120 if (!enabled) return;121 capRef.current = isLowPower() ? MOBILE_CAP : null;122 let ctrl: AbortController | null = null;123 let timer: ReturnType<typeof setTimeout> | null = null;124 let disposed = false;125126 const tick = async () => {127 ctrl?.abort();128 ctrl = new AbortController();129 try {130 const res = await clientApi.positions(ctrl.signal);131 if (disposed) return;132 setState({ data: prepare(res.data, capRef.current), error: null, loading: false });133 } catch (e) {134 if (disposed || (e as Error).name === 'AbortError') return;135 setState((s) => ({ data: s.data, error: s.data ? null : 'Live positions unavailable', loading: false }));136 } finally {137 if (!disposed) timer = setTimeout(tick, REFRESH_MS);138 }139 };140 const onVis = () => {141 if (document.visibilityState === 'visible') {142 if (timer) clearTimeout(timer);143 void tick();144 }145 };146 void tick();147 document.addEventListener('visibilitychange', onVis);148 return () => {149 disposed = true;150 ctrl?.abort();151 if (timer) clearTimeout(timer);152 document.removeEventListener('visibilitychange', onVis);153 };154 }, [enabled]);155156 return state;157}158