CesiumJS is an open-source WebGL globe and map engine purpose-built for time-dynamic geospatial visualization — the same category of tooling used in aerospace mission command & control to track spacecraft, plan contacts, and replay orbital history. This project is a miniature, self-contained version of a satellite-tracking dashboard I built: three Starlink satellites, rendered on a fully interactive 3D globe, with their positions computed live from orbital element sets rather than pre-baked animation paths.
The full version of this dashboard (built for an aerospace mission-control coding exercise) tracked an entire fleet against a live GraphQL backend, with ground stations and computed satellite-to-ground contact windows. This reduced build strips that down to the mechanics that matter most: three Starlink satellites, hardcoded Two-Line Element (TLE) sets, propagated client-side and rendered on a live Cesium globe.
All three are drawn from the same sample dataset used in the original dashboard build — frozen at a February 2022 TLE epoch, so propagating to "now" is a multi-year extrapolation. That's fine for demonstrating the mechanics; it isn't a live feed of real Starlink positions.
Each satellite's Two-Line Element (TLE) set is fed through satellite.js's SGP4 propagator to get a position in the ECI reference frame at a given instant, then converted to geodetic lat/lon/height and finally to a Cesium Cartesian3. Sampling that every 5 minutes across a time window builds a SampledPositionProperty— Cesium interpolates between samples so the satellite moves smoothly, and colors the trail differently for the "past" segment (already flown) versus the "future" segment (projected).
TLE → position samples
const satrec = twoline2satrec(tle.line1, tle.line2);
for (let offsetMs = -halfSpanMs; offsetMs <= halfSpanMs; offsetMs += stepMs) {
const date = new Date(centreMs + offsetMs);
const { position } = propagate(satrec, date); // SGP4, ECI frame
const gmst = gstime(date);
const geo = eciToGeodetic(position, gmst); // ECI -> lat/lon/height
trailProp.addSample(
JulianDate.fromDate(date),
Cartesian3.fromDegrees(geo.longitude * RAD_TO_DEG, geo.latitude * RAD_TO_DEG, geo.height * 1000)
);
}On load, the clock centers on right now and spans ±12 hours (a full 24-hour window) — far enough to see a complete low-Earth-orbit pass in both directions without the timeline becoming unreadable. The full dashboard build took this one step further: projecting each sample's ground-track point (same lat/lon, altitude zero) and checking its distance to known ground stations. Whenever that distance dropped under a threshold, it opened a "contact window" — the span of time a ground station could actually talk to the satellite — and only drew a connecting line during those windows. This reduced demo omits the ground-station side (there are no real stations to check against here), but the toggleable ground-track line per satellite in the interactive map is the same projected-point calculation this relies on.
Ground-track contact windows (full dashboard build)
// Ground-track point projected to altitude 0, compared against
// each ground station's fixed position. Distance <= 5,000 km opens
// a "contact window"; the accumulated windows become the segments
// a satellite-to-ground-station line is only drawn during.
for (let i = 0; i < groundTrackPositions.length; i++) {
const dist = Cartesian3.distance(groundTrackPositions[i], groundStationPosition);
const t = trailSamples[i].julianDate;
if (dist <= CONTACT_RANGE_M) {
if (!intervalStart) intervalStart = JulianDate.clone(t);
} else if (intervalStart) {
intervals.push(new TimeInterval({ start: intervalStart, stop: t }));
intervalStart = null;
}
}