Track Every Satellite in Orbit
Render 5000+ satellites at 60fps using GPU instancing and live orbital data.
Introduction
30 Second Summary
There are thousands of satellites circling Earth right now. They power your GPS, beam down your internet, and track the weather. Most of us never think about them. What if you could see every single one of them at once, moving in real time around a 3D globe?
In this project, you will build a full constellation viewer that renders 5000+ satellites as color-coded dots orbiting a textured globe, pulling live orbital data from CelesTrak's public API. You will solve the performance challenge of rendering thousands of objects at 60fps using GPU instancing.
What You'll Build
Imagine zooming out from your ISS tracker and watching the entire orbital neighborhood light up. Thousands of colored dots drift across the globe in formation. You click one, and its name, altitude, and speed appear instantly.
By the end of this project, you'll have:
- A live satellite cloud rendering 5000+ objects from Starlink, GPS, Weather, and Space Station groups at smooth 60fps using a single InstancedMesh draw call.
- A searchable filter panel that toggles entire constellation groups on and off and finds any satellite by name among thousands.
- Click-to-select raycasting on instanced geometry that reveals a satellite's name, NORAD ID, altitude, and velocity in a live telemetry panel.
- Secret Mission: Visualize a selected satellite's ground footprint as a semi-transparent circle projected onto the globe surface.
Do I need to complete Project 1 first?
Yes. This project builds directly on the OrbitX Project 1 codebase (your working ISS tracker with Vite, React, TypeScript, React Three Fiber, and satellite.js). You will upgrade and extend that existing project rather than starting from scratch.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up for Multi-Satellite Support
Your Project 1 ISS tracker uses satellite.js v5.x, which only understands the legacy TLE text format. CelesTrak's modern API serves data as OMM JSON, and parsing thousands of TLE strings is fragile and slow.
Upgrading to satellite.js v7.1.0 unlocks json2satrec(), which accepts CelesTrak's JSON objects directly. This eliminates text parsing entirely when you fetch thousands of satellites in the next step.
In this step, get ready to:
- Upgrade satellite.js and fix breaking API changes.
- Create shared TypeScript types for satellite data.
- Verify your existing ISS tracker still works with the upgrade.
Upgrade satellite.js and fix breaking changes
The v7.x release of satellite.js changes how propagate() reports failures. Instead of returning { position: false }, it now returns null when propagation fails.
- Install the upgraded version by running this command in your terminal:
npm install satellite.js@7.1.0
What does this upgrade give you?
Version 7.1.0 adds json2satrec(), which converts CelesTrak's OMM JSON objects directly into satellite records. No more splitting TLE text strings by line and hoping the format is correct.
Install failed?
Make sure you are running the command from inside your orbitx project folder (where package.json lives). If you see a peer dependency warning, that is safe to ignore for this project.
Help me fix my satellite.js install
The upgrade introduces a breaking change you need to fix before the app will run. Your existing getISSPosition function checks positionAndVelocity.position for a falsy value, but now propagate() returns null directly when it fails.
- In src/utils/satellite.ts, find the getISSPosition function.
- Locate this line:
if (!positionAndVelocity.position)
- Replace it with a null check on the entire result:
if (!positionAndVelocity)
Why this change?
In v5, propagate() always returned an object, but its position property could be false. In v7, it returns null on failure and a valid object with .position and .velocity on success. Once you confirm the result is not null, .position is guaranteed to be valid.
- Save src/utils/satellite.ts.
- Start the dev server by running:
npm run dev
- Open localhost:5173 in your browser. Confirm the globe renders and the ISS marker is still tracking its orbit.
Seeing a TypeError in the console?
If you see TypeError: Cannot read properties of null (reading 'position'), the null check fix did not save correctly. Make sure the condition reads if (!positionAndVelocity) with no .position on the end.
Help me fix the satellite.js v7 propagate error
Create shared types
The constellation viewer will pass satellite data between several components. Shared TypeScript interfaces keep that contract consistent as the project grows.
- Create a new file src/types/satellite.ts and add the following type definitions:
import type { SatRec } from 'satellite.js';
export interface SatelliteData {
name: string;
noradId: number;
satrec: SatRec;
group: string;
}
export interface SatelliteGroup {
id: string;
label: string;
color: string;
}
export interface SatellitePosition {
x: number;
y: number;
z: number;
lat: number;
lng: number;
alt: number;
}
What do these types represent?
- SatelliteData holds everything you need for one satellite: its display name, NORAD catalog ID, the propagation record (satrec), and which group it belongs to.
- SatelliteGroup defines a category of satellites (like Starlink or GPS) with its display label and color for the visualization.
- SatellitePosition stores both the 3D scene coordinates (x, y, z) and the geographic position (lat, lng, alt) for rendering and telemetry display.
- Save src/types/satellite.ts.
- Check your browser. The dev server should hot-reload with no errors since the new file is not imported anywhere yet.
Red squiggles on the import?
If Cursor shows an error on import type { SatRec } from 'satellite.js', confirm the npm install from earlier completed successfully. Run npm list satellite.js in your terminal to verify version 7.1.0 is installed.
Help me fix the SatRec import error
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/types/satellite.ts before moving on.
ⓧ I'd like to double check the full code
Here is the complete src/types/satellite.ts file:
import type { SatRec } from 'satellite.js';
export interface SatelliteData {
name: string;
noradId: number;
satrec: SatRec;
group: string;
}
export interface SatelliteGroup {
id: string;
label: string;
color: string;
}
export interface SatellitePosition {
x: number;
y: number;
z: number;
lat: number;
lng: number;
alt: number;
}
Verify everything still works
Before you move on, take a moment to predict what you expect to see. Do you think the ISS marker is still tracking correctly after all these changes?
- Check your browser at localhost:5173.
- Confirm the globe renders and the red ISS marker is moving along its orbit.
- Open your browser's developer console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows).
- Verify there are no import errors or TypeErrors in the console output.
You should see the same ISS tracker from Project 1 running smoothly. The upgrade happened behind the scenes with no visible changes yet.
ISS marker not moving?
If the marker appears but stays frozen, the propagate() call might be returning null silently. Double-check that your null guard uses if (!positionAndVelocity) and that the rest of the function still accesses positionAndVelocity.position after the guard.
Help me debug my ISS marker after the satellite.js upgrade
Your environment is upgraded and ready for multi-satellite support. Next up, you will fetch entire satellite groups from CelesTrak and parse them into propagation-ready records.
Fetch Satellite Groups from CelesTrak
Your satellite.js upgrade is complete and the ISS still tracks smoothly. Now it's time to go from tracking one satellite to tracking thousands.
CelesTrak serves orbital data for thousands of satellites organized by category. Starlink, GPS, Weather, Space Stations. Each category contains hundreds to thousands of satellites with up-to-date orbital parameters. In this step, you'll fetch entire groups and parse them into propagation-ready records that your visualization can use.
In this step, get ready to:
- Create a catalog utility that defines satellite groups and fetches data from the CelesTrak GP API.
- Load a default satellite group on app mount and confirm the data arrives.
- Expand to load all four groups and see 5000+ satellites in your console.
Create the catalog utility
CelesTrak's GP API returns orbital data in OMM JSON format. Each JSON object contains all the orbital parameters for a satellite. The json2satrec() function from satellite.js can parse these objects directly into propagation-ready records.
- Create a new file src/utils/catalog.ts in your orbitx project.
- Add the imports and satellite group definitions by pasting this code:
import { json2satrec } from 'satellite.js';
import type { SatelliteData, SatelliteGroup } from '../types/satellite';
// Define the satellite constellation groups available on CelesTrak
export const SATELLITE_GROUPS: SatelliteGroup[] = [
{ id: 'stations', label: 'Space Stations', color: '#ff4444' },
{ id: 'starlink', label: 'Starlink', color: '#4488ff' },
{ id: 'gps-ops', label: 'GPS', color: '#ffaa00' },
{ id: 'weather', label: 'Weather', color: '#44ff88' },
];
What does this code do?
Each group object maps a CelesTrak group ID (like stations or gps-ops) to a human-readable label and a display color.
These IDs match CelesTrak's GROUP query parameter exactly. Later, the visualization will use the colors to distinguish constellations at a glance.
- Below the SATELLITE_GROUPS array, add the fetchGroup function that fetches and parses satellite data:
export async function fetchGroup(groupId: string): Promise<SatelliteData[]> {
// Fetch OMM JSON data from CelesTrak's GP API
const response = await fetch(
`https://celestrak.org/NORAD/elements/gp.php?GROUP=${groupId}&FORMAT=JSON`
);
if (!response.ok) {
throw new Error(`Failed to fetch group ${groupId}: ${response.status}`);
}
const ommArray: unknown[] = await response.json();
// Parse each OMM object into a propagation-ready satellite record
return ommArray
.map((omm: any) => {
try {
return {
name: omm.OBJECT_NAME as string,
noradId: omm.NORAD_CAT_ID as number,
satrec: json2satrec(omm),
group: groupId,
};
} catch {
return null;
}
})
.filter((sat): sat is SatelliteData => sat !== null);
}
What does fetchGroup do?
It hits CelesTrak's GP API with FORMAT=JSON to get orbital data in the modern OMM format. CelesTrak's default format changed to CSV in May 2024, so you must specify JSON explicitly.
Each JSON object in the response gets passed to json2satrec(), which converts the orbital parameters into a satrec record. That record is what propagate() needs to calculate a satellite's position at any point in time. The try/catch silently skips any malformed entries.
- Save src/utils/catalog.ts.
- Check that Cursor shows no red squiggles in the file. The imports should resolve cleanly against your types file and satellite.js.
Seeing import errors?
If json2satrec is not recognized, confirm you upgraded satellite.js to 7.1.0 in the previous step. Run npm list satellite.js to verify the installed version.
If the SatelliteData or SatelliteGroup imports fail, double check that src/types/satellite.ts exists and exports both interfaces.
help me fix import errors in my catalog.ts file
✔️ Awesome, I've got everything!
Great. Double check you've saved the file before moving on.
ⓧ I'd like to double check the full code
import { json2satrec } from 'satellite.js';
import type { SatelliteData, SatelliteGroup } from '../types/satellite';
export const SATELLITE_GROUPS: SatelliteGroup[] = [
{ id: 'stations', label: 'Space Stations', color: '#ff4444' },
{ id: 'starlink', label: 'Starlink', color: '#4488ff' },
{ id: 'gps-ops', label: 'GPS', color: '#ffaa00' },
{ id: 'weather', label: 'Weather', color: '#44ff88' },
];
export async function fetchGroup(groupId: string): Promise<SatelliteData[]> {
const response = await fetch(
`https://celestrak.org/NORAD/elements/gp.php?GROUP=${groupId}&FORMAT=JSON`
);
if (!response.ok) {
throw new Error(`Failed to fetch group ${groupId}: ${response.status}`);
}
const ommArray: unknown[] = await response.json();
return ommArray
.map((omm: any) => {
try {
return {
name: omm.OBJECT_NAME as string,
noradId: omm.NORAD_CAT_ID as number,
satrec: json2satrec(omm),
group: groupId,
};
} catch {
return null;
}
})
.filter((sat): sat is SatelliteData => sat !== null);
}
Load a default group on app mount
The catalog utility is ready but nothing calls it yet. You'll wire it into App.tsx so it fetches the Space Stations group when the app mounts, and logs the results to prove data is flowing.
- In src/App.tsx, add these imports near the top of the file alongside your existing imports:
import { fetchGroup, SATELLITE_GROUPS } from './utils/catalog';
import type { SatelliteData } from './types/satellite';
- Inside the App function, add state for the satellite array and a useEffect that fetches the Space Stations group:
// Add this state at the top of the App function
const [allSatellites, setAllSatellites] = useState<SatelliteData[]>([]);
// Add this useEffect after the state declaration
useEffect(() => {
async function loadStations() {
const satellites = await fetchGroup('stations');
setAllSatellites(satellites);
console.log(`Loaded ${satellites.length} satellites:`, satellites[0]?.name);
}
loadStations();
}, []);
What does this do?
On mount, the app calls fetchGroup('stations') which hits the CelesTrak API and returns an array of parsed satellite records. The console.log confirms data arrived by printing the count and the first satellite's name.
- Save src/App.tsx.
- Open your browser's developer console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows).
- You should see a log like Loaded 35 satellites: ISS (ZARYA) (the exact count may vary slightly).
Console showing a fetch error?
If you see a CORS or network error, check that the URL in catalog.ts uses https://celestrak.org (not .com). CelesTrak's API is accessible from browser fetch without CORS issues on the .org domain.
If the log shows 0 satellites, the API may have returned CSV instead of JSON. Confirm your URL includes FORMAT=JSON as the default format is CSV.
help me debug why my CelesTrak fetch isn't returning satellite data
Load multiple groups
One group works. Now you'll expand to load all four groups so the app has a full catalog of satellites from Space Stations, Starlink, GPS, and Weather.
- In src/App.tsx, find the useEffect you just added. Find this code:
useEffect(() => {
async function loadStations() {
const satellites = await fetchGroup('stations');
setAllSatellites(satellites);
console.log(`Loaded ${satellites.length} satellites:`, satellites[0]?.name);
}
loadStations();
}, []);
- Replace the entire useEffect with this version that loops through all four groups sequentially:
useEffect(() => {
async function loadAllGroups() {
const results: SatelliteData[] = [];
for (const group of SATELLITE_GROUPS) {
try {
const satellites = await fetchGroup(group.id);
results.push(...satellites);
setAllSatellites([...results]);
console.log(`Loaded ${group.label}: ${satellites.length.toLocaleString()} satellites`);
} catch (err) {
console.error(`Failed to load ${group.label}:`, err);
}
}
console.log(`Total satellites loaded: ${results.length}`);
}
loadAllGroups();
}, []);
Why load sequentially instead of in parallel?
Loading groups one at a time with await lets you update state progressively. Each time a group finishes, setAllSatellites([...results]) triggers a re-render with the satellites loaded so far.
This means the visualization can start rendering immediately with the first group's data instead of waiting for all 5000+ satellites to finish downloading.
- Save src/App.tsx.
Before you check the console, predict: how many total satellites do you expect across all four groups?
- Check your browser console. You should see four sequential log lines as each group loads, followed by a total.
The total should be 5000+ satellites. Starlink alone contributes over 6000 objects to the catalog.
Only seeing some groups load?
If one group fails while others succeed, it's likely a temporary CelesTrak rate limit or network hiccup. The try/catch in the loop means one failure won't stop the others. Refresh the page to retry.
If ALL groups fail, check your internet connection and confirm the fetch URL matches https://celestrak.org/NORAD/elements/gp.php?GROUP=${groupId}&FORMAT=JSON exactly.
help me debug why some CelesTrak satellite groups fail to load
Your app now pulls live orbital data for thousands of satellites in one go. Next up, you'll put all those dots on screen and discover why rendering them one-by-one brings your GPU to its knees.
Render Thousands of Satellites with GPU Instancing
You now have 5000+ satellite records loaded into state, each with a valid satrec ready for propagation. The next challenge is rendering them all on-screen in real-time.
Every 3D mesh you add to a scene generates a separate draw call to the GPU. The React Three Fiber docs warn: no more than 1000 meshes as the very maximum. With 6000+ satellites, a naive one-mesh-per-satellite approach will choke your GPU. GPU instancing solves this by rendering all satellites in a single draw call.
In this step, get ready to:
- Try the naive rendering approach and observe the performance cliff.
- Replace individual meshes with a single InstancedMesh.
- Optimize propagation frequency for smooth 60fps rendering.
Try the naive approach first
The most intuitive way to render satellites is to create one <mesh> per satellite. Let's try it and see what happens.
- Create a new file src/components/SatelliteCloud.tsx and paste the following naive implementation:
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import { propagate, eciToGeodetic, gstime } from 'satellite.js';
import type { SatelliteData } from '../types/satellite';
const EARTH_RADIUS_KM = 6371;
interface SatelliteCloudProps {
satellites: SatelliteData[];
}
// Naive approach: one <mesh> per satellite
export function SatelliteCloud({ satellites }: SatelliteCloudProps) {
const groupRef = useRef<THREE.Group>(null!);
useFrame(() => {
const now = new Date();
const gmst = gstime(now);
groupRef.current.children.forEach((mesh, i) => {
if (!satellites[i]) return;
const result = propagate(satellites[i].satrec, now);
if (!result) return;
const geo = eciToGeodetic(result.position, gmst);
const r = 1 + geo.height / EARTH_RADIUS_KM;
const phi = (90 - geo.latitude * 180 / Math.PI) * (Math.PI / 180);
const theta = (geo.longitude * 180 / Math.PI + 180) * (Math.PI / 180);
mesh.position.set(-(r * Math.sin(phi) * Math.cos(theta)), r * Math.cos(phi), r * Math.sin(phi) * Math.sin(theta));
});
});
return (
<group ref={groupRef}>
{satellites.map((_, i) => (
<mesh key={i}>
<sphereGeometry args={[0.005, 4, 4]} />
<meshBasicMaterial color="#4488ff" />
</mesh>
))}
</group>
);
}
What does this code do?
This creates one separate mesh for every satellite in the array. Each mesh is a tiny sphere. Every animation frame, it propagates all satellites and moves their mesh to the correct position.
With Starlink alone at ~6000 satellites, that means 6000+ individual draw calls hitting the GPU every single frame.
- In src/App.tsx, add the import at the top of the file:
import { SatelliteCloud } from './components/SatelliteCloud';
- Inside the <Canvas> element, add the component after the existing <OrbitTrail />:
<SatelliteCloud satellites={allSatellites} />
- Save both files.
Before you check the browser, predict: what do you think happens to your framerate when React renders 6000 individual meshes?
Open the browser performance monitor (press Cmd+Shift+P in Chrome DevTools, type "Show FPS", select Show frames per second (FPS) meter). You should see the framerate plummet to single digits or freeze entirely.
This is supposed to be broken
Each of those 6000 meshes is a separate draw call. GPUs are fast at filling pixels but slow at switching between objects. The R3F docs explicitly warn: no more than 1000 meshes as the very maximum, and optimally a few hundred or less.
This is the draw call bottleneck. You have just hit the performance cliff that GPU instancing was invented to solve.
Replace with InstancedMesh
An InstancedMesh renders thousands of copies of the same geometry in a single draw call. Instead of 6000 separate meshes, the GPU receives one set of geometry data plus a per-instance transform matrix for each copy.
Why raw InstancedMesh instead of drei's Instances?
The drei library offers an Instances wrapper that is declarative and convenient for small counts. But the drei docs note it "comes at the cost of CPU overhead" and recommend raw THREE.InstancedMesh for cases with thousands of instances where you want no CPU overhead.
With 6000+ satellites updated every frame, raw InstancedMesh with imperative matrix updates is the right choice.
- Delete all the code in src/components/SatelliteCloud.tsx and replace it with this file header, interface, and coordinate helper:
import { useRef, useMemo, useCallback } from 'react';
import { useFrame, type ThreeEvent } from '@react-three/fiber';
import * as THREE from 'three';
import { propagate, eciToGeodetic, gstime } from 'satellite.js';
import type { SatelliteData } from '../types/satellite';
import { SATELLITE_GROUPS } from '../utils/catalog';
const EARTH_RADIUS_KM = 6371;
const PROPAGATION_INTERVAL = 60; // re-propagate every 60 frames
interface SatelliteCloudProps {
satellites: SatelliteData[];
selectedIndex: number | null;
onSelect: (index: number) => void;
}
function latLngAltToVector3(lat: number, lng: number, alt: number): [number, number, number] {
const radius = 1 + alt / EARTH_RADIUS_KM;
const phi = (90 - lat) * (Math.PI / 180);
const theta = (lng + 180) * (Math.PI / 180);
const x = -(radius * Math.sin(phi) * Math.cos(theta));
const z = radius * Math.sin(phi) * Math.sin(theta);
const y = radius * Math.cos(phi);
return [x, y, z];
}
What does this code do?
The component now accepts selectedIndex and onSelect props for click-to-select support. The PROPAGATION_INTERVAL constant controls how often positions are recalculated (every 60 frames = once per second at 60fps).
The latLngAltToVector3 helper converts geographic coordinates (latitude, longitude, altitude) into 3D scene coordinates that match your globe's orientation.
- Below the helper function, add the component declaration with its refs and memos:
export function SatelliteCloud({ satellites, selectedIndex, onSelect }: SatelliteCloudProps) {
const meshRef = useRef<THREE.InstancedMesh>(null!);
const dummy = useMemo(() => new THREE.Object3D(), []);
const frameCount = useRef(0);
const positions = useRef<[number, number, number][]>([]);
const groupColorMap = useMemo(() => {
const map: Record<string, THREE.Color> = {};
SATELLITE_GROUPS.forEach((g) => {
map[g.id] = new THREE.Color(g.color);
});
return map;
}, []);
useMemo(() => {
if (satellites.length === 0) return;
positions.current = new Array(satellites.length).fill([0, 0, 0]);
}, [satellites]);
What does this code do?
- meshRef holds a reference to the single InstancedMesh that renders all satellites.
- dummy is a reusable Object3D used to compute per-instance transforms without creating new objects every frame.
- frameCount tracks frames so propagation only runs every 60 frames.
- positions caches the last computed position for each satellite between propagation cycles.
- groupColorMap pre-computes THREE.Color objects for each group so they are not recreated every frame.
- After the useMemo blocks (you will add the animation loop here in the next substep), add the click handler and JSX return to close out the component:
// Animation loop will go here (next substep)
const handleClick = useCallback(
(e: ThreeEvent<MouseEvent>) => {
e.stopPropagation();
if (e.instanceId !== undefined) {
onSelect(e.instanceId);
}
},
[onSelect]
);
if (satellites.length === 0) return null;
return (
<instancedMesh
ref={meshRef}
args={[undefined, undefined, satellites.length]}
frustumCulled={false}
onClick={handleClick}
>
<sphereGeometry args={[0.005, 4, 4]} />
<meshBasicMaterial vertexColors toneMapped={false} />
</instancedMesh>
);
}
What does this code do?
- handleClick uses e.instanceId to identify which satellite dot was clicked. e.stopPropagation() prevents the click from hitting instances behind the first hit.
- The <instancedMesh> element receives args={[undefined, undefined, satellites.length]} which sets the instance count. The geometry and material are shared across all instances.
- vertexColors tells the material to read per-instance colors set via setColorAt.
- Update src/App.tsx to pass the new props. Add this state and callback near your other state declarations:
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const handleSelect = useCallback((index: number) => {
setSelectedIndex(index);
}, []);
- Make sure you import useCallback alongside your existing React imports at the top of the file.
- Update the <SatelliteCloud> line in the Canvas to pass all three props:
<SatelliteCloud
satellites={allSatellites}
selectedIndex={selectedIndex}
onSelect={handleSelect}
/>
- Save both files and check the terminal for TypeScript errors.
You should see no compile errors. The globe renders but the satellite dots are not yet visible because the animation loop (which positions them) is missing. That is the next piece.
Seeing TypeScript errors?
Check that you imported useCallback from React in App.tsx. Also verify that type ThreeEvent is imported from @react-three/fiber in SatelliteCloud.tsx.
If you see an error about SATELLITE_GROUPS, make sure the import path matches your file structure: ../utils/catalog.
Help me debug InstancedMesh TypeScript errors in my satellite component
Add the animation loop with optimized propagation
Propagating 6000 satellites every single frame would mean 360,000 SGP4 calculations per second. Satellites at orbital altitudes move a few km/s, which is invisible at this zoom level between frames.
The optimization: recalculate positions only every 60 frames (once per second), cache them, and update the instance transforms every frame from the cache. This keeps camera interaction smooth while reducing propagation work by 60x.
- In SatelliteCloud.tsx, find the comment // Animation loop will go here (next substep) and replace it with this useFrame block that handles propagation:
useFrame(() => {
if (!meshRef.current || satellites.length === 0) return;
frameCount.current += 1;
// Re-propagate positions once per second (every 60 frames)
if (frameCount.current % PROPAGATION_INTERVAL === 1 || frameCount.current === 1) {
const now = new Date();
const gmst = gstime(now);
for (let i = 0; i < satellites.length; i++) {
const result = propagate(satellites[i].satrec, now);
if (result) {
const geodetic = eciToGeodetic(result.position, gmst);
const lat = geodetic.latitude * (180 / Math.PI);
const lng = geodetic.longitude * (180 / Math.PI);
const alt = geodetic.height;
positions.current[i] = latLngAltToVector3(lat, lng, alt);
} else {
positions.current[i] = [0, 0, 0];
}
}
}
What does this code do?
The frame counter increments each render. Only when frameCount % 60 === 1 does the expensive SGP4 propagation run. Each satellite's position is computed in ECI coordinates, converted to geodetic (lat/lng/alt), then transformed to 3D scene coordinates and cached in the positions ref.
- Immediately after the propagation block (still inside the same useFrame), add the matrix and color update loop:
// Update instance transforms and colors every frame
const selectedColor = new THREE.Color('#ffffff');
for (let i = 0; i < satellites.length; i++) {
const [x, y, z] = positions.current[i];
dummy.position.set(x, y, z);
if (i === selectedIndex) {
dummy.scale.set(3, 3, 3);
} else {
dummy.scale.set(1, 1, 1);
}
dummy.updateMatrix();
meshRef.current.setMatrixAt(i, dummy.matrix);
if (i === selectedIndex) {
meshRef.current.setColorAt(i, selectedColor);
} else {
const groupColor = groupColorMap[satellites[i].group] || new THREE.Color('#ffffff');
meshRef.current.setColorAt(i, groupColor);
}
}
meshRef.current.instanceMatrix.needsUpdate = true;
if (meshRef.current.instanceColor) {
meshRef.current.instanceColor.needsUpdate = true;
}
});
What does this code do?
- The dummy Object3D is positioned using cached coordinates, then its matrix is copied into the InstancedMesh buffer for each satellite.
- The selected satellite gets scaled 3x and colored white so it stands out visually.
- Each non-selected satellite gets its group color (red for stations, blue for Starlink, gold for GPS, green for weather).
- Setting needsUpdate = true tells Three.js to re-upload the matrix and color buffers to the GPU this frame.
- Save SatelliteCloud.tsx.
Before you check the browser, predict: do you think 5000+ satellites will render smoothly now with a single InstancedMesh?
Switch to the browser. You should see the globe surrounded by thousands of color-coded dots. Blue dots (Starlink) will dominate, with clusters of red (stations), gold (GPS in medium Earth orbit), and green (weather) at different altitudes. Open the FPS meter and confirm you are at or near 60fps.
Dots not appearing?
Make sure the propagation block and the matrix update block are both inside the same useFrame(() => { ... }) callback. They are two parts of one animation loop, not two separate useFrame calls.
Check that frustumCulled={false} is set on the <instancedMesh>. Without it, Three.js may cull the entire mesh if its bounding sphere is off-screen.
Help me debug my InstancedMesh satellite rendering
✔️ Awesome, I've got everything!
Great. Double check you have saved SatelliteCloud.tsx and the browser shows thousands of colored dots orbiting the globe at 60fps.
ⓧ I'd like to double check the full code
Scroll up and confirm these chunks are present in order inside src/components/SatelliteCloud.tsx:
- Imports, constants (EARTH_RADIUS_KM, PROPAGATION_INTERVAL), interface, and latLngAltToVector3 helper.
- Component declaration with meshRef, dummy, frameCount, positions refs and groupColorMap memo.
- The useFrame block: propagation conditional (every 60 frames) followed by the matrix/color update loop.
- handleClick callback, early return for empty arrays, and the <instancedMesh> JSX.
You went from a frozen browser to 5000+ satellites rendering in real-time. Next up, you will add search, filter, and click-to-select so those thousands of dots become a tool you can actually explore.
Add Search, Filter, and Selection
You have thousands of satellites orbiting your globe, but they are just anonymous dots. Without a way to identify them, you cannot tell Starlink from GPS or find a specific satellite among the crowd.
In this step, you will build a control panel that lets you toggle entire constellations, search by name, and click any dot to reveal its live telemetry data.
In this step, get ready to:
- Create a FilterPanel component with group toggle checkboxes.
- Add a search input that filters satellites by name.
- Build a SatelliteInfo panel that displays telemetry when you click a dot.
Create FilterPanel with group toggles
The filter panel gives the learner control over which satellite groups are visible. Each group gets a colored checkbox that matches its dot color in the 3D scene.
- Create a new file src/components/FilterPanel.tsx and add the component structure with its props interface:
import { SATELLITE_GROUPS } from '../utils/catalog';
interface FilterPanelProps {
visibleGroups: Set<string>;
onToggleGroup: (groupId: string) => void;
satelliteCounts: Record<string, number>;
totalCount: number;
searchQuery: string;
onSearchChange: (query: string) => void;
loading: boolean;
loadingStatus: string[];
}
export function FilterPanel({
visibleGroups,
onToggleGroup,
satelliteCounts,
totalCount,
searchQuery,
onSearchChange,
loading,
loadingStatus,
}: FilterPanelProps) {
return (
<div className="filter-panel">
<h3>
Satellites {loading ? '(Loading...)' : `(${totalCount.toLocaleString()})`}
</h3>
{loading && (
<div className="loading-status">
{loadingStatus.map((status, i) => (
<div key={i} className="loading-item">{status}</div>
))}
</div>
)}
</div>
);
}
What does this code do?
The FilterPanelProps interface defines all the data the panel needs from App: which groups are visible, the toggle handler, satellite counts per group, the search query, and loading state.
The header dynamically shows either "Loading..." or the total satellite count formatted with locale separators (like 5,421).
- Now add the search input and group toggle checkboxes inside the <div className="filter-panel">, below the loading status block:
<input
type="text"
className="search-input"
placeholder="Search satellites..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
/>
<div className="group-toggles">
{SATELLITE_GROUPS.map((group) => (
<label key={group.id} className="group-toggle">
<input
type="checkbox"
checked={visibleGroups.has(group.id)}
onChange={() => onToggleGroup(group.id)}
/>
<span
className="color-dot"
style={{ backgroundColor: group.color }}
/>
<span className="group-label">{group.label}</span>
<span className="group-count">
{satelliteCounts[group.id] || 0}
</span>
</label>
))}
</div>
What does this code do?
The search input is a controlled component bound to searchQuery state. Every keystroke calls onSearchChange which will filter the satellites array upstream.
Each group toggle maps over the SATELLITE_GROUPS constant from your catalog utility. The colored dot uses inline backgroundColor so it matches the satellite dots in the 3D scene.
- Save FilterPanel.tsx.
Now style the panel so it overlays the 3D scene. The panel should float in the top-left corner with a semi-transparent dark background.
- Add the following styles to your src/App.css file at the bottom:
.filter-panel {
position: absolute;
top: 20px;
left: 20px;
background: rgba(0, 0, 0, 0.8);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 16px;
color: white;
min-width: 220px;
backdrop-filter: blur(10px);
}
.filter-panel h3 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
.loading-status {
margin-bottom: 12px;
}
.loading-item {
font-size: 11px;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 4px;
}
- Add the search input and group toggle styles immediately below:
.search-input {
width: 100%;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: white;
font-size: 13px;
outline: none;
margin-bottom: 12px;
box-sizing: border-box;
}
.search-input::placeholder {
color: rgba(255, 255, 255, 0.4);
}
.search-input:focus {
border-color: rgba(255, 255, 255, 0.4);
}
.group-toggles {
display: flex;
flex-direction: column;
gap: 8px;
}
.group-toggle {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 13px;
}
- Add the remaining toggle element styles right after:
.group-toggle input[type="checkbox"] {
width: 14px;
height: 14px;
cursor: pointer;
}
.color-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.group-label {
flex: 1;
}
.group-count {
color: rgba(255, 255, 255, 0.5);
font-size: 11px;
}
What does the styling do?
The position: absolute with top: 20px; left: 20px pins the panel over the 3D canvas. The backdrop-filter: blur(10px) creates a frosted-glass effect so the globe is visible but not distracting behind the panel.
- Save App.css.
Now wire the FilterPanel into your App component with the state it needs to control which groups are visible.
- In src/App.tsx, add these imports at the top of the file:
import { useState, useEffect, useCallback, useMemo } from 'react';
import { FilterPanel } from './components/FilterPanel';
import { SATELLITE_GROUPS } from './utils/catalog';
import type { SatelliteData } from './types/satellite';
What does this code do?
You are importing useCallback and useMemo for memoized state computations. SATELLITE_GROUPS provides the group IDs for initializing which groups are visible.
- Inside your App function, add the new state variables below your existing allSatellites state:
// Group visibility and search state
const [visibleGroups, setVisibleGroups] = useState<Set<string>>(
new Set(SATELLITE_GROUPS.map((g) => g.id))
);
const [searchQuery, setSearchQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
What does this code do?
The visibleGroups Set starts with all group IDs, meaning every constellation is visible by default. searchQuery tracks what the learner types in the search box. selectedIndex tracks which satellite dot was clicked.
- Below your useEffect that loads satellite data, add the filtering logic and handlers:
// Filter satellites by visible groups
const filteredSatellites = useMemo(() => {
return allSatellites.filter((sat) => visibleGroups.has(sat.group));
}, [allSatellites, visibleGroups]);
// Count satellites per group for the panel display
const satelliteCounts = useMemo(() => {
const counts: Record<string, number> = {};
allSatellites.forEach((sat) => {
counts[sat.group] = (counts[sat.group] || 0) + 1;
});
return counts;
}, [allSatellites]);
// Toggle a group on or off
const handleToggleGroup = useCallback((groupId: string) => {
setVisibleGroups((prev) => {
const next = new Set(prev);
if (next.has(groupId)) {
next.delete(groupId);
} else {
next.add(groupId);
}
return next;
});
setSelectedIndex(null);
}, []);
const handleSelect = useCallback((index: number) => {
setSelectedIndex(index);
}, []);
What does this code do?
The filteredSatellites memo recalculates only when allSatellites or visibleGroups changes. Toggling a group instantly removes thousands of satellites from the render without re-fetching anything.
The handleToggleGroup callback clears the selection when you toggle groups. This prevents a stale selection index pointing at the wrong satellite after the array changes.
- Update your SatelliteCloud component to receive filteredSatellites instead of allSatellites:
<SatelliteCloud
satellites={filteredSatellites}
selectedIndex={selectedIndex}
onSelect={handleSelect}
/>
- Add the FilterPanel component after your closing </Canvas> tag and any existing overlay components:
<FilterPanel
visibleGroups={visibleGroups}
onToggleGroup={handleToggleGroup}
satelliteCounts={satelliteCounts}
totalCount={filteredSatellites.length}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
loading={loading}
loadingStatus={loadingStatus}
/>
- Save App.tsx and check your browser at localhost:5173.
You should see the filter panel in the top-left corner with four group checkboxes, each showing a colored dot and satellite count.
- Uncheck the **Starlink** checkbox.
The total count in the header should drop from 5000+ to a few hundred, and the blue Starlink dots should vanish from the globe.
Panel not showing or toggles not working?
- If the panel is invisible, check that your .filter-panel CSS uses position: absolute and that the parent .app div has position: relative in your existing styles.
- If toggling does nothing, confirm you are passing filteredSatellites (not allSatellites) to your SatelliteCloud component.
Why aren't my group toggles filtering satellites?
Add search functionality
The search input already renders in your FilterPanel. Now you need to wire it into the filtering logic so typing a name narrows down which satellites appear in the cloud.
- In App.tsx, find your filteredSatellites memo. It currently looks like this:
const filteredSatellites = useMemo(() => {
return allSatellites.filter((sat) => visibleGroups.has(sat.group));
}, [allSatellites, visibleGroups]);
- Replace it with this expanded version that also filters by the search query:
const filteredSatellites = useMemo(() => {
let filtered = allSatellites.filter((sat) => visibleGroups.has(sat.group));
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter((sat) =>
sat.name.toLowerCase().includes(query)
);
}
return filtered;
}, [allSatellites, visibleGroups, searchQuery]);
What does this code do?
The filter first removes satellites whose group is toggled off. Then, if there is a search query, it narrows the result to only satellites whose name contains the query (case-insensitive). The memo dependency on searchQuery means the filtered list updates on every keystroke.
- Save App.tsx and return to your browser.
- Type ISS into the search input.
The header count should drop to a small number and the globe should show only a handful of dots matching "ISS" in their name. Clear the search field and all satellites reappear.
Search not filtering?
- Make sure searchQuery is in the useMemo dependency array. Without it, the memo will not recompute when you type.
- Confirm you are passing onSearchChange={setSearchQuery} to FilterPanel so keystrokes update the state.
Help me debug why typing in search doesn't filter satellites.
Implement click-to-select with a satellite info panel
Your SatelliteCloud already highlights clicked dots at 3x scale in white. Now you need a panel that shows meaningful data about the selected satellite: its name, NORAD ID, altitude, and velocity.
- Create a new file src/components/SatelliteInfo.tsx and add the telemetry display component:
import { useMemo } from 'react';
import { propagate, eciToGeodetic, gstime } from 'satellite.js';
import type { SatelliteData } from '../types/satellite';
interface SatelliteInfoProps {
satellite: SatelliteData;
}
export function SatelliteInfo({ satellite }: SatelliteInfoProps) {
const telemetry = useMemo(() => {
const now = new Date();
const result = propagate(satellite.satrec, now);
if (!result) return null;
const gmst = gstime(now);
const geodetic = eciToGeodetic(result.position, gmst);
const { x, y, z } = result.velocity;
const speed = Math.sqrt(x * x + y * y + z * z);
return {
altitude: geodetic.height.toFixed(1),
velocity: speed.toFixed(2),
lat: (geodetic.latitude * 180 / Math.PI).toFixed(2),
lng: (geodetic.longitude * 180 / Math.PI).toFixed(2),
};
}, [satellite]);
What does this code do?
The useMemo propagates the satellite to the current time and extracts its geodetic position (latitude, longitude, altitude) plus velocity magnitude. The speed calculation takes the 3D velocity vector's magnitude in km/s.
- Add the JSX return directly below the useMemo block to complete the component:
if (!telemetry) return null;
return (
<div className="satellite-info">
<h3>{satellite.name}</h3>
<div className="info-row">
<span className="info-label">NORAD ID</span>
<span className="info-value">{satellite.noradId}</span>
</div>
<div className="info-row">
<span className="info-label">Altitude</span>
<span className="info-value">{telemetry.altitude} km</span>
</div>
<div className="info-row">
<span className="info-label">Velocity</span>
<span className="info-value">{telemetry.velocity} km/s</span>
</div>
<div className="info-row">
<span className="info-label">Position</span>
<span className="info-value">{telemetry.lat}, {telemetry.lng}</span>
</div>
</div>
);
}
What does this code do?
The panel displays four rows of live telemetry. If propagation fails (which happens for satellites with very old orbital elements), the component returns null and nothing renders. The NORAD ID uniquely identifies every tracked object in Earth orbit.
- Save SatelliteInfo.tsx.
- Add the satellite info panel styles to the bottom of src/App.css:
.satellite-info {
position: absolute;
bottom: 20px;
right: 20px;
background: rgba(0, 0, 0, 0.8);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 16px;
color: white;
min-width: 200px;
backdrop-filter: blur(10px);
}
.satellite-info h3 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
.info-row {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
font-size: 12px;
}
.info-label {
color: rgba(255, 255, 255, 0.5);
}
.info-value {
color: rgba(255, 255, 255, 0.9);
font-family: monospace;
}
- Save App.css.
Finally, wire the SatelliteInfo panel into App so it appears when a satellite is selected.
- In App.tsx, add the import for SatelliteInfo at the top:
import { SatelliteInfo } from './components/SatelliteInfo';
- Above your return statement, add the derived value that resolves the selected satellite from the filtered array:
// Resolve the selected satellite from the filtered array
const selectedSatellite = selectedIndex !== null ? filteredSatellites[selectedIndex] : null;
- In your JSX, add the conditional render for SatelliteInfo below your FilterPanel component:
{selectedSatellite && <SatelliteInfo satellite={selectedSatellite} />}
What does this code do?
The selectedSatellite variable maps the numeric index (from the instancedMesh click) back to the actual satellite object. The panel only renders when a satellite is selected, which is why the conditional && gates the render.
- Save App.tsx and return to your browser.
Before you click, predict: what data do you expect to see for a GPS satellite at medium Earth orbit versus a Starlink satellite in low Earth orbit?
- Click any satellite dot in the 3D scene.
The clicked dot turns white and scales up. An info panel appears in the bottom-right corner showing the satellite's name, NORAD ID, altitude in km, and velocity in km/s. GPS satellites orbit around 20,200 km altitude at about 3.9 km/s. Starlink satellites sit at roughly 550 km altitude at about 7.6 km/s.
Clicking does nothing or shows wrong data?
- If clicks do not register, make sure your SatelliteCloud receives onSelect={handleSelect} (not a no-op or undefined).
- If the panel shows the wrong satellite, check that selectedIndex indexes into filteredSatellites (the same array passed to SatelliteCloud), not allSatellites.
Help me debug satellite click-to-select not working.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files: FilterPanel.tsx, SatelliteInfo.tsx, and App.tsx.
ⓧ I'd like to double check the full code
Scroll up and confirm each file contains the chunks listed below, in order.
FilterPanel.tsx should contain:
- Import from ../utils/catalog.
- FilterPanelProps interface with 8 props.
- Component function returning a div with: h3 header showing count, loading status block, search input, and group toggles mapping over SATELLITE_GROUPS.
SatelliteInfo.tsx should contain:
- Imports for useMemo, propagate, eciToGeodetic, gstime.
- A telemetry useMemo computing altitude, velocity (sqrt of x,y,z velocity squared), lat, and lng.
- JSX returning a satellite-info div with four info-row entries.
App.tsx should include:
- Imports for FilterPanel, SatelliteInfo, SATELLITE_GROUPS, and React hooks (useState, useEffect, useCallback, useMemo).
- State: visibleGroups (Set), searchQuery (string), selectedIndex (number | null).
- A filteredSatellites useMemo filtering by both group and search query.
- A satelliteCounts useMemo, handleToggleGroup and handleSelect callbacks.
- SatelliteCloud receiving filteredSatellites, FilterPanel and conditional SatelliteInfo rendered outside the Canvas.
Your constellation viewer now has full interactivity. You can isolate any group, find specific satellites by name, and inspect their live orbital data. Next up, you will add loading feedback and integrate your ISS tracker from Project 1 into the multi-satellite scene.
Add Loading States and ISS Integration
Your satellite cloud is rendering and interactive, but there is a user experience gap. Fetching thousands of satellites takes several seconds, and during that time the learner sees nothing happening.
In this step, you will add progressive loading feedback so each group reports in as it arrives. You will also confirm that the ISS marker from Project 1 still pulses alongside the new satellite cloud, and add polished styles to the overlay panels.
In this step, get ready to:
- Add sequential loading with per-group status messages.
- Confirm the ISS marker coexists with the satellite cloud.
- Style the FilterPanel and SatelliteInfo overlay panels.
Add loading progress to App
Right now, the app fetches all four satellite groups in parallel and updates state once at the end. The user has no idea anything is happening until all 5000+ satellites appear at once.
Switching to sequential loading lets you report each group as it arrives, giving the user immediate feedback.
- In src/App.tsx, find your existing state declarations (allSatellites, visibleGroups, etc.).
- Add these two new state variables below your existing ones:
const [loading, setLoading] = useState(true);
const [loadingStatus, setLoadingStatus] = useState<string[]>([]);
What do these states do?
The loading boolean tracks whether any group is still being fetched. The loadingStatus array accumulates one message per group as each finishes loading, giving the user a real-time progress log.
- Now replace your existing useEffect (the one that fetches satellite groups) with this sequential loading version:
useEffect(() => {
async function loadAllGroups() {
setLoading(true);
const results: SatelliteData[] = [];
for (const group of SATELLITE_GROUPS) {
try {
const satellites = await fetchGroup(group.id);
results.push(...satellites);
setAllSatellites([...results]);
setLoadingStatus((prev) => [
...prev,
`Loaded ${group.label}: ${satellites.length.toLocaleString()} satellites`,
]);
} catch (err) {
console.error(`Failed to load ${group.label}:`, err);
setLoadingStatus((prev) => [...prev, `Failed: ${group.label}`]);
}
}
setLoading(false);
}
loadAllGroups();
}, []);
What does this code do?
- Instead of Promise.all, a for...of loop awaits each group one at a time so state updates between fetches.
- After each group loads, setAllSatellites([...results]) immediately pushes the new satellites into the scene. The user sees dots appear progressively.
- Each setLoadingStatus call appends a status message like "Loaded Starlink: 6,421 satellites" to the FilterPanel.
- Make sure your FilterPanel component receives the loading and loadingStatus props. Find the <FilterPanel JSX in your return statement and confirm it includes:
<FilterPanel
visibleGroups={visibleGroups}
onToggleGroup={handleToggleGroup}
satelliteCounts={satelliteCounts}
totalCount={filteredSatellites.length}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
loading={loading}
loadingStatus={loadingStatus}
/>
- Save src/App.tsx.
- Switch to your browser and hard-refresh the page (Cmd+Shift+R on macOS, Ctrl+Shift+R on Windows).
Before the page finishes loading, what do you expect to see in the top-left panel?
You should see the FilterPanel showing "(Loading...)" in the header, with status messages appearing one by one as each group finishes: "Loaded Space Stations: 12 satellites", "Loaded Starlink: 6,421 satellites", and so on. Once all groups load, the header switches to the total count.
Not seeing loading messages?
- Check the browser console for network errors. If CelesTrak is unreachable, you will see "Failed:" messages in the status log instead.
- Confirm you replaced the old Promise.all useEffect entirely. If both exist, you may get duplicate fetches.
- Make sure loading and loadingStatus are passed as props to FilterPanel.
Why aren't my loading messages showing up in FilterPanel?
Confirm ISS integration
Your ISSMarker component from Project 1 uses its own fetch and propagation loop, completely independent of SatelliteCloud. Both render inside the same Canvas without conflict.
- In src/App.tsx, confirm your Canvas JSX includes both <ISSMarker /> and <SatelliteCloud ... /> as siblings. They should already be there from your prior steps.
- Look at your browser. The ISS appears as a pulsing red mesh from Project 1, and it also appears as a red dot in the Space Stations group of the satellite cloud.
Why does the ISS appear twice?
The ISSMarker component fetches TLE data and propagates the ISS position independently. The SatelliteCloud also includes the ISS as part of the Space Stations group from CelesTrak. The pulsing marker overlays the dot, making the ISS easy to spot among its neighbors.
You should see the red pulsing ISS marker surrounded by other small red dots (the remaining space stations). Both update in real-time without interfering with each other.
Style the overlay panels
Your FilterPanel and SatelliteInfo panels need proper styling to sit as semi-transparent overlays on top of the 3D scene. You will add all the CSS now.
- Open src/App.css and add the following styles for the filter panel container and loading indicators:
.filter-panel {
position: absolute;
top: 20px;
left: 20px;
background: rgba(0, 0, 0, 0.8);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 16px;
color: white;
min-width: 220px;
backdrop-filter: blur(10px);
}
.filter-panel h3 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
.loading-status {
margin-bottom: 12px;
}
.loading-item {
font-size: 11px;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 4px;
}
What does this code do?
- The .filter-panel is absolutely positioned in the top-left corner with a dark blurred backdrop.
- The .loading-item styles render each status message in small muted text, giving a progress-log appearance.
- Below those styles, add the search input and group toggle container styles:
.search-input {
width: 100%;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: white;
font-size: 13px;
outline: none;
margin-bottom: 12px;
box-sizing: border-box;
}
.search-input::placeholder {
color: rgba(255, 255, 255, 0.4);
}
.search-input:focus {
border-color: rgba(255, 255, 255, 0.4);
}
.group-toggles {
display: flex;
flex-direction: column;
gap: 8px;
}
What does this code do?
The search input gets a translucent dark background with a subtle border that brightens on focus. The .group-toggles container stacks the checkbox rows vertically with consistent spacing.
- Save src/App.css and check your browser.
The filter panel in the top-left should now appear as a dark rounded card with blurred backdrop. The search input and loading messages should be styled inside it.
- Continue adding styles for individual group toggle items:
.group-toggle {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 13px;
}
.group-toggle input[type="checkbox"] {
width: 14px;
height: 14px;
cursor: pointer;
}
.color-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.group-label {
flex: 1;
}
.group-count {
color: rgba(255, 255, 255, 0.5);
font-size: 11px;
}
What does this code do?
- Each .group-toggle row is a flex container aligning a checkbox, color dot, label, and count in one line.
- The .color-dot renders a 10px colored circle representing the group's assigned color (red, blue, gold, green).
- The .group-count pushes to the right side in muted text, showing how many satellites are in that group.
- Finally, add the SatelliteInfo panel styles below:
.satellite-info {
position: absolute;
bottom: 20px;
right: 20px;
background: rgba(0, 0, 0, 0.8);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 16px;
color: white;
min-width: 200px;
backdrop-filter: blur(10px);
}
.satellite-info h3 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
.info-row {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
font-size: 12px;
}
.info-label {
color: rgba(255, 255, 255, 0.5);
}
.info-value {
color: rgba(255, 255, 255, 0.9);
font-family: monospace;
}
What does this code do?
The .satellite-info panel mirrors the filter panel's style but anchors to the bottom-right corner. Each .info-row uses flex to space the label and value apart, with monospace font on values for clean numeric alignment.
- Save src/App.css and refresh your browser.
- Click any satellite dot. A dark info panel should appear in the bottom-right corner showing the satellite's name, NORAD ID, altitude, and velocity.
Both panels should feel consistent: dark blurred glass cards floating over the 3D scene.
Panels overlapping or not visible?
- Make sure the parent .app container has position: relative (or that the panels' position: absolute references the viewport). Your existing CSS from Project 1 should already handle this.
- If the filter panel is behind the canvas, check that your HTML overlay divs render after the <Canvas> element in the JSX return.
Help me fix my panel positioning over the 3D canvas.
Now verify the full experience. Hard-refresh your browser and watch the loading sequence complete. Toggle Starlink off and watch the satellite count drop from thousands to a few hundred. The ISS still pulses red among the remaining space station dots. Click any dot to see its telemetry in the bottom-right panel.
How does filtering optimize performance?
When you toggle a group off, filteredSatellites in App.tsx excludes those satellites entirely. SatelliteCloud receives a smaller array, so fewer instances are propagated and rendered each frame. This means per-frame work scales with visible satellites, not total loaded satellites.
Toggling Starlink off (6000+ satellites) immediately drops the propagation count by 6000 per cycle. You should notice the scene feels more responsive with fewer groups visible.
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/App.tsx and src/App.css.
ⓧ I'd like to double check the full code
Your src/App.tsx and src/App.css are both over 30 lines. Scroll up and confirm each section is present in order:
- App.tsx: imports include ISSMarker, OrbitTrail, InfoPanel, SatelliteCloud, FilterPanel, SatelliteInfo, fetchGroup, and SATELLITE_GROUPS.
- App.tsx: state declarations include allSatellites, visibleGroups, searchQuery, selectedIndex, loading, and loadingStatus.
- App.tsx: the useEffect uses a sequential for...of loop (not Promise.all) with setLoadingStatus calls inside.
- App.tsx: Canvas contains <ISSMarker />, <OrbitTrail />, and <SatelliteCloud ... /> as siblings.
- App.tsx: FilterPanel receives loading and loadingStatus props.
- App.css: contains .filter-panel, .loading-status, .loading-item, .search-input, .group-toggles, .group-toggle, .color-dot, .group-label, .group-count, .satellite-info, .info-row, .info-label, and .info-value selectors.
Your constellation viewer now gives real feedback during loading and keeps the ISS front and center. Next up, you will tackle the secret mission and visualize a satellite's ground footprint on the globe surface.
Secret mission
Satellite Footprint Visualization
Your constellation viewer shows where satellites are, but not what they can see. A satellite's ground footprint is the area on Earth's surface within its line of sight. Can you visualize it as a glowing circle on the globe that grows and shrinks with altitude?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The Vite dev server running at localhost:5173.
- The orbitx project folder on your Desktop (extended from Project 1 with new components and utilities).
Keep everything running
No action needed. Choose this if you're still actively building or want to continue experimenting with the constellation viewer.
- Your dev server stays running at localhost:5173.
- Your orbitx folder remains on your Desktop with all Project 1 and Project 2 code intact.
Pause - I'll come back to this later
Stop the dev server to free up your terminal and the port it occupies.
- Switch to the terminal where the dev server is running.
- Press Ctrl+C to stop the server.
- Your orbitx folder stays on your Desktop. To resume later, open the folder in Cursor and run npm run dev again.
Delete - I don't want to use this again
Remove the project folder entirely. This deletes all code from both Project 1 and Project 2.
- Switch to the terminal where the dev server is running and press Ctrl+C to stop it.
- Delete the project folder by running the command for your operating system.
macOS
cd ~/Desktop
rm -rf orbitx
Windows
cd ~\Desktop
Remove-Item -Recurse -Force orbitx
- Confirm the folder is gone by running ls (macOS) or dir (Windows). The orbitx folder should no longer appear.
That's a Wrap!
That's a Wrap!
Nice work! You just built a real-time constellation viewer that renders 5,000+ satellites orbiting a 3D globe at 60fps.
You've learned how to:
- Scale from tracking a single satellite to rendering 5,000+ objects in real-time by solving the GPU draw call bottleneck with InstancedMesh instancing.
- Consume CelesTrak's OMM JSON API and use json2satrec() from satellite.js for clean bulk satellite data ingestion.
- Build a searchable, filterable constellation viewer with click-to-select raycasting on instanced geometry.
- Secret Mission: Visualize satellite ground footprints using orbital mechanics and the horizon angle formula.
Ready to quiz yourself?