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.
src/physics/formulas.js— Kepler’s equation solver (solveKepler), J2000 orbital elements extrapolated to any date (elementsAtJD,meanAnomalyAtJD), and the heliocentric 3D position (keplerHeliocentric). This is what tells us the angular position of every planet at a given Julian Date.src/physics/moon.js— Meeus §47 abridged lunar model (phase, illumination, geocentric position), backed by the perturbation tables insrc/data/moon.js.src/physics/satellites.js— Earth-orbit propagator (Kepler + J2 secular rates) for ISS / Hubble / Tiangong.src/physics/events.js,seasons.js,tides.js,geo.js— pure scanners and closed-form models built on top (oppositions, conjunctions, solar declination, equilibrium tides, Earth-frame geometry).
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.
- The view (
src/views/solar-system.js) carries its own compressedPLANETStable (display radii and distances chosen for looks), separate from the true values insrc/data/planets.js. src/solar-system/state/scale.jsis the reprojection knob. It interpolates between the compressed table (slider0) and true proportions (slider1) across four sub-axes:size,distance,exaggeration,lunarOrbit. (This is why there is no separatescale.html: true scale is just this axis at1.)- Every geometric consumer reads the live effective value —
effectiveRadius(p)/effectiveDistance(p)in the view,effectiveMoonRadius/effectiveMoonDistance/getEffectiveScale(id)in the state module — never a captured constant. This is the scale-axis discipline rule (#183) in CLAUDE.md: a body stays coherent at any scale because meshes, vcam offsets, layer geometry, and the camera near-plane all track the same live number.
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.
src/solar-system/state/time.js—timeState = { jd, signedSpeed }, withsetTime/setTimeSpeed(instant or tweened),onTimeChangesubscribers, a per-frametick(now), andresolveRelativeJDfor relative tokens (next opposition, next sunset, …).src/solar-system/timeline/— the full-width timeline strip and its pure helpers (jdToFraction,fractionToJD,zoomRange,eventsInRange): the scale of the timeline.src/solar-system/blocks/{time,date}-block.js— speed presets (1 h/s … 1 yr/s, pause, reverse) and the humanised date readout + editor.
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:
planets.js,moons.js,sun.js— textured spheres, orbit rings, markers.earth.js— tilt group, equator/tropics, polar caps, city markers, tide water, tidal-force arrows.trails.js, plus CSS2D name labels, orbit rings, and trails per body.stars.js(~8800 Hipparcos stars),constellations.js(88 IAU figures),satellites.js(ISS / Hubble / Tiangong with ground tracks),cardinal-rose.js,motion-blur.js.body-refs.jsis the spine: a registry every mounter registers into, which drives the visibility/opacity cascade from the model axis.
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/.
src/camera/vcam.js— a vcam is a stateless config:body(position producer),aim(target producer),upaxis, priority, optionalnearReference.src/camera/brain.js— each frame picks the highest-priority enabled vcam, blends position/target/fov/up across activation changes, and sets the adaptive near plane from the active vcam’snearReference. Inactive vcams are never evaluated (Cinemachine “Standby Update Method = Never”), so configuring many costs nothing per frame.src/camera/{body,aim,up,anchors,target-group,noise,confiner,storytelling}.js— the component library (transposers, framing, POV, horizon/rise-set aims, up-axis vectors, synthetic anchors, compound targets, post-process extensions). The module writes only to a duck-typedcontrols.target— it does not depend onOrbitControlsdirectly.src/solar-system/vcams/—destinations.js(Sun + planets + moons + phenomena),cities.js(121 per-city ground vcams),constellations.js,satellites.js,follow-modes.js(geostationary / surface-POV / inertial / tidally-locked).- Mouse / keyboard interaction is itself a vcam concern: the
povaim component is the only stateful one — it takes over OrbitControls on activation and restores it on deactivation.
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.
src/solar-system/state/mode.js— three modes:eleve(student),dev,createur, persisted tolocalStorage, overridable via?mode=.src/solar-system/panel/— the orchestrator (index.js) mounts the active mode’s tabs (eleve.js,dev.js,createur.js) into#panel-rootand rebuilds on mode change.src/solar-system/blocks/— the panel is composed of blocks. Eachmount<Name>Block(target, ctx) → teardownbuilds one.section-block, subscribes to the relevant axis (onTimeChange/onScaleChange/onModelChange), refreshes its labels ononLangChange, and tears itself down idempotently.src/solar-system/readouts.js+ the three info-panel overlay slots (src/widgets/info-panel.js) are the read-only HUD floating over the canvas.
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.
schema.js— pure validator with allowlists (curves, selectors, param paths, relative tokens).runner.js— dispatches scripted voyages (voyage.run(ctx)) and composable ones (walksphases[], dispatching each to its axis setter). Cinema-protected, AbortSignal end-to-end.index.js— built-in registry;library.js— localStorage CRUD for user voyages;url-share.js—?voyage=share codec;welcome-overlay.js— boot-time share modal.- Authored in the Créateur panel (
panel/createur.js+voyage-editor-block.js) and surfaced to students as cards in the Histoires tab.
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:
- Filename-hashed assets — releases bust the browser cache automatically
- HMR in dev — most edits reflect in the browser without a full reload
- Tree-shaken Three.js — smaller, self-hosted, no CDN dependency
- Lint and format wired in (
npm run lint,npm run format) src/is still the single source of truth — imported, not duplicated- A view can be forked standalone with
npm install
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:
getLang()/setLang(code)— read / switch the active language (fr/en);setLangpersists tolocalStorageand firesonLangChangelisteners.t(key, params?)— translated string forkeyin the active language.onLangChange(fn)— register a callback fired on every language change.objectName(obj)—obj.names[activeLang]with an English fallback.fmtNumber(n, opts?)— locale-awareIntl.NumberFormatwrapper.mountLangToggle(target)— FR⇄EN toggle button; self-refreshes its label.applyLangToLinks(root)— rewrites internal<a href>to carry?lang=.applyPageMeta({ titleKey, descKey })— updates<title>/<meta>.
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.