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%
5.1 KB · 158 lines typescript
Raw Blame History
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