ls-horizons

August 20, 2026 · View on GitHub

A terminal UI for visualizing NASA's Deep Space Network in real-time.

Note: This project is under active development. Features may change and bugs are expected.

ls-horizons demo

Features

  • Real-time DSN monitoring — Live data from NASA's Deep Space Network XML feed
  • Pass planning — Computed visibility windows for all three DSN complexes using JPL Horizons ephemeris
  • Elevation sparkline — Real-time ±2h elevation trace with truecolor gradient in Mission view
  • Real star catalog — the full naked-eye sky (8,404 stars to magnitude 6.5, J2000) from the Yale Bright Star Catalogue, embedded and published as a static endpoint
  • Astronomical projection — Proper RA/Dec to Az/El conversion using GMST/LST calculations
  • Local planetary ephemeris — Planet positions propagated in-process from Keplerian orbital elements, accurate to a few arcminutes with no network dependency
  • JPL Horizons integration — Trajectory path arcs and geocentric RA/Dec for pass planning
  • Signal propagation visualizer — Animated light-time display showing one-way/round-trip delay with pulse animation
  • Mission Spotlight — Curated mission profiles with live phase tracking, MET countdown, crew info, and timeline rail (Voyager 1)
  • Four view modes:
    • Dashboard — Complex status and active spacecraft table with multi-antenna tracking and mission spotlight badges
    • Mission Detail — Per-spacecraft deep dive with pass schedules, link details, propagation delay, and mission spotlight panel
    • Sky View — Animated star field with spacecraft positions and smooth camera transitions
    • Orbit View — Solar system visualization with real planet positions, spacecraft trajectories, and mission-aware HUD
  • Derived metrics:
    • Distance calculated from round-trip light time (RTLT), with JPL Horizons fallback
    • Velocity estimation from RTLT delta
    • "Struggle index" — composite difficulty metric based on distance, data rate, and elevation
  • Event detection — Tracks link handoffs between complexes, new acquisitions, and signal losses
  • Headless mode — JSON export and text summaries for scripting and monitoring
  • Data endpoints — Publish DSN state and heliocentric solar system positions as JSON for a web server to serve (deployment guide)
  • Deliberately light on upstreams — Conditional requests, honest User-Agent, Retry-After handling, jittered backoff, and strict request serialization against JPL's fair use policy (details)

Screenshots

Dashboard View

Real-time status of all three DSN complexes with active spacecraft table showing antennas, bands, data rates, distances, and struggle indicators.

Dashboard

Mission Detail View

Deep dive into individual spacecraft with link details, pass schedules, elevation sparkline, and signal propagation visualizer showing light-time delay with animated pulse. Press Enter from Dashboard to jump directly here. Curated missions (like Voyager 1) show a spotlight panel with phase, MET, and timeline rail — clearly labeled as schedule-derived data.

Mission Detail

Sky View

Animated celestial view with real star positions, spacecraft locations, and trajectory path arcs. Smooth camera transitions when cycling between spacecraft.

Sky View

Orbit View

Solar system visualization showing planets at real positions and active spacecraft with their trajectories. Planet positions are propagated locally from orbital elements, so this view works with no network and never waits on an API. Toggle star background with t.

Orbit View

Installation

Requirements

  • Terminal with truecolor support — The UI uses 24-bit color for gradients and styling. Most modern terminals work fine (iTerm2, Alacritty, Kitty, Windows Terminal, GNOME Terminal, etc.). Basic terminals like older xterm or screen may have limited color support.
  • Go 1.21+ — For building from source

From source

go install github.com/litescript/ls-horizons/cmd/ls-horizons@latest

Make sure your Go bin directory is on your PATH:

export PATH="$(go env GOPATH)/bin:$PATH"

Build locally

git clone https://github.com/litescript/ls-horizons.git
cd ls-horizons
go build -o ls-horizons ./cmd/ls-horizons

Pre-built binaries

Pre-built binaries are available from Releases and in os-builds/. These have no runtime dependencies. Note that the in-tree copies may lag behind the latest source.

Linux (x64):

./os-builds/linux-amd64/ls-horizons

macOS ARM (Apple Silicon):

./os-builds/mac-arm/ls-horizons

Windows (x64):

.\os-builds\windows-amd64\ls-horizons.exe

Windows users: Use Windows Terminal for best results. It's included by default on Windows 11, or install free from the Microsoft Store on Windows 10. The legacy cmd.exe and PowerShell windows have limited color support and may not render correctly. Windows Terminal defaults to a dark background; the legacy blue PowerShell background will look odd with this app.

Usage

Interactive TUI

# Launch with default 5-second refresh
ls-horizons

# Custom refresh interval
ls-horizons --refresh 30s

# Use specific ephemeris source
ls-horizons --ephem horizons   # JPL Horizons (default)
ls-horizons --ephem dsn        # DSN-derived only
ls-horizons --ephem auto       # Horizons with fallback

Keybindings:

KeyAction
1 or dDashboard view
2 or mMission detail view
3 or sSky view
4 or oOrbit view
TabCycle through views
EnterOpen Mission view for selected spacecraft (Dashboard)
j/k or ↑/↓Navigate lists
[/] or ←/→Cycle spacecraft (Mission/Sky/Orbit)
hToggle pass panel (Mission view)
lToggle labels (Sky view)
cCycle complex filter (Sky view)
pToggle trajectory path (Sky view)
tToggle star background (Orbit view)
qQuit

To upgrade, re-run the go install command above or download a newer build from Releases.

Headless Mode

# Print summary table once
ls-horizons --summary

# Summary with ASCII mini sky view
ls-horizons --summary --mini-sky

# Watch mode: refresh every 30 seconds
ls-horizons --summary --watch 30s

# Single-line "now playing" mode
ls-horizons --now

# Show card for specific spacecraft
ls-horizons --sc VGR1

# Show only changes between fetches
ls-horizons --diff --watch 30s

# Beep on important events (TTY only)
ls-horizons --summary --watch 30s --beep

# Show event log
ls-horizons --events

# Export DSN JSON snapshot to file
ls-horizons --snapshot-path snapshot.json

# Export JSON to stdout (for piping)
ls-horizons --snapshot-path -

# Export solar system positions (heliocentric, for 3D consumers)
ls-horizons --solar-snapshot-path solarsystem.json

# Export the star catalog (static; needs no network)
ls-horizons --stars-path stars.json

Data endpoints

--serve-dir writes the JSON payloads into a directory for a web server to serve as static files. Writes are atomic, so a reader never sees a partial file.

# Write once and exit (pair with a systemd timer or cron)
ls-horizons --serve-dir /var/lib/ls-horizons/web

# Run as a daemon, refreshing every 60 seconds
ls-horizons --serve-dir /var/lib/ls-horizons/web --watch 60s

This produces three files:

FileContentsRefreshes
dsn.jsonStations, antennas, active links, per-complex loadEvery poll
solarsystem.jsonHeliocentric positions for the Sun, planets, and range-resolved spacecraftEvery poll
stars.jsonThe naked-eye star catalog as celestial-sphere directionsNever

solarsystem.json uses J2000 heliocentric ecliptic coordinates in AU with the Sun at the origin and the ecliptic as the XY plane — no astronomical frame conversion needed, only whatever basis change your renderer's own axis convention calls for. Each body reports whether its position came from local orbital propagation or live DSN tracking.

Note that a spacecraft only appears in solarsystem.json when the DSN feed publishes a ranging solution for it. The feed often reports -1 across every target at once, in which case the payload contains the Sun and planets only. Consumers should tolerate an empty spacecraft list.

stars.json

A static catalog of the naked-eye sky, for clients that want a real starfield rather than procedural noise.

PropertyValue
SourceBright Star Catalogue, 5th Revised Ed. (Hoffleit & Warren, 1991), VizieR V/50
SelectionApparent visual magnitude ≤ 6.5
Count8,404 stars
Epoch and equinoxJ2000
Size~3 MB, ~450 KB gzipped

The catalog is compiled into the binary. Publishing it contacts nothing, and it is written once at startup rather than on every poll, even under --watch — so it is safe to serve with a very long max-age. Two runs of the same binary produce byte-identical output, so the payload carries no timestamp and its ETag survives a restart.

Each star carries its J2000 right ascension and declination and the same direction precomputed as unit vectors in two frames:

  • equatorial — right-handed, +X toward the vernal equinox, +Z toward the north celestial pole.
  • ecliptic — right-handed, sharing +X with the equatorial frame, +Z toward the north ecliptic pole. This is the frame solarsystem.json uses, so the same one transformation places the stars and the planets consistently.

Those are the published frames, and they are the standard astronomical ones. Mapping either onto a particular engine's axes — three.js's Y-up scene, say — is a consumer-side basis transformation, not part of what ls-horizons defines; deploy/README.md works one through as an example.

These are directions, not positions. The vectors are unit length and carry no distance, deliberately: this is a celestial-sphere catalog, not a local map of stellar neighbourhoods. Scale them to whatever shell radius your scene wants. Real stellar distances would put every one of them far outside any scene you would build around the solar system, and rendering them at true scale would leave you with an empty box.

Colour is published as raw bv, the B−V colour index, and as spectral_type. It is deliberately not pre-converted to RGB: mapping colour index to a display colour depends on your renderer's colour space, exposure, and how much saturation you want, and baking one answer into the data would freeze a presentation choice in a format that has to outlive it. bv is null for the 3% of stars with no published photometry — a real value, since B−V of exactly 0.00 is an A0 star like Vega.

Names are optional and are omitted rather than invented. Only 141 stars carry a proper name; most have a Bayer/Flamsteed designation instead, and every star has a catalog_id such as HR 2491. Records are sorted brightest first, so a client that wants a lighter payload can simply stop reading early.

{
  "schema": "ls-horizons/stars",
  "schema_version": "1.0",
  "generator": "ls-horizons/0.12.0",
  "epoch": "J2000",
  "frames": {
    "equatorial": "equatorial-J2000",
    "ecliptic": "ecliptic-J2000"
  },
  "magnitude_limit": 6.5,
  "count": 8404,
  "source": {
    "catalog": "Bright Star Catalogue, 5th Revised Ed. (Preliminary Version)",
    "reference": "Hoffleit D., Warren Jr W.H., Astronomical Data Center, NSSDC/ADC (1991)",
    "id": "VizieR V/50",
    "url": "https://cdsarc.cds.unistra.fr/ftp/V/50/"
  },
  "stars": [
    {
      "name": "Sirius",
      "catalog_id": "HR 2491",
      "designation": "9Alp CMa",
      "ra_deg": 101.28708,
      "dec_deg": -16.71611,
      "mag": -1.46,
      "bv": 0,
      "spectral_type": "A1Vm",
      "equatorial": { "x": -0.187454, "y": 0.939218, "z": -0.28763 },
      "ecliptic":   { "x": -0.187454, "y": 0.747303, "z": -0.637495 }
    }
  ]
}

schema_version is independent of the one dsn.json and solarsystem.json carry. Those track a live upstream feed; this describes a frozen catalog, and tying them together would force version bumps on payloads that had not changed. Check it the same way: refuse a major version you do not understand.

name, designation, spectral_type, and catalog_id are omitted when absent rather than emitted empty. bv is the exception — it is always present, and null when the catalog has no photometry, because B−V of exactly 0 is a real measurement and must not be confused with a missing one.

See deploy/README.md for a systemd unit, a Caddy config, and a three.js consumption example.

All Flags

FlagDefaultDescription
--refresh5sTUI data refresh interval (5s - 5m). Headless cadence is set by --watch
--ephemautoEphemeris source: horizons, dsn, or auto
--summaryfalsePrint text summary instead of TUI
--mini-skyfalseShow ASCII mini sky view
--nowfalseSingle-line now-playing mode
--sc""Show card for specific spacecraft
--difffalseShow only changes between fetches
--beepfalseBeep on important events (TTY only)
--eventsfalseShow event log
--watch0Repeat output at interval (floored at 10s with --serve-dir)
--snapshot-path""Export DSN JSON to file (- for stdout)
--solar-snapshot-path""Export solar system JSON to file (- for stdout)
--stars-path""Export the static star catalog JSON to file (- for stdout). Needs no network
--serve-dir""Write dsn.json, solarsystem.json, and stars.json into a directory
--log-levelinfoLog level (debug, info, warn, error)
-l, --log-file""Write logs to file (e.g., ~/ls-horizons.log)

Data Sources

NASA Deep Space Network

Live telemetry data from NASA's publicly available DSN feed:

https://eyes.nasa.gov/dsn/data/dsn.xml

The DSN consists of three antenna complexes positioned roughly 120° apart around Earth:

  • Goldstone (GDSCC) — Mojave Desert, California, USA
  • Canberra (CDSCC) — Tidbinbilla, Australia
  • Madrid (MDSCC) — Robledo de Chavela, Spain

This positioning ensures continuous coverage for deep space missions as Earth rotates.

JPL Horizons

Spacecraft trajectory data from NASA/JPL's Horizons system:

https://ssd.jpl.nasa.gov/api/horizons.api

Used for computing accurate sky positions, trajectory path arcs, and range/light-time estimates when DSN data is unavailable. Supports 45+ spacecraft with NAIF SPICE ID mappings including Voyager 1/2, JWST, Mars rovers, Juno, New Horizons, and more.

Horizons is not used for planet positions — those are propagated locally (see below).

Local planetary ephemeris

Planet positions come from JPL's published "Approximate Positions of the Major Planets" Keplerian element set, propagated in-process. Validated against Horizons at a fixed epoch, the inner planets agree to under a tenth of an arcminute and Saturn — the worst case — to 4.5 arcminutes, far below the resolution of any view here.

This is a deliberate trade. Querying Horizons for eight planets on a timer was the single heaviest demand this app placed on a live NASA computation service, for bodies that move imperceptibly between refreshes. Computing them locally costs microseconds, needs no network, and makes the Orbit view work offline.

Yale Bright Star Catalogue

Star positions, magnitudes, colour indices, and spectral types come from the Bright Star Catalogue, 5th Revised Ed. (Hoffleit & Warren, 1991), published by the Astronomical Data Center at NASA Goddard and distributed as VizieR catalogue V/50. The catalog asserts no copyright; NASA's open data catalog lists it as a US Government work. It is cited here and in THIRD-PARTY-NOTICES because attribution is owed, not because a licence compels it.

The full magnitude 6.5 selection is embedded in the binary and published as stars.json. The terminal views draw a magnitude 3.0 subset — 174 stars, which is about as much as an ASCII grid can resolve — with brightness-based glyphs. Proper names come from a small curated layer cross-matched onto the catalog by position; stars without one are labelled by Bayer/Flamsteed designation or left unlabelled rather than given an invented name.

Being a good citizen

Both upstreams are public science services, run on public money, free to use and requiring no API key. That's a privilege worth not abusing. This client tries to be a guest worth having:

NASA DSN feed — a static XML file behind CloudFront, regenerated roughly every five seconds:

  • Revalidates with ETag / If-Modified-Since, so an unchanged feed transfers zero bytes instead of re-downloading.
  • Identifies itself with a real version and a project URL, so someone can make contact rather than silently block an anonymous agent.
  • Honors Retry-After on 429 and 503 and pauses process-wide when asked to.
  • Retries with jittered exponential backoff, and never retries a 4xx.
  • Jitters every poll interval so separate instances don't synchronize into a thundering herd.
  • Floors the interactive interval at the feed's own 5s regeneration period — polling faster cannot surface newer data.

JPL Horizons — a live computation service under a fair use policy that this client honors:

"You agree to submit only one API request at a time (no simultaneous requests)."

Every Horizons request in the process passes through a single gate that holds its lock across the network call, with at least a second between requests. Results are cached for five minutes.

"You may not embed these APIs in your website (per NASA CORS policy)."

Horizons is never reachable from a browser and is never proxied per-visitor. The --serve-dir deployment publishes cached, derived snapshots on a timer, so upstream request rate depends only on time and never on how many people are looking. Do not reverse-proxy Horizons to work around its missing CORS headers — the absent header is that policy being enforced, not an oversight.

If you deploy this at meaningful scale, contact JPL and NASA rather than assuming these defaults still apply.

Architecture

cmd/ls-horizons/
├── main.go             Entry point and CLI flags
└── serve.go            Atomic snapshot publishing and poll jitter
deploy/                 systemd unit, Caddy config, deployment guide
internal/
├── astro/              Astronomical calculations
│   ├── coords.go       RA/Dec ↔ Az/El transforms, GMST/LST
│   ├── frames.go       Coordinate frame conversions (ecliptic, etc.)
│   ├── planets.go      Keplerian planet propagation (local ephemeris)
│   ├── visibility.go   Ground station visibility calculations
│   ├── sun.go          Sun position calculations
│   ├── stars.go        Embedded star catalog (Bright Star Catalogue, mag <= 6.5)
│   ├── export_stars.go Static stars.json export
│   └── data/           Generated catalog table
├── dsn/
│   ├── models.go       Data structures (Station, Antenna, Link, etc.)
│   ├── parser.go       XML feed parsing
│   ├── fetcher.go      HTTP client with conditional requests and backoff
│   ├── derive.go       Distance, velocity, struggle index
│   ├── passplan.go     Pass planning with elevation thresholds
│   ├── elevtrace.go    Elevation trace computation for sparklines
│   ├── spacecraft.go   Spacecraft catalog with mission metadata
│   ├── spacecraft_view.go  Multi-antenna tracking abstraction
│   ├── solarsystem.go  Solar system cache with planet positions
│   ├── observer.go     DSN complex observer locations
│   ├── export.go       DSN JSON and text export
│   └── export_solarsystem.go  Heliocentric body export for 3D consumers
├── ephem/              Ephemeris providers
│   ├── provider.go     EphemerisProvider interface
│   ├── horizons.go     JPL Horizons API client (ephemeris + RA/Dec)
│   ├── fairuse.go      Process-wide Horizons request gate and backoff
│   ├── dsn_provider.go DSN-derived fallback
│   └── targets.go      NAIF SPICE ID mappings (45+ spacecraft)
├── state/
│   └── state.go        Thread-safe state with pass plan and elevation trace caching
├── missions/           Mission spotlight layer
│   ├── models.go       MissionProfile, SpotlightState, DataProvenance
│   ├── catalog.go      Curated profiles (Voyager 1)
│   ├── aliases.go      Spacecraft name/code resolution
│   ├── runtime.go      Live phase/MET/countdown computation
│   └── viewmodel.go    Display formatting helpers
├── ui/
│   ├── ui.go           Bubble Tea main model with request queue
│   ├── dashboard.go    Dashboard view with Enter→Mission flow and spotlight badges
│   ├── mission_detail.go  Mission view with pass panel, elevation sparkline, and spotlight
│   ├── sky_view.go     Sky projection with braille arc rendering
│   └── solarsystem_view.go  Orbit view with ecliptic projection and mission HUD
├── logging/
│   └── logging.go      Structured logging
└── version/
    └── version.go      Version constant and upstream User-Agent

Why "ls-horizons"?

A play on the Unix ls command — this tool lets you "list" what's happening at the horizons of our solar system. Also a nod to NASA's New Horizons mission to Pluto and beyond.

Changelog

  • 0.12.0 — New stars.json data endpoint publishing the naked-eye sky as a static payload for 3D clients: 8,404 stars to magnitude 6.5, each with J2000 RA/Dec, precomputed unit vectors in the equatorial and ecliptic frames, B−V colour index, and spectral type. The built-in star catalog is now imported from the Yale Bright Star Catalogue rather than maintained by hand, correcting duplicated and mispositioned entries the old table carried, and the sky views draw from the same source. Added --stars-path for a one-shot export; --serve-dir now publishes stars.json alongside the other two, written once at startup and adding no upstream traffic
  • 0.11.1 — Fixed the Linux release binary, which was dynamically linked against a recent glibc and would not start on distributions shipping anything older than glibc 2.34 (Debian 11, Ubuntu 20.04, and similar). It is statically linked again, as documented. Only the Linux download was affected
  • 0.11.0 — Relicensed from MIT to Apache-2.0 (adds an explicit patent grant and a NOTICE attribution mechanism; earlier releases remain MIT). Added THIRD-PARTY-NOTICES reproducing the license texts of every dependency statically linked into the release binaries, and release archives now ship LICENSE and NOTICE alongside the binary
  • 0.10.0 — Solar system JSON endpoint with heliocentric positions for external consumers, --serve-dir to publish data endpoints for a web server, planet positions computed locally so the Orbit view no longer depends on network availability, and far lighter traffic against NASA and JPL. Removed: the in-app update check and installer, which relied on go install and so never worked for anyone running a pre-built binary. Breaking: JSON exports now carry schema_version, complex_loads keys are snake_case, and unknown range/light-time is null rather than -1
  • 0.9.1 — Retire completed Artemis II mission profile from spotlight catalog
  • 0.9.0 — Mission Spotlight: curated Artemis II & Voyager 1 profiles with live phase/MET/countdown, crew display, timeline rail, data provenance labels, and graceful handling of unsupported ephemeris lookups
  • 0.8.0 — Signal propagation delay visualizer, ephemeris range/light-time fallback via Horizons
  • 0.7.3 — Fix orbit trace mismatch when rapidly switching focused spacecraft
  • 0.7.2 — Fix Mission tab spacecraft selection, fix "pass in now" grammar
  • 0.7.1 — Only shimmer update result, not "checking" state
  • 0.7.0 — Seamless in-app restart after update (Unix), Windows graceful fallback
  • 0.6.0 — Update check UX with shimmer reveal animation, in-app update install
  • 0.5.0 — Elevation sparkline in Mission view, per-spacecraft caching
  • 0.4.0 — Visibility engine, sun separation angle, Doppler modeling
  • 0.3.0 — JPL Horizons ephemeris integration, trajectory path arcs, --ephem flag
  • 0.2.0 — Real star catalog, astronomical projection, SpacecraftView abstraction
  • 0.1.0 — Initial release: TUI dashboard, sky view, headless modes, event tracking

Contributing

Contributions welcome! Please:

  1. Fork the repository
  2. Create a feature branch
  3. Run tests: go test ./... and go vet ./...
  4. Submit a pull request

License

Apache License 2.0 — see LICENSE for the full text and NOTICE for attribution.

If you redistribute ls-horizons, in source or binary form, the license asks you to keep the copyright notice, include a copy of the license, carry the NOTICE file forward, and state that you changed any files you modified.

Releases up to and including v0.10.0 were published under the MIT License. That grant is perpetual and irrevocable, so those versions remain available under MIT; v0.11.0 onward is Apache-2.0. Both are permissive, so this changes very little in practice — it mainly adds an explicit patent grant and a clearer attribution mechanism.

Third-party components

ls-horizons ships as a statically linked binary that incorporates open source components under the MIT and BSD-3-Clause licenses. Their copyright notices and full license texts are reproduced in THIRD-PARTY-NOTICES, which is included with every release. Regenerate it with scripts/gen-notices.sh after changing dependencies.

The binary also embeds a processed subset of the Bright Star Catalogue, which is credited in the same file and in NOTICE. Unlike the DSN and Horizons feeds, which are fetched at runtime, this data is redistributed inside the binary, so the attribution travels with every copy.

Acknowledgments

  • NASA/JPL for the public DSN data feed and Horizons ephemeris system
  • Dorrit Hoffleit and Wayne H. Warren Jr. for the Bright Star Catalogue (Astronomical Data Center, NSSDC/ADC, 1991), the source of every star this renders
  • Bubble Tea for the excellent TUI framework
  • Lip Gloss for terminal styling