Architecture — Three.js, real orbital mechanics, and zero dependencies
This app is a faithful port of the iOS SolarSystem app. Every algorithm, constant, and visual detail maps directly.
| iOS (SceneKit) | Web (Three.js) | Notes |
|---|---|---|
SCNScene | THREE.Scene | Black background, same graph structure |
SCNSphere | SphereGeometry | Same segment count (36/48) |
PBR SCNMaterial | MeshStandardMaterial | Same roughness/metalness values |
.constant lighting | MeshBasicMaterial | Sun, orbit lines |
.add blend mode | AdditiveBlending | Sun corona glow |
SCNView.projectPoint() | Vector3.project() | Label screen projection |
SCNView hit test | Raycaster | Body selection on click/tap |
CADisplayLink | requestAnimationFrame | 60fps render loop |
UIGraphicsImageRenderer | Canvas2D | Procedural textures |
UIPanGestureRecognizer | mouse/touch events | Custom gesture handling |
simd_quatf | THREE.Quaternion | IAU rotation model |
UserDefaults | — | Future: localStorage |
Date to Julian Date Number via the Meeus algorithm. Essential for astronomical time: a continuous day count since 4713 BC, eliminating calendar irregularities.T = (JD - 2451545.0) / 36525.0. Time since J2000.0 epoch in centuries. All JPL element rates are per-century.a = a0 + aRate × T for all six Keplerian elements. Linear approximation valid for several centuries around J2000.M = L - wBar, normalised to [0, 2π). The angle that increases uniformly with time.E ← E - (E - e·sin(E) - M) / (1 - e·cos(E)). Converges in 3–5 iterations for all planetary eccentricities.ν = 2·atan2(√(1+e)·sin(E/2), √(1-e)·cos(E/2)). The actual angle of the planet from perihelion.r = a(1 - e·cos(E)), then rotate by ω, I, w from the orbital plane to ecliptic (x, y, z) coordinates in AU.log(1 + AU/0.5) × 15, then coordinate swap: Three.js x = ecliptic x, y = ecliptic z, z = -ecliptic y.Every body has a sidereal rotation period, axial obliquity, and prime meridian at J2000.0. Rotation is applied using quaternion composition to avoid Euler angle wobble:
Rings are child nodes of Saturn, so they inherit the planet's rotation. To keep them fixed in the equatorial plane while the cloud bands rotate beneath, the ring node applies an inverse spin quaternion each frame:
Venus (177.4° tilt, -5832.5h period), Uranus (97.8° tilt, -17.24h period), and Pluto (122.5° tilt, -153.3h period) all rotate backwards. The negative period flips the spin direction; the extreme obliquity tilts the axis past vertical.
8,920 stars from the HYG v38 catalogue (Hipparcos/Yale/Gliese amalgamation), filtered to naked-eye visibility (magnitude ≤ 6.5).
Each star's Right Ascension (hours) and Declination (degrees) are converted to a point on a celestial sphere of radius 500 scene units. The coordinate transform: x = R·cos(dec)·cos(ra), y = R·sin(dec), z = -R·cos(dec)·sin(ra).
| Tier | Magnitude | Count | Point Size |
|---|---|---|---|
| 0 (brightest) | < 1.5 | ~20 | 4.0 px |
| 1 | 1.5 – 3.5 | ~200 | 2.5 px |
| 2 | 3.5 – 5.0 | ~1500 | 1.5 px |
| 3 (faintest) | 5.0 – 6.5 | ~7000 | 0.8 px |
Per-vertex colour computed from B-V index using a piecewise linear approximation of the Planckian locus. B-V ranges from -0.4 (hot blue-white O/B stars) through 0.0 (white A stars) to 2.0+ (cool red M stars). Rendered via PointsMaterial with vertexColors: true and sizeAttenuation: false.
The application uses HTML/CSS overlays on top of the full-screen WebGL canvas. All UI panels use glass-morphism (backdrop-filter: blur(20px)) for depth.
The toolbar's right section contains textured circular thumbnails for each body. Each thumbnail uses the actual NASA JPEG as a CSS background-image, clipped to a circle. Saturn includes a decorative ring overlay using a CSS border-radius: 50% ellipse with rotateX(65deg) 3D transform. The selected body gets an orange border highlight.
On wide screens (>700px), the planet strip sits inline in the toolbar alongside playback controls. On narrow screens (phones in portrait), a CSS flex-direction: column media query breaks it into its own row above the controls:
When selecting a body, the camera distance is calculated to frame the body and its moon system. Planets with moons use a 2.2× base multiplier on the system extent. Moonless planets use 6.0× so they appear at a similar visual scale. On portrait viewports, the multiplier is scaled down by aspect ratio (0.5 + 0.5 × min(aspect, 1.0)) so planets fill the narrower width appropriately.
Three issues required specific fixes for iOS Safari, all related to how the browser handles touch events on overlaid elements:
touch-action: none only to the <canvas> element, not the container.<body>, suppresses click event synthesis from touch events. Fix: scope to the canvas container only, leaving UI elements with default user-select.click events reliably. Fix: the onTap() helper binds both touchend (with preventDefault()) and click, using a flag to prevent double-firing on devices where both work.All interactive elements use onTap(element, handler) instead of addEventListener('click', handler). The touchend fires immediately (no 300ms delay), and the click serves as a mouse fallback. A touchFired flag prevents the click from re-firing after a touch.
Two more mobile gotchas surfaced after deployment. First, unicode emoji icons (e.g. 🚀) render inconsistently across mobile browsers — some show tofu boxes. All toolbar icons were replaced with inline SVG paths using currentColor fill so they render identically everywhere. Second, Mobile Safari aggressively caches HTML, leaving users stuck on old versions after a deployment. The page now sets Cache-Control: no-cache, no-store, must-revalidate plus Pragma: no-cache and Expires: 0 meta tags. Textures (large, immutable) still cache normally.
Saturn's rings use a custom BufferGeometry disc with radial UV mapping, identical to the iOS app's approach. The key insight is that the UV u coordinate must run radially (inner to outer), not linearly across the disc face.
The geometry is built with 72 radial segments × 4 ring segments (288 triangles). Two Cassini textures are applied: a colour map (saturn_ring_color.jpg) for the band colours and an alpha map (saturn_ring_alpha.gif) for the transparency structure including the Cassini Division gap. The ring is a child of the Saturn mesh but counter-rotates each frame to cancel the parent's spin, keeping the rings fixed in the equatorial plane.
Four billboard sprites with additive blending create the multi-layer corona glow. Each sprite uses a procedural radial gradient CanvasTexture.
Each layer is a THREE.Sprite with AdditiveBlending and depthWrite: false. Sprites always face the camera (billboard), making them ideal for glow effects. The procedural texture is a 256×256 radial gradient with four colour stops fading from the target colour to transparent.
Orbital mechanics operates in heliocentric ecliptic coordinates. Three.js uses a Y-up right-handed system. The mapping preserves handedness:
The entire app is static files. No npm, no webpack, no transpilation. Three.js is loaded at runtime from a CDN via an ES module import map in index.html. A convenience script starts the local server:
For production deployment, copy the entire directory to any static hosting (GitHub Pages, Netlify, S3, etc.). No server-side logic needed.
Browsers enforce strict CORS restrictions on file:// URLs. ES module import statements, fetch() for the star catalogue CSV, and TextureLoader for JPEG textures all fail with opaque origin errors. A local HTTP server solves this by serving from http://localhost.
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
| WebGL2 | 56+ | 51+ | 15+ | 79+ |
| ES Modules | 61+ | 60+ | 11+ | 79+ |
| Import Maps | 89+ | 108+ | 16.4+ | 89+ |
| backdrop-filter | 76+ | 103+ | 9+ | 79+ |
Import maps are the newest requirement — Safari 16.4 (March 2023) is the effective minimum.
Each mission contains a vehicles array. Each vehicle has independent waypoints, colour, and a primary flag for camera tracking. This supports missions where craft separate — Artemis II has SLS, SRBs, and Orion; Apollo 11 has Columbia (CSM) and Eagle (LM) diverging at the Moon.
Waypoints are defined in a Moon-aligned geocentric frame: X points toward the Moon at flyby time, Y is perpendicular in the orbital plane, Z is out-of-ecliptic. At initialization, the code computes the Moon’s ecliptic direction via moonPosition() and rotates all waypoints. This means any launch date works — the trajectory always wraps around the Moon’s actual position.
Trajectories use the same pow(ratio, MOON_DIST_EXPONENT) × MOON_DIST_SCALE compression as moon positioning (constants centralised in sceneBuilder.js: 0.6 and 1.5). The 0.6 exponent (changed from 0.4) gives more realistic proportions — the Moon appears at 17.6 Earth radii (was 7.7). Flyby waypoints at ~398,000 km (close to real 395,000 km) now produce visible separation.
The pow(0.6) compression (changed from 0.4) has two non-obvious effects on trajectory rendering, though both are less severe than before. First, it amplifies out-of-plane (Z) motion: at Moon distance, X and Y coordinates are compressed but Z stays relatively large in the scene. A constant Z offset (e.g. 4,500 km) makes the trajectory appear to pass over the Moon’s pole instead of behind the far side — Z must be reduced to near-zero at the flyby point. Second, the compression still reduces separation at large distances, though the 0.6 exponent preserves more detail than 0.4 — flyby waypoints at ~398,000 km (close to the real 395,000 km) now produce visible separation from the Moon. These effects mean geocentric waypoints still benefit from scene-aware tuning.
The CatmullRom curve gives smooth visual shape, but is sampled at uniform time steps (not arc-length). Waypoint index maps to curve parameter u = (i + frac) / (N-1), ensuring the line progresses in sync with mission elapsed time.
Vehicles with a moonOrbit: { startTime, endTime, periodHours, radiusKm } property orbit the Moon at runtime using smooth cos/sin circular motion around the Moon’s actual scene position. The period is typically 2x real (e.g. 4 hours vs ~2 hours) for visual comfort at replay speeds. Apollo 11’s Columbia uses this for the full lunar orbit phase; Eagle has a three-phase lifecycle: moonOrbit (orbits with Columbia pre-descent), moonLanding (tracks Moon surface during EVA), moonOrbitReturn (orbits post-ascent). Eagle undocks from Columbia’s computed orbit position, not from a fixed waypoint. All Moon-relative positions use the Moon’s semi-major axis distance (matching the rendered mesh) rather than the varying actual distance from moonPosition(), which fluctuates ±21,000 km due to eccentricity.
The mission camera uses a two-part strategy. Initial snap: the camera snaps to Earth’s current position + the trajectory’s local center, zoomed to fit just the trajectory extent. getMissionBounds() returns both wide absolute bounds (center/radius — Earth start+end + trajectory at both) and tight trajectory-only bounds (localCenter/localRadius — relative to Earth). Initial framing uses localRadius so the trajectory fills the view tightly. The camera azimuth is set ~31° off the Sun direction (0.55 radians) for a dramatic two-thirds illuminated view; elevation 0.3 radians (~17°) above the ecliptic.
Lazy follow during playback: each frame, the camera target lerps toward Earth’s current position + trajectory center at lerp(0.02). This keeps the trajectory large and centered while Earth visibly drifts through space — stars move in the background. The initial snap position matches the lazy-follow target, so there is no animation glitch at start. User interaction (drag/scroll) clears activeMissionId, breaking the lazy follow and giving full manual control.
getTelemetry() computes distance from geocentric position length, and speed from a finite-difference derivative (position delta over 0.01 hours). Displayed in dual units: km + miles, km/s + mph.
Heliocentric missions (Voyager, New Horizons, Pioneer, etc.) define waypoints at planetary encounters, but approximate coordinates would not match the actual computed planet positions in the scene. The anchorBody property on each waypoint names a planet (e.g. "jupiter", "saturn"). At mission initialisation, the code imports heliocentricPosition() from orbitalMechanics.js and resolves each anchored waypoint to the planet’s real ecliptic position at the waypoint’s timestamp. This guarantees the trajectory passes exactly through each planet’s rendered position.
The geocentric equivalent of anchorBody: waypoints with anchorMoon: true are resolved to the Moon’s actual ecliptic position at that timestamp using moonPosition(), converting AU to km and using the semi-major axis for distance matching. Used for Apollo 11 Columbia’s LOI, TEI, and early return waypoints so the trajectory line departs smoothly from where the Moon actually is.
Anchored waypoints (where the trajectory snaps to a planet) can cause speed spikes because adjacent waypoints may be far in distance but close in time. The fix is to always add transition waypoints ~1,000–2,000 hours before and after anchored points. Applied across Cassini (near Venus/Saturn), New Horizons (near Jupiter/Pluto), Voyager 1 (post-Jupiter tightened from 30+ km/s to ~17 km/s), and Voyager 2 (consistent 12–17 km/s throughout).
Many interplanetary missions follow Hohmann-like transfer orbits between planetary encounters. Rather than requiring dozens of hand-placed intermediate waypoints, the autoTrajectory flag triggers the _generateTransferArc() method. This computes a smooth elliptical arc between consecutive anchor points:
The International Space Station is modelled as an Earth satellite (moon) with an orbital period of 0.064 days (~92 minutes). Its visual representation uses procedural Three.js geometry rather than a texture-mapped sphere:
The timeline slider allows scrubbing through a mission’s duration. The slider is time-mapped: position 0% corresponds to the mission start date and 100% to the end date. During scrub, the simulation date is overridden (even while paused) and all dependent systems update:
Each frame, the animation loop checks whether a mission is selected and the elapsed time has exceeded the mission’s durationHours. If so and timeScale > 1, it snaps back to 1x real-time, preventing the simulation from continuing to race at high speed after the mission completes.
Missions can be deep-linked via URL query parameters: ?mission=voyager1 loads the page with that mission pre-selected, the camera focused on the trajectory, and the timeline slider visible. The parameter is parsed from window.location.search at startup and matched against mission IDs in the data.