Celestiary is a web-based astronomical simulator — an interactive 3D solar system and star field viewer. It renders real-scale celestial objects in WebGL via Three.js, animates orbital mechanics using VSOP87 planetary theory, and presents a React/MUI UI overlay for navigation and controls.
| Concern | Library/Tool |
|---|---|
| 3D rendering | Three.js 0.171 (WebGL2) |
| React framework | React 18 + React DOM |
| UI components | MUI v5 (Material UI) |
| Routing | Wouter 3 (hash-based for celestial targets, path-based for app sections) |
| State management | Zustand 4 |
| Animation tweening | @tweenjs/tween.js |
| Orbital mechanics | vsop87 package + custom elliptical fallback |
| Build | esbuild (custom config in esbuild/) |
| Test runner | Bun test |
| Linting | ESLint 9 (Google style + JSDoc + jsx-a11y) |
js/index.tsx Root React mount: Style -> Routed
js/Routed.jsx Path router: '/' -> App, '/guide' -> Guide (lazy-loaded)
js/App.jsx Main simulator UI shell
js/guide/Guide.jsx Interactive tutorial/demo sections
index.tsxmounts<Root>→<Style>→<Routed>Routeduses Wouter to lazy-load eitherApp(simulator) orGuide(tutorial)Appinstantiates theCelestiarycontroller on mount, passing DOM refs for the canvas container and nav panelCelestiarywires together all subsystems and callsload()to fetch the initial target (defaults tosun)
Celestiary Main controller (window.c for debug)
├── Time Simulation clock (timeScale, pause, Julian Day)
├── Animation Per-frame orbit/rotation updater (VSOP87 + elliptical)
├── ThreeUi Three.js wrapper (renderer, camera, controls, render loop)
│ └── Scene (three.js)
├── Scene Celestiary scene manager (object registry, targeting, picking)
│ ├── Stars Star field (buffer geometry, shader, labels, asterisms)
│ ├── Star Individual named star (LOD, Perlin noise surface shader, PointLight)
│ ├── Planet Planet or moon (LOD, orbit shape, surface mesh, atmosphere, labels)
│ └── Galaxy Invisible grouping root (Milky Way container)
├── ARController Mobile sky view: device sensors → camera orientation
├── Loader Async JSON fetcher for celestial object descriptors
├── ControlPanel DOM-based nav display (breadcrumb path)
└── Keys Keyboard shortcut registry
All scene objects inherit from js/scene/object.js (a thin Three.js Object3D wrapper).
Distances are stored in real SI meters. Key constants from js/shared.js:
ASTRO_UNIT_METER = 149597870700m (1 AU)SUN_RADIUS_METER = 6.957e8mSTARS_RADIUS_METER = LIGHTYEAR_METER * 1e4mSMALLEST_SIZE_METER = 6e5m (allows zooming into Deimos)- Camera near/far are set to
SMALLEST_SIZE_METER…STARS_RADIUS_METER * 2
Three.js scene units equal meters. Planets use VSOP87 coordinates scaled by ASTRO_UNIT_METER; stars use Celestia binary catalog coordinates scaled by LIGHTYEAR_METER.
All celestial bodies (sun, planets, stars, asterisms) live under a single WorldGroup Object3D. Shifting worldGroup.position rebases the entire universe in one operation — used by star navigation to bring the current target star to world origin, so camera world coordinates stay small for float32 precision even across light-year distances. See Navigation (goTo flow).
Loader fetches /data/<name>.json files recursively along a path (e.g. sun/earth/moon). Each JSON descriptor includes:
type:galaxy | stars | star | planet | moonradius,orbit,axialInclination,siderealRotationPeriodsystem: array of child names to load next- Optional:
texture_*,has_locations
Loading is cached per name; the URL hash (e.g. #sun/earth) drives the target path. hashchange events trigger re-loads.
Stars are loaded separately from the Celestia binary star catalog (StarsCatalog), not from JSON.
<parent>.orbitPosition
└── Planet (Object3D)
└── group
└── orbitPlane
├── orbit (ellipse Line)
└── orbitPosition ← animation sets position here
└── planetTilt
└── planet (Object3D)
├── planetLOD
│ ├── [near] surface mesh + atmosphere + clouds
│ ├── [far] single Point sprite
│ └── [very far] FAR_OBJ (invisible)
└── labelLOD
├── [near] FAR_OBJ
├── [mid] SpriteSheet label
└── [far] FAR_OBJ
Moons follow the same pattern, parented to their planet's orbitPosition.
ThreeUi.renderLoop() runs every frame (via renderer.setAnimationLoop):
- Process click events (raycasting)
- Save
camera.quaternion(_savedCamQuat) controls.update()(TrackballControls — zoom/pan only; rotation disabled)- Restore
camera.quaternion— suppresses thelookAtthat TrackballControls applies each frame, so camera orientation is owned by navigation tweens and user input _applyAsymptoticZoom()— remaps zoom to altitude space and adjustscamera.nearanimationCb(scene)→Animation.animate(scene):Time.updateTime()advances simulation clock bytimeDelta * timeScalevsop87c(julianDay)computes heliocentric XYZ for 8 major planetsanimateSystem()recurses the scene graph, setting orbit positions and sidereal rotations- If
targets.trackis set, callslookAtTarget()each frame
- Camera-look tween update (
targets.tween) _applyCameraArrowKeys()— apply held-key pitch/roll last so they always winrenderer.render(scene, camera)
- Major planets (Mercury–Neptune): VSOP87c theory via the
vsop87npm package, giving high-accuracy heliocentric ecliptic coordinates - Minor bodies / moons / Pluto: Simple Keplerian ellipse parameterized by
semiMajorAxis,eccentricity,siderealOrbitPeriod
Camera orientation and position are separated across three input modes, all accumulating independently:
| Input | Effect |
|---|---|
| Scroll wheel | Zoom (TrackballControls, asymptotic near surface) |
| Mouse drag | Free look — pitch (up/down) and yaw (left/right) around camera's local axes |
| Option+drag | Orbit — rotates camera as a rigid body around the planet center (position + orientation rotate together) |
| ↑ / ↓ arrow keys (hold) | Pitch camera nose up/down |
| ← / → arrow keys (hold) | Roll camera left/right |
t |
Toggle continuous tracking (camera auto-looks at target as it orbits) |
c |
Snap look at current target |
Asymptotic zoom (js/zoom.js): scroll zoom is remapped from distance-space to altitude-space so the camera approaches the surface asymptotically. The camera.near plane is dynamically scaled to altitude * 0.1 (clamped 100 m – SMALLEST_SIZE_METER) so the surface remains visible without clipping.
Camera platform: the camera is a child of camera.platform, a scene-root Object3D reparented on each goTo(). For planet targets the new parent is obj.orbitPosition so the camera tracks orbital motion automatically; for star targets it's _starAnchor, a dedicated scene-root anchor at world origin (paired with a WorldGroup rebase that moves the target star to origin). See Navigation (goTo flow) for the full flow.
Navigation tweens (js/camera.js) — stays at root as general infrastructure:
newCameraLookTween— 600 ms quaternion slerp used bysetTarget(key navigation,'c'key)newCameraGoToTween— 1800 ms unified tween used bygoTo; rotation runs 0–60%, position 40–100%, with a 40–60% overlap so the camera never stops between turning and traveling. Details in Navigation (goTo flow).
Scene.goTo(star) reorients the camera onto a new target body. It has to satisfy two
competing pressures:
- Float32 precision. At star-scale distances the camera's world coordinates cannot
be large — catastrophic cancellation in
(objWorld − cameraWorld)would destroy positional accuracy for any non-RTE object. - Smooth transitions. Users expect a visible "turn then travel" beat that animates from wherever the camera currently is toward the new target.
The design resolves both by rebasing the universe so the new target sits at world
origin, keeping camera world coordinates small, while preserving the camera's position
within the WorldGroup frame so the subsequent look + travel tween has meaningful
start and end poses.
The camera platform is parented differently depending on target type:
| Target | Parent after goTo() |
Notes |
|---|---|---|
| Planet / sun | obj.orbitPosition |
That group is what orbital animation writes into, so the camera follows the body's orbit automatically. |
| Star (catalog entry) | _starAnchor |
A scene-root Object3D permanently fixed at world (0, 0, 0), paired with worldGroup.position = -star.xyz so the target star lands at world origin. |
Six synchronous steps before any tween runs:
- Capture pre-rebase camera world pos, world quat, and
wgOld = worldGroup.position. - Rebase
worldGroup.positionto(0, 0, 0)for planet targets or-star.xyzfor star targets. - Compute
wgDelta = worldGroup.position − wgOldand shift the captured camera world pos bywgDelta. (See Invariant below.) - Reparent
camera.platformto the new anchor and reset its local transform to identity. - Restore the shifted camera world pos (via
platform.worldToLocal) and reconstructcamera.quaternionso the camera's world orientation matches what was captured in step 1. - Compute the arrival pose (a point at
radius × STEP_BACKalong the camera→target line) and start a singlenewCameraGoToTween.
Across goTo(), the preserved quantity is camera position in the WorldGroup frame
(camera_world − worldGroup.position), not camera world position. Camera world
position deliberately shifts by wgDelta so the camera "moves with the universe":
- When camera is already under
WorldGroup(via some planet'sorbitPosition), this happens automatically because its parent moved during the rebase. - When camera is under
_starAnchor(scene-root, unaffected by the rebase), step 3 applies the shift manually.
Without the shift, star → star travel collapses to zero distance (camera and the new
target both sit at origin because the old target was already at origin and _starAnchor
didn't move). Pressing h from any star also produces an identical "instant look back"
with no rotation animation, because the camera was coincidentally still pointing at
origin after the rebase moved the sun into origin.
With the shift, the direction from the (shifted) camera to the new target encodes where the user started from, so the follow-up rotation depends on the starting body.
newCameraGoToTween is a single 1800 ms tween with two independently eased channels:
| Channel | Active | Eased |
|---|---|---|
Rotation (slerp to lookAt(target) from arrival) |
0 – 60% (0 – 1080 ms) | quadratic in-out |
| Position (lerp from start to arrival) | 40 – 100% (720 – 1800 ms) | quadratic in-out |
| Overlap | 40 – 60% (720 – 1080 ms, 360 ms) | both active |
The overlap means the camera starts moving before it finishes turning — there is no frame between the two phases where nothing is animating.
Stars, asterisms, and catalog star-name labels use Relative-To-Eye shaders that compute
camera-relative positions every frame from double-precision emulation (high + low
float32 split). They are visually stable across a WorldGroup rebase: the uniforms
update one frame, the rendered positions on screen don't change.
Non-RTE objects — the sun and planet meshes — are ordinary Three.js objects under
WorldGroup, so they do visibly teleport when the rebase shifts their world
positions. This teleport is the visible "warp" of star navigation: planets jump,
stars hold still.
Out of scope for the goTo flow. These use newCameraLookTween (rotation-only, 600 ms)
and do not rebase or reparent. They only change camera.quaternion while leaving the
scene graph alone.
| Object | Technique |
|---|---|
| Star field (~120k stars) | Custom GLSL shader on Points geometry; size/brightness from magnitude |
| Named star (e.g. Sun) | Procedural Perlin noise GLSL surface shader (convection-like texture) |
| Planets | MeshStandardMaterial with optional diffuse, bump, hydrosphere, and cloud textures |
| Atmospheres | Semi-transparent additive-blend sphere shell |
| Saturn rings | Double-sided RingGeometry with texture |
| Orbit paths | EllipseCurve → Line with additive blending |
| Labels | Canvas-rendered SpriteSheet compiled to a single Points geometry |
| Asterisms | Line segments loaded from asterisms-clean.dat |
LOD (THREE.LOD) is used throughout to swap between detailed meshes, point sprites, and invisible placeholders based on camera distance.
Every visual feature in the app must opt into one of two top-level visibility groups so the user has predictable global hide/show controls:
v(lowercase v key) — HTML chrome: nav panels, search bar, time/target HUD, settings dialogs. React/MUI overlays only — anything rendered outside the WebGL canvas.V(Shift+v key, "presentation mode") — scene annotations: everything drawn into the WebGL scene that isn't a celestial body or texture. Labels, asterism lines, orbit ellipses, reference grids, surface place labels, etc. A secondVpress restores the prior per-element state (snapshotted on first press) so users can flip between "bare" and "annotated" views without losing their per-element toggles.
Each scene-annotation feature also has its OWN scoped lowercase toggle
(a asterisms, p planet+moon+place labels, s star labels, o orbits,
; equatorial grid, etc.). V is the union of all the lowercase
scene-annotation toggles.
When adding a new visual feature, decide which group it belongs in and
wire it through the corresponding toggle method. Surface place labels,
for example, live under the p group: their visibility is controlled
inside Scene.togglePlanetLabels alongside the planet name LODs, so the
single 'p' shortcut and the global 'V' both pick them up automatically.
Failing to opt in means the user has no way to hide the new element
short of reloading the page — and V (presentation mode) won't be
truly bare.
js/store/useStore.js composes four slices:
AsterismsSlice— asterisms visibility and catalog stateSearchSlice— search-bar state, anchor index, committed path / star, preview fields;setCommittedPathandsetCommittedStarare mutually exclusiveStarsSlice— star selection / filter stateTimeSlice— time panel UI state
The store is passed into non-React classes (ThreeUi, Stars) to let them read/write shared state without React prop-drilling.
Two routing layers coexist:
- Wouter path routing (
/,/guide,/about,/settings) — controls which React panels are shown - URL hash (
#sun/earth/moon) — drives which celestial object is targeted and loaded; managed imperatively byCelestiaryviahashchangeevents
The hash is extended with optional camera/time state to form a permalink — see js/permalink.md for the format specification.
Thin MUI-based overlay panels:
TimePanel— displays sim time, pause/play, time-scale controlsSettings— keyboard shortcut referenceAbout— app info and star catalog statsSearchBar— breadcrumb-anchored search (chips, MUIAutocomplete, crosshair picker toggle, preview + commit flow). See js/search/DESIGN.md for the index architecture.DatePicker,NumberField,NumberInput— supporting inputsTooltipToggleButton,TooltipIconButton,NavToggleButton— icon button wrappers
A separate interactive tutorial route (/guide) built with React Three Fiber (@react-three/fiber) and Drei. Each guide section is an isolated demo (Cube, Sphere, Star, Planet, Orbit, Stars, Asterisms, Atmosphere, Galaxy, VSOP, Labels, etc.) navigated via a side-drawer TOC. The guide and main app are fully independent bundles — the guide does not use the Celestiary class.
esbuild bundles js/index.tsx to docs/ (GitHub Pages target). The build script:
yarn clean— resetsdocs/frompublic/- Copies shaders and public assets
- Runs esbuild bundler
yarn bundle-check (esbuild/check.js) does a dry-run bundle (write: false) to verify all imports resolve without writing any output — used in yarn precommit alongside lint and tests.
Hot-reload in development: esbuild/serve.js calls ctx.watch() unconditionally; index.tsx opens an EventSource('/esbuild') that reloads on change events and closes itself on error (silent in production).
| Path | Role |
|---|---|
js/shared.js |
Global constants and targets state object |
js/Celestiary.js |
Top-level controller, keyboard bindings |
js/ThreeUI.js |
Three.js renderer/camera/controls wrapper |
js/Loader.js |
Recursive JSON asset loader |
js/Time.js |
Simulation clock with time-scale control |
js/camera.js |
Navigation tween factories (newCameraLookTween, newCameraGoToTween) |
js/zoom.js |
Pure zoom math: asymptoticZoomDist, dynamicNear |
js/permalink.js |
Permalink encode/decode: encodePermalink, decodePermalink, pathFromFragment |
js/coords.js |
Geographic coordinate conversions: worldToLatLngAlt, latLngAltToLocal |
js/store/useStore.js |
Zustand store root |
public/data/*.json |
Celestial object descriptors |
| Path | Role |
|---|---|
js/search/SearchIndex.js |
Tiered index + app-wide singleton |
js/search/SearchRegistry.js |
Provider registration singleton |
js/search/SearchProvider.js |
JSDoc typedefs for SearchEntry / provider contract |
js/search/providers/SceneProvider.js |
Bodies loaded by Loader |
js/search/providers/StarsProvider.js |
Named stars + exact HIP resolver |
js/search/providers/PlacesProvider.js |
Future surface-place stub |
See js/search/DESIGN.md for the full architecture: tier structure (A/B/C), Fuse.js tuning, scoping semantics, commit flow, and the provider extension contract.
| Path | Role |
|---|---|
js/scene/Scene.js |
Scene object registry, targeting, raycasting |
js/scene/Animation.js |
VSOP87 + Keplerian orbit/rotation animation |
js/scene/Planet.js |
Planet/moon scene graph construction |
js/scene/Star.js |
Named star with noise shader |
js/scene/Stars.js |
Star field from Celestia catalog |
js/scene/Galaxy.js |
Animated galaxy particle system |
js/scene/Asterisms.js |
Constellation line drawings |
js/scene/Orbit.js |
Orbital path visualization |
js/scene/StarsCatalog.js |
Celestia binary star catalog parser |
js/scene/AsterismsCatalog.js |
Constellation pattern definitions |
js/scene/object.js |
Base Object3D wrapper with registry tracking |
js/scene/shapes.js |
Geometry factory functions (sphere, rings, etc.) |
js/scene/material.js |
Texture/material cache |
js/scene/SpriteSheet.js |
Canvas-based label sprite atlas |
js/scene/GalaxyBufferGeometry.js |
Packed vertex data for galaxy particles |
js/scene/StarsBufferGeometry.js |
Packed vertex data for star catalog |
js/scene/Picker.js |
Raycasting for 3D object picking |
js/scene/PickLabels.js |
Label picking and marker display |
js/scene/atmos/Atmosphere.js |
Atmosphere mesh + fullscreen post-process pass |
js/scene/atmos/AtmospherePrecompute.js |
Bruneton transmittance + in-scatter LUT precomputation |
Pin the camera to a body surface and drive its orientation from device sensors so the rendered sky overlays the user's real-life view. The heavy lifting — the rotating-body parent, the body-fixed lat/lng frame, the sidereal-rotation animation — already exists for the landed view; the AR module composes those with a sensor-driven camera→ENU quaternion to close the loop.
Frame chain (per frame):
camera.quaternion =
enu_to_bodyFixed(lat, lng) // js/ar/enuFrame.js
· q_calibration_enu // js/ar/Calibration.js (optional)
· q_camera_to_enu // js/ar/PoseSource.js (sensors)
The body-fixed → inertial step is inherited from the scene graph
(camera.platform reparented to the rotating body via Scene.land).
ARController.updateFrame writes the composed quaternion AFTER all other
camera-orientation logic in ThreeUI.renderLoop, so AR pose always wins
while active.
Stage 1a (current): no camera-passthrough video, atmosphere disabled in
AR mode (Scene.enterAR sets ui._arMode, ThreeUI._updateAtmUniforms
honors it). Stage 1b enables WebXR when supported. Stage 1c adds
navigator.geolocation. Stage 1d wires the gear-icon calibration
flow. Stage 2 adds <video> passthrough behind the canvas with
premultiplied-alpha atmosphere blending.
| Path | Role |
|---|---|
js/ar/ARController.js |
Lifecycle (enter/exit), per-frame pose compose, calibration capture |
js/ar/PoseSource.js |
Pose-source contract + factory; auto-probes WebXR → DeviceOrientation → Null |
js/ar/NullPoseSource.js |
Identity-pose fallback (desktop / no sensors) |
js/ar/DeviceOrientationPoseSource.js |
W3C DeviceOrientation → camera→ENU quaternion |
js/ar/WebXRPoseSource.js |
WebXR immersive-ar / viewer-space (stub in stage 1a) |
js/ar/enuFrame.js |
enuTriadAtLatLng, enuToBodyFixedQuat — body-agnostic ENU↔body-fixed math |
js/ar/Calibration.js |
Single-vector calibration solver + per-(source, screen-angle) localStorage |
js/ui/ARButton.jsx |
Mobile-only entry button + gear toggle when calibration is needed |
js/store/ARSlice.js |
Zustand slice tracking active AR state for UI subscriptions |