Space Debris Collision Risk Tracker

Visualize 17,000 real orbital debris objects and detect collision risks.

Introduction

30 Second Summary

Over 17,000 tracked objects orbit Earth right now, and most of them are not functioning satellites. They are spent rocket stages, collision fragments, and discarded hardware that threaten every active mission in orbit.

In this project, you will build a real-time debris visualization that renders thousands of real tracked objects around your OrbitX globe. You will use Web Workers to keep the UI responsive while color-coding debris by altitude and detecting potential collisions between objects.

What You'll Build

Toggle the debris layer on and thousands of altitude-colored dots bloom around your globe, each one a real tracked object updating its position every second while the globe stays buttery smooth.

By the end of this project, you'll have:

  • A live debris field of ~17,000 real tracked objects orbiting your globe as altitude-colored particles, staying smooth at 60fps.
  • A worker pool that handles all orbital math on separate threads, keeping the globe fully interactive while thousands of positions update every second.
  • Conjunction warnings that flag objects within 10 km of each other with clickable alerts, plus a stats dashboard with animated counters showing the debris breakdown by altitude band.
  • Secret Mission: Simulate a Kessler Syndrome collision and watch debris fragments cascade outward from the impact point.

Are there any prerequisites?

This project builds on your existing OrbitX codebase from Projects 1-4. You need a working React Three Fiber globe with a FastAPI backend, satellite.js propagation, and multi-satellite view before starting.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters for the future of spaceflight.

Set Up the Debris Data Endpoint

Your existing OrbitX backend fetches TLE data for active satellites, but the space debris problem is on a completely different scale. CelesTrak's analyst satellite catalog tracks roughly 17,000 objects, from spent rocket bodies to tiny fragments.

There is a catch: this catalog cannot use the TLE format your backend already knows. CelesTrak exhausted 5-digit NORAD catalog numbers in July 2026, and TLE format cannot represent the new 6-digit IDs. The replacement is OMM (Orbit Mean-Elements Message) JSON, which supports 9-digit catalog numbers. In this step, you will build a new endpoint that fetches, caches, and serves this data.

In this step, get ready to:
  • Create a debris data endpoint that fetches and caches OMM records from CelesTrak.
  • Register the new endpoint in your backend and confirm the Vite worker config.
  • Verify the endpoint returns the full analyst satellite catalog.
Create the debris data endpoint

The analyst satellite catalog is a large payload that takes several seconds to download. Fetching it on every API call would be wasteful and could hit CelesTrak's usage limits. Your endpoint will cache the data in memory for one hour.

  • Create a new file called debris.py inside your backend/app/ folder (the same folder as your existing main.py).
  • Paste the following code into debris.py:
import time

import httpx
from fastapi import APIRouter, HTTPException

router = APIRouter()

# In-memory cache: stores the fetched data and the fetch timestamp
debris_cache: dict = {"data": None, "timestamp": 0}

# CelesTrak analyst satellite catalog in OMM JSON format
CELESTRAK_ANALYST_URL = (
    "https://celestrak.org/NORAD/elements/gp.php?GROUP=analyst&FORMAT=JSON"
)
CACHE_TTL = 3600  # Re-fetch after 1 hour

@router.get("/api/debris")
async def get_debris():
    now = time.time()
    if debris_cache["data"] is None or now - debris_cache["timestamp"] > CACHE_TTL:
        try:
            # 60s timeout: the analyst catalog payload is large (~17k objects)
            async with httpx.AsyncClient() as client:
                response = await client.get(CELESTRAK_ANALYST_URL, timeout=60.0)
                response.raise_for_status()
                debris_cache["data"] = response.json()
                debris_cache["timestamp"] = now
        except httpx.HTTPError as e:
            raise HTTPException(status_code=502, detail=f"CelesTrak fetch failed: {e}")
    return debris_cache["data"]

What does this code do?

This endpoint fetches the full analyst satellite catalog from CelesTrak in OMM JSON format. Each record contains orbital parameters like MEAN_MOTION, ECCENTRICITY, and NORAD_CAT_ID that the frontend will use to compute orbits.

  • debris_cache stores the entire JSON array in memory so repeated calls skip the network round-trip.
  • CACHE_TTL = 3600 means the cache expires after one hour. CelesTrak asks users not to make excessive automated requests, so this keeps you within their usage policy.
  • The timeout=60.0 on the httpx request gives the server a full 60 seconds to respond, because the analyst catalog is several megabytes.
  • Save debris.py.

Seeing a red underline on httpx?

If your editor flags httpx as unresolved, it may not be installed in your virtual environment. Your backend from earlier projects likely already has it, but if not, install it by running:

pip install httpx

help me debug an httpx import error in my FastAPI backend

✔️ Awesome, I've got everything!

Great. Make sure you have saved debris.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete backend/app/debris.py file for comparison:

import time

import httpx
from fastapi import APIRouter, HTTPException

router = APIRouter()

debris_cache: dict = {"data": None, "timestamp": 0}

CELESTRAK_ANALYST_URL = (
    "https://celestrak.org/NORAD/elements/gp.php?GROUP=analyst&FORMAT=JSON"
)
CACHE_TTL = 3600

@router.get("/api/debris")
async def get_debris():
    now = time.time()
    if debris_cache["data"] is None or now - debris_cache["timestamp"] > CACHE_TTL:
        try:
            async with httpx.AsyncClient() as client:
                response = await client.get(CELESTRAK_ANALYST_URL, timeout=60.0)
                response.raise_for_status()
                debris_cache["data"] = response.json()
                debris_cache["timestamp"] = now
        except httpx.HTTPError as e:
            raise HTTPException(status_code=502, detail=f"CelesTrak fetch failed: {e}")
    return debris_cache["data"]
Register the debris router in your backend

The endpoint exists in its own file, but FastAPI does not know about it yet. You need to import the router and attach it to your app.

  • Open backend/app/main.py.
  • Add this import near the top of the file, alongside your existing imports:
# Import the debris data router
from app.debris import router as debris_router
  • Find the section where your FastAPI app is defined (the app = FastAPI() line) and add the following below it:
# Register the debris data endpoint
app.include_router(debris_router)

What does include_router do?

include_router merges all the routes defined in debris.py into your main app. The as debris_router alias avoids naming conflicts with any other router variables you already have.

  • Save main.py.

One more thing to confirm before you test. Later in this project you will add Web Workers that import satellite.js as an ES module. Vite needs a specific config flag for that to work.

  • Open vite.config.ts in your frontend folder.
  • Confirm that your config includes worker: { format: "es" } inside the build or top-level config. It should look something like this:
// Inside your Vite config, confirm this line exists:
worker: { format: "es" }

Why does Vite need this setting?

satellite.js 7.1.0 is ESM-only. Without worker: { format: "es" }, Vite builds workers in a format that cannot import ES modules, causing build failures. This setting was added as a workaround for a known issue with satellite.js 7.x and Vite.

Don't see the worker config?

If worker: { format: "es" } is missing from your vite.config.ts, add it at the top level of the config object (next to plugins and server). Save the file after adding it.

help me add the worker format config to my vite.config.ts

Start the backend and verify the endpoint
  • Start your backend server by running this command from your backend folder:
uvicorn app.main:app --reload

Before you hit the endpoint, think about what you expect. The analyst catalog contains roughly 17,000 tracked objects. What format will each object be in, and what fields do you expect to see?

  • Open a second terminal and test the endpoint by running:
curl http://localhost:8000/api/debris | python -m json.tool | head -20

You should see the first few OMM JSON objects, each with fields like OBJECT_NAME, MEAN_MOTION, ECCENTRICITY, and NORAD_CAT_ID. The first request may take 10-30 seconds as the backend downloads the full catalog from CelesTrak.

What are these OMM fields?

Each record is an OMM (Orbit Mean-Elements Message) object. It contains the same orbital parameters as a TLE, but in a structured JSON format that supports 6-digit catalog numbers.

  • OBJECT_NAME is the name of the tracked object (e.g. a rocket body or debris fragment).
  • MEAN_MOTION and ECCENTRICITY describe the shape and speed of the orbit.
  • NORAD_CAT_ID is the unique catalog number for each object. Some of these are now 6 digits, which is why TLE format can no longer represent the full catalog.

Getting a connection error or empty response?

  • Make sure your backend server is running in the first terminal. You should see a line in the terminal output confirming it started on port 8000.
  • If you see a 502 error mentioning "CelesTrak fetch failed", the request to CelesTrak may have timed out. Wait a moment and try the curl command again.
  • Check that the import in main.py uses from app.debris import router as debris_router exactly. A typo in the module name causes an import error on startup.

help me debug my /api/debris endpoint

Your debris endpoint is live and serving thousands of orbital records. Next up, you will see firsthand what happens when you try to propagate all those orbits on the main thread.

Freeze the Browser

Your backend is serving ~17,000 tracked objects from the CelesTrak analyst satellite catalog. Now you need to turn that data into moving dots orbiting your globe.

The quickest way to get those dots on screen is to propagate every orbit directly in the render loop. In this step, you will build that naive version and see exactly what happens when you ask the browser's main thread to run ~17,000 SGP4 calculations every single frame.

In this step, get ready to:
  • Create a debris field component that fetches OMM data and propagates orbits on the main thread.
  • Add a toggle button to your UI and experience the browser freeze firsthand.
Create the debris field component

Each OMM record from the backend describes an orbit using mean elements. To turn that into a 3D position, you pass the record through json2satrec (which parses it into a satellite record) and then propagate (which computes the object's position at a given time).

  • Create a new file frontend/src/components/DebrisField.tsx and paste the following code to set up the component shell:
import { useEffect, useState, useMemo } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import { json2satrec, propagate } from 'satellite.js';

const EARTH_RADIUS_KM = 6371;
const SCALE = 1 / EARTH_RADIUS_KM;
const MAX_OBJECTS = 20000;

export default function DebrisField({ visible }: { visible: boolean }) {
  const [ommData, setOmmData] = useState<Record<string, unknown>[] | null>(null);

  // Pre-allocate geometry buffers for up to 20,000 points
  const geometry = useMemo(() => {
    const geo = new THREE.BufferGeometry();
    const positions = new Float32Array(MAX_OBJECTS * 3);
    geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    geo.setDrawRange(0, 0);
    return geo;
  }, []);

  // Fetch OMM records when toggled on
  useEffect(() => {
    if (!visible) return;
    fetch('/api/debris')
      .then((res) => res.json())
      .then((data: Record<string, unknown>[]) => setOmmData(data))
      .catch((err) => console.error('Failed to fetch debris:', err));
  }, [visible]);

What does this code do?

SCALE = 1 / EARTH_RADIUS_KM normalizes ECI coordinates (in km) so Earth has a radius of 1.0 in Three.js units. This matches the globe you built in previous projects.

The useMemo pre-allocates a Float32Array large enough for 20,000 points. Pre-allocating avoids creating new arrays every frame, which would trigger garbage collection stutters.

  • Add the propagation loop and render output by pasting this code directly below the useEffect block you just wrote:
  // Propagate every object on the main thread each frame
  useFrame(() => {
    if (!visible || !ommData) return;
    const posAttr = geometry.getAttribute('position') as THREE.BufferAttribute;
    const date = new Date();
    let validCount = 0;
    for (let i = 0; i < ommData.length; i++) {
      try {
        const satrec = json2satrec(ommData[i]);
        const result = propagate(satrec, date);
        if (result !== null) {
          const pos = result.position;
          posAttr.setXYZ(validCount, pos.x * SCALE, pos.y * SCALE, pos.z * SCALE);
          validCount++;
        }
      } catch { /* skip failed propagations */ }
    }
    posAttr.needsUpdate = true;
    geometry.setDrawRange(0, validCount);
    geometry.computeBoundingSphere();
  });

  if (!visible) return null;
  return (
    <points geometry={geometry}>
      <pointsMaterial size={0.02} sizeAttenuation color="white" transparent opacity={0.8} depthWrite={false} />
    </points>
  );
}

What does this code do?

The useFrame callback runs on every render frame. It loops through every OMM record, converts it to a satellite record with json2satrec, computes its current ECI position with propagate, and writes the scaled XYZ coordinates into the pre-allocated buffer.

Setting needsUpdate = true tells Three.js to upload the modified position data to the GPU. setDrawRange limits rendering to only the points that propagated successfully.

  • Save DebrisField.tsx.
  • Confirm the file exists by running:
ls frontend/src/components/DebrisField.tsx

You should see the file path printed back. The component is ready but nothing renders it yet.

File not found?

Make sure you created the file inside frontend/src/components/, not in the project root or the backend folder.

Help me find the right path for my DebrisField component.

✔️ Awesome, I've got everything!

Double check you have saved DebrisField.tsx. Now you need to wire it into your scene.

ⓧ I'd like to double check the full code

Scroll up and confirm your DebrisField.tsx contains both chunks in order:

  • Chunk 1: Imports, constants (EARTH_RADIUS_KM, SCALE, MAX_OBJECTS), component declaration with state, geometry useMemo, and the useEffect fetch.
  • Chunk 2: The useFrame propagation loop, the if (!visible) return null guard, and the <points> JSX return with the closing }.
Add the debris toggle and experience the freeze

The component exists, but nothing renders it. You need a toggle in your UI that mounts DebrisField inside your R3F Canvas when enabled.

  • Open the component that wraps your R3F <Canvas> (this is typically your main scene or app component from previous projects).
  • Add the import for your new component at the top of the file:
import DebrisField from './components/DebrisField';
  • Add a state variable for the debris toggle inside the component:
const [showDebris, setShowDebris] = useState(false);
  • Add a toggle button to your UI controls (outside the <Canvas>, alongside any existing buttons):
<button onClick={() => setShowDebris((prev) => !prev)}>
  {showDebris ? 'Hide Debris' : 'Show Debris'}
</button>
  • Add <DebrisField> inside your <Canvas>, next to your existing globe and satellite components:
<DebrisField visible={showDebris} />
  • Save the file.

What does this wiring do?

The visible prop controls everything. When showDebris is false, the component skips the fetch, skips propagation, and returns null. When you click the toggle, the component mounts, fetches ~17,000 OMM records, and starts propagating every single one on the main thread.

Before you click that toggle, think about what is going to happen. Your useFrame callback runs on every animation frame. Each frame, it calls json2satrec and propagate for all ~17,000 objects. Do you think the UI will stay responsive?

  • Switch to your browser where the OrbitX app is running.
  • Click the Show Debris button.
  • Try to rotate the globe while you wait.

The browser freezes. For 5 or more seconds, nothing responds. Your mouse drags do nothing. The UI is completely locked. Then white dots appear around the globe. Try rotating now. It stutters badly because the propagation loop re-runs on every frame, hogging the main thread before the browser can paint the next frame.

This is exactly the result you should see. This is ~17,000 SGP4 calculations running synchronously on the main thread every single frame.

Why does the browser freeze?

Browsers use a single main thread for JavaScript execution, DOM updates, layout, and painting. When your useFrame callback takes longer than 16ms (the budget for 60fps), the browser cannot process mouse events, update the DOM, or repaint the screen until the callback finishes.

Each SGP4 propagation is fast individually. But ~17,000 of them back-to-back adds up to several seconds of blocking work per frame. The result is the frozen, unresponsive UI you just experienced.

Not seeing any dots at all?

  • Open the browser console and check for errors. A 404 on /api/debris means the backend endpoint from Step 1 is not running or the router is not registered.
  • Check that <DebrisField visible={showDebris} /> is inside your <Canvas> component, not outside it. R3F components must be children of <Canvas>.
  • Make sure both your backend and frontend dev servers are running.

Help me debug why my debris dots are not appearing.

You have proven the problem. The main thread cannot handle ~17,000 SGP4 calculations per frame without locking up the entire UI. In the next step, you will move all that orbital math off the main thread using a pool of Web Workers, keeping the globe smooth at 60fps while thousands of orbits propagate in parallel.

Build the Web Worker Pool

In the previous step, you felt the pain of running ~17,000 SGP4 calculations on the main thread. The browser locked up for seconds, and the globe became completely unresponsive.

The fix is Web Workers. A Web Worker runs JavaScript on a separate thread, keeping the main thread free to render and handle interaction. In this step, you will build a pool of 4 workers that split the OMM records between them, propagate orbits in parallel, and send results back using zero-copy buffer transfers.

In this step, get ready to:
  • Create a propagator worker that receives OMM records and returns positions as transferred Float32Arrays.
  • Build a worker pool manager that spawns 4 workers and merges their results.
  • Refactor DebrisField to use the pool, keeping the globe interactive at 60fps.
Create the propagator worker

Each worker receives a batch of OMM records and a timestamp, propagates every orbit using satellite.js, and sends the resulting positions and altitudes back to the main thread. The key performance trick is transferring the Float32Array buffers instead of copying them.

  • Create a new folder frontend/src/workers/ and inside it create a file called propagator.worker.ts.
  • Add the imports, message type interfaces, and the start of the onmessage handler by pasting this code:
import { json2satrec, propagate, eciToGeodetic, gstime } from 'satellite.js';

export interface PropagatorMessage {
  ommRecords: Record<string, unknown>[];
  timestamp: number;
}

export interface PropagatorResult {
  positions: Float32Array;
  altitudes: Float32Array;
  count: number;
}

self.onmessage = (e: MessageEvent<PropagatorMessage>) => {
  const { ommRecords, timestamp } = e.data;
  const date = new Date(timestamp);
  const gmst = gstime(date);

  const count = ommRecords.length;
  const positions = new Float32Array(count * 3);
  const altitudes = new Float32Array(count);

What does this code do?

PropagatorMessage and PropagatorResult define the shapes of data flowing in and out of the worker. The worker pre-allocates two typed arrays: positions (3 floats per object for x, y, z) and altitudes (1 float per object for altitude in km).

gstime computes Greenwich Mean Sidereal Time, which eciToGeodetic needs later to convert ECI coordinates to latitude, longitude, and height.

  • Add the propagation loop and the transfer call directly below the code you just pasted, still inside the onmessage handler:
  for (let i = 0; i < count; i++) {
    try {
      const satrec = json2satrec(ommRecords[i]);
      const result = propagate(satrec, date);

      if (result !== null) {
        const pos = result.position;
        positions[i * 3] = pos.x;
        positions[i * 3 + 1] = pos.y;
        positions[i * 3 + 2] = pos.z;

        const geo = eciToGeodetic(pos, gmst);
        altitudes[i] = geo.height;
      } else {
        altitudes[i] = -1;
      }
    } catch {
      altitudes[i] = -1;
    }
  }

  const response: PropagatorResult = { positions, altitudes, count };
  self.postMessage(response, { transfer: [positions.buffer, altitudes.buffer] });
};

What does this code do?

The loop calls json2satrec to parse each OMM record into a satellite record, then propagate to compute its ECI position at the given timestamp. Failed propagations get an altitude of -1 so the main thread can filter them out later.

The final line is critical: self.postMessage(response, { transfer: [positions.buffer, altitudes.buffer] }) transfers ownership of both ArrayBuffers to the main thread instead of copying them. After the transfer, the buffers are detached from the worker and cannot be reused there. This zero-copy handoff is what makes the worker fast.

  • Save propagator.worker.ts.
  • Confirm the file exists by running:
ls frontend/src/workers/

You should see propagator.worker.ts listed.

✔️ Awesome, I've got everything!

Great. Make sure you have saved propagator.worker.ts before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm both code blocks are present in propagator.worker.ts, in order:

  • Chunk 1: the imports from satellite.js, the PropagatorMessage and PropagatorResult interfaces, and the start of self.onmessage through the typed array allocations.
  • Chunk 2: the for loop with json2satrec and propagate calls, the response object, and the self.postMessage transfer call ending with }; to close the handler.

Seeing a TypeScript error in the worker file?

  • Make sure you import all four functions from satellite.js: json2satrec, propagate, eciToGeodetic, and gstime.
  • Check that the file is saved inside frontend/src/workers/, not inside frontend/src/components/.

Ask for help debugging your propagator worker.

Build the worker pool manager

A single worker is faster than the main thread, but splitting the workload across 4 workers lets them propagate in parallel on separate CPU cores. The pool manager handles spawning workers, distributing OMM records evenly, and merging the results into combined arrays.

  • Create a new folder frontend/src/utils/ and inside it create a file called workerPool.ts.
  • Add the class skeleton with the constructor and terminate method by pasting this code:
import type { PropagatorResult } from '../workers/propagator.worker';

export class PropagatorPool {
  private workers: Worker[] = [];

  constructor(workerCount: number = 4) {
    for (let i = 0; i < workerCount; i++) {
      this.workers.push(
        new Worker(
          new URL('../workers/propagator.worker.ts', import.meta.url),
          { type: 'module' }
        )
      );
    }
  }

  terminate(): void {
    this.workers.forEach((w) => w.terminate());
    this.workers = [];
  }
}

What does this code do?

The constructor spawns 4 module workers using Vite's recommended pattern: new Worker(new URL(..., import.meta.url), { type: 'module' }). The { type: 'module' } option tells the browser to treat the worker script as an ES module so it can use import statements.

terminate kills all 4 worker threads and empties the array. You will call this when the debris layer is toggled off to free up resources.

  • Add the propagateAll method between the constructor and the terminate method by pasting this code:
  async propagateAll(
    ommRecords: Record<string, unknown>[],
    timestamp: number
  ): Promise<{ positions: Float32Array; altitudes: Float32Array }> {
    // Split the OMM array into equal chunks for each worker
    const chunkSize = Math.ceil(ommRecords.length / this.workers.length);

    const results = await Promise.all(
      this.workers.map((worker, i) => {
        const start = i * chunkSize;
        const chunk = ommRecords.slice(start, start + chunkSize);

        return new Promise<PropagatorResult>((resolve) => {
          worker.onmessage = (e: MessageEvent<PropagatorResult>) => {
            resolve(e.data);
          };
          worker.postMessage({ ommRecords: chunk, timestamp });
        });
      })
    );

    // Merge all worker results into single combined arrays
    const totalCount = ommRecords.length;
    const allPositions = new Float32Array(totalCount * 3);
    const allAltitudes = new Float32Array(totalCount);

    let offset = 0;
    for (const result of results) {
      allPositions.set(result.positions, offset * 3);
      allAltitudes.set(result.altitudes, offset);
      offset += result.count;
    }

    return { positions: allPositions, altitudes: allAltitudes };
  }

What does this code do?

propagateAll splits the OMM array into 4 equal chunks and sends each chunk to a different worker via postMessage. All 4 workers propagate their batch simultaneously on separate threads.

Promise.all waits for all 4 workers to finish. Then the merge step copies each worker's results into a single combined Float32Array using .set() with offset math. The main thread only does this lightweight merge, not the heavy SGP4 math.

  • Save workerPool.ts.
  • Confirm the file exists by running:
ls frontend/src/utils/

You should see workerPool.ts listed.

✔️ Awesome, I've got everything!

Great. Make sure you have saved workerPool.ts before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm both code blocks are present in workerPool.ts, in order:

  • Chunk 1: the import, the PropagatorPool class declaration, the constructor that spawns 4 workers, and the terminate method.
  • Chunk 2: the propagateAll method placed between the constructor and terminate. It splits records into chunks, dispatches to workers via Promise.all, and merges results into combined arrays.

Seeing a module import error?

  • Confirm that worker: { format: "es" } is set in your vite.config.ts. Without this, Vite cannot bundle the worker as an ES module, and satellite.js imports will fail inside the worker.
  • Check that the path in new URL('../workers/propagator.worker.ts', import.meta.url) matches your file structure exactly.

Ask for help debugging your worker pool setup.

Refactor DebrisField to use the pool

The worker and pool are ready. Now you will replace the synchronous main-thread propagation in DebrisField.tsx with the off-thread pool. The globe will stay interactive while thousands of orbits update every second.

  • In DebrisField.tsx, add this import for the worker pool at the top of the file alongside your existing imports:
import { PropagatorPool } from '../utils/workerPool';
  • Add a throttle constant near your other constants (like SCALE and MAX_OBJECTS):
const UPDATE_INTERVAL_MS = 1000;
  • Inside the DebrisField component function, add two new refs alongside your existing state and a useEffect that manages the worker pool lifecycle:
  // Worker pool refs
  const poolRef = useRef<PropagatorPool | null>(null);
  const lastUpdateRef = useRef<number>(0);

  // Spin up 4 workers when debris is visible, terminate on cleanup
  useEffect(() => {
    if (!visible) return;
    poolRef.current = new PropagatorPool(4);
    return () => {
      poolRef.current?.terminate();
      poolRef.current = null;
    };
  }, [visible]);

What does this effect do?

When visible becomes true, 4 Web Workers spin up. When the learner toggles debris off, the cleanup function calls terminate() to kill all 4 threads and free resources.

lastUpdateRef tracks the last propagation timestamp so the useFrame callback can throttle updates to once per second. Propagating 60 times per second would be wasteful because orbital positions barely change in 16ms.

  • Now replace the body of your existing useFrame callback. Remove the synchronous propagation loop (the code that calls json2satrec and propagate directly) and replace it with this:
  useFrame(() => {
    if (!visible || !ommData || !poolRef.current) return;

    const now = Date.now();
    if (now - lastUpdateRef.current < UPDATE_INTERVAL_MS) return;
    lastUpdateRef.current = now;

    poolRef.current.propagateAll(ommData, now).then(({ positions, altitudes }) => {
      const posAttr = geometry.getAttribute('position') as THREE.BufferAttribute;
      const count = Math.min(positions.length / 3, MAX_OBJECTS);

      for (let i = 0; i < count; i++) {
        posAttr.setXYZ(
          i,
          positions[i * 3] * SCALE,
          positions[i * 3 + 1] * SCALE,
          positions[i * 3 + 2] * SCALE
        );
      }

      posAttr.needsUpdate = true;
      geometry.setDrawRange(0, count);
      geometry.computeBoundingSphere();
    });
  });

What does this code do?

The throttle check ensures propagation runs at most once per second, not every animation frame. When the throttle passes, propagateAll dispatches the work to 4 workers and returns a Promise.

When the Promise resolves, the main thread writes the worker results into the BufferGeometry position attribute, scaling ECI positions by SCALE (1/6371) to match the globe's unit radius. This lightweight buffer update is the only work the main thread does now. The altitudes data will be used for color-coding in the next step.

  • Save DebrisField.tsx.

Before you test, what do you think will happen when you toggle debris on now? Will the globe freeze like before, or stay smooth?

  • Make sure both your backend and frontend dev servers are running.
  • Toggle the debris layer on in your UI.

Dots appear within a second, and the globe remains fully interactive. Try rotating, zooming, and clicking while debris particles update. No freeze.

  • Open DevTools in your browser by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
  • Click the Performance tab and record a few seconds while debris is updating.
  • Check that the main thread stays under 16ms per frame.

What should I see in the Performance tab?

The main thread flame chart should show short, even bars (each under 16ms). The heavy SGP4 computation is no longer visible on the main thread.

If you expand the Workers section in the flame chart, you will see 4 separate worker threads doing the propagation math in parallel.

Debris not appearing after the refactor?

  • Check the browser console for errors. A common issue is the worker path being incorrect. The new URL path in workerPool.ts must be '../workers/propagator.worker.ts' relative to the utils/ folder.
  • Make sure you removed the old synchronous propagation loop from useFrame in DebrisField.tsx. If both the old loop and the new pool call exist, they will conflict.
  • Confirm your backend is running and /api/debris returns data. The worker pool needs OMM records to propagate.

Ask for help debugging your Web Worker debris rendering.

Your debris field is now buttery smooth. Next up, you will color the particles by altitude band to reveal where debris actually concentrates in orbit.

Add Altitude-Band Coloring

Your debris field now renders thousands of dots at 60fps with the globe staying fully interactive. The Web Workers are doing their job.

But a cloud of white dots does not actually tell you anything about the debris problem. Where is the danger concentrated? Which altitude bands are most crowded? In this step, you will color each particle by its altitude band, revealing that LEO is packed compared to higher orbits.

In this step, get ready to:
  • Add altitude-to-color mapping that classifies debris into LEO, MEO, GEO, and HEO bands.
  • Enable depth-based sizing and blending so the particle field feels three-dimensional.
  • Add cleanup logic so toggling debris off terminates workers and clears state cleanly.
Add altitude color constants and a mapping function

Orbital debris clusters at specific altitude bands. LEO (Low Earth Orbit) below 2,000 km is the busiest region. GEO (Geostationary Orbit) at roughly 35,786 km is where communication satellites cluster in a thin ring.

  • In DebrisField.tsx, add these color constants and the getAltitudeColor helper function above the DebrisField component, after your existing constants like SCALE and MAX_OBJECTS:
// Altitude-band colors
const LEO_COLOR = new THREE.Color(1, 1, 1);       // White: Low Earth Orbit
const MEO_COLOR = new THREE.Color(1, 1, 0);       // Yellow: Medium Earth Orbit
const GEO_COLOR = new THREE.Color(1, 0.65, 0);    // Orange: Geostationary Orbit
const HEO_COLOR = new THREE.Color(1, 0, 0);       // Red: High Earth Orbit
const FAILED_COLOR = new THREE.Color(0.3, 0.3, 0.3); // Gray: propagation failed

// Map an object's altitude to its band color
function getAltitudeColor(altitude: number): THREE.Color {
  if (altitude < 0) return FAILED_COLOR;
  if (altitude < 2000) return LEO_COLOR;
  if (altitude < 35000) return MEO_COLOR;
  if (altitude < 36000) return GEO_COLOR;
  return HEO_COLOR;
}

What do these altitude bands mean?

LEO (below 2,000 km) holds the ISS, most Earth-observation satellites, and the vast majority of tracked debris. MEO (2,000 to 35,000 km) includes navigation constellations like GPS.

GEO (35,000 to 36,000 km) is the narrow band where satellites orbit in sync with Earth's rotation. HEO (above 36,000 km) covers highly elliptical orbits used for specialized missions. Objects that fail propagation get gray so they are still visible but visually distinct.

Next, the geometry needs a color buffer so THREE.Points can color each particle independently.

  • In the same file, find the useMemo where you create the BufferGeometry. Find these lines:
const positions = new Float32Array(MAX_OBJECTS * 3);
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  • Add a colors array and a 'color' attribute below them, so the block now looks like this:
const positions = new Float32Array(MAX_OBJECTS * 3);
const colors = new Float32Array(MAX_OBJECTS * 3);
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));

Now you need to write color values into that buffer every time the workers return new positions.

  • Find the .then callback inside your useFrame where you write positions from worker results. Replace the entire .then callback with this updated version that writes both positions and colors:
poolRef.current.propagateAll(ommData, now).then(({ positions, altitudes }) => {
  const posAttr = geometry.getAttribute('position') as THREE.BufferAttribute;
  const colorAttr = geometry.getAttribute('color') as THREE.BufferAttribute;
  const count = Math.min(positions.length / 3, MAX_OBJECTS);

  for (let i = 0; i < count; i++) {
    // Scale ECI position to match globe radius
    posAttr.setXYZ(
      i,
      positions[i * 3] * SCALE,
      positions[i * 3 + 1] * SCALE,
      positions[i * 3 + 2] * SCALE
    );

    // Map altitude to band color
    const alt = altitudes[i];
    const color = getAltitudeColor(alt);
    colorAttr.setXYZ(i, color.r, color.g, color.b);
  }

  posAttr.needsUpdate = true;
  colorAttr.needsUpdate = true;
  geometry.setDrawRange(0, count);
  geometry.computeBoundingSphere();
});

What changed in this callback?

  • colorAttr grabs the new 'color' buffer you just added to the geometry.
  • Inside the loop, getAltitudeColor(alt) picks the right color for each object's altitude, and colorAttr.setXYZ writes the RGB values into the buffer.
  • Setting colorAttr.needsUpdate = true tells Three.js to re-upload the color data to the GPU on the next render.

One last piece: tell the material to use per-vertex colors instead of a single flat color.

  • Find your <pointsMaterial> in the JSX return. It currently looks like this:
<pointsMaterial size={0.02} />
  • Add the vertexColors prop so it reads the color buffer:
<pointsMaterial size={0.02} vertexColors />
  • Save DebrisField.tsx.
  • Switch to your browser and toggle the debris layer on. You should see dots colored by altitude: mostly white (LEO) clustered close to the globe, with a thin ring of orange dots (GEO) visible at geostationary altitude further out.

Still seeing all-white dots?

  • Make sure you added vertexColors to the <pointsMaterial>. Without it, Three.js ignores the color buffer entirely.
  • Check that colorAttr.needsUpdate = true is set after the loop. If you only set posAttr.needsUpdate, the GPU never receives the updated colors.
  • Confirm that the 'color' attribute name in your useMemo matches exactly what getAttribute('color') reads in the callback.

Why aren't my debris particles showing altitude colors?

Enable depth and add cleanup logic

The colors are working, but every dot is the same size regardless of distance. Particles also render opaquely over each other, which looks harsh when they overlap against the globe.

  • Find your <pointsMaterial> again. It currently looks like this:
<pointsMaterial size={0.02} vertexColors />
  • Replace it with the full material configuration:
<pointsMaterial
  size={0.02}
  sizeAttenuation
  vertexColors
  transparent
  opacity={0.8}
  depthWrite={false}
/>

What do these material props do?

  • sizeAttenuation makes distant particles appear smaller, giving real depth to the particle cloud.
  • transparent and opacity={0.8} let particles blend softly instead of drawing as hard opaque squares.
  • depthWrite={false} prevents particles from occluding each other in the depth buffer. Without this, particles behind others would disappear instead of blending through.

There is one more problem to solve. When the learner toggles debris off, the worker pool keeps running in the background, wasting CPU cycles.

  • Find the useEffect that creates the PropagatorPool. It currently looks like this:
useEffect(() => {
  if (!visible) return;
  poolRef.current = new PropagatorPool(4);
}, [visible]);
  • Add a cleanup return function that terminates the pool when visible changes to false or the component unmounts:
useEffect(() => {
  if (!visible) return;
  poolRef.current = new PropagatorPool(4);
  // Terminate workers when debris is toggled off
  return () => {
    poolRef.current?.terminate();
    poolRef.current = null;
  };
}, [visible]);

What does this cleanup do?

React runs the return function from a useEffect whenever the dependency (visible) changes or the component unmounts. Calling terminate() kills all 4 worker threads immediately. Setting the ref to null ensures the useFrame guard (!poolRef.current) stops any further propagation attempts.

  • Save DebrisField.tsx.

Before you check the result, think about what you expect to see differently compared to last time. What should change about the dots when you rotate the globe?

  • Toggle debris on in your browser. Dots are colored by altitude band: mostly white (LEO) close to the globe, a thin ring of orange (GEO) further out, and scattered yellow (MEO) and red (HEO) points between and beyond.
  • Rotate the globe. Particles farther from the camera now appear smaller, giving real depth to the cloud.
  • Toggle debris off, then on again. The dots disappear cleanly and reappear when toggled back on. No stale dots linger.

Dots disappear when you rotate?

  • Check that depthWrite is set to false. If it is true, particles write to the depth buffer and occlude other particles behind them.
  • If toggling off leaves ghost dots, make sure your cleanup useEffect has [visible] in the dependency array so it fires when the toggle changes.

Help me debug my particle rendering issues.

✔️ Awesome, I've got everything!

Double check you have saved DebrisField.tsx and that your colored debris field toggles cleanly on and off.

ⓧ I'd like to double check the full code

Your DebrisField.tsx file is getting long. Scroll up and confirm each of these sections is present, in order:

  • Imports for useRef, useEffect, useState, useMemo from React, useFrame from R3F, THREE, and PropagatorPool.
  • Constants: EARTH_RADIUS_KM, SCALE, MAX_OBJECTS, UPDATE_INTERVAL_MS.
  • Five color constants (LEO_COLOR through FAILED_COLOR) and the getAltitudeColor function.
  • The useMemo geometry creation with both 'position' and 'color' buffer attributes.
  • The data-fetch useEffect and the pool useEffect with the cleanup return function.
  • The useFrame with the .then callback writing both positions and colors.
  • The JSX return with <points> and <pointsMaterial> using sizeAttenuation, vertexColors, transparent, and depthWrite={false}.

Your debris visualization now tells a story. The flood of white dots in LEO shows exactly why low orbits are the highest-risk region for collisions. Next up, you will build a conjunction detection system that flags when two objects pass dangerously close to each other.

Add Conjunction Detection and Stats Dashboard

In Step 4, you colored debris by altitude band, revealing that LEO is the most crowded orbital region. The visualization is informative, but it only shows where objects sit right now.

The real danger is when two objects drift close enough to collide. Detecting these near-misses (called conjunctions) and displaying debris statistics transforms your visualization from an impressive display into a space safety tool.

In this step, get ready to:
  • Add spatial-grid conjunction detection to DebrisField.
  • Create a conjunction alert panel for the closest object pairs.
  • Create a stats dashboard with animated counters.
Add a spatial-grid conjunction detector

Checking every possible pair of 17,000 objects would mean billions of distance calculations per cycle. A spatial grid makes this feasible by only comparing objects that share the same 50 km cell.

What is spatial-grid conjunction detection?

Real-world conjunction assessment (run by the U.S. Space Force) involves propagating all objects at fine time steps with covariance analysis over prediction windows. That is far too expensive for a browser.

Your simplified approach partitions current positions into a 3D grid of 50 km cells, then only computes pairwise distances for objects sharing a cell. Pairs closer than 10 km are flagged as conjunctions. This teaches the concept without requiring a full predictive pipeline.

You will update DebrisField.tsx with new constants, type exports, updated props, stats counting, conjunction detection, and a highlight line for selected conjunctions. These changes touch several parts of the file. Follow each addition carefully.

  • In DebrisField.tsx, find the UPDATE_INTERVAL_MS constant. Add these three new constants directly below it:
const CONJUNCTION_INTERVAL_MS = 5000;
const CONJUNCTION_THRESHOLD_KM = 10;
const GRID_CELL_SIZE_KM = 50;
  • After the getAltitudeColor function, add these two exported type interfaces:
export interface DebrisStats {
  total: number;
  leo: number;
  meo: number;
  geo: number;
  heo: number;
  failed: number;
}

export interface Conjunction {
  indexA: number;
  indexB: number;
  distance: number;
  positionA: [number, number, number];
  positionB: [number, number, number];
}

What do these types represent?

  • DebrisStats tracks the total count and per-band breakdown (LEO, MEO, GEO, HEO) plus objects that failed propagation.
  • Conjunction describes a near-miss: the indices of both objects, the distance between them in km, and their scaled 3D positions for rendering the highlight line.
  • Replace your existing DebrisFieldProps interface with this updated version that adds callback props and a selected conjunction:
interface DebrisFieldProps {
  visible: boolean;
  selectedConjunction: Conjunction | null;
  onStatsUpdate: (stats: DebrisStats) => void;
  onConjunctionsUpdate: (conjunctions: Conjunction[]) => void;
}
  • Update the component function signature to destructure the new props:
export default function DebrisField({
  visible,
  selectedConjunction,
  onStatsUpdate,
  onConjunctionsUpdate,
}: DebrisFieldProps) {
  • Inside the component body, add these two new refs alongside your existing ones:
const lastConjunctionRef = useRef<number>(0);
const latestPositionsRef = useRef<Float32Array | null>(null);

Now update the useFrame callback to count stats per altitude band and trigger conjunction detection every 5 seconds.

  • Inside the .then() callback of propagateAll, find the line const count = Math.min(positions.length / 3, MAX_OBJECTS);. Add these counter variables directly below it:
      let leo = 0;
      let meo = 0;
      let geoCount = 0;
      let heo = 0;
      let failed = 0;
  • Inside the for loop, find the line colorAttr.setXYZ(i, color.r, color.g, color.b);. Add the altitude counting logic directly below it:
        if (alt < 0) failed++;
        else if (alt < 2000) leo++;
        else if (alt < 35000) meo++;
        else if (alt < 36000) geoCount++;
        else heo++;
  • After geometry.computeBoundingSphere();, add the stats callback and conjunction detection trigger by pasting this code below it:
      latestPositionsRef.current = positions;

      onStatsUpdate({
        total: count - failed,
        leo,
        meo,
        geo: geoCount,
        heo,
        failed,
      });

      if (now - lastConjunctionRef.current > CONJUNCTION_INTERVAL_MS) {
        lastConjunctionRef.current = now;
        const conjunctions = findConjunctions(positions, count);
        onConjunctionsUpdate(conjunctions);
      }

What does this code do?

  • The counter variables tally objects by altitude band as positions are written into the buffer. This reuses work the loop already does, adding negligible overhead.
  • onStatsUpdate pushes the counts to the parent component every second (matching the propagation interval).
  • Conjunction detection runs on a separate 5-second timer to avoid overloading the main thread. It calls findConjunctions (which you will add next) and sends results up via onConjunctionsUpdate.
  • Update the component's return statement. After the closing </points> tag, add this block that renders a red line between two selected conjunction objects:
      {selectedConjunction && (
        <line>
          <bufferGeometry>
            <bufferAttribute
              attach="attributes-position"
              array={new Float32Array([
                ...selectedConjunction.positionA,
                ...selectedConjunction.positionB,
              ])}
              count={2}
              itemSize={3}
            />
          </bufferGeometry>
          <lineBasicMaterial color="red" />
        </line>
      )}

How does the highlight line work?

When selectedConjunction is not null, R3F renders a <line> element with a bufferGeometry containing exactly two points. The attach="attributes-position" syntax tells React Three Fiber to bind the buffer to the geometry's position attribute. The result is a red line segment connecting the two near-miss objects in 3D space.

Now add the findConjunctions function at the bottom of DebrisField.tsx, outside the component. This function is split across two code blocks because of its length.

  • Add the first part of findConjunctions below the DebrisField component's closing brace. This part builds the spatial grid:
function findConjunctions(
  positions: Float32Array,
  count: number
): Conjunction[] {
  // Build a 3D grid of 50km cells
  const grid = new Map<string, number[]>();

  for (let i = 0; i < count; i++) {
    const x = positions[i * 3];
    const y = positions[i * 3 + 1];
    const z = positions[i * 3 + 2];
    if (x === 0 && y === 0 && z === 0) continue;

    const cx = Math.floor(x / GRID_CELL_SIZE_KM);
    const cy = Math.floor(y / GRID_CELL_SIZE_KM);
    const cz = Math.floor(z / GRID_CELL_SIZE_KM);
    const key = `${cx},${cy},${cz}`;

    let cell = grid.get(key);
    if (!cell) {
      cell = [];
      grid.set(key, cell);
    }
    cell.push(i);
  }
  • Directly below the grid-building loop (still inside findConjunctions), add the pairwise distance check that flags pairs within 10 km:
  const conjunctions: Conjunction[] = [];
  for (const indices of grid.values()) {
    if (indices.length < 2) continue;
    for (let a = 0; a < indices.length; a++) {
      for (let b = a + 1; b < indices.length; b++) {
        const ia = indices[a];
        const ib = indices[b];
        const dx = positions[ia * 3] - positions[ib * 3];
        const dy = positions[ia * 3 + 1] - positions[ib * 3 + 1];
        const dz = positions[ia * 3 + 2] - positions[ib * 3 + 2];
        const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
        if (dist < CONJUNCTION_THRESHOLD_KM && dist > 0) {
          conjunctions.push({
            indexA: ia,
            indexB: ib,
            distance: dist,
            positionA: [
              positions[ia * 3] * SCALE,
              positions[ia * 3 + 1] * SCALE,
              positions[ia * 3 + 2] * SCALE,
            ],
            positionB: [
              positions[ib * 3] * SCALE,
              positions[ib * 3 + 1] * SCALE,
              positions[ib * 3 + 2] * SCALE,
            ],
          });
        }
      }
    }
  }
  • Close out the function with the sort and return. Add these lines directly below the loop you just pasted:
  conjunctions.sort((a, b) => a.distance - b.distance);
  return conjunctions.slice(0, 5);
}

Why sort and slice to 5?

Sorting by distance puts the most dangerous near-misses first. Keeping only the top 5 prevents the UI from being overwhelmed. With thousands of objects in similar orbits, there can be many pairs within 50 km of each other, but only the closest ones represent real collision risk.

  • Save DebrisField.tsx.

Seeing TypeScript errors?

Your app will not compile yet because the parent component does not pass the new props (selectedConjunction, onStatsUpdate, onConjunctionsUpdate). This is expected. You will wire everything together after creating the remaining components.

help me fix TypeScript errors in DebrisField after adding conjunction detection props

✔️ Awesome, I've got everything!

Great. Make sure you have saved DebrisField.tsx.

ⓧ I'd like to double check the full code

Since DebrisField.tsx is a large file, scroll through it and confirm these sections are present in order:

  • Imports (useRef, useEffect, useState, useMemo, useFrame, THREE, PropagatorPool).
  • Constants including CONJUNCTION_INTERVAL_MS, CONJUNCTION_THRESHOLD_KM, and GRID_CELL_SIZE_KM.
  • Color constants and getAltitudeColor function.
  • Exported DebrisStats and Conjunction interfaces.
  • Updated DebrisFieldProps with selectedConjunction, onStatsUpdate, onConjunctionsUpdate.
  • Component body with lastConjunctionRef and latestPositionsRef refs.
  • useFrame callback with stats counters, altitude counting, onStatsUpdate call, and conjunction trigger.
  • JSX return with <points> and the conditional selectedConjunction <line>.
  • findConjunctions function with grid building, pairwise distance check, sort, and slice.
Create the conjunction alert panel

The conjunction detector finds the closest pairs, but the learner needs a clickable UI to explore them. ConjunctionAlert renders a list panel where each row shows a pair of object indices and their distance.

  • Create a new file frontend/src/components/ConjunctionAlert.tsx.
  • Add the imports, props interface, and component signature:
import type { Conjunction } from './DebrisField';

interface ConjunctionAlertProps {
  conjunctions: Conjunction[];
  selectedIndex: number | null;
  onSelect: (index: number) => void;
}

export default function ConjunctionAlert({
  conjunctions,
  selectedIndex,
  onSelect,
}: ConjunctionAlertProps) {
  if (conjunctions.length === 0) return null;
  • Below the guard clause, add the JSX return that renders the clickable conjunction list:
  return (
    <div className="conjunction-alerts">
      <h3>Conjunction Warnings</h3>
      <p className="conjunction-subtitle">Objects within 10 km</p>
      <ul className="conjunction-list">
        {conjunctions.map((conj, i) => (
          <li key={`${conj.indexA}-${conj.indexB}`}>
            <button
              className={`conjunction-item ${selectedIndex === i ? 'selected' : ''}`}
              onClick={() => onSelect(i)}
            >
              <span className="conjunction-pair">
                #{conj.indexA} / #{conj.indexB}
              </span>
              <span className="conjunction-distance">
                {conj.distance.toFixed(2)} km
              </span>
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

What does this component do?

  • It imports the Conjunction type from DebrisField so both components share the same data shape.
  • If no conjunctions are detected, it returns null and renders nothing.
  • Each conjunction renders as a clickable button showing the pair indices and the distance. Clicking calls onSelect, which the parent uses to set selectedConjunction on DebrisField, triggering the red highlight line.
  • Save ConjunctionAlert.tsx.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file.

ⓧ I'd like to double check the full code

Scroll up and confirm these sections are present in order:

  • Import of Conjunction type from ./DebrisField.
  • ConjunctionAlertProps interface with conjunctions, selectedIndex, and onSelect.
  • Guard clause returning null for empty conjunctions.
  • JSX rendering a list of buttons with pair indices and distances.
Create the stats dashboard with animated counters

The stats dashboard shows the total tracked count, a per-band breakdown, and the number of conjunction warnings. To make the numbers feel alive, you will animate each counter from 0 to its real value using requestAnimationFrame with a cubic ease-out curve.

  • Create a new file frontend/src/components/DebrisStats.tsx.
  • Add the imports, props interface, and the animation logic:
import { useEffect, useRef, useState } from 'react';
import type { DebrisStats as StatsType } from './DebrisField';

interface DebrisStatsProps {
  stats: StatsType | null;
  conjunctionCount: number;
}

export default function DebrisStats({ stats, conjunctionCount }: DebrisStatsProps) {
  const [display, setDisplay] = useState({
    total: 0,
    leo: 0,
    meo: 0,
    geo: 0,
    heo: 0,
  });
  const prevRef = useRef(display);

  useEffect(() => {
    if (!stats) return;

    const start = { ...prevRef.current };
    const target = {
      total: stats.total,
      leo: stats.leo,
      meo: stats.meo,
      geo: stats.geo,
      heo: stats.heo,
    };
  • Below the target object, add the animation loop that interpolates from the previous values to the new ones over 1 second:
    const duration = 1000;
    const startTime = performance.now();
    let frameId: number;

    // Animate from previous to target over 1 second
    const animate = (now: number) => {
      const elapsed = now - startTime;
      const progress = Math.min(elapsed / duration, 1);
      // Cubic ease-out: fast start, smooth deceleration
      const ease = 1 - Math.pow(1 - progress, 3);

      const current = {
        total: Math.round(start.total + (target.total - start.total) * ease),
        leo: Math.round(start.leo + (target.leo - start.leo) * ease),
        meo: Math.round(start.meo + (target.meo - start.meo) * ease),
        geo: Math.round(start.geo + (target.geo - start.geo) * ease),
        heo: Math.round(start.heo + (target.heo - start.heo) * ease),
      };

      setDisplay(current);

      if (progress < 1) {
        frameId = requestAnimationFrame(animate);
      } else {
        prevRef.current = current;
      }
    };

    frameId = requestAnimationFrame(animate);
    return () => cancelAnimationFrame(frameId);
  }, [stats]);

How does the ease-out animation work?

The line const ease = 1 - Math.pow(1 - progress, 3) implements a cubic ease-out curve. It maps linear time (0 to 1) to a value that starts fast and decelerates smoothly. When progress is 0.5 (halfway through the 1-second duration), the eased value is already 0.875. The numbers jump quickly at first, then settle into their final values.

prevRef stores the last displayed values. When new stats arrive, the animation interpolates from the previous display to the new target, so numbers always animate smoothly instead of jumping.

  • Add the guard clause and JSX return below the useEffect:
  if (!stats) return null;

  return (
    <div className="debris-stats">
      <h3>Orbital Debris</h3>
      <div className="stat-row">
        <span>Total Tracked</span>
        <span className="stat-value">{display.total.toLocaleString()}</span>
      </div>
      <div className="stat-row">
        <span className="dot white" />
        <span>LEO (&lt;2,000 km)</span>
        <span className="stat-value">{display.leo.toLocaleString()}</span>
      </div>
      <div className="stat-row">
        <span className="dot yellow" />
        <span>MEO (2,000-35,000 km)</span>
        <span className="stat-value">{display.meo.toLocaleString()}</span>
      </div>
      <div className="stat-row">
        <span className="dot orange" />
        <span>GEO (~35,786 km)</span>
        <span className="stat-value">{display.geo.toLocaleString()}</span>
      </div>
  • Close out the JSX with the HEO row, the conjunction warning row, and the component's closing tags:
      <div className="stat-row">
        <span className="dot red" />
        <span>HEO (&gt;36,000 km)</span>
        <span className="stat-value">{display.heo.toLocaleString()}</span>
      </div>
      <div className="stat-row warning">
        <span>Conjunction Warnings</span>
        <span className="stat-value">{conjunctionCount}</span>
      </div>
    </div>
  );
}

What does each stat row show?

  • Each altitude band row has a colored dot matching the debris color (white for LEO, yellow for MEO, orange for GEO, red for HEO) and the animated count.
  • The conjunction warnings row shows the live count of detected near-misses. This value is passed directly as conjunctionCount without animation since it changes infrequently.
  • Save DebrisStats.tsx.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file.

ⓧ I'd like to double check the full code

Scroll up and confirm these sections are present in order in DebrisStats.tsx:

  • Imports of useEffect, useRef, useState, and the DebrisStats type.
  • DebrisStatsProps interface with stats and conjunctionCount.
  • display state and prevRef ref.
  • useEffect with the animate function using requestAnimationFrame and cubic ease-out.
  • Guard clause returning null.
  • JSX with stat rows for Total, LEO, MEO, GEO, HEO, and Conjunction Warnings.

Now wire all three components together in your parent component.

  • In your app component, import DebrisStats, ConjunctionAlert, and the DebrisStats and Conjunction types from DebrisField.
  • Add state for the stats, conjunction list, and selected conjunction index:
// State for debris stats, conjunctions, and selection
const [debrisStats, setDebrisStats] = useState<DebrisStats | null>(null);
const [conjunctions, setConjunctions] = useState<Conjunction[]>([]);
const [selectedConjunctionIndex, setSelectedConjunctionIndex] = useState<number | null>(null);

// Derive the selected conjunction object from the index
const selectedConjunction = selectedConjunctionIndex !== null
  ? conjunctions[selectedConjunctionIndex]
  : null;
  • Update your <DebrisField> element to pass the new props:
<DebrisField
  visible={showDebris}
  selectedConjunction={selectedConjunction}
  onStatsUpdate={setDebrisStats}
  onConjunctionsUpdate={setConjunctions}
/>
  • Add the DebrisStats and ConjunctionAlert components to your UI (outside the R3F Canvas, in your HTML overlay area):
<DebrisStats
  stats={debrisStats}
  conjunctionCount={conjunctions.length}
/>
<ConjunctionAlert
  conjunctions={conjunctions}
  selectedIndex={selectedConjunctionIndex}
  onSelect={setSelectedConjunctionIndex}
/>
  • Save all files.

Before you toggle debris on, what do you think will happen? Will the numbers jump instantly to their final values, or animate up?

  • Toggle debris on in your app.

The stats panel appears with animated counters climbing from 0 to the real values. You should see the total tracked count, a breakdown by altitude band with colored indicators, and the number of conjunction warnings. The conjunction alert panel lists the closest object pairs with their distances.

  • Click one of the conjunction warnings in the alert panel.

A red line appears in the 3D scene connecting the two flagged objects. The clicked row highlights in the panel.

Stats panel not appearing?

  • Make sure DebrisStats and ConjunctionAlert are rendered outside the R3F <Canvas>. They are HTML elements, not 3D objects.
  • Check that onStatsUpdate is wired to setDebrisStats. If this callback is missing, stats stays null and the component returns null.
  • If conjunction warnings show 0, wait at least 5 seconds. The conjunction detector runs on a 5-second interval. Not every propagation cycle produces conjunctions within 10 km.

help me debug why my DebrisStats panel is not showing up after toggling debris on

Secret mission

Simulate a Kessler Syndrome Collision

Your debris visualization shows how crowded orbit is. But the real nightmare scenario is what happens when two objects actually collide. In 1978, NASA scientist Donald Kessler predicted that a single collision could generate enough fragments to trigger a chain reaction, making entire orbital bands unusable. Ready to simulate it?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally and fetches data from CelesTrak's free public API, so there are no ongoing costs.

CelesTrak usage policy

CelesTrak limits excessive automated requests. The 1-hour cache in debris.py respects this policy. Do not reduce the CACHE_TTL below 3600 (1 hour).

Resources you used:

  • Running frontend dev server (Vite).
  • Running backend dev server (uvicorn).
  • New project files added to the OrbitX codebase (backend endpoint, worker pool, debris components).

Keep everything running

No action needed. Choose this if you are still actively exploring the debris visualization or plan to continue building on OrbitX.

  • Leave both dev servers running in their terminal windows.
  • Your debris data is cached for 1 hour, so you will not hit CelesTrak repeatedly during normal use.

Pause - I'll come back to this later

Shut down the running servers to free up memory and terminal windows. Your code is saved locally and ready to restart.

  • Switch to the terminal running the frontend dev server and press Ctrl+C to stop it.
  • Switch to the terminal running the backend server and press Ctrl+C to stop it.
  • When you are ready to return, restart the backend with uvicorn and the frontend with npm run dev.

Delete - I don't want to use this again

Remove all files added in this project and stop the running servers. This undoes the debris visualization feature while keeping the rest of your OrbitX codebase intact.

  • Switch to the terminal running the frontend dev server and press Ctrl+C to stop it.
  • Switch to the terminal running the backend server and press Ctrl+C to stop it.
  • From your project root, remove the backend debris endpoint by running:
rm backend/app/debris.py
  • Remove the worker files by running:
rm frontend/src/workers/propagator.worker.ts
rm frontend/src/utils/workerPool.ts
  • Remove the debris visualization components by running:
rm frontend/src/components/DebrisField.tsx
rm frontend/src/components/ConjunctionAlert.tsx
rm frontend/src/components/DebrisStats.tsx
rm frontend/src/components/KesslerSimulation.tsx
  • Open backend/app/main.py and remove the import and include_router line for the debris router.
  • Remove any debris toggle button and related state from your main app component.

That's a Wrap!

That's a Wrap!

Nice work. You just built a real-time orbital debris tracker that visualizes the scale of the space debris problem, detects collision risks, and proves why Web Workers are essential for heavy computation in the browser.

What you learned:

  • Fetched ~17,000 real tracked objects from CelesTrak's analyst satellite catalog using the OMM JSON format and rendered them as altitude-colored particles at 60fps using THREE.Points with pre-allocated BufferGeometry and vertex colors.
  • Experienced the main-thread freeze firsthand and fixed it by building a pool of 4 Web Workers that propagate orbits in parallel, keeping the UI fully responsive.
  • Built a spatial-grid conjunction detector that flags objects within 10 km of each other and a stats dashboard with animated counters showing debris distribution across LEO, MEO, GEO, and HEO bands.
  • Secret Mission: Simulated a Kessler Syndrome collision, watching 100 debris fragments cascade outward from an impact point.

Ready to quiz yourself?