Architecture
Project layout
/
├── index.html Landing page (links to the 4 views)
├── solar-system.html Solar system (compressed / pleasing scale)
├── scale.html Solar system (true sizes and distances)
├── galaxy.html Milky Way with differential rotation
├── universe.html Cosmic hierarchy (Local Group → observable universe)
│
├── src/
│ ├── data/
│ │ ├── constants.js Physical constants (c, AU, ly, …)
│ │ ├── planets.js Real astronomical data for the 8 planets
│ │ └── moons.js Real data for 15 major moons
│ ├── physics/
│ │ └── formulas.js Kepler, Lorentz, unit conversions
│ ├── textures/
│ │ ├── loader.js Async loader for photographic planet maps
│ │ ├── procedural.js Canvas2D procedural fallback textures
│ │ └── images/ 4K-class JPG/PNG maps (Solar System Scope, CC-BY 4.0)
│ ├── ui/
│ │ ├── sidebar.css Shared sidebar base: reset, panel rules, hamburger, backdrop, responsive, a11y
│ │ └── sidebar.js initSidebar() helper: wires clicks, Escape, localStorage, localization
│ ├── views/
│ │ ├── solar-system.js Per-page logic for solar-system.html
│ │ ├── scale.js Per-page logic for scale.html
│ │ ├── galaxy.js Per-page logic for galaxy.html
│ │ └── universe.js Per-page logic for universe.html
│ ├── i18n.js Internationalisation: language detection, t(), toggle
│ ├── i18n-url.js Pure URL helper used by i18n.js (Node-importable)
│ └── version.js Single source of truth for the app version
│
├── docs/
│ ├── physics.md Formulas & what's accurate where
│ ├── scales.md Why each view compresses what it compresses
│ ├── camera.md Camera module developer guide (cookbook + API)
│ ├── architecture.md This file
│ └── releasing.md Release process and versioning conventions
│
├── CHANGELOG.md Per-version notes, Keep-a-Changelog format
├── 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, passthrough copy
├── 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/ for verification). See vite.config.js
for the multi-page input list (one entry per HTML view).
Benefits of this approach:
- 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:
src/data/— astronomical constants, planet and moon tablessrc/physics/— Kepler, Lorentz, unit conversionssrc/textures/— photographic planet maps (Solar System Scope, CC-BY 4.0) with procedural Canvas2D fallbacks
When editing a value, edit it in src/ and every view that imports it
picks up the change. No duplication.
Physics formulas in one place
src/physics/formulas.js collects the re-usable math: Kepler’s 3rd law
(applied as sim-time angular velocity), flat galactic rotation, Lorentz
factor, unit conversions. Even if a view inlines one of these, the canonical
version is here.
Camera layer
src/camera/ follows Unity’s Cinemachine model: virtual cameras (vcams)
describe a camera configuration, and a single brain picks the
highest-priority active vcam each frame and blends camera state on
transitions. This replaces the imperative slot-based orchestrator that
the original WI80 implementation shipped — see the spec under
docs/superpowers/specs/2026-04-30-wi80-cinemachine-extension-design.md
for the rationale.
For the developer cookbook (recipes, API reference, “cool ideas to
pitch the product owner”), see docs/camera.md.
Today only solar-system.js consumes the brain; other views still hold
their own per-frame lerp and will adopt the brain in follow-up issues.
Layers:
easings.js— four curves, used by brain blends and any caller that needs a curve.vcam.js—createVcam(...)factory. A vcam is a stateless{ id, body, aim, fov?, priority, enabled, extensions? }plus optionalonActivate/onDeactivatelifecycle hooks (used by stateful components like POV).brain.js— manages the vcam list, picks the active one each frame, blends position/target/fov on activation change with the named easing curve over a configurable duration. Supports per-pair blend hints with wildcard*fallback. Runs per-vcam and brain-level extensions on the final ctx state.body.js— body components (camera-position producers):transposer,framingTransposer,sunRelativeFollow,sunRelativeOrbit,sunVisibleOrbit,hardLockToTarget,orbitalTransposer,trackedDolly,doNothing. All stateless factories returning(now, ctx) → Vector3.aim.js— aim components (controls.target producers):composer,hardLookAt,groupComposer,sameAsFollowTarget,pov,doNothing. All stateless exceptpov, which integrates pointermove deltas and toggles OrbitControls.target-group.js—createTargetGroupaggregates a list of bodies into a single target with weighted-average position and aggregate bounding radius. Compatible withframingTransposerandgroupComposer.noise.js—noise({amplitude, frequency, seed})extension; deterministic position perturbation.confiner.js—sphereConfiner/boxConfinerextensions; post-process clamps for the camera position.
The camera module imports Vector3 and CatmullRomCurve3 from 'three'
but does not depend on OrbitControls directly; it writes to
controls.target only, treating the Three controls object as a
duck-typed { target: Vector3, enabled: boolean, domElement? }.
Inactive-vcam skip — only the active vcam’s body+aim are evaluated each frame (and during a blend, the brain reuses a snapshot of the prior camera state, not the prior vcam’s per-frame output). Adding many configured vcams therefore costs nothing per frame. This is Cinemachine’s “Standby Update Method = Never” default.
Internationalisation (src/i18n.js and src/i18n-url.js)
src/i18n.js is the public i18n surface. Key exports:
getLang()— returns the active language code ('fr'or'en').t(key, params?)— returns the translated string forkeyin the active language.setLang(code)— switches the active language, persists tolocalStorage, and fires allonLangChangelisteners.onLangChange(fn)— registers a callback that fires whenever the language changes.objectName(obj)— returnsobj.names[activeLang]with an English fallback.fmtNumber(n, opts?)— locale-awareIntl.NumberFormatwrapper.mountLangToggle(target)— creates the FR⇄EN language toggle button and appends it totarget. Self-refreshes its label andaria-labelon every language flip.applyLangToLinks(root = document)— walks all internal<a href>elements and rewrites each to carry?lang=<active>. Self-registers anonLangChangelistener on first call.applyPageMeta({ titleKey, descKey })— updates<title>and<meta name="description">from translation keys.
src/i18n-url.js is a peer module that exports a single function:
rewriteHrefForLang(href, lang, baseUrl) — pure URL helper that rewrites an href to carry ?lang=<lang>. It lives in its own file (rather than inside src/i18n.js) so it can be imported from Node tests without triggering i18n.js’s auto-init, which depends on browser globals (window, localStorage, navigator).
hreflang convention
Every view declares three <link rel="alternate" hreflang="…"> tags immediately after <title> in its <head>: one for fr, one for en, and one for x-default (pointing to the unsuffixed URL of the same view file). Any new view must follow the same pattern.
Adding a new view
Each view is split between two files: a thin HTML shell with markup and
inline CSS, and a self-mounting ES module under src/views/ that does
all the work.
- Copy the nearest existing view’s HTML (e.g.
solar-system.html) as the shell template. - Copy the matching
src/views/<name>.jsas the logic template. Adjust import paths as needed (data lives in../data/, textures in../textures/, physics in../physics/). - The view module self-mounts — there is no
init(root)entry point. Importing it kicks off the scene setup, animation loop, and UI wiring. - Three.js imports use bare specifiers —
import * as THREE from 'three',import { OrbitControls } from 'three/addons/controls/OrbitControls.js'. Vite resolves both via thethreenpm package’sexportsmap. - Register the new HTML entry in
vite.config.js(build.rollupOptions.input). - Add your view to the landing
index.htmland the navigation footer of the other pages. - Add the three
hreflang<link>tags immediately after<title>in your view’s<head>(see the hreflang convention above). - Call
mountLangToggleandapplyLangToLinksfrom your view module, mirroring the pattern in the othersrc/views/*.jsfiles. - Document any physics / scaling choices in
docs/scales.md.
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