Notes on what the app actually computes, in the order a position flows through it. Each section names the file that implements it. Equations are in plain text so they read the same everywhere.
References I lean on throughout:
- Vallado, Fundamentals of Astrodynamics and Applications, 4th ed. (2013)
- Hoots & Roehrich, Spacetrack Report No. 3 (1980) — SGP4
- Vallado, Crawford, Hujsak & Kelso, Revisiting Spacetrack Report #3 (AIAA 2006-6753)
- King-Hele, Satellite Orbits in an Atmosphere (1987)
A two-line element set (TLE) is not a position. It is a set of mean elements fitted so that the SGP4/SDP4 propagator reproduces observations. They are only meaningful when fed back into SGP4, which is why OrbitView never uses them with plain Kepler equations.
Propagation is done by satellite.js, a port of the Vallado 2006 code. It returns position and velocity in the TEME frame (true equator, mean equinox of date), in km and km/s.
satrec.error is checked after every call; a non-zero value (eccentricity out
of range, decayed orbit…) means the object is hidden rather than drawn at a
wrong place. — src/lib/orbit.ts, src/workers/satellite.worker.ts
From the SGP4 record, not from the instantaneous radius:
period = 2π / n (n: un-Kozai'd mean motion, rad/min)
a = rec.a · 6378.135 km
perigee alt = a (1 − e) − 6378.135
apogee alt = a (1 + e) − 6378.135
Earlier versions used 6371 + current altitude as the semi-major axis, which
is wrong for anything eccentric. — elementsFromSatrec in src/lib/orbit.ts
TEME is rotated to the Earth-fixed frame by the Greenwich mean sidereal angle
θ (IAU-82, the model SGP4 is built on). OrbitView uses satellite.gstime
everywhere.
Version 3 had its own GMST function that evaluated the 0h-UT polynomial at the
full Julian date and added the rotation for the time of day, counting it
twice — up to about 1° late by 23:59 UTC. A test now compares against
gstime across a day to 1e-12 rad.
r_ecf = R3(θ) · r_teme
v_ecf = R3(θ) · v_teme − ω⊕ × r_ecf ω⊕ = 7.292115e-5 rad/s
The second term is the one people forget. Rotating the velocity is not
enough: a point fixed on the ground moves at up to 0.46 km/s in the inertial
frame, so the velocity relative to the ground has to subtract it. Every
calculation that uses velocity (Doppler, range rate) goes through
temeToEcf in src/lib/orbit.ts.
Latitude is found iteratively on the WGS-84 ellipsoid; height is computed as
h = p cos φ + z sin φ − a √(1 − e² sin² φ)
which, unlike p / cos φ − N, doesn't blow up at the poles.
— ecfToGeodetic in src/lib/CoordinateConverter.ts
For an observer at geodetic (φ, λ, h), the range vector ρ = r_sat − r_obs is projected onto the local South-East-Zenith axes:
S = sin φ cos λ ρx + sin φ sin λ ρy − cos φ ρz
E = −sin λ ρx + cos λ ρy
Z = cos φ cos λ ρx + cos φ sin λ ρy + sin φ ρz
azimuth = atan2(E, −S) (clockwise from north)
elevation = asin(Z / |ρ|)
range rate = (ρ · v_ecf) / |ρ| (positive = moving away)
— lookAngles in src/lib/orbit.ts
A pass is the interval in which elevation ≥ the mask (10° by default, adjustable in settings).
- Scan the window every 60 s. A short pass can rise above the mask and set again between two samples, so whenever three samples below the mask peak in the middle (within 15° of the mask), that interval is searched for its culmination; if the peak clears the mask, rise and set are bisected on either side. Without this, a 44-second ISS pass above 20° was missed.
- Bisect each sign change of (elevation − mask) down to 0.5 s to get rise (AOS) and set (LOS).
- Golden-section search between AOS and LOS for culmination; elevation is unimodal over a single pass.
The window starts 30 minutes before "now" so a pass already in progress keeps its real rise time, and a pass still in progress at the end of the window is followed until it sets. An object that never sets (a geostationary satellite in view) is reported as one pass covering the window.
Visibility. A pass is marked sunlit when, at any moment of the pass, the satellite is in sunlight and the Sun is more than 6° below the observer's horizon (civil twilight). The pass is checked every 30 s plus its ends and culmination: evening passes often enter the shadow before their highest point. The app says "sunlit in a dark sky" rather than "visible" because it doesn't estimate brightness — a small cubesat can be sunlit and still far too faint to see.
Earth's shadow is a cone, not a cylinder (Vallado 2013, Algorithm 34). With the Sun's radius R☉ = 696,000 km, the Earth's R⊕ = 6,378.137 km and the Sun's distance r☉, the umbra and penumbra half-angles are
α_umb = asin((R☉ − R⊕) / r☉) ≈ 0.264°, α_pen = asin((R☉ + R⊕) / r☉) ≈ 0.269°
A satellite a distance d behind the Earth (along the anti-Sun axis) and s
from that axis is in the umbra if s ≤ tan α_umb · (R⊕ / sin α_umb − d), and in
the penumbra if s ≤ tan α_pen · (R⊕ / sin α_pen + d). In the penumbra part of
the Sun is still visible and the satellite still shines, so only the umbra
counts as "in shadow". For the ISS the umbra starts ~4 s later and ends ~5 s
earlier than a cylinder of the Earth's radius would say; a geostationary
satellite at equinox spends about 70 minutes in it. The atmosphere is not
allowed for. — shadowState in src/lib/CoordinateConverter.ts
Not modelled: atmospheric refraction (≈ 0.5° at the horizon, so real rises are a little earlier), and the Earth's oblateness in the horizon itself.
The test suite checks AOS/LOS against a 1-second brute-force scan (± 2 s) and
that no 5-second sample in a pass is higher than the reported maximum.
— src/lib/PassPrediction.ts
The night is the interval in which the Sun is more than 6° below the
observer's horizon. It is found with a 10-minute scan of the Sun's elevation
from the current simulation time (if it's already dark the window starts
now), and both edges are refined by bisection to about 10 s. Passes of the
objects on CelesTrak's "visual" list (about 150 bright ones, taken from the
catalog already loaded; their ids are in src/lib/visualIds.ts) are then
predicted inside that window with the method above, and only those marked
sunlit are listed.
— src/lib/tonight.ts
Δf = −(ṙ / c) · f₀
f_received = f₀ + Δf
with ṙ the range rate from section 3 and c = 299 792 458 m/s. Negative ṙ (approaching) raises the frequency. This is first-order Doppler; for LEO speeds the relativistic terms are below 1 Hz at VHF/UHF.
Because ṙ uses the Earth-fixed velocity, a geostationary satellite correctly
shows almost no shift. The tests check ṙ against the numerical derivative of
the range (< 1 m/s) and a GEO case (< 20 m/s).
— computeDoppler in src/lib/DopplerCalculator.ts
This is an estimate, not a reentry prediction.
Ballistic coefficient from B*. SGP4 defines
B* = ρ₀ B / 2, ρ₀ = 0.15696615 kg m⁻² ER
B = C_D A / m = 2 B* / ρ₀ [m²/kg]
Decay. For a near-circular orbit (King-Hele):
da/dt = −B ρ(h) √(μ a)
Drag acts mostly near perigee, so the integration runs on the perigee height, which is pessimistic for eccentric orbits. The step is adaptive (at most 0.5 km of height loss or 30 days per step) down to 120 km; beyond 25 years the result is reported as "> 25 years".
Atmosphere. Exponential bands from Vallado table 8-4 (CIRA-72 based), all 28 bands from 0 to 1000 km. Earlier versions skipped the 110–180 km bands, which put a 19× density jump at 150 km.
Uncertainty. Solar activity changes thermospheric density by roughly ×0.5–×2 over a cycle, so the range shown is the same integration with ρ×2 and ρ×0.5. B* in a TLE is a fitted, noisy parameter (it absorbs other model errors too); it is used as |B*|, and anything below 1e-7 is treated as "drag not measurable".
Space weather (F10.7, its 81-day mean, Kp) from NOAA SWPC is shown for
context but not multiplied into the result: B* already reflects the drag the
satellite actually experienced when the element set was fitted.
— src/lib/OrbitalDecay.ts, src/lib/SpaceWeather.ts
For the selected object against everything loaded:
- Apogee/perigee filter (Hoots, Crawford & Roehrich 1984). Two orbits whose altitude shells are further apart than the threshold can never meet, so they're skipped. This removes most of the catalog.
- Coarse scan of the separation every 60 s over the window. A local
minimum below
threshold + 15 km/s × 60 sis a candidate (relative speeds in LEO reach about 15 km/s, so a closer approach could hide between samples). - Refine each candidate with a golden-section search to about 0.1 s, giving the time of closest approach, miss distance and relative speed.
TLE positions are uncertain by a kilometre or more, and there is no
covariance here, so this produces a list worth looking at, not a probability
of collision. Operators use conjunction data messages from 18 SDS for that.
— src/lib/ConjunctionAnalysis.ts
- Ground track: sub-satellite points 1–5 periods ahead (a setting) and half a period behind, recomputed every 30 s of simulation time.
- Orbit ring: one revolution of TEME positions, drawn with a model matrix that rotates it by −θ (the sidereal angle at the current time). That is the inertial orbit as seen from the turning Earth, so the satellite sits exactly on it, and turning the Earth only changes the matrix. The positions themselves are recomputed every 15 minutes of simulation time to follow slow changes such as nodal precession.
- Coverage circle: the ground area from which the object is above the elevation mask ε:
λ = arccos(R cos ε / (R + h)) − ε (Earth central angle)
radius on the ground = R λ
— src/lib/GroundTrack.ts, src/components/globe/SelectedTrack.tsx