Introduction
What I was actually trying to build
I did not set out to build a full solar-system suite. I wanted something much narrower: predict when planets align. Everything else, eclipses, porkchop plots, SIMBAD deep sky, DE kernels, validation suites, is what happened after the alignment idea refused to cooperate.
I built Horizon 87 as a solo browser project: an interactive geocentric solar-system model with real ephemeris data, sky tools, and mission screening. Still one high school student behind the keyboard.
In plain English
Horizon 87 started as a question: "When do a bunch of planets line up in the sky?" Answering that honestly forced me to learn real astronomy, real ephemerides, and real humility. This page is the long version of that story, with math, file names, and the mistakes I would rather you not repeat.
The product arc
The app in index.html is a browser-based solar-system visualization and mission-analysis sandbox. You pick a date, scrub time, scan for alignments and eclipses, inspect the sky dome from a location on Earth, and (if you enable the precision tier) compare positions against NASA/JPL Horizons. None of that existed on day one.
Day one looked like this:
- Each planet orbits the Sun on a roughly circular path.
- Each planet has a roughly fixed period.
- Therefore each planet has a roughly fixed angular speed on the sky.
- Find when their ecliptic longitudes line up.
That mental model is seductive and wrong. The rest of this document explains why, and what I built instead.
How to read this page
If you want the science without my opinions, read HORIZON_87_MATH_AND_SCIENCE.md. If you want licenses and third-party notices, read THIRD_PARTY_NOTICES.md. This page sits between those: technical enough to reproduce the thinking, personal enough to show where I wasted weeks.
Math blocks use plain text, no MathJax, no external scripts, so the page stays compatible with the app's Content Security Policy.
Phase one
Naive angles (and being wrong)
My first alignment scanner treated the solar system like synchronized clock hands on a flat circle. Planets move at constant angular rates. Find when the hands overlap. Ship it.
The clock-hand model
For a planet with orbital period P days, I assumed mean motion:
n = 360° / P (degrees per day, simplified) λ(t) = λ₀ + n · (t - t₀) (ecliptic longitude at time t)
I picked a reference longitude λ₀ at some epoch t₀, usually "today," and stepped forward day by day. When several longitudes clustered within a threshold, I called it an alignment.
In plain English
Imagine seven hands on a clock face, each spinning at a different but constant speed. I was asking: "When are they all close together?" Real planets are not clock hands. Their speeds change. Their orbits wobble. And even when longitudes match, you might not see anything from your city.
Why perturbations break the model
Real orbits are not fixed Kepler ellipses forever. Jupiter tugs Mars. Saturn tugs Jupiter. The Earth–Moon barycenter matters when you care about precision. These are perturbations: small ongoing nudges that accumulate.
Over a few weeks, mean motion looks fine. Over years or decades, the error grows. Worse: the error can look plausible. Charts still show planets in roughly the right constellations. You stop questioning the numbers because they "feel" right.
True longitude: λ_true(t) Mean motion guess: λ_mean(t) = λ₀ + n·Δt Error: Δλ(t) = λ_true(t) - λ_mean(t) After many periods, Δλ is NOT periodic with period P. Perturbations add secular and mixed terms.
Orbital drift
Even if average motion over one orbit is predictable, the instantaneous longitude is not something you forecast with a one-line formula across centuries. Outer planets (Uranus, Neptune) are especially painful: long periods mean small mistakes in mean motion become large angular errors before you notice.
Geometry vs visibility
"Aligned in ecliptic longitude" is not the same as "visible in a parade at dusk from Mumbai." Longitude alignment ignores:
- Whether the planets are on the same side of the Sun as the observer (elongation).
- Whether they are above the local horizon at a civil time humans care about.
- Whether the sky is dark enough (twilight).
Lesson learned
If your alignment tool cannot explain where it gets planet positions, it is a screensaver. I spent a long time getting plausible-looking answers that were wrong in subtle ways. That is worse than obviously wrong answers.
Foundations
Coordinate systems primer
Horizon 87 juggles three coordinate systems because no single grid is convenient for every problem. Understanding when to use which frame saved me from a lot of silent bugs.
Ecliptic coordinates
Ecliptic coordinates are natural for planets because most planets orbit near the plane of Earth's orbit (the ecliptic).
λ = ecliptic longitude (0°–360° around the ecliptic plane) β = ecliptic latitude (degrees north/south of the ecliptic) r = distance from origin (AU or km depending on tier)
For heliocentric coordinates, the origin is the Sun. For geocentric coordinates, the origin is Earth's center. Alignment scanning mostly cares about geocentric ecliptic longitude λ because that is how planets appear to us.
In plain English
Picture the flat plate of Earth's orbit around the Sun. Ecliptic longitude is where you are on that plate, measured as an angle from a fixed direction in space. Most planets stay close to that plate, so longitude is a good first guess for "are they in the same part of the sky?"
Equatorial coordinates
Equatorial coordinates are natural for the fixed star background and catalog entries.
RA = right ascension (often in hours: 24h = 360°) Dec = declination (degrees north/south of celestial equator)
Declination behaves like celestial latitude. Right ascension behaves like celestial longitude, but astronomers traditionally measure it in time units because the sky rotates with Earth's equator.
1 hour RA = 15 degrees RA_deg = RA_hours × 15
Horizontal coordinates
Horizontal coordinates are what an observer actually sees from a point on Earth's surface.
alt = altitude (angle above horizon; 90° = zenith, 0° = horizon) az = azimuth (compass direction around horizon, from north through east)
Same object, same UTC instant, different cities → different alt/az. That is not a bug. That is geometry.
Converting between frames
The pipeline in code generally looks like:
Heliocentric ecliptic (VSOP or SPICE) → subtract Earth vector → geocentric ecliptic → rotate ecliptic → equatorial (obliquity of ecliptic ε ≈ 23.44°) → apply sidereal rotation → hour angle → observer latitude/longitude → alt/az
js/astro_core.js: shared time and coordinate helpers
js/sky_astrometry.js: RA/Dec ↔ alt/az transforms
js/parade_horizon_math.js: horizon checks for parade modes
Spherical to Cartesian
Once you have ecliptic longitude, latitude, and radius, you can build a 3D vector:
x = r · cos(β) · cos(λ) y = r · cos(β) · sin(λ) z = r · sin(β)
Subtraction gives relative vectors (e.g., planet minus Earth). Dot products give separations. Cross products show orbital plane orientation. Most of the 3D scene in the orrery uses this chain.
Foundations
Time scales: UTC, Julian Date, J2000, DeltaT, JDE
Astronomy runs on several clocks at once. Mixing them is how you get "correct-looking" eclipses that are five minutes wrong. I learned this the hard way.
UTC: what users see
UTC (Coordinated Universal Time) is civil clock time. It includes leap seconds and follows human calendars. Every date picker in the UI ultimately represents a UTC instant (or a local civil time converted to UTC).
Julian Date
Astronomers often count time as Julian Date: a continuous day count, not a calendar.
JD_UTC = unixMilliseconds / 86400000 + 2440587.5 86400000 = milliseconds in one day 2440587.5 = Julian Date of 1970-01-01 00:00:00 UTC
The .5 matters: Julian Dates traditionally begin at noon, while Unix time begins at midnight UTC.
In plain English
Instead of saying "March 15, 2026 at 3:42 PM," ephemeris code often says "day number 2461045.654." It is easier to add fractions of a day without wrestling with month lengths and leap years.
J2000.0: the standard epoch
J2000.0 = JD 2451545.0
= 2000-01-01 12:00 Terrestrial Time
Star catalogs and many ephemeris formulas express coordinates relative to J2000. When HYG or bright-star data says "J2000 RA/Dec," that is the reference grid.
DeltaT: Earth's messy rotation
Earth's rotation is not perfectly uniform. Tidal friction, mantle motion, and atmospheric angular momentum make UT (time from Earth's rotation) drift relative to TT (a smooth atomic/dynamical time scale).
DeltaT = TT - UT (in seconds) For modern years in Horizon 87 (js/astro_core.js): t = year - 2000 DeltaT = 62.92 + 0.32217·t + 0.005589·t²
These coefficients are empirical curve fits, not laws of nature. They approximate measured Earth-rotation behavior near the modern era. Far from the modern era, DeltaT uncertainty grows, which is one reason eclipse timing gets harder for historical dates.
JDE: Julian Ephemeris Date
JDE = JD_UTC + DeltaT / 86400
JDE (Julian Ephemeris Date) is the time argument many celestial-mechanics series expect. VSOP87 uses a time variable T measured from J2000 in Julian millennia:
T = (JDE - 2451545.0) / 365250.0
js/astro_core.js: getDeltaT, julianDateUTCFromDate, dynamicalJDEFromUTCDate
js/ui.js: imports the time bridge for UI date handling
Bug I shipped once
I compared a UTC Julian Date against a JDE-based VSOP series without adding DeltaT. Positions were close enough to look fine on a chart but wrong enough to fail Horizons regression by kilometers. Always label which time scale a number uses.
Ephemeris tier one
VSOP87 and the "87" in Horizon 87
The first serious ephemeris I adopted was VSOP87: Variations Séculaires des Orbites Planétaires, published in 1987. That is where the project name comes from.
What VSOP87 is
VSOP87 represents each planet's heliocentric position as sums of cosines with slowly changing amplitudes and phases. It is analytical (formula-based), fast, and good enough for many sky-visualization tasks.
Generic VSOP-style series: value(T) = Σ_p T^p · Σ_i A_i · cos(B_i + C_i · T) T = time in Julian millennia from J2000 A_i = amplitude of periodic term i B_i = phase offset C_i = angular frequency
In plain English
Imagine describing a wiggly line as a pile of sine waves added together. VSOP87 is that pile, pre-computed for each planet coordinate (longitude, latitude, radius). The waves are chosen so the sum tracks real planetary motion over centuries.
What the code computes
For each planet, the worker evaluates series for:
L = heliocentric ecliptic longitude B = heliocentric ecliptic latitude R = heliocentric distance (AU)
Then it subtracts Earth's heliocentric vector to get geocentric coordinates, what we see from Earth.
File references
data/vsop87_data.js: coefficient tables (thousands of terms per planet)
vsop_worker.js: Web Worker that evaluates series, runs parade scans, legacy sandbox path
js/astro_core.js: shared geocentric helpers, time bridge, re-exports
Why "Horizon 87"
The name is a deliberate nod to VSOP87. Not because the app only uses VSOP87 anymore, it does not, but because that was the first time the project graduated from toy angles to real orbital mechanics. The "Horizon" part is about what you see from Earth's horizon: alt/az, visibility, local sky context.
Limits I hit with VSOP87 alone
- VSOP87 is an approximation fitted to a dynamical model, not a numerical integration of every force at every step.
- For broad sky surveys across decades, it is excellent.
- For sub-kilometer comparisons against JPL Horizons, it drifts, especially for high-precision eclipse geometry and outer planets over long spans.
- It is "good enough to be dangerous": results look professional while edge cases still fail.
That gap pushed me toward JPL Development Ephemerides.
Term truncation and performance
Evaluating every VSOP term for every planet for every day in a 50-year scan is expensive. The worker supports a maxTerms cap (often 40 for scanning) to trade accuracy for speed. Display mode may use more terms. Document which cap you used when comparing results.
Dead end (interesting)
Yoshida integrator sandbox (scrapped)
Before jumping to JPL kernels, I tried to "simulate the solar system properly" myself. I built an N-body sandbox with a 4th-order Yoshida symplectic integrator. It was cool. It was not the product.
What symplectic integration means
Planetary motion is a Hamiltonian system: there is conserved structure (energy-ish quantities, phase-space volume) you do not want a naive integrator to destroy. Symplectic integrators are designed to preserve that structure better than Euler or plain Runge-Kutta over long steps.
In plain English
Imagine pushing a swing. A bad integrator adds or removes energy each push, the swing eventually flies off or stops unrealistically. A symplectic integrator pushes in a pattern that keeps the motion stable over many orbits, even with large time steps.
Yoshida 4th-order scheme
The Yoshida method splits each timestep into substeps with carefully chosen weights. For gravitational N-body:
Each step Δt:
1. Seed positions r_i, velocities v_i from ephemeris state at t₀
2. For k = 1..N_steps:
compute accelerations a_i = Σ_j G·m_j · (r_j - r_i) / |r_j - r_i|³
Yoshida substeps update (r_i, v_i)
3. Read positions at t₀ + k·Δt
The implementation lived in the VSOP worker sandbox path in vsop_worker.js. You could scrub time and watch mutual perturbations evolve.
Why I shelved it
- Accuracy cost: Without a high-quality initial state and without modeling everything JPL models (asteroid belts, relativistic corrections, etc.), my sandbox drifted.
- Performance cost: Browser-side N-body stepping across large time jumps is expensive. A 50-year alignment scan would choke.
- Product cost: It did not solve alignment prediction better than analytical ephemerides. It was a demo, not an engine.
Lesson learned
Do not re-derive DE441 in JavaScript unless you are NASA. Professionals publish SPICE kernels for a reason. My sandbox still exists as legacy plumbing; it is not the product direction.
Ephemeris tier two
DE441, DE442s, and the OPFS problem
Eventually I found what professionals actually use: JPL Development Ephemerides, distributed as SPICE kernels. DE441 is the full high-precision set. It is also roughly three gigabytes. That is not a "cache a JSON file" problem.
What DE441 is
DE441 is a numerically integrated ephemeris from JPL. It includes positions of major planets, the Moon, and more, fitted to observations including spacecraft tracking. It is the authoritative tier when you need to trust residuals in meters.
What DE442s is
DE442s is a smaller "short" kernel covering a modern window (roughly 1850–2150 in the shipped configuration). Same family, smaller download, still SPICE-compatible. This is the default Stargazer path because asking every visitor to download three gigabytes is a non-starter.
OPFS: Origin Private File System
Browsers expose OPFS via navigator.storage.getDirectory(). I use it because:
- Kernel files are huge (hundreds of MB to GB).
- They must persist across sessions.
localStorageis capped at a few MB, useless here.- Cache API is awkward for multi-hundred-MB binary blobs with resume support.
OPFS gives the app a private directory on disk inside the browser profile. DE442s downloads once (with progress UI), then loads locally on subsequent visits.
js/precision_storage.js: OPFS layout, download state, kernel paths
js/zenith_downloader_worker.js: chunked download worker
js/zenith_storage.js: related persistence helpers
In plain English
Think of OPFS as a private folder the website is allowed to use on your computer, hidden from other sites, big enough for ephemeris files. Without it, every visit would re-download hundreds of megabytes.
Tier fallback behavior
Outside DE442s' valid window, or if the kernel is missing, the app falls back to VSOP87 (and analytical lunar theory where needed). The UI shows which tier is active. DE441 remains available for the deeper Zenith / Oracle workflow for users who want the full kernel.
Precision engine
CSPICE in WebAssembly
The precision path compiles NASA NAIF CSPICE to WASM. For a covered Julian date inside the loaded kernel, planet states come from SPICE geometry, not from VSOP series.
Why CSPICE
SPICE is the standard toolkit for spacecraft navigation and ephemeris access. Functions like spkpos (SPK position) read kernel files and return vectors in specified reference frames with documented aberration flags. Reimplementing that correctly in hand-rolled JavaScript would be a multi-year project.
Architecture
UI requests body position at UTC instant → convert to ET (ephemeris time) / JDE → precision_engine.js loads WASM CSPICE module → read SPK kernel from OPFS path → spkpos(target, ET, frame, aberration, observer) → return km vector in requested frame
js/precision_engine.js: WASM boot, spkpos wrappers, eclipse 3D classifiers
assets/precision/cspice.js: compiled CSPICE WASM glue
js/zenith_engine.js: Horizons comparison orchestration
js/zenith_spice_shared.js: shared SPICE constants and frame IDs
Frame conventions
Horizons comparisons forced me to be explicit about:
- Geocentric vs heliocentric origin.
- Reference frame (e.g., J2000 ecliptic vs equatorial).
- Aberration flags (geometric vs astrometric vs apparent).
- TDB vs UTC vs UT handling at the boundary.
A vector that is "correct" in the wrong frame is still wrong. Frame bugs travel in packs.
3D eclipse classification
When DE442s is active, solar eclipse classification can use classifySolarEclipse3D, treating the Moon's shadow as a moving cone in km space rather than a 2D angle on the ecliptic. This lives alongside Besselian classifiers in the precision engine path.
Validation
Horizons validation and ZENITH tooling
Once DE-backed positions existed, I could no longer hand-wave accuracy. I built the ZENITH validation flow to compare Horizon 87 against NASA/JPL Horizons on frozen test cases.
What ZENITH checks
- Same body (Mercury, Venus, Mars, etc.).
- Same epoch with documented dynamical time / TDB handling.
- Same reference frame conventions as the Horizons query.
- Residuals in meters, not "looks close on a chart."
js/reloaded_labs.js: ZENITH UI and comparison harness
js/validation_core.js: shared validation math
js/zenith_config.js: fixture configuration
Sub-millimeter residuals
After a lot of frame fixes, I pushed until worst-case residuals on shipped regression fixtures reached sub-millimeter levels. That does not mean "perfect everywhere forever." It means I have a harness that catches regressions when I break something.
Residual vector: Δr = r_Horizon87 - r_Horizons (km) Residual magnitude: |Δr| × 1000 (meters) Target on fixtures: |Δr| < 1 mm (0.000001 km) worst case
In plain English
I ask JPL's public Horizons system where Mars is at a specific instant, ask my app the same question the same way, subtract the answers, and measure the gap. If the gap grows after a code change, something broke.
How to evaluate the project
If you are judging Horizon 87 technically, start at SETTINGS → INFO / ZENITH before trusting scanner output. The scanners inherit whatever ephemeris tier is active. VSOP and DE442s can disagree at the arcminute level, enough to matter for eclipses.
Product design
DE442s tier design: Stargazer vs Zenith
DE441 accuracy is great. DE441 onboarding is not. The tiered design is my compromise between "student project" and "serious ephemeris tool."
Stargazer path (default)
- DE442s kernel, auto-downloaded to OPFS on first use.
- Progress UI during download.
- Strong accuracy within ~1850–2150.
- Good enough for alignments, sky dome, most eclipse screening.
Zenith / Oracle path (deep)
- DE441 full kernel workflow for users who want maximum authority.
- Larger storage and longer first-time setup.
- Used heavily in validation and regression.
VSOP87 fallback
When precision kernels are unavailable (offline, out of window, user declined download), VSOP87 keeps the app usable. The badge in the UI shows which tier is active. I would rather be honest about fallback than silently degrade.
js/ephemeris_status.js: tier detection and UI badge state
Core feature
Planetary alignment scanner: minimum circular arc
Once positions are trustworthy, alignment scanning is conceptually simple. The geometry is a classic "smallest covering arc on a circle" problem.
Geometric parade algorithm
For each candidate day:
- Compute geocentric ecliptic longitudes for active planets.
- Sort longitudes around the circle.
- Compute gaps between neighbors, including the wrap gap across 0°/360°.
- Let
largest_gapbe the maximum gap. - Define
arc_span = 360° - largest_gap.
Intuition: if planets cluster, one big slice of the circle is empty. That empty slice is largest_gap. A smaller arc_span means a tighter alignment.
Worked example: 358°, 1°, 4°
Suppose three planet longitudes are 358°, 1°, and 4° (degrees). Sort them: 1, 4, 358.
Adjacent gaps along sorted circle: gap(1 → 4) = 4 - 1 = 3° gap(4 → 358) = 358 - 4 = 354° (going forward on circle) wrap gap(358 → 1) = (1 + 360) - 358 = 3° largest_gap = 354° arc_span = 360° - 354° = 6°
All three planets fit in a 6° arc even though raw longitudes span from 1° to 358°, because the cluster wraps across the 0° meridian. Naive "max minus min longitude" would report 357° and miss the parade entirely.
In plain English
Planets near 359° and 1° are actually neighbors on the sky circle, only 2° apart, even though subtracting the numbers looks like 358° apart. The algorithm finds the biggest empty slice of the circle and measures what's left.
Implementation
function spanOf(longitudes):
sort longitudes ascending
maxGap = 0
for each adjacent pair (including wrap):
gap = forward angular distance
maxGap = max(maxGap, gap)
return 360 - maxGap
vsop_worker.js: spanOf() and parade scan chunk loop
js/scanners/parade_scanner.js: UI-facing scanner orchestration
Event merging
When arc_span stays below threshold for consecutive days, the scanner merges days into one event window rather than spamming one row per day. Tuning that merge window changes how "broad" an alignment feels in the results table.
Core feature
Observable parades: elongation, alt/az, twilight
A tight arc on the ecliptic is not enough for outreach astronomy. I added modes that ask: can a human actually see this from a location at a civil time?
Solar elongation filter
Elongation is the angle on the sky between a planet and the Sun as seen from Earth.
Δλ = |λ_planet - λ_sun| if Δλ > 180°: Δλ = 360° - Δλ reject if Δλ < minSunSep (default often ~15°)
Planets too close to the Sun are lost in glare. Mercury and Venus spend much of their time inside small elongation limits, that is why they are hard to see even when geometrically "aligned" with other worlds.
Morning vs evening sides
Split planets by whether they lead or trail the Sun along the ecliptic:
diff = (λ_planet - λ_sun + 360) mod 360 if 0 < diff < 180: evening sky (planet sets after Sun) else: morning sky (planet rises before Sun)
The worker builds separate morning and evening candidate lists, runs spanOf on each, and picks the better parade per day.
Altitude and horizon limits
For observable modes, planets must exceed a minimum altitude at a reference time (often civil dusk or dawn). That requires the full RA/Dec → alt/az pipeline with observer latitude and longitude.
js/parade_horizon_math.js: altitude checks at twilight
js/parade_observable.js: observable parade orchestration
Twilight thresholds
Astronomical twilight ends when the Sun is 18° below the horizon. Nautical twilight is 12°. Civil twilight is 6°. The scanner can gate on these depending on mode. "dark enough for naked-eye outer planets" vs "visible at all."
In plain English
Geometric alignment answers "are they in the same direction from Earth?" Observable parade answers "if I walk outside after dinner in Chennai, can I see them above the western horizon before the sky goes black?" Different question. Stricter answer.
Eclipses
Round one: 2D hacks and humility
After alignments worked well enough, I assumed eclipses would be straightforward. They were not.
What I tried first
Early eclipse logic leaned on 2D ecliptic angular separation:
- Sun–Moon longitude difference near 0° or 180°.
- Simple latitude thresholds for "maybe umbral."
- Rough shadow cones drawn as 2D wedges on the ecliptic plane.
Timings were "close" but wrong in the way that ruins trust. Minutes matter for eclipses. Shadow geometry is three-dimensional. The Moon's shadow is a cone moving through space, not a circle on a flat map.
Why 2D fails
2D check: |λ_moon - λ_sun| ≈ 0 → "maybe solar eclipse"
3D reality: Moon shadow axis misses Earth by thousands of km
even when ecliptic longitudes "align"
Lunar eclipses have analogous failure modes with Earth's umbra/penumbra cones.
What I did right
I shelved eclipse work and built mission tools instead of shipping confident wrong timings. Coming back with DE442s km vectors and Besselian machinery was slower but honest.
Mission deck
Lambert problem, Stumpff functions, and porkchop plots
The MISSION deck includes transfer-window screening. The visual is a classic porkchop plot. Each pixel solves a Lambert problem. This section is written for beginners because I was a beginner when I built it.
What a porkchop plot shows
- X axis: departure epoch.
- Y axis: arrival epoch.
- Color: total delta-V (or proxy) for a transfer between two bodies.
Dark blue "valleys" are cheaper transfer windows. The shape looks like a porkchop, hence the name.
The Lambert problem
Given two position vectors r₁, r₂ and a time of flight Δt, find the conic arc connecting them under gravity.
Known: r1, r2, Δt, μ (gravitational parameter of central body) Find: velocity at r1 (and r2) such that object arrives at r2 after Δt Branches matter: - short way vs long way around the orbit - prograde vs retrograde
In plain English
You know where Earth is today and where Mars will be on arrival day. Lambert's question is: "What launch speed do I need to leave Earth on a curved path that hits Mars exactly on time?" Different departure/arrival pairs need different speeds, some much more expensive than others.
Universal variable formulation
Horizon 87 uses a universal variable Lambert solver with Stumpff functions (solveLambertUniversal in js/reloaded_labs.js). Stumpff functions package the math so one formulation handles elliptic, parabolic, and hyperbolic arcs without switching formulas mid-solve.
Stumpff c(z) and s(z) are series that behave like cos/sin for universal variable z across conic types. Universal variable χ satisfies a time-of-flight equation: Δt = f(χ, r1, r2, μ) Iterate χ until Δt matches desired time of flight. Recover velocities from χ and geometry.
Delta-V and patched conic
In a patched conic approximation:
Δv_depart = |v_depart_lambert - v_planet_depart| Δv_arrive = |v_planet_arrive - v_arrive_lambert| Δv_total ≈ Δv_depart + Δv_arrive (simple sum, no gravity assist)
This ignores continuous thrust, gravity assists, multi-body effects, and navigation uncertainty. It is a sandbox for intuition, not a flight dynamics sign-off.
What porkchop is good for here
- Teaching patched-conic transfer geometry.
- Screening "is there a reasonable window near this year?"
- Comparing relative costs across launch/arrival pairs.
What it is not
- Full ephemeris mission design.
- Low-thrust spirals.
- Gravity assist chains.
- Navigation-grade trajectory optimization.
js/reloaded_labs.js: solveLambertUniversal, solveLambertMinimumVinf, porkchop grid builder
Sky stack
Sky dome pipeline: RA/Dec, refraction, parallax
Parallel track: I wanted an answer alignments ignore, from my location, at this time, is the object above the horizon and dark enough to see?
Catalog layer
- HYG / J2000-era star catalog for baseline deep sky.
- d3-celestial constellation lines: cultural geometry, not physics.
- SIMBAD-derived pack (
js/simbad_sky_pack.js) cached in OPFS for richer object metadata.
Transform chain
catalog RA/Dec (J2000) → topocentric equatorial (observer on Earth surface) → hour angle H = LST - RA → horizontal alt/az (latitude φ, longitude) → apply refraction correction near horizon → visibility gates (alt > limit, Sun far enough below horizon)
js/sky_astrometry.js: core transforms
js/sky_dome_ui.js: dome UI wiring
js/sky_dome_3d.js: Three.js dome rendering
js/sky_frame_cache.js: frame caching for performance
RA/Dec to alt/az
Given observer latitude φ, declination δ, hour angle H: alt = asin( sin(φ)·sin(δ) + cos(φ)·cos(δ)·cos(H) ) az = atan2( -sin(H), tan(δ)·cos(φ) - sin(φ)·cos(H) )
Same formulas as in HORIZON_87_MATH_AND_SCIENCE.md. Longitude enters through Local Sidereal Time. Latitude decides which declinations can ever rise.
Saemundsson refraction
Atmospheric refraction lifts objects near the horizon. The app uses the Saemundsson formula (related to Bennett's work):
R (arcminutes) = 1.02 / tan( alt + 10.3 / (alt + 5.11) ) alt and the fraction inside tan are in DEGREES. R is in ARCMINUTES. Observed altitude ≈ true altitude + R/60
In plain English
Near the horizon, air bends light like a weak lens. The Sun looks higher than it geometrically is at sunset. Stars near the horizon appear slightly elevated. The formula is an empirical fit, not a full weather model.
Topocentric Moon parallax
The Moon is close enough that observers on opposite sides of Earth see it at noticeably different positions on the sky (~1° scale). Topocentric correction subtracts observer position from geocentric Moon vector before converting to alt/az. Skipping parallax breaks low-altitude Moon placement and eclipse local circumstances.
Aladin / SIMBAD inspiration
The SIMBAD pack is about object identity and metadata in the browser, not claiming full Aladin server integration. Offline catalog + OPFS cache keeps dossier search usable without hammering Strasbourg on every click.
Sky stack
Greenwich Mean Sidereal Time
Right ascension is fixed to the stars. Clock time is fixed to the Sun. Sidereal time bridges them so alt/az math knows how the sky has rotated.
Sidereal vs solar day
Sidereal day ≈ 86164.09 seconds (one rotation relative to stars) Solar day = 86400 seconds (one rotation relative to Sun) Difference ≈ 4 minutes per day, sky drifts earlier each night
GMST polynomial
Horizon uses a standard polynomial form (see math reference):
D = JDE - 2451545.0 T = D / 36525 GMST (degrees) = 280.46061837 + 360.98564736629 · D + 0.000387933 · T² Normalize to 0–360°
The coefficient 360.98564736629 is slightly more than 360 because Earth advances in its orbit each day, we need a bit extra rotation to catch up to the same star field.
Local Sidereal Time
LST = GMST + observer_longitude_east Hour angle: H = LST - RA
Two observers at the same latitude but different longitudes see the same RA line cross the meridian at different clock times. That is why eclipse contact times are location-dependent even when the global eclipse geometry is shared.
In plain English
Sidereal time is "where the star grid is pointing" at Greenwich. Add your longitude to get "where it points for me." Subtract an object's RA to get how far it is from crossing your meridian.
Sky stack
Moon phase math
The Moon phase widget looks decorative. Underneath it is elongation geometry tied to the same ephemeris tier as everything else.
Phase angle from elongation
Let ε be the Sun–Moon elongation angle on the celestial sphere. A common illumination fraction:
illumination = 0.5 · (1 - cos(ε)) (0 = new, 1 = full) equivalently for phase angle φ between Sun direction and Moon direction: illumination = (1 + cos(φ)) / 2
Phase fraction and names
phase_fraction = (Moon elongation from Sun) / 360° (simplified track) 0.0 new moon 0.25 first quarter 0.5 full moon 0.75 last quarter
The deck shell draws the lit limb on a canvas using the phase angle. When precision vectors are available, elongation comes from dot products of geocentric Sun and Moon unit vectors, not from a separate approximate table.
js/deck_shell.js: Moon phase widget, updateMoonPhase, canvas draw
Searching darkest / brightest nights
For planning, the widget can sample illumination across a day window and rank candidates, useful when pairing parade scans with "will the Moon wash out faint planets?"
In plain English
Moon phase is not a calendar table lookup. It is geometry: how much of the Moon's day side faces Earth given where the Sun and Moon are in 3D. Same ephemeris, same rules.
Eclipses
Round two: Besselian elements and 3D shadow cones
I came back to eclipses with less confidence and better tools. Round two couples km vectors, Besselian polynomials, and 3D classification.
Data tiers in the eclipse scanner
js/scanners/eclipse_scanner.js: orchestrates scans
- DE442s km vectors when precision engine is active for that epoch.
- VSOP/analytical km vectors as fallback.
- Besselian element machinery for solar eclipse geometry and contact timing.
Why Besselian elements
For solar eclipses, flat angle hacks fail because the Moon's shadow is a moving 3D cone. Besselian elements compress the Sun–Moon–Earth geometry around an eclipse epoch into a standard parameter set (latitude of shadow axis, radii of penumbra/umbra, etc.) that supports contact time solving.
I integrated Fred Espenak / NASA GSFC Besselian canon ideas and built polynomial contact finders:
js/besselian_eclipse.js: classification helpers
js/eclipse_besselian_polynomial.js: element construction and polynomial fit
js/eclipse_contacts.js: C1/C2/C3/C4 contact boundaries
js/espenak_jsex.js: Espenak canon data access
Flow
1. Sample Sun/Moon geometry around eclipse epoch (km vectors) 2. Build Besselian-style elements over local time window 3. Fit polynomial in time around peak 4. Solve for contact times (partial, total, annular boundaries) 5. If DE442s available: classifySolarEclipse3D in precision_engine.js 6. Map global contacts to local circumstances with observer lat/lon
Lunar eclipses
Lunar eclipses use Earth's umbra and penumbra limits with analogous geometry (classifyLunarEclipseBesselian). The Moon passes through Earth's shadow cone, again a 3D problem, not a longitude difference.
Why this took so long
Eclipses couple ephemeris accuracy, time scales (ΔT / UT / TDB), observer location, and shadow model choice. Fixing one layer while another is wrong looks like progress but is not. I burned time on contact times that were "within a few minutes" until all four layers agreed.
Reporting bugs
If you find an eclipse timing issue, tell me the date, location, engine tier (VSOP vs DE442s), and which scanner you ran. Coordinate-frame bugs travel in packs, fix one, three others appear.
Ship status
v1.1: stability and validation phase
As of v1.1 I am in a stability and validation phase, not a feature sprint. New features wait until existing ones are measured.
Current priorities
- Harden Horizons regression coverage and document RMS outcomes.
- Profile hot paths, orrery render, sky dome, scanner workers.
- Keep educator-facing surfaces honest (About panel, onboarding tour, limits on-screen).
- Keep horizon87.com deployable from the repo without secret handoffs.
Onboarding and About
js/onboarding_guide.js: first-run tour with DOM-built images (no string-built src)
js/about_story.js: in-app About narrative (short version of this page)
Performance HUD
js/performance_hud.js: frame timing overlay for profiling
Scanner workers chunk days (CHUNK_SIZE = 200 in parade path) to keep the UI thread responsive and report progress. Eclipse scans similarly batch to avoid freezing the deck.
Reference
File map: where the bodies are buried
Starting points if you are reading the repo cold.
| Topic | Starting points |
|---|---|
| App shell / UI | index.html, js/ui.js, js/deck_shell.js |
| VSOP survey engine | vsop_worker.js, js/astro_core.js, data/vsop87_data.js |
| DE442s / CSPICE | js/precision_engine.js, js/precision_storage.js |
| Parade / alignment scans | js/scanners/parade_scanner.js, vsop_worker.js |
| Eclipse scans | js/scanners/eclipse_scanner.js, js/besselian_eclipse.js |
| Besselian contacts | js/eclipse_besselian_polynomial.js, js/eclipse_contacts.js |
| Porkchop / Lambert | js/reloaded_labs.js |
| Horizons validation | ZENITH in js/reloaded_labs.js, js/validation_core.js |
| Sky catalog / SIMBAD | js/simbad_sky_pack.js, js/simbad_offline_catalog.js |
| Sky dome / astrometry | js/sky_astrometry.js, js/sky_dome_ui.js |
| Time bridge | js/astro_core.js, js/time_zone_utils.js |
| HTML safety | js/html_safe.js |
| Deploy headers | vercel.json |
| Science reference | HORIZON_87_MATH_AND_SCIENCE.md |
| In-app About | js/about_story.js |
Reference
Dead ends worth naming
So you do not repeat them. I repeated several before writing this table.
| Idea | Verdict | Why |
|---|---|---|
| Fixed-angle alignment without ephemeris | Too naive | Perturbations and wrap geometry break mean motion |
| VSOP-only for all precision claims | Wrong as sole truth | Good fallback; drifts vs Horizons on edge cases |
| Yoshida sandbox as primary engine | Cool demo, not product | Drift and cost without beating DE kernels |
| 2D ecliptic eclipse angles | Good stub, wrong for timing | Shadow is 3D; minutes matter |
| Multi-GB kernels without OPFS | Bad UX | Storage failures and repeat downloads |
| Skipping Horizons regression | Ships confident bugs | Frame mismatches hide until users notice |
| Max-min longitude for parades | Misses wrap clusters | Use minimum circular arc (spanOf) |
| String-built HTML for user/catalog text | XSS surface | Use html_safe.js and DOM APIs |
Reference
Security posture (static app)
Horizon 87 is a static client-side app. There is no user database and no server-side session store in this repo.
What I ship defensively
js/html_safe.js, shared HTML escaper (window.HorizonHtml.escape)- Escaped crash overlay text in
js/ui.js - Escaped scanner event matrix detail rows in
js/deck_shell.js - SIMBAD dossier/search paths escape catalog text via
htmlSafe - Onboarding tour images built with DOM APIs (no string-built
src) vercel.jsonresponse headers: CSP,X-Content-Type-Options,Referrer-Policy,Permissions-Policy
CSP tradeoff
Content Security Policy still allows 'unsafe-inline' because the app has inline boot scripts and Ko-fi embed requirements. Tightening further would need nonces or a bundler pass. This devlog page intentionally uses no external math renderers for the same reason.
Realistic threat model
Protect your GitHub and Vercel accounts (2FA). That is the realistic path to site defacement for a static site, not SQL injection. There is no SQL here.
In plain English
I treat anything that renders catalog names or user-visible strings as untrusted input. I do not put secrets in the client bundle. The biggest risk is someone compromising the deploy pipeline, not someone hacking the Lambert solver.
End
Closing note
Horizon 87 started as a question about planetary alignments and became a browser-sized lesson in ephemeris humility.
If you read this far: thanks. The short version lives in the in-app About panel. The equations live in HORIZON_87_MATH_AND_SCIENCE.md. This page is the middle, the story of what broke, what I tried, and what finally worked.
If you find a bug, tell me:
- The date and UTC time (or local time + timezone).
- Your observer latitude and longitude if it is a sky or eclipse issue.
- Engine tier: VSOP vs DE442s vs DE441.
- Which scanner or deck you were running.
Coordinate-frame bugs are social creatures. They travel in packs.
Naresh Prasanna
Horizon 87
July 2026