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:

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.

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:

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:

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.

  1. Copy the nearest existing view’s HTML (e.g. solar-system.html) as the shell template.
  2. Copy the matching src/views/<name>.js as the logic template. Adjust import paths as needed (data lives in ../data/, textures in ../textures/, physics in ../physics/).
  3. The view module self-mounts — there is no init(root) entry point. Importing it kicks off the scene setup, animation loop, and UI wiring.
  4. Three.js imports use bare specifiers — import * as THREE from 'three', import { OrbitControls } from 'three/addons/controls/OrbitControls.js'. Vite resolves both via the three npm package’s exports map.
  5. Register the new HTML entry in vite.config.js (build.rollupOptions.input).
  6. Add your view to the landing index.html and the navigation footer of the other pages.
  7. Add the three hreflang <link> tags immediately after <title> in your view’s <head> (see the hreflang convention above).
  8. Call mountLangToggle and applyLangToLinks from your view module, mirroring the pattern in the other src/views/*.js files.
  9. 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