Back to portfolio
Case Study

OrbitVoyage

Real-time satellite tracking dashboard with SGP4 propagation, conjunction detection, and 3D WebGL globe visualization.

React · Three.js · WebGL · NestJS · SGP4 · WebSockets Live site ↗

The Problem

I wanted to build something that pushed beyond typical web development — something that required real math, real data pipelines, and real-time rendering constraints. Satellite tracking was the answer: it demands SGP4 orbital mechanics, sub-frame rendering budgets, and the ability to process thousands of state vectors per second.

The core challenge: take raw Two-Line Element (TLE) data from NORAD/CelesTrak, propagate orbital positions using the SGP4 algorithm, detect potential collision events in near-real-time, and render all of it on a 3D WebGL globe — all within a 16ms frame budget to maintain 60fps.

Architecture

The system is split into three layers:

Data Ingestion (NestJS Backend)

A scheduled cron job pulls TLE catalogs from CelesTrak every hour across 8 satellite groups (Starlink, GPS, GEO, weather, ISS, etc.). Each group has a target allocation — 130 Starlink objects, 55 GEO, 45 GPS, etc. — capped at 360 objects per propagation batch to keep latency manageable. TLEs are parsed from NORAD's three-line format, deduplicated by NORAD ID, and cached in memory with a fallback catalog of 12 hardcoded objects (ISS, Hubble, GOES, Sentinel, Landsat, GPS) in case CelesTrak is unreachable.

Orbital Propagation

The backend sends TLE batches to a Python ML service that runs SGP4 propagation. The service returns full state vectors — position (x, y, z in ECI coordinates) and velocity (vx, vy, vz) — plus derived latitude, longitude, and altitude. On the frontend, I wrote orbitMath.ts to derive classical orbital elements from these state vectors: semi-major axis, eccentricity, inclination, period, and perigee/apogee altitudes. The math uses the vis-viva equation (specific orbital energy = v²/2 − μ/r) and the angular momentum cross product to extract Keplerian elements.

Conjunction Detection

A separate service scans all propagated states every 30 seconds. It sends positions to the ML service which computes pairwise miss distances, time-of-closest-approach (TCA), collision probability, and a normalized 0–1 risk score. Events below a 0.1 risk threshold are suppressed to avoid noise. Events above threshold are broadcast to all connected clients via WebSocket. The conjunction event model includes both objects' NORAD IDs, the TCA timestamp, miss distance in km, and a discrete risk level (LOW / MEDIUM / HIGH / CRITICAL).

The Hardest Problems

1. ECI-to-Sphere Coordinate Mapping

Real orbital altitudes vary enormously — LEO satellites orbit at ~400km while GEO satellites are at ~36,000km. If I mapped these linearly onto the globe, LEO objects would be invisible dots on the surface and GEO objects would be off-screen. I solved this with logarithmic altitude compression: visualAltitude = log(1 + altitude / 450) × 0.45. This preserves the relative ordering of orbital bands (LEO < MEO < GEO < HEO) while keeping everything visible and distinguishable on screen. Satellites are color-coded by band — cyan for LEO, purple for MEO, amber for GEO, rose for HEO.

2. Frame Budget Management

Each render frame needs to update 250+ satellite positions, recalculate 3D sphere coordinates, and render instanced geometry on the globe. The useSatelliteRenderData hook uses React's useMemo to only recompute positions when the orbital state array changes — not on every frame. The Three.js globe uses instanced meshes rather than individual mesh objects, reducing draw calls from 250+ to 1.

3. TLE Staleness

TLEs degrade in accuracy over time. A TLE that's 3 days old can produce position errors of several kilometers. The hourly refresh cycle keeps the catalog fresh, but I also built the fallback system so the dashboard never shows an empty globe — even if CelesTrak is down for hours, the 12 hardcoded high-priority objects (ISS, Hubble, NOAA, GOES, Sentinel, Landsat, GPS) keep the visualization meaningful.

What I'd Do Differently

The conjunction detection currently relies on the ML service for all pairwise distance calculations. For a production system, I'd implement a spatial indexing structure (octree or k-d tree) on the backend to pre-filter candidate pairs before sending them to the ML model — reducing the O(n²) comparison space to O(n log n). I'd also add historical TLE archiving to track orbital decay over time, and implement SGP4 propagation directly in the browser using a WASM-compiled version of the Vallado reference implementation, eliminating the backend round-trip for position updates.

By the Numbers

360 Objects per propagation batch
8 CelesTrak satellite groups
30s Conjunction scan interval
<16ms Target frame budget
12 Hardcoded fallback objects
4 Orbital band classifications