Satellites overhead

About 190 satellites where they are right now: the two crewed space stations, the International Space Station and China's Tiangong, the brightest satellites in the night sky, including Hubble, and the GPS constellation. Click one to draw its path for one orbit. The ISS goes around in about an hour and a half, and a GPS satellite in twelve hours.

Space stations Brightest satellites GPS

Build it yourself

CelesTrak publishes each satellite's orbit as two lines of numbers, a format called TLE. The satellite.js library works out where a satellite is at any moment from them, so the page updates every satellite's position each second without asking anything again. It was tested with MapLibre GL JS 6.15 and satellite.js 7.1 in Chromium.

What you need

Nothing but a web server to serve the page: the map tiles and CelesTrak's orbits need no key.

Download the orbits

Make a folder, and in it download CelesTrak's group of the brightest satellites, which includes the ISS, Tiangong, and Hubble:

curl -o visual.txt "https://celestrak.org/NORAD/elements/gp.php?GROUP=visual&FORMAT=tle"

Each satellite is three lines: its name, then the two lines of its orbit. CelesTrak updates its orbits every two hours and refuses computers that download the same group more often, so save the file once rather than fetching it each time the page loads. Orbits change, so download it again after a few days.

Make the page

Next to visual.txt, make index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Satellites overhead</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/dist/maplibre-gl.css">
  </head>
  <body style="margin: 0; background: #05070d">
    <div id="map" style="height: 100vh"></div>
    <script type="module">
      import * as maplibregl from "https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/+esm";
      import { degreesLat, degreesLong, eciToGeodetic, gstime, propagate, twoline2satrec } from "https://cdn.jsdelivr.net/npm/satellite.js@7.1.0/+esm";

      const lines = (await (await fetch("visual.txt")).text()).trim().split(/\r?\n/);
      const satellites = [];
      for (let i = 0; i < lines.length; i += 3) {
        satellites.push({ name: lines[i].trim(), satrec: twoline2satrec(lines[i + 1], lines[i + 2]) });
      }

      function where(satellite, date) {
        const state = propagate(satellite.satrec, date);
        if (!state?.position) return null;
        const { longitude, latitude } = eciToGeodetic(state.position, gstime(date));
        return [degreesLong(longitude), degreesLat(latitude)];
      }

      function positions() {
        const now = new Date();
        const features = satellites.flatMap((satellite, id) => {
          const position = where(satellite, now);
          if (!position) return [];
          return [{ type: "Feature", properties: { id, name: satellite.name }, geometry: { type: "Point", coordinates: position } }];
        });
        return { type: "FeatureCollection", features };
      }

      function path(satellite) {
        const coordinates = [];
        const minutes = (2 * Math.PI) / satellite.satrec.no;
        for (let minute = 0; minute <= minutes; minute++) {
          const position = where(satellite, new Date(Date.now() + minute * 60000));
          if (!position) continue;
          const previous = coordinates.at(-1);
          if (previous) position[0] += Math.round((previous[0] - position[0]) / 360) * 360;
          coordinates.push(position);
        }
        return { type: "Feature", properties: {}, geometry: { type: "LineString", coordinates } };
      }

      const map = new maplibregl.Map({
        container: "map",
        style: "https://tiles.mapricot.com/styles/dark.json",
        center: [-40, 20],
        zoom: 2,
      });
      await map.once("load");
      map.setProjection({ type: "globe" });

      const iss = satellites.find((satellite) => satellite.name.startsWith("ISS"));
      map.addSource("path", { type: "geojson", data: path(iss) });
      map.addLayer({ id: "path", type: "line", source: "path", paint: { "line-color": "#ffd43b", "line-width": 1.5 } });
      map.addSource("satellites", { type: "geojson", data: positions(), attribution: "Orbits: CelesTrak" });
      map.addLayer({
        id: "satellites",
        type: "circle",
        source: "satellites",
        paint: { "circle-radius": 4, "circle-color": "#2a78d6", "circle-stroke-color": "#ffffff", "circle-stroke-width": 1 },
      });
      setInterval(() => map.getSource("satellites").setData(positions()), 1000);

      map.on("click", "satellites", (event) => {
        const { id, name } = event.features[0].properties;
        map.getSource("path").setData(path(satellites[id]));
        new maplibregl.Popup().setLngLat(event.lngLat).setText(name).addTo(map);
      });
    </script>
  </body>
</html>
  • twoline2satrec reads each satellite's two lines into the record that propagate uses to find where it is at a given time.
  • propagate gives a position in space, and eciToGeodetic turns it into longitude and latitude over the turning Earth, using the time of day from gstime.
  • satrec.no is how far around its orbit a satellite goes each minute, in radians, so one orbit takes 2π divided by it. path finds a point for each minute of that, carrying longitudes past 180 so the line doesn't jump across the map.
  • Every second, setData replaces the satellites' positions, and MapLibre redraws them.

Open it

Serve the folder with npx serve or python3 -m http.server, and open the address it prints. Opened straight from the file, the map stays blank, as Open the page explains. You should see about 150 satellites around a dark globe, and the ISS's next orbit in yellow. Click a satellite for its own.

CelesTrak's other groups include GPS, weather satellites, and Starlink. The map above adds the space stations and GPS, and saves a copy of each group when the site is built to fall back on.

Data