Architecture

How Astrarium is built and how the pieces fit together. This document has two halves: a conceptual pipeline (how the solar-system view is layered, from physics to UI) and the concrete layout / build decisions (file tree, Vite, i18n, deployment). For the per-module operational catalog (signatures, gotchas) see ../CLAUDE.md; for the camera developer cookbook see camera.md; for the cross-axis tween conventions see storytelling.md.

The conceptual pipeline

A tour of how solar-system.html is layered — from the physics that decides where a planet really is, up through the reprojection that makes it watchable, to the UI that lets a student drive it.

scientific model (Kepler)  →  reprojected model (scale axis)  →  scene (meshes)
                                                                      ↑
        time ──────────────────────────────────────────────────────┘
                                                                      ↓
                                vcams (how you look)  →  UI/HUD (how you drive)
                                                                      ↑
                                voyages (scripted animation of every axis)

The scientific model — Kepler

The ground truth. Pure-JS, no Three.js, fully Node-testable.

Data backing this layer lives in src/data/ and is the single source of truth for astronomical facts.

The reprojected model — the scale axis

Space is mostly empty: drawn to true scale, the planets are invisible specks and the Sun dwarfs the inner orbits (see scales.md). So the view keeps Kepler’s angle but reprojects the radius.

So a planet’s position each frame is keplerHeliocentric(elementsAtJD(...)) for the direction, scaled by the live effective distance for the radius.

Time

The clock that drives the scientific model.

The scene

The Three.js layer that turns model + scale into meshes. Each mounter lives in src/solar-system/scene/ and exposes a mount<Thing> returning refs + a per-frame tick:

The VCAMs — how you look

Virtual cameras decouple what the camera does from what’s on screen, following Unity’s Cinemachine model. The generic layer is src/camera/; the solar-system bindings are src/solar-system/vcams/.

Today only solar-system.js consumes the brain; galaxy.js and universe.js still hold their own per-frame lerp and will adopt it in follow-up issues. For recipes and the full API, see camera.md.

The UI / HUD

The panel reflects model state by subscribing to the axis events; it never holds its own copy of the truth.

Because every block is a subscriber, any change — a slider drag, a voyage tween, a programmatic setVisible('@orbits', false) — propagates to the UI for free.

Animation — almost everything is tweenable

The four state axes form one animatable model. Each exposes the same imperative grammar:

setX(value, { duration, curve })  // instant or tweened, returns Promise
onXChange(cb)                     // subscription
Axis Module
Time src/solar-system/state/time.js
Scale src/solar-system/state/scale.js
Model src/solar-system/state/model.js
Camera src/camera/{brain,vcam,…}.js

Shared easing curves live in src/camera/easings.js; every setter is cancel-replace (a new call silently resolves the in-flight Promise at its current value). New knobs should be designed tween-capable from the start. See storytelling.md for the cross-axis conventions.

Voyages — scripting the animation

The orchestration layer that makes “almost everything is animable” useful: src/voyages/ scripts sequences of axis tweens to tell a story.

Project layout

/
├── index.html              Landing page (links to the 3 views)
├── solar-system.html       Solar system — compressed→true via the scale axis,
│                           moons, satellites, stars, constellations, voyages
├── galaxy.html             Milky Way with differential rotation
├── universe.html           Cosmic hierarchy (Local Group → observable universe)
├── launches.html           Earth satellite orbits (SGP4 + live Celestrak TLEs)
│
├── src/
│   ├── data/               Astronomical data (single source of truth)
│   │   ├── constants.js    Physical constants (c, AU, ly, …)
│   │   ├── planets.js      Real data for the 8 planets
│   │   ├── moons.js        Real data for 15 major moons
│   │   ├── moon.js         Meeus §47 lunar perturbation tables
│   │   ├── satellites.js   Keplerian elements for ISS / Hubble / Tiangong
│   │   ├── stars.js        ~8800-star Hipparcos catalog + IAU names
│   │   ├── constellations.js  88 IAU stick figures
│   │   ├── locations.js    11 curated cities (lat/lon)
│   │   └── populations.js  Asteroid-population statistics
│   ├── physics/            Pure-JS, Node-testable (no Three / Vite)
│   │   ├── formulas.js     Kepler, Lorentz, unit conversions
│   │   ├── moon.js  satellites.js  events.js  seasons.js  tides.js  geo.js
│   ├── camera/             Generic Cinemachine-style camera layer
│   │   ├── vcam.js  brain.js  body.js  aim.js  up.js  anchors.js
│   │   ├── target-group.js  noise.js  confiner.js  easings.js  storytelling.js
│   ├── solar-system/       solar-system.html's state, scene, vcams, panel
│   │   ├── state/          time.js  scale.js  model.js  mode.js (the axes)
│   │   ├── scene/          mount<Thing> Three.js builders + body-refs registry
│   │   ├── vcams/          destinations / cities / constellations / satellites / follow-modes
│   │   ├── panel/          mode orchestrator + eleve / dev / createur tabs
│   │   ├── blocks/         self-contained panel blocks (mount<Name>Block)
│   │   ├── timeline/       timeline strip + pure helpers
│   │   ├── dev/            dev-only helpers (log ring buffer, vcam tree)
│   │   ├── index.js        public barrel for the state modules
│   │   └── readouts.js     per-frame read-only HUD overlays
│   ├── voyages/            scripted + composable voyage engine, library, URL share
│   ├── widgets/            cross-view UI widgets (events, info-panel)
│   ├── textures/           loader.js + procedural.js + images/ (CC-BY 4.0)
│   ├── ui/                 sidebar, panel, tabs, mode-switcher, view-bootstrap, CSS
│   ├── views/              solar-system.js  galaxy.js  universe.js  launches.js (self-mounting)
│   ├── populations.js      asteroid-population Three.js renderer
│   ├── i18n.js             Internationalisation: detection, t(), toggle
│   ├── i18n-url.js         Pure URL helper used by i18n.js (Node-importable)
│   ├── satellite-shim.js   Re-exports satellite.js pure-JS layer (strips WASM)
│   └── version.js          Single source of truth for the app version
│
├── docs/
│   ├── architecture.md     This file (conceptual pipeline + layout + decisions)
│   ├── camera.md           Camera module developer guide (cookbook + API)
│   ├── physics.md          Formulas & what's accurate where
│   ├── scales.md           Why each view compresses what it compresses
│   ├── storytelling.md     Cross-axis tween conventions
│   └── releasing.md        Release process and versioning conventions
│
├── CHANGELOG.md            Per-version notes, Keep-a-Changelog format
├── CLAUDE.md               Per-module operational catalog + design rules
├── CONTRIBUTING.md         Git workflow & project norms
├── README.md
├── LICENSE                 MIT
├── package.json            npm scripts and pinned deps
├── vite.config.js          Multi-page input, hreflang opt-out, md→html rendering
├── eslint.config.js        ESLint flat config
├── .prettierrc.json        Prettier rules
├── .gitlab-ci.yml          GitLab Pages deployment
└── .gitignore

Design decisions

Build with Vite

The project is bundled by Vite. The same module graph the dev server hot-reloads is what gets bundled, tree-shaken, and content-hashed into dist/ at build time. Three.js resolves from node_modules (no CDN), so unused Three modules are tree-shaken out and the deployed payload is fully self-hosted.

Day-to-day commands: npm run dev (HMR), npm run build (dist/), npm run preview (serve dist/), npm test (Node --test over test/*.test.js). See vite.config.js for the multi-page input list (one entry per HTML view).

A Vite plugin (#72) renders top-level docs/*.md, README.md, and src/data/*.js to HTML at build and dev time; the raw .md / .js files are not shipped to dist/, so don’t link a raw .md from a view’s footer — it 404s in production.

Benefits:

src/ is the single source of truth

Everything reusable lives under src/ and is imported by the HTML views. When editing a value, edit it in src/ and every view that imports it picks up the change — no duplication.

One deliberate exception: each view module inlines its own compressed PLANETS table for visual reasons, so today no view imports src/data/planets.js directly. New planet/moon facts still belong in src/data/; a view may inline a different-shaped table but must document the divergence in CLAUDE.md’s Design rules. See scales.md for the per-view rationale.

Physics formulas in one place

src/physics/formulas.js collects the re-usable math: Kepler (third law + heliocentric solver), flat galactic rotation, Lorentz factor, unit conversions. Even if a view inlines one of these, the canonical version is here. The whole src/physics/ tree is pure-JS with no Three / Vite imports, so it is Node-testable (npm test).

Thin HTML shell + self-mounting module

Each visualization is split into a thin HTML shell (markup + inline-CSS divergences) and a self-mounting ES module under src/views/<name>.js that contains all scene setup, animation, and UI logic. The HTML loads it with a single <script type="module" src="src/views/<name>.js">; there is no init(root) entry point — importing the module kicks everything off. Three.js resolves from node_modules via bare specifiers (import * as THREE from 'three', import { OrbitControls } from 'three/addons/...'), no importmap. The version is pinned in package.json.

Internationalisation (src/i18n.js and src/i18n-url.js)

src/i18n.js is the public i18n surface. Key exports:

src/ui/view-bootstrap.js collapses the per-view boot dance (await ready + applyPageMeta + mountLangToggle + applyLangToLinks + initSidebar + footer) into a single bootstrapView(...) call.

src/i18n-url.js is a peer module exporting rewriteHrefForLang(href, lang, baseUrl) — a pure URL helper kept separate so Node tests can import it without triggering i18n.js’s browser-globals auto-init.

hreflang convention

Every view declares three <link rel="alternate" hreflang="…"> tags immediately after <title>: one for fr, one for en, and one for x-default (the unsuffixed URL of the same view). Any new view follows the same pattern.

GitLab Pages

.gitlab-ci.yml has one pages job, fired on vX.Y.Z tag pushes only. It runs npm run build against the tagged commit and ships dist/ to public/, which GitLab Pages serves at the project root. Vite emits content-hashed filenames, so a new release fully replaces the previous one in the browser without a manual refresh.

← Astrarium