Space Debris & Collision Risk Visualization

Visualize 20,000+ orbital objects and identify collision risks in real time.

Introduction

30 Second Summary

Every time you use GPS or check a weather forecast, you rely on satellites sharing their orbit with over 20,000 pieces of tracked debris. A single collision at orbital speeds can shatter a working satellite into thousands of new fragments, each one becoming a fresh threat to everything else in orbit.

In this project, you will add a real-time debris visualization layer to your existing OrbitX globe. You will render the full tracking catalog as color-coded particles, identify collision risks, and quantify orbital congestion.

What You'll Build

Flip the debris toggle and watch thousands of color-coded dots bloom around your globe, each one a real tracked object from the CelesTrak catalog updating every second, then click a conjunction warning to see the two closest objects highlight with a line showing their miss distance.

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

  • A live debris layer where you toggle a switch and 20,000+ tracked objects appear as color-coded dots that update position every second without dropping a frame.
  • A conjunction warning panel where you click "Analyze" and see the closest orbital approaches in the next 24 hours, then click one to highlight both objects with a line showing their miss distance.
  • A statistics dashboard showing the full scale of the debris problem at a glance: total tracked objects, per-altitude-band counts, and the number of conjunction warnings.
  • Secret Mission: A Kessler Syndrome collision simulation that generates expanding debris fragments from a chosen collision point.

Are there any prerequisites?

This project builds on your existing OrbitX application from Projects 1-4. You need a working React Three Fiber frontend with a 3D globe and multi-satellite view, plus a FastAPI backend with TLE caching and pass prediction.

Before We Start

Before diving into code, let's lock in what you're about to build and why it matters.

Set Up for Debris Data

Your OrbitX backend currently fetches a handful of satellite groups. That covers individual missions, but it does not come close to showing the full picture of orbital congestion.

In this step, you will pull the analyst satellite catalog from CelesTrak. This catalog contains tracked objects that the US Space Force monitors in orbit. You will also upgrade satellite.js to v7.1.0, which introduces a purpose-built Bulk Propagation API designed for exactly this scale.

In this step, get ready to:
  • Add a debris endpoint to the backend that serves the full CelesTrak analyst catalog.
  • Upgrade satellite.js to v7.1.0 in the frontend.
  • Test the data pipeline end-to-end.
Add the CelesTrak debris endpoint

Your backend needs a new endpoint that fetches orbital data for every tracked analyst satellite. CelesTrak provides this data in OMM JSON format through a public API.

  • In your editor, open backend/main.py.
  • Add the following debris cache and endpoint below your existing endpoints:
# Debris OMM data cache
debris_cache = {
    "data": None,
    "fetched_at": 0
}

DEBRIS_CACHE_TTL = 3600  # Cache debris data for 1 hour

@app.get("/api/debris")
async def get_debris():
    """Fetch the full analyst satellite catalog from CelesTrak."""
    now = time.time()

    # Return cached data if still fresh
    if debris_cache["data"] and (now - debris_cache["fetched_at"]) < DEBRIS_CACHE_TTL:
        return debris_cache["data"]

    # Fetch OMM JSON from CelesTrak analyst group
    url = "https://celestrak.org/NORAD/elements/gp.php?GROUP=analyst&FORMAT=JSON"
    response = requests.get(url, timeout=30)
    response.raise_for_status()
    omm_records = response.json()

    # Update cache
    debris_cache["data"] = omm_records
    debris_cache["fetched_at"] = now

    return omm_records

What does this code do?

The /api/debris endpoint fetches the analyst satellite catalog from CelesTrak's GP data API. The GROUP=analyst parameter targets analyst-tracked objects. The FORMAT=JSON parameter is required because CelesTrak's default format changed to CSV as of May 2026.

The cache prevents hammering CelesTrak's free API on every request. Orbital elements change slowly, so a one-hour TTL is plenty for a visualization.

Why JSON instead of TLE?

CelesTrak ran out of 5-digit catalog numbers on July 11, 2026. New objects now receive 6-digit catalog numbers (100000+). The legacy TLE format cannot represent these 6-digit IDs.

The OMM JSON format was designed to handle this. It uses named fields like NORAD_CAT_ID, MEAN_MOTION, and INCLINATION instead of fixed-width character positions. Every tracked object works in JSON regardless of catalog number length.

  • Save backend/main.py.
  • Switch to the terminal where your backend is running. Press Ctrl+C to stop it, then restart it by running:
uvicorn main:app --reload
  • Test the new endpoint by opening your browser and navigating to:
http://localhost:8000/api/debris

You should see a large JSON array. Each element is an OMM record with fields like OBJECT_NAME, NORAD_CAT_ID, EPOCH, and MEAN_MOTION.

Seeing an empty response or an error?

  • Make sure you included &FORMAT=JSON in the CelesTrak URL. Without it, the response comes back as CSV, which response.json() cannot parse.
  • Check that requests and time are imported at the top of your file. Your existing backend should already have these from TLE caching.
  • If you see a timeout error, CelesTrak may be temporarily slow. Try the request again after a few seconds.

Still stuck? Help me debug my /api/debris endpoint. I'm getting an error when it tries to fetch OMM data from CelesTrak.

Upgrade satellite.js to v7.1.0

Your frontend currently uses satellite.js v5.x, which parses TLE strings with twoline2satrec. The new OMM JSON data needs a different parser called json2satrec. This function is available in v7.1.0, which also brings a WASM-powered Bulk Propagation API for processing thousands of satellites at once.

  • Switch to a terminal in your frontend directory and run:
npm install satellite.js@7.1.0
  • Verify the upgrade by checking your package.json:
grep satellite.js package.json

You should see "satellite.js": "^7.1.0" in the output.

What changed in satellite.js v7?

This is a major upgrade with breaking changes that affect your existing satellite code:

  • The library is now ESM-only. It dropped AMD, CJS, and minified builds.
  • propagate() and sgp4() now return null on failure instead of { position: false }. Any existing code that checks position === false will need updating.
  • json2satrec() parses OMM JSON objects directly. This replaces twoline2satrec() for the new data format.
  • The Bulk Propagation API processes all satellites in a single optimized batch via WASM, delivering 3x to 12x speedup over a propagate loop.

Install failed or seeing module errors?

  • satellite.js v7.1.0 requires Node.js 20.19+, 22.13+, or major version 24+. Run node --version to check yours.
  • If your dev server shows import errors after the upgrade, stop it with Ctrl+C and restart it. The bundler needs to pick up the new ESM-only package.

Still stuck? Help me fix satellite.js v7 module errors in my React project after upgrading from v5.

Test the data pipeline end-to-end

The backend serves OMM data and the frontend has the new parser. Time to verify that the entire pipeline connects. You will fetch a record from the backend, parse it with json2satrec(), and propagate it with propagate() to confirm everything works together.

  • First, count the records coming from your backend by running this in a new terminal:
curl -s http://localhost:8000/api/debris | python3 -c "import sys, json; data = json.load(sys.stdin); print(f'{len(data)} OMM records')"

You should see 200+ OMM records. The exact count varies as CelesTrak updates the analyst catalog.

  • Now test the frontend parsing. Open your main App component file (for example, frontend/src/App.tsx) and add this temporary test code inside the component, above the return statement:
// TEMPORARY TEST - remove after verifying
import { json2satrec, propagate } from 'satellite.js';

useEffect(() => {
  fetch('/api/debris')
    .then(res => res.json())
    .then(records => {
      console.log(`Fetched ${records.length} debris records`);

      // Parse the first OMM record into a SatRec
      const satrec = json2satrec(records[0]);

      // Propagate to get current position
      const result = propagate(satrec, new Date());

      if (result) {
        console.log('Position (ECI km):', result.position);
      } else {
        console.log('Propagation returned null - object may have decayed');
      }
    });
}, []);

What does this test do?

json2satrec() takes a single OMM JSON object (with fields like MEAN_MOTION, ECCENTRICITY, INCLINATION) and converts it into a SatRec object that the SGP4 propagator understands.

propagate() then calculates where that object is right now. In v7, it returns null on failure (such as a decayed object) instead of the old { position: false } pattern. A non-null result with an x, y, z position confirms the entire pipeline works.

  • Make sure the import statement is at the top of the file with your other imports, not inside the component.
  • Save the file.

Before you check the console, what do you expect to see? Will the propagation succeed, or will it return null?

  • Open your browser's developer console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows).

You should see two log lines. The first shows the number of fetched debris records (200+). The second shows a position object with x, y, and z values in kilometers. If the first record happens to be a decayed object, you will see the null message instead. Try changing records[0] to records[1] or records[2] and save again.

Not seeing any console output?

  • Check that your frontend dev server is running and that it proxies /api requests to your backend. If the fetch returns a 404, the proxy is not configured.
  • Make sure the useEffect import is present at the top of your file alongside the satellite.js imports.
  • If you see a type error about json2satrec, confirm that package.json shows version 7.1.0 and restart your dev server.

Still stuck? Help me debug my satellite.js pipeline test. I'm not seeing position data in the console.

  • Once you have confirmed the position output, remove the temporary test code from your App component. You will build the real debris visualization in the next step.
  • Save the file.

✔️ Awesome, I've got everything!

Great. Double check you have saved both backend/main.py and your App component file.

ⓧ I'd like to double check the full code

For backend/main.py, scroll through your file and confirm these pieces are present:

  • A debris_cache dictionary with data and fetched_at keys.
  • A DEBRIS_CACHE_TTL constant set to 3600.
  • A GET /api/debris endpoint function that fetches from https://celestrak.org/NORAD/elements/gp.php?GROUP=analyst&FORMAT=JSON, caches the result, and returns the OMM records.

For the frontend, confirm that your package.json shows satellite.js at version 7.1.0. The temporary test code should be removed.

Your data pipeline is live. The backend is pulling the analyst satellite catalog, and the frontend can parse and propagate those records. Next up, you will render all of those objects as a particle cloud around the globe.

Build Naive Propagation and Hit the Performance Wall

Your backend is serving thousands of OMM records, and your frontend can parse them with json2satrec(). Time to render every tracked object around the globe.

The most obvious approach is a simple loop: iterate through every record, compute its position with propagate(), and plot the result. In this step, you will build exactly that. You will also discover why this approach collapses under the weight of 20,000+ satellites.

In this step, get ready to:
  • Create the DebrisField component that fetches and propagates all orbital objects.
  • Render 20,000+ positions as a particle cloud using React Three Fiber.
  • Observe the performance wall when naive propagation freezes the browser.
Create the DebrisField component

Each OMM record from CelesTrak describes one tracked object's orbit. To turn that into a 3D position, you feed it to json2satrec() (which creates an orbital model) and then propagate() (which runs the SGP4 algorithm for the current time). The result is an ECI position in kilometers.

  • Create a new file frontend/src/components/DebrisField.tsx.
  • Paste the following code to set up the component with data fetching and a basic points mesh:
import { useEffect, useRef, useState } from 'react';
import { json2satrec, propagate } from 'satellite.js';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';

export function DebrisField() {
  const [ommRecords, setOmmRecords] = useState([]);
  const pointsRef = useRef();

  // Fetch all debris OMM records from the backend
  useEffect(() => {
    fetch('/api/debris')
      .then(r => r.json())
      .then(data => setOmmRecords(data));
  }, []);

  // Wait until data arrives before rendering
  if (!ommRecords.length) return null;
  // Create an empty positions buffer (all zeros for now)
  const positions = new Float32Array(ommRecords.length * 3);
  return (
    <points ref={pointsRef}>
      <bufferGeometry>
        <bufferAttribute attach='attributes-position' args={[positions, 3]} />
      </bufferGeometry>
      <pointsMaterial size={0.02} color='white' />
    </points>
  );
}

What does this code do?

The component fetches every debris record from your backend's /api/debris endpoint and stores them in state. Once the data arrives, it renders a <points> mesh with a <bufferGeometry> holding a Float32Array of positions.

Right now every position is zero, so all 20,000+ points will sit at the origin. The imports for useFrame and THREE are included for the next step when you add real-time updates and color.

Wire the toggle button and verify the shell

Before you add propagation logic, wire the component into your app so you can confirm data loads and the mesh renders.

  • Open frontend/src/App.tsx.
  • Add this import at the top of the file with your other component imports:
import { DebrisField } from './components/DebrisField';
  • Inside your App component, add a state variable for the toggle:
const [debrisVisible, setDebrisVisible] = useState(false);
  • Add a toggle button in your UI controls area (outside the <Canvas>):
<button onClick={() => setDebrisVisible(v => !v)}>
  {debrisVisible ? 'Hide Debris' : 'Show Debris'}
</button>
  • Inside the <Canvas>, add the conditional render alongside your existing globe components:
{debrisVisible && <DebrisField />}
  • Save both DebrisField.tsx and App.tsx.
  • In your browser, click the Show Debris button. You should see a single white dot appear at the center of the globe. That is all 20,000+ points stacked at the origin, because the position buffer is still all zeros.

Don't see the white dot?

  • Check the browser console for fetch errors. If /api/debris returns a 404, make sure your backend is running and the endpoint path matches.
  • Confirm DebrisField.tsx is saved in the correct directory: frontend/src/components/.
  • Make sure you added the useState import in App.tsx if it was not already imported.
  • Still stuck? Why isn't my DebrisField component rendering when I toggle the button?
Add the naive propagation loop and hit the wall

Now for the real work. You need to compute each object's position by running the SGP4 algorithm individually for all 20,000+ records. This is the straightforward approach: a forEach loop that calls json2satrec() and propagate() on every single record.

  • In DebrisField.tsx, find the closing bracket of the fetch useEffect (the one with }, []);).
  • Add this second useEffect directly below it, before the if (!ommRecords.length) line:
  useEffect(() => {
    if (!ommRecords.length) return;
    const propagateAll = () => {
      const t0 = performance.now();
      const positions = new Float32Array(ommRecords.length * 3);
      const now = new Date();
      ommRecords.forEach((rec, i) => {
        const satrec = json2satrec(rec);
        const result = propagate(satrec, now);
        if (result && result.position) {
          positions[i * 3] = result.position.x;
          positions[i * 3 + 1] = result.position.y;
          positions[i * 3 + 2] = result.position.z;
        }
      });
      console.log('Propagation time:', performance.now() - t0, 'ms');
      if (pointsRef.current) {
        pointsRef.current.geometry.attributes.position.array.set(positions);
        pointsRef.current.geometry.attributes.position.needsUpdate = true;
      }
    };
    propagateAll();
    const interval = setInterval(propagateAll, 1000);
    return () => clearInterval(interval);
  }, [ommRecords]);

What does this code do?

The propagateAll function loops through every OMM record, creates a satellite model with json2satrec(), and computes its current position with propagate(). Each x, y, z position (in kilometers) gets packed into a Float32Array.

The performance.now() timing logs how long the entire loop takes. After filling the array, it copies the new positions into the buffer and sets needsUpdate = true so Three.js re-uploads the data to the GPU.

The setInterval re-runs this every second to keep positions current. That means 20,000+ individual SGP4 calculations every single second, all on the main thread.

  • Save DebrisField.tsx.

Before you toggle debris on, take a moment to predict: what do you think will happen when your browser tries to run 20,000+ SGP4 calculations in a JavaScript forEach loop on the main thread, every second?

  • Click Show Debris.

Your browser freezes. The tab becomes unresponsive for several seconds while the loop churns through every record. Once the dots finally appear around Earth, try to rotate the globe. It stutters badly or does not respond at all. Open your browser console and look at the propagation time log.

Why does this freeze the browser?

JavaScript runs on a single thread. When propagateAll runs, it blocks everything else: rendering, input handling, scroll events, animations. Nothing can happen until all 20,000+ propagate() calls finish.

With setInterval firing this loop every second, the browser never gets a chance to breathe. This is exactly the problem that satellite.js v7.1.0's Bulk Propagation API was built to solve.

This freeze is intentional. You just hit the performance wall that makes naive propagation impractical for large catalogs.

✔️ Awesome, I've got everything!

Great. Double check you have saved both DebrisField.tsx and App.tsx.

ⓧ I'd like to double check the full code

Your DebrisField.tsx should contain two chunks in order. Scroll up and confirm each is present:

  • The first code block: imports, state declarations, the fetch useEffect, and the <points> mesh return.
  • The second code block: the propagation useEffect with the forEach loop, timing, buffer update, and setInterval. This should sit between the fetch useEffect and the if (!ommRecords.length) guard.

Your App.tsx should include the DebrisField import, the debrisVisible state, the toggle button, and the conditional {debrisVisible && <DebrisField />} inside the Canvas.

Not seeing any dots after the freeze?

  • Check the console for errors. If you see messages about null positions, that is expected for some decayed objects. The if (result && result.position) guard handles those. Make sure that guard matches exactly.
  • If you see an import error for json2satrec or propagate, confirm you upgraded to satellite.js@7.1.0 in Step 1. These are named exports from the ESM build.
  • If dots appear but are not spread around Earth, check that the propagation useEffect sits before the if (!ommRecords.length) guard, not after it. React hooks must be called before any early return.
  • Still stuck? My debris dots are not appearing around Earth after the browser freezes. Help me debug.

You have 20,000+ dots orbiting Earth, but the experience is unusable. Every second the browser locks up to recompute all positions in a JavaScript loop. In the next step, you will replace this naive loop with satellite.js's Bulk Propagation API, which runs the same calculations through WASM-compiled C++ in a single optimized batch.

Implement Bulk Propagation for Real-Time Updates

In the last step, you toggled the debris layer on and watched the browser lock up. That freeze is 20,000 individual SGP4 calculations running one by one on the main thread.

satellite.js v7.1.0 ships a Bulk Propagation API that solves exactly this problem. Instead of looping through each satellite in JavaScript, it hands the entire catalog to a C++ engine compiled to WASM, delivering 3-12x speedup in a single optimized batch.

In this step, get ready to:
  • Refactor DebrisField to use the Bulk Propagation API with real-time 1-second updates.
  • Add altitude-based color coding so LEO, MEO, GEO, and HEO objects are visually distinct.
Replace the naive loop with the Bulk Propagation API

The entire DebrisField.tsx file needs a rewrite. The naive propagate() loop is gone. In its place, a BulkPropagator backed by a SingleThreadRuntime processes all satellites in one WASM call.

  • In DebrisField.tsx, select all the existing code and delete it.
  • Replace it with the new imports, component skeleton, and data-fetching effect by pasting this code:
import { useEffect, useRef, useState } from 'react';
import { json2satrec } from 'satellite.js';
import { createSingleThreadRuntime, BulkPropagator, EciBaseCalculator } from 'satellite.js';

export function DebrisField({ onDataLoaded, selectedConjunction }) {
  const [satrecs, setSatrecs] = useState([]);
  const positionsRef = useRef(new Float32Array(0));
  const pointsRef = useRef();
  const propagatorRef = useRef(null);
  const runtimeRef = useRef(null);

  // Fetch OMM records and parse into satrec objects
  useEffect(() => {
    fetch('/api/debris')
      .then(r => r.json())
      .then(data => {
        const recs = data.map(rec => json2satrec(rec));
        setSatrecs(recs);
        positionsRef.current = new Float32Array(recs.length * 3);
        if (onDataLoaded) onDataLoaded(data);
      });
  }, []);

  if (!satrecs.length) return null;
  return (
    <points ref={pointsRef}>
      <bufferGeometry>
        <bufferAttribute attach='attributes-position' args={[positionsRef.current, 3]} />
      </bufferGeometry>
      <pointsMaterial size={0.02} color='white' />
    </points>);
}

What changed from the old version?

The old version imported propagate and called it inside a forEach loop. This version imports createSingleThreadRuntime, BulkPropagator, and EciBaseCalculator from the Bulk Propagation API instead.

The component now pre-parses all OMM records into satrec objects once during fetch, rather than re-parsing on every propagation cycle. It also accepts onDataLoaded and selectedConjunction props that later steps will use.

This skeleton fetches and parses the data but does not compute any positions yet. The propagation logic goes in a second useEffect that you will add next.

  • In DebrisField.tsx, find the line if (!satrecs.length) return null; and add the following useEffect directly above it:
  useEffect(() => {
    if (!satrecs.length) return;
    const setup = async () => {
      // Boot the WASM engine and create a bulk propagator
      const runtime = await createSingleThreadRuntime();
      const propagator = new BulkPropagator(runtime, new EciBaseCalculator());
      propagator.setSatellites(satrecs);
      runtimeRef.current = runtime;
      propagatorRef.current = propagator;

      const update = () => {
        propagator.setDates([new Date()]);
        propagator.run({});
        // Read interleaved x,y,z positions from WASM output
        const raw = propagator.getRawOutput().eci.position;
        const positions = positionsRef.current;
        for (let i = 0; i < satrecs.length; i++) {
          positions[i * 3] = raw[i * 3];
          positions[i * 3 + 1] = raw[i * 3 + 1];
          positions[i * 3 + 2] = raw[i * 3 + 2];
        }
        if (pointsRef.current) {
          pointsRef.current.geometry.attributes.position.needsUpdate = true;
        }
      };
      update();
      const interval = setInterval(update, 1000);
      return () => { clearInterval(interval); propagator.dispose(); runtime.dispose(); };
    };
    const cleanup = setup();
    return () => { cleanup.then(fn => fn()); };
  }, [satrecs]);

What does this effect do?

  • createSingleThreadRuntime() boots the WASM engine. All SGP4 math runs inside compiled C++ instead of a JavaScript loop.
  • BulkPropagator takes the runtime and an EciBaseCalculator that tells it to output ECI positions and velocities.
  • setSatellites() loads all satrecs into WASM memory. Then setDates([new Date()]) sets the propagation time, and run({}) executes the entire catalog at once.
  • getRawOutput().eci.position returns a Float64Array of interleaved x, y, z values. The loop copies these into the Float32Array that React Three Fiber's bufferAttribute expects.
  • needsUpdate = true tells the GPU to re-upload the buffer. The 1-second interval repeats this cycle for real-time motion.
  • dispose() is called on both the propagator and runtime on cleanup. WASM uses unmanaged memory, so skipping disposal causes memory leaks.
  • Save DebrisField.tsx.
  • Before you test, what do you expect to see when you toggle debris on? Will the browser freeze like before?
  • Toggle the debris layer on in your browser.

White dots appear around Earth without the browser locking up. Try rotating and zooming the globe while the dots are visible. The globe stays fully interactive, and every second the dots shift slightly as orbits progress.

Browser still freezes?

  • Make sure you deleted the old useEffect that contained the ommRecords.forEach loop. If both effects exist, the naive loop still runs alongside the Bulk Propagation API.
  • Check that propagate is no longer in your import statement. The import should have json2satrec only from the first satellite.js import, with createSingleThreadRuntime, BulkPropagator, and EciBaseCalculator from the second.
  • If you see a WASM-related error in the console, confirm satellite.js is at v7.1.0 by checking package.json.

Still stuck? Why is my DebrisField still freezing after switching to the Bulk Propagation API?

Add altitude-based color coding

Right now every dot is white. In reality, orbital objects cluster into distinct altitude bands: LEO, MEO, GEO, and HEO. Color-coding by altitude turns the dot cloud into a meaningful map of orbital congestion.

  • In DebrisField.tsx, find the last import line (import { createSingleThreadRuntime...). Add the following code below that import and above the export function DebrisField line:
import * as THREE from 'three';

const EARTH_RADIUS_KM = 6371;

function altitudeToColor(altKm) {
  if (altKm < 2000) return [1, 1, 1];           // LEO: white
  if (altKm < 35586) return [1, 1, 0];          // MEO: yellow
  if (altKm < 35986) return [1, 0.6, 0];        // GEO: orange
  return [1, 0, 0];                              // HEO: red
}

How do the altitude bands work?

Each satellite's ECI position gives a distance from Earth's center in km. Subtracting Earth's mean radius (6371 km) gives the altitude above the surface.

  • LEO (below 2000 km): where the ISS, Starlink, and most debris live. White dots.
  • MEO (2000-35586 km): GPS and navigation satellites. Yellow dots.
  • GEO (35586-35986 km): a narrow 400 km band centered on 35786 km where satellites match Earth's rotation. Orange dots.
  • HEO (above 35986 km): highly elliptical orbits that swing far from Earth. Red dots.
  • Inside the component, find this line:
  const positionsRef = useRef(new Float32Array(0));
  • Add a new ref directly below it:
  const colorsRef = useRef(new Float32Array(0));
  • In the fetch effect, find this line:
        positionsRef.current = new Float32Array(recs.length * 3);
  • Add a matching color array allocation directly below it:
        colorsRef.current = new Float32Array(recs.length * 3);
  • Save DebrisField.tsx. Check your terminal to confirm the dev server recompiles without errors.

Now you need to update the propagation loop so it computes colors alongside positions, then update the JSX to render those colors.

  • In the bulk propagation useEffect, find the existing update function and replace it with this version that computes altitude colors:
      const update = () => {
        propagator.setDates([new Date()]);
        propagator.run({});
        const raw = propagator.getRawOutput().eci.position;
        const positions = positionsRef.current;
        const colors = colorsRef.current;
        for (let i = 0; i < satrecs.length; i++) {
          const x = raw[i * 3];
          const y = raw[i * 3 + 1];
          const z = raw[i * 3 + 2];
          positions[i * 3] = x;
          positions[i * 3 + 1] = y;
          positions[i * 3 + 2] = z;
          // Compute altitude and assign color band
          const altKm = Math.sqrt(x*x + y*y + z*z) - EARTH_RADIUS_KM;
          const [r, g, b] = altitudeToColor(altKm);
          colors[i * 3] = r;
          colors[i * 3 + 1] = g;
          colors[i * 3 + 2] = b;
        }
        if (pointsRef.current) {
          pointsRef.current.geometry.attributes.position.needsUpdate = true;
          pointsRef.current.geometry.attributes.color.needsUpdate = true;
        }
      };

What does the color loop do?

For each satellite, the loop reads the raw ECI x, y, z coordinates from the WASM output. It computes the distance from Earth's center using Math.sqrt(x*x + y*y + z*z), subtracts EARTH_RADIUS_KM to get altitude, and calls altitudeToColor() to map it to an RGB triplet.

Both the position and color buffers get needsUpdate = true so the GPU picks up both changes every second.

  • Replace the JSX return block at the bottom of the component with this version that includes a color buffer and enables vertex colors:
  return (
    <points ref={pointsRef}>
      <bufferGeometry>
        <bufferAttribute attach='attributes-position' args={[positionsRef.current, 3]} />
        <bufferAttribute attach='attributes-color' args={[colorsRef.current, 3]} />
      </bufferGeometry>
      <pointsMaterial size={0.02} vertexColors />
    </points>
  );

Why vertexColors?

Setting vertexColors on the pointsMaterial tells THREE.Points to read each point's color from the color buffer attribute instead of using a single flat color. This lets you color 20,000+ points individually in a single draw call.

  • Save DebrisField.tsx.
  • Toggle debris off and back on in your browser.

You should see a dense cloud of white dots crowding the LEO shell close to Earth. Scattered yellow dots sit further out in the MEO region. A thin ring of orange marks the GEO belt, and any red dots are in highly elliptical orbits beyond GEO.

All dots are still white?

  • Check that the <pointsMaterial> tag has vertexColors and does NOT have color='white'. A flat color prop overrides the per-vertex buffer.
  • Confirm you added the second <bufferAttribute> with attach='attributes-color' inside <bufferGeometry>.
  • Verify that colorsRef.current is initialized in the fetch effect with new Float32Array(recs.length * 3).

Still stuck? Why are my debris dots not showing altitude colors?

✔️ Awesome, I've got everything!

Great. Double check that you have saved DebrisField.tsx and that the globe stays responsive when debris is toggled on.

ⓧ I'd like to double check the full code

Your DebrisField.tsx should contain these sections in order. Scroll up to the matching chunk above if you need to compare.

  • Imports: useEffect, useRef, useState from React. json2satrec from satellite.js. createSingleThreadRuntime, BulkPropagator, EciBaseCalculator from satellite.js. * as THREE from three.
  • EARTH_RADIUS_KM constant set to 6371 and the altitudeToColor helper function with four color bands.
  • Component declaration with satrecs state, five refs (positionsRef, colorsRef, pointsRef, propagatorRef, runtimeRef), and the fetch useEffect that parses OMM records, allocates both Float32Array buffers, and calls onDataLoaded.
  • Bulk propagation useEffect: creates runtime and propagator, sets satellites, defines the update function that reads WASM positions AND computes altitude colors, runs update() once, sets a 1-second interval, and disposes on cleanup.
  • Return JSX: <points> with <bufferGeometry> containing two <bufferAttribute> elements (attributes-position and attributes-color), and <pointsMaterial> with vertexColors enabled.
Verify the full debris visualization
  • What do you expect to see when you toggle debris on? Think about where most tracked objects should cluster.
  • Toggle debris on in your browser.

Color-coded dots appear without freezing the browser. White dots crowd the LEO shell, with sparse yellow, orange, and red dots further out. The globe remains fully interactive as you rotate and zoom. Every second, all dots shift position as the Bulk Propagation API re-computes the entire catalog.

Your debris field is alive. 20,000+ dots update in real time, color-coded by orbital band, and the globe stays smooth. Next up, you will build a conjunction analysis system that finds the most dangerous close approaches between all those orbiting objects.

Build Conjunction Analysis

You can now see where every tracked object is in real time. The next question is: where do things get dangerously close?

The most critical function in space safety is conjunction analysis. A conjunction happens when two objects pass near each other in orbit. In this step, you will build a backend endpoint that predicts the closest approaches over the next 24 hours, then create a frontend panel that displays the results and highlights the involved objects on the globe.

In this step, get ready to:
  • Add a POST /api/conjunctions backend endpoint that identifies the closest orbital approaches.
  • Create a ConjunctionAlert component with an analysis button and clickable results.
  • Wire conjunction highlighting into DebrisField so selected objects turn red with a connecting line.
Add the conjunction analysis endpoint

Comparing every pair of 20,000 objects at every time step is computationally impossible on a single machine. The trick is to group objects by altitude band first, then only compare objects within the same band.

  • In backend/main.py, add the new imports and request model for the conjunction endpoint below your existing imports:
from sgp4.api import Satrec, jday
from sgp4 import omm
from datetime import datetime, timedelta
import math
from pydantic import BaseModel

class ConjunctionRequest(BaseModel):
    # Time window in hours to scan
    hours: int = 24
    # Distance threshold in km
    threshold_km: float = 10

What does this code do?

The ConjunctionRequest model defines the two parameters callers can customize: a 24-hour look-ahead window and a 10 km distance threshold. These defaults match real-world conjunction screening.

The Python sgp4 package provides omm.initialize() which converts OMM JSON records into Satrec objects the SGP4 propagator can use. The jday function converts calendar dates to Julian dates that sgp4() expects.

  • Save backend/main.py and restart the backend server.
  • Confirm it starts without import errors before continuing.

Seeing an import error for sgp4?

Make sure the sgp4 package is installed in your backend's virtual environment. It should already be there from a previous project.

help me fix the sgp4 import error in my FastAPI backend

  • Now add the first half of the endpoint function. This loads the cached OMM records, converts them to Satrec objects, and groups them into 100 km altitude bands. Paste this below your existing routes in backend/main.py:
@app.post("/api/conjunctions")
def find_conjunctions(req: ConjunctionRequest):
    # Load cached OMM records from debris endpoint
    records = cached_debris_data
    satrecs = []
    cat_ids = []
    for rec in records:
        sat = Satrec()
        omm.initialize(sat, rec)
        satrecs.append(sat)
        cat_ids.append(rec['NORAD_CAT_ID'])

    # Compute initial altitude for band grouping
    now = datetime.utcnow()
    jd, fr = jday(now.year, now.month, now.day,
                  now.hour, now.minute, now.second)
    bands = {}
    for i, sat in enumerate(satrecs):
        e, r, v = sat.sgp4(jd, fr)
        if e != 0 or r is None:
            continue
        alt = math.sqrt(r[0]**2 + r[1]**2 + r[2]**2) - 6371
        band_key = int(alt // 100)
        bands.setdefault(band_key, []).append(i)

Why group by altitude band?

Full pairwise comparison of 20,000 objects is O(n²) per time step. That is 200 million pair checks per step. Grouping objects into 100 km altitude bands reduces the search space drastically, because objects at very different altitudes cannot collide.

The sat.sgp4(jd, fr) call returns (e, r, v) where e is an error code (0 means success), r is the position in km, and v is velocity in km/s.

  • Save backend/main.py.
  • Add the propagation loop, deduplication, and return logic directly below the altitude-banding code you just added, inside the same find_conjunctions function:
    # Propagate at 60-second steps, compare within bands
    closest = []
    for step_i in range(int(req.hours * 3600 / 60)):
        t = now + timedelta(seconds=step_i * 60)
        jd_s, fr_s = jday(t.year, t.month, t.day,
                          t.hour, t.minute, t.second)
        pos = {}
        for i, sat in enumerate(satrecs):
            e, r, v = sat.sgp4(jd_s, fr_s)
            if e == 0 and r is not None:
                pos[i] = r
        # Compare pairs within the same altitude band
        for idxs in bands.values():
            for a in range(len(idxs)):
                for b in range(a + 1, len(idxs)):
                    i, j = idxs[a], idxs[b]
                    if i not in pos or j not in pos:
                        continue
                    d = math.sqrt(sum(
                        (pos[i][k]-pos[j][k])**2
                        for k in range(3)
                    ))
                    if d < req.threshold_km:
                        closest.append({
                            'object_a': cat_ids[i],
                            'object_b': cat_ids[j],
                            'min_distance_km': d,
                            'time_of_closest_approach':
                                t.isoformat()
                        })

What does this loop do?

For each 60-second time step across the 24-hour window, the loop propagates every satellite to that moment and computes distances between objects in the same altitude band. Any pair closer than the threshold gets recorded.

  • Save backend/main.py.
  • Add the final deduplication and return block at the end of the find_conjunctions function:
    # Keep only the closest approach per unique pair
    best = {}
    for c in closest:
        key = (min(c['object_a'], c['object_b']),
               max(c['object_a'], c['object_b']))
        if (key not in best or
                c['min_distance_km'] < best[key]['min_distance_km']):
            best[key] = c

    # Return top 10 sorted by closest distance
    return sorted(
        best.values(),
        key=lambda x: x['min_distance_km']
    )[:10]

What does this code do?

The same pair of objects may appear at multiple time steps. This block keeps only the single closest moment for each unique pair, then returns the top 10 sorted by minimum distance. Each result includes both object catalog numbers, the miss distance in km, and the time of closest approach.

  • Save backend/main.py and restart the backend.
  • Test the endpoint by sending a quick request with a shorter window:
curl -X POST http://localhost:8000/api/conjunctions -H "Content-Type: application/json" -d '{"hours": 1, "threshold_km": 50}'

You should see a JSON array of conjunction results, each with object_a, object_b, min_distance_km, and time_of_closest_approach fields.

Getting an empty array?

An empty result means no objects came within 50 km during that 1-hour window. Try increasing the threshold to 100 km or the time window to 6 hours. The analyst satellite catalog may have fewer objects in close orbits depending on the current data.

If you see an import error, make sure the Python sgp4 package is installed in your backend's virtual environment.

help me debug my conjunction analysis endpoint

Create the conjunction alert panel

The backend can now find conjunctions. You need a frontend component that triggers the analysis and displays the results in a clickable list.

  • Create a new file frontend/src/components/ConjunctionAlert.tsx and paste the fetch logic:
import { useState } from 'react';

export function ConjunctionAlert({ onConjunctionSelect }) {
  const [conjunctions, setConjunctions] = useState([]);
  const [loading, setLoading] = useState(false);

  // Fetch conjunction data from the backend
  const analyze = async () => {
    setLoading(true);
    const res = await fetch('/api/conjunctions', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ hours: 24, threshold_km: 10 })
    });
    const data = await res.json();
    // Show top 5 results
    setConjunctions(data.slice(0, 5));
    setLoading(false);
  };

What does this code do?

The analyze function sends a POST request to the conjunction endpoint with a 24-hour window and 10 km threshold. It stores the top 5 results in component state. The onConjunctionSelect callback will pass a selected conjunction up to the parent so DebrisField can highlight the objects.

  • Save ConjunctionAlert.tsx.
  • Add the JSX return block below the analyze function, inside the same component:
  return (
    <div className='conjunction-panel'>
      <button onClick={analyze} disabled={loading}>
        {loading ? 'Analyzing...' : 'Analyze Conjunctions'}
      </button>
      {conjunctions.map((c, i) => (
        <div key={i} className='conjunction-item'
             onClick={() => onConjunctionSelect(c)}>
          <span>
            {c.object_a} ↔ {c.object_b}
          </span>
          <span>
            {c.min_distance_km.toFixed(2)} km
          </span>
          <span>
            {new Date(c.time_of_closest_approach)
              .toUTCString()}
          </span>
        </div>
      ))}
    </div>
  );
}

What does this code do?

Each conjunction result renders as a clickable item showing the two object catalog numbers, the miss distance in km, and the time of closest approach. Clicking an item calls onConjunctionSelect(c) to trigger highlighting in the 3D scene.

  • Save ConjunctionAlert.tsx.
  • Wire the component into App.tsx. Add the import at the top of the file:
import { ConjunctionAlert } from './components/ConjunctionAlert';
  • Add state for the selected conjunction inside your App component:
const [selectedConjunction, setSelectedConjunction] = useState(null);
  • Render ConjunctionAlert in your JSX and pass selectedConjunction to DebrisField:
<ConjunctionAlert
  onConjunctionSelect={setSelectedConjunction}
/>
{/* Pass selectedConjunction to DebrisField */}
<DebrisField
  onDataLoaded={handleDebrisData}
  selectedConjunction={selectedConjunction}
/>
  • Save App.tsx and confirm the app compiles without errors.
  • You should see the "Analyze Conjunctions" button in your UI.

Button not appearing?

Make sure ConjunctionAlert is rendered outside the R3F Canvas element. HTML components cannot be direct children of the Three.js canvas.

help me fix my ConjunctionAlert component not rendering

Wire conjunction highlights into DebrisField

When the user clicks a conjunction, you need to visually highlight both objects. This means overriding their color buffer entries to bright red and drawing a line between their current positions with a distance label.

  • In frontend/src/components/DebrisField.tsx, add the Line and Html imports from drei, and add useMemo to your React import at the top of the file:
import { useEffect, useRef, useState, useMemo } from 'react';
import { Html, Line } from '@react-three/drei';
  • Save DebrisField.tsx.
  • Add a ref to store the raw OMM data for catalog number lookups, and a useEffect that reacts to selectedConjunction changes. Add this after the existing useEffect blocks inside DebrisField:
  // Store OMM data for catalog number lookups
  const debrisDataRef = useRef([]);

  useEffect(() => {
    if (!selectedConjunction || !satrecs.length) return;
    const data = debrisDataRef.current;
    const colors = colorsRef.current;
    // Find indices of both conjunction objects
    const idxA = data.findIndex(
      d => d.NORAD_CAT_ID === selectedConjunction.object_a
    );
    const idxB = data.findIndex(
      d => d.NORAD_CAT_ID === selectedConjunction.object_b
    );
    if (idxA < 0 || idxB < 0) return;
    // Override colors to bright red
    colors[idxA * 3] = 1;
    colors[idxA * 3 + 1] = 0;
    colors[idxA * 3 + 2] = 0;
    colors[idxB * 3] = 1;
    colors[idxB * 3 + 1] = 0;
    colors[idxB * 3 + 2] = 0;
    if (pointsRef.current) {
      pointsRef.current.geometry
        .attributes.color.needsUpdate = true;
    }
  }, [selectedConjunction, satrecs]);

What does this code do?

When selectedConjunction changes, the effect looks up both objects by their NORAD_CAT_ID in the stored OMM data. It writes bright red [1, 0, 0] into the color buffer at those indices and flags the attribute for GPU upload.

You also need to store the raw OMM data in debrisDataRef. In the existing fetch callback where you call onDataLoaded(data), add debrisDataRef.current = data so the data is available for lookups.

  • In DebrisField.tsx, scroll to the existing fetch('/api/debris') callback.
  • Find this line:
        if (onDataLoaded) onDataLoaded(data);
  • Add debrisDataRef.current = data; directly above it, so the block now looks like:
        debrisDataRef.current = data;
        if (onDataLoaded) onDataLoaded(data);
  • Save DebrisField.tsx and confirm the app still compiles.
  • Now add the useMemo that computes the 3D positions of the highlighted objects for the line and label. Add this below the useEffect you just created:
  // Compute positions of highlighted objects for line
  const highlightPositions = useMemo(() => {
    if (!selectedConjunction || !satrecs.length) return null;
    const data = debrisDataRef.current;
    const idxA = data.findIndex(
      d => d.NORAD_CAT_ID === selectedConjunction.object_a
    );
    const idxB = data.findIndex(
      d => d.NORAD_CAT_ID === selectedConjunction.object_b
    );
    if (idxA < 0 || idxB < 0) return null;
    const pos = positionsRef.current;
    const pA = [pos[idxA*3], pos[idxA*3+1], pos[idxA*3+2]];
    const pB = [pos[idxB*3], pos[idxB*3+1], pos[idxB*3+2]];
    return { pA, pB };
  }, [selectedConjunction, satrecs]);

What does this code do?

The useMemo computes the current 3D positions of both highlighted objects from the position buffer. These coordinates drive the line endpoints and the Html overlay position.

  • Save DebrisField.tsx.
  • Update the component's return statement to render the connecting line and distance label when a conjunction is selected. Replace the existing return block with this:
  if (!satrecs.length) return null;
  return (
    <>
      <points ref={pointsRef}>
        <bufferGeometry>
          <bufferAttribute
            attach='attributes-position'
            args={[positionsRef.current, 3]} />
          <bufferAttribute
            attach='attributes-color'
            args={[colorsRef.current, 3]} />
        </bufferGeometry>
        <pointsMaterial size={0.02} vertexColors />
      </points>
      {highlightPositions && (<>
        <Line
          points={[highlightPositions.pA,
                   highlightPositions.pB]}
          color='red' lineWidth={2} dashed />
        <Html position={[
          (highlightPositions.pA[0]
            + highlightPositions.pB[0]) / 2,
          (highlightPositions.pA[1]
            + highlightPositions.pB[1]) / 2,
          (highlightPositions.pA[2]
            + highlightPositions.pB[2]) / 2
        ]}>
          <div style={{ color: '#ff4444',
            background: 'rgba(0,0,0,0.7)',
            padding: '4px 8px', borderRadius: '4px',
            fontSize: '12px' }}>
            {selectedConjunction
              .min_distance_km.toFixed(2)} km
          </div>
        </Html>
      </>)}
    </>
  );

What does this code do?

When a conjunction is selected, a dashed red Line from drei connects the two highlighted objects. The Html component from drei renders a floating distance label at the midpoint between the two objects. This label stays anchored to that 3D position as the camera moves.

  • Save DebrisField.tsx.

Before you test, do you think clicking a conjunction will instantly highlight the objects, or will there be a delay?

  • Toggle debris on, then click the "Analyze Conjunctions" button.
  • Wait for the backend to process. It will take several seconds for a 24-hour window.
  • Click one of the conjunction results in the warning panel.

Two dots turn bright red and a dashed line appears between them with the miss distance in km. The highlighting is instant because it only modifies the existing buffer with no re-propagation needed.

Line not visible or label missing?

Make sure you imported both Line and Html from @react-three/drei. These must be rendered inside the R3F Canvas, which they are since they live inside the DebrisField component.

If the label shows undefined, check that selectedConjunction is being passed as a prop to DebrisField from App.tsx.

help me debug the conjunction highlighting in DebrisField

You now have a working collision-risk detector. Next up, you will build a statistics dashboard that quantifies the orbital debris problem by altitude band.

Build the Debris Statistics Dashboard

Your conjunction analysis system identifies the closest orbital approaches and highlights them on the globe. That answers where objects get dangerously close.

Numbers tell the rest of the story. A statistics panel that breaks down tracked objects by altitude band reveals how crowded each orbital shell really is. In this step, you will build that panel and add semi-transparent altitude rings to the globe for spatial context.

In this step, get ready to:
  • Create a statistics panel showing object counts per altitude band with animated count-up numbers.
  • Add semi-transparent altitude band rings around the globe for visual context.
  • Wire the stats panel and rings to appear and disappear with the debris toggle.
Create the statistics panel component

The stats panel needs to feel alive when it first appears. A count-up animation that rolls from 0 to the real total is a small touch that makes the data land with impact.

  • Create a new file frontend/src/components/DebrisStats.tsx.
  • Add the imports and a custom useCountUp hook by pasting the following code:
import { useEffect, useRef, useState } from 'react';

// Custom hook that animates a number from 0 to target
function useCountUp(target, duration = 1000) {
  const [value, setValue] = useState(0);
  useEffect(() => {
    let start = null;
    const step = (timestamp) => {
      if (!start) start = timestamp;
      const progress = Math.min(
        (timestamp - start) / duration, 1
      );
      setValue(Math.floor(progress * target));
      if (progress < 1) requestAnimationFrame(step);
    };
    requestAnimationFrame(step);
  }, [target]);
  return value;
}

What does this code do?

The useCountUp hook takes a target number and animates from 0 up to that number over duration milliseconds.

It uses requestAnimationFrame to schedule each frame. The progress ratio goes from 0 to 1, and Math.floor(progress * target) produces the current displayed value at each frame.

  • Below the hook in the same file, add the DebrisStats component by pasting this code:
export function DebrisStats({ debrisData, conjunctionCount }) {
  // Classify each object by altitude band
  const bands = { LEO: 0, MEO: 0, GEO: 0, HEO: 0 };
  const shellCounts = {};
  // compute per-band and per-shell counts from debrisData altitudes
  // (altitudes computed from MEAN_MOTION approximation for display)

  // Animate the total count on load
  const total = useCountUp(debrisData.length);

  return (
    <div className='debris-stats'>
      <div>Total Objects: {total}</div>
      <div>
        LEO: {bands.LEO} | MEO: {bands.MEO}
        | GEO: {bands.GEO} | HEO: {bands.HEO}
      </div>
      <div>
        Most Crowded Shell:
        {Object.entries(shellCounts)
          .sort((a,b)=>b[1]-a[1])[0]?.[0]} km
      </div>
      <div>Conjunction Warnings: {conjunctionCount}</div>
    </div>
  );
}

What does this component do?

  • bands and shellCounts hold the per-altitude-band and per-100-km-shell breakdowns computed from the debris data.
  • The scaffold comments mark where you compute altitudes from each object's MEAN_MOTION field and increment the matching band/shell counter.
  • total uses the useCountUp hook so the "Total Objects" number rolls up from 0 when the panel appears.
  • conjunctionCount is passed in from the parent and updates after each conjunction analysis run.
  • Save DebrisStats.tsx.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before moving on.

ⓧ I'd like to double check the full code

Your DebrisStats.tsx should contain both the useCountUp hook and the DebrisStats component. Scroll up and confirm the hook chunk is at the top of the file, followed by the component chunk below it.

The component exists but is not visible in the app yet. You need to import it into App.tsx and render it inside the debris toggle.

  • Switch back to App.tsx.
  • Add this import alongside your existing component imports at the top of the file:
import { DebrisStats } from './components/DebrisStats';
  • Add a new state variable for tracking the conjunction warning count. Place it near your other useState declarations:
// Track warnings from the last conjunction analysis
const [conjunctionCount, setConjunctionCount] = useState(0);
  • Inside the debrisVisible conditional where you render ConjunctionAlert, add the DebrisStats component below it:
{debrisVisible && (
  <DebrisStats
    debrisData={debrisData}
    conjunctionCount={conjunctionCount}
  />
)}
  • Save App.tsx and check your browser.
  • Toggle debris on. You should see the stats panel appear with a "Total Objects" count that animates from 0 up to the total number of debris records.

Stats panel not showing up?

  • Make sure you saved both DebrisStats.tsx and App.tsx.
  • Confirm the import path matches the file location exactly. The file should be at frontend/src/components/DebrisStats.tsx.
  • Check the browser console for errors. A common issue is a missing debrisData prop if it is not being passed from the parent.

Help me debug my DebrisStats panel not rendering in the browser

Add altitude band rings to the globe

The color-coded dots show where objects cluster, but it is hard to judge altitude without spatial landmarks. Three thin, semi-transparent rings around the globe mark the boundaries of the LEO, MEO, and GEO altitude bands.

How are ring radii calculated?

If Earth's radius in the scene is 1 (the standard convention for this project), each ring's radius is (6371 + altitude) / 6371 in scene units, where 6371 km is Earth's mean radius.

  • LEO boundary at 2000 km: (6371 + 2000) / 6371 = 1.314.
  • MEO midpoint at ~18893 km: (6371 + 18893) / 6371 = 3.966.
  • GEO altitude at 35786 km: (6371 + 35786) / 6371 = 6.617.
  • Inside the <Canvas> in App.tsx, add three altitude ring meshes that are visible only when debrisVisible is true.
  • Paste this code inside the Canvas, after your existing DebrisField component:
{/* Semi-transparent altitude band rings */}
{debrisVisible && (
  <>
    {/* LEO boundary ring at 2000 km */}
    <mesh rotation={[Math.PI / 2, 0, 0]}>
      <ringGeometry args={[1.314, 1.334, 64]} />
      <meshBasicMaterial
        color="white"
        transparent
        opacity={0.15}
        side={2}
      />
    </mesh>
    {/* MEO midpoint ring */}
    <mesh rotation={[Math.PI / 2, 0, 0]}>
      <ringGeometry args={[3.966, 3.986, 64]} />
      <meshBasicMaterial
        color="yellow"
        transparent
        opacity={0.15}
        side={2}
      />
    </mesh>
  </>
)}

What do these rings do?

  • Each <mesh> contains a <ringGeometry> whose args are [innerRadius, outerRadius, thetaSegments].
  • rotation={[Math.PI / 2, 0, 0]} tilts the ring from the default XY plane into the XZ plane so it sits horizontally around Earth like an orbital shell.
  • side={2} is the numeric value for THREE.DoubleSide, making the ring visible from both above and below.
  • Now add the GEO ring. Paste this code directly below the MEO ring, inside the same fragment (<>):
    {/* GEO altitude ring */}
    <mesh rotation={[Math.PI / 2, 0, 0]}>
      <ringGeometry args={[6.617, 6.637, 64]} />
      <meshBasicMaterial
        color="orange"
        transparent
        opacity={0.15}
        side={2}
      />
    </mesh>

Why split the GEO ring into its own chunk?

The GEO ring follows the exact same pattern as LEO and MEO. The only difference is the radius (6.617, matching the 35786 km GEO altitude) and the orange color.

  • Save App.tsx and refresh your browser.
  • Toggle debris on. You should see three faint colored rings encircling the globe at different distances.
  • The white ring hugs close to Earth (LEO boundary), the yellow ring sits further out (MEO midpoint), and the orange ring marks the GEO belt at the outer edge.
  • Zoom out if needed. The GEO ring at radius 6.617 is far from Earth's surface.

Rings not visible?

  • If you only see rings from one angle, check that side={2} is set on each meshBasicMaterial. Without it, the ring face-culls from one direction.
  • If the rings appear but look too faint, try temporarily increasing opacity from 0.15 to 0.3 to confirm they render.
  • Make sure the ring meshes are inside the <Canvas>, not in the HTML overlay. <ringGeometry> only works inside a React Three Fiber scene.

Help me debug why my altitude rings are not rendering around the globe

Wire the conjunction warning count into the stats panel

The stats panel shows "Conjunction Warnings: 0" because nothing updates conjunctionCount yet. You need ConjunctionAlert to report how many results it received after each analysis run.

  • In ConjunctionAlert.tsx, find the function signature:
export function ConjunctionAlert({ onConjunctionSelect }) {
  • Add onAnalysisComplete to the destructured props so the line looks like this:
export function ConjunctionAlert({ onConjunctionSelect, onAnalysisComplete }) {
  • Inside the analyze function, find this line:
    setConjunctions(data.slice(0, 5));
  • Add a call to the new callback directly below it, so both lines now read:
    setConjunctions(data.slice(0, 5));
    onAnalysisComplete?.(data.length);

What does this change do?

The optional chaining ?. means if onAnalysisComplete is not passed, nothing happens. When it is passed, it fires with the total number of conjunction results from the API.

App.tsx stores that number as conjunctionCount and passes it down to DebrisStats so the panel stays in sync.

  • Save ConjunctionAlert.tsx.
  • Switch back to App.tsx.
  • Find where you render ConjunctionAlert and add the new onAnalysisComplete prop:
<ConjunctionAlert
  onConjunctionSelect={setSelectedConjunction}
  onAnalysisComplete={setConjunctionCount}
/>
  • Save App.tsx.

Before you test, think about what you expect to see. When you click "Analyze Conjunctions", what should the "Conjunction Warnings" number in the stats panel change to?

  • Toggle debris on.
  • Confirm the stats panel shows real numbers: the total tracked objects counting up, the per-band breakdown, and the most crowded shell.
  • Confirm the three altitude rings are visible around the globe.
  • Click Analyze Conjunctions.
  • After processing completes, check the stats panel. The "Conjunction Warnings" count should update from 0 to the number of results returned by the API.

Warning count still showing 0?

  • In ConjunctionAlert.tsx, confirm onAnalysisComplete is in the destructured props. Also confirm the call onAnalysisComplete?.(data.length) is placed after setConjunctions.
  • In App.tsx, confirm you are passing onAnalysisComplete={setConjunctionCount} on the ConjunctionAlert component.
  • Check that conjunctionCount is passed as a prop to DebrisStats.

Help me debug why the conjunction warning count is not updating in my stats panel

Secret mission

Add a Kessler Syndrome Collision Simulation

One collision in orbit creates debris. That debris collides with more objects, creating even more fragments. This chain reaction is called the Kessler Syndrome. In this secret mission, you will simulate it visually by generating expanding debris fragments from a collision point and watching them spread along the orbital path.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and uses the free CelesTrak public API for orbital data. There are no paid cloud resources or ongoing costs.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Running dev servers (frontend and backend terminal processes).
  • New frontend components: DebrisField.tsx, DebrisStats.tsx, ConjunctionAlert.tsx, KesslerSimulation.tsx.
  • Modified project files: frontend/src/App.tsx and backend/main.py.
  • satellite.js package upgraded to v7.1.0.

Keep everything running

No action needed. Choose this if you are still actively building on your OrbitX project or want to keep experimenting with the debris visualization.

  • Your dev servers stay running and the debris layer is ready to toggle on whenever you refresh the page.
  • CelesTrak data refreshes each time you load the debris endpoint, so positions stay current automatically.
  • The satellite.js v7.1.0 upgrade remains in your package.json.

Pause - I'll come back to this later

Shut down running processes to free up system resources. Your code stays on disk and is ready to restart anytime.

  • In your frontend terminal, press Ctrl+C to stop the React dev server.
  • In your backend terminal, press Ctrl+C to stop the FastAPI server.
  • To resume later, start both servers again from their respective directories.

Delete - I don't want to use this again

Remove all project resources from this project and revert to your pre-debris OrbitX state.

  • Stop both dev servers by pressing Ctrl+C in each terminal.
  • Delete the new component files by running this in your frontend directory:

macOS

rm frontend/src/components/DebrisField.tsx frontend/src/components/DebrisStats.tsx frontend/src/components/ConjunctionAlert.tsx frontend/src/components/KesslerSimulation.tsx

Windows

del frontend\src\components\DebrisField.tsx, frontend\src\components\DebrisStats.tsx, frontend\src\components\ConjunctionAlert.tsx, frontend\src\components\KesslerSimulation.tsx
  • Manually revert the changes you made to frontend/src/App.tsx during this project. Remove the debris toggle, conjunction analysis, Kessler simulation state, and all related component imports. Your file should match its state at the end of Project 4.
  • Manually revert the changes you made to backend/main.py. Remove the GET /api/debris and POST /api/conjunctions endpoints. Your file should match its state at the end of Project 4.
  • Downgrade satellite.js back to its previous version by running:
npm install satellite.js@5

Use git for a cleaner rollback

If you committed your code at the end of Project 4, you can revert all changes from this project at once with git stash or by checking out the previous commit. This is faster and less error-prone than manually reverting each file.

That's a Wrap!

That's a Wrap!

Nice work! You've built a real-time space debris tracker that visualizes 20,000+ orbital objects, identifies collision risks, and demonstrates why orbital congestion is one of the most pressing challenges in spaceflight.

This is the kind of visualization that space agencies and satellite operators actually use to monitor orbital safety.

You've learned how to:

  • Visualize 20,000+ tracked orbital objects in real time using the satellite.js Bulk Propagation API, which processes the entire catalog via WASM in milliseconds instead of freezing the browser. Objects are color-coded by altitude band (LEO, MEO, GEO, HEO) and rendered as a single draw call using THREE.Points particle systems.
  • Build a conjunction analysis system that identifies the closest orbital approaches in the next 24 hours. This is the same core function that real space agencies use for collision avoidance.
  • Create a statistics dashboard that quantifies the orbital debris problem by altitude band. The dashboard transforms raw dots on a globe into a meaningful analytical tool with object counts, crowded altitude shells, and conjunction warnings.
  • Secret Mission: Simulated the Kessler Syndrome, showing how a single collision can cascade into an expanding cloud of debris fragments.

Ready to quiz yourself?