04 · Orrery — Technical Architecture
April 2026 · v1.0 · Part of the Orrery Concept Package (00 through 05)
Historical note (May 2026) — this doc describes the originally-proposed Phase 1 stack (vanilla JS + Vite + Docker Compose). The production app has since adopted SvelteKit + TypeScript-strict + GitHub Pages. For the locked production stack see
../adr/TA.mdand the ADR index — in particular ADR-011 (TypeScript), ADR-012 (SvelteKit), ADR-013 (History API routing), ADR-014 (CI / Pages), ADR-016 (build-time assets), ADR-017 (Paraglide-js + locale overlays). This doc is preserved as the founding architectural narrative.
Purpose
This document defines the technical architecture for building Orrery as a production-grade, self-hostable web application. It is written after the six prototypes (P01 through P06) and after 05 Design System — meaning every decision here is grounded in what the UI actually needs, not what was aspirationally planned.
Section 10 of 05 identified eight design decisions that create direct technical requirements. This document responds to all eight and organises them into a coherent architecture.
1. Guiding constraints
Before any stack decision, three constraints bound the architecture completely:
1. Runs in a browser, offline. The Docker Compose deployment target means the app must function without external CDN dependencies. Google Fonts, Three.js CDN, NASA logo hotlinks — all must be bundled or self-hosted. The NASA Images API is the one intentional runtime dependency, and it degrades gracefully when unavailable.
2. No server required for Phase 1. The six prototype screens are entirely client-side. No database, no auth, no server-side rendering. The Docker Compose target is simply an nginx container serving static files. This is a feature, not a limitation — it means zero backend operational burden for a self-hoster.
3. The prototypes are the ground truth. The architecture must not invent new constraints that require rewriting working screens. Everything the prototypes do is correct; the architecture exists to connect them, bundle them, and make them deployable.
2. Stack decisions
2.1 JavaScript — no framework
All six prototype screens are written in vanilla JavaScript. No React, no Vue, no Svelte. This was not an oversight — it was correct.
The primary rendering targets are Three.js (3D) and Canvas 2D (porkchop plot, Moon map flat view). Both are imperative APIs. A declarative component framework adds abstraction without benefit and introduces reconciliation overhead in animation loops that run at 60 fps.
Decision: vanilla JS throughout Phase 1. If Phase 2 introduces complex shared state (mission builder, user accounts), evaluate a lightweight state library (Zustand or Nano Stores) without adopting a full framework.
2.2 3D rendering — Three.js r128
Three.js r128 is pinned. It is used across four screens (P01, P03, P05, P06). The CDN reference https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js must be replaced with a locally bundled copy in production.
Why r128 specifically: Later versions (r140+) introduced breaking changes to WebGLRenderer initialisation and MeshStandardMaterial defaults. An upgrade would require testing all four 3D screens. This is a Phase 2 task if a specific Three.js feature is needed.
Decision: bundle three.min.js locally at r128. Pin in package.json. Do not auto-update.
2.3 2D rendering — Canvas API
The porkchop plot (P02) and Moon map flat view (P06) use raw Canvas 2D. The porkchop heatmap uses ImageData for direct pixel manipulation — the only way to render 11,200 cells at interactive speed. This cannot be replaced with SVG or DOM elements.
Decision: Canvas 2D for all 2D rendering. No charting library.
2.4 CSS — no utility framework
All styling is inline CSS in JavaScript string templates, or <style> blocks within each HTML file. This is consistent and correct for the prototype stage. In the production build, these migrate to module CSS or a single shared stylesheet.
Decision: no Tailwind, no Bootstrap. Module CSS in production build.
2.5 Build tooling — Vite
Vite is the build tool for Phase 1 production. It provides:
- ES module bundling without configuration overhead
- Asset handling for Three.js and font files
- Dev server with hot module replacement for fast iteration
- Static output compatible with nginx serving
Decision: Vite. vite.config.js with base: '/' and asset inlining for fonts.
3. Repository structure
orrery/
├── docker-compose.yml ← Phase 1 deployment
├── nginx.conf ← Static file serving config
├── package.json ← Vite + dependencies
├── vite.config.js
│
├── public/
│ ├── fonts/ ← Self-hosted Google Fonts (see section 5.3)
│ │ ├── space-mono-400.woff2
│ │ ├── space-mono-700.woff2
│ │ ├── bebas-neue-400.woff2
│ │ └── crimson-pro-400italic.woff2
│ ├── logos/ ← Agency logos (see section 5.4)
│ │ ├── nasa.svg
│ │ ├── esa.svg
│ │ ├── cnsa.svg
│ │ └── ...
│ ├── textures/ ← Three.js textures
│ │ ├── earth_atmos_2048.jpg
│ │ └── moon_1024.jpg
│ └── favicon.ico
│
├── data/ ← Plain JSON — no build step needed
│ ├── missions/
│ │ ├── index.json ← Lightweight manifest for card grid
│ │ ├── mars/ ← One file per Mars mission
│ │ │ ├── curiosity.json
│ │ │ ├── perseverance.json
│ │ │ └── ...
│ │ └── moon/ ← One file per Moon mission
│ │ ├── apollo-11.json
│ │ ├── chandrayaan-3.json
│ │ └── ...
│ ├── planets.json ← Orbital elements, physical constants
│ ├── rockets.json ← Launch vehicle specs
│ └── earth-objects.json ← ISS, JWST, Hubble, etc.
│
├── src/
│ ├── main.js ← Router entry point
│ ├── router.js ← Client-side router
│ ├── state.js ← Shared application state (session-only)
│ │
│ ├── workers/
│ │ └── lambert.worker.js ← Lambert solver (off main thread)
│ │
│ ├── screens/
│ │ ├── explorer/ ← P01 Solar system
│ │ ├── configurator/ ← P02 Porkchop
│ │ ├── arc/ ← P03 Mission arc
│ │ ├── library/ ← P04 Mission library
│ │ ├── earth/ ← P05 Earth orbit
│ │ └── moon/ ← P06 Moon map
│ │
│ ├── components/
│ │ ├── nav.js ← Shared navigation bar
│ │ ├── panel.js ← Shared detail panel
│ │ ├── toggle.js ← 3D/2D view toggle
│ │ ├── logo.js ← Agency logo component
│ │ └── links.js ← Educational link rows
│ │
│ └── lib/
│ ├── data.js ← Fetch + cache layer for JSON data files
│ ├── orbital.js ← Keplerian mechanics, vis-viva
│ ├── scale.js ← auToPx(), altToVis() — design constants
│ ├── lambert.js ← Lambert solver (called from worker)
│ └── images.js ← NASA Images API client
│
└── docs/
├── 01_Orrery_Vision.md
├── 03_Data_Catalog.md
├── 04_Technical_Architecture.md ← this file
└── 05_Design_System.md4. Client-side router
The six screens are currently six independent HTML files. In production they become six routes in a single-page application. The router is simple — no nested routes, no lazy loading complexity in Phase 1.
4.1 URL schema
| Route | Screen | Notes |
|---|---|---|
/ | Solar system explorer | Default view |
/earth | Earth orbit viewer | |
/moon | Moon map | |
/plan | Mission configurator (porkchop) | |
/plan?dep=2026-10-15&tof=280 | Configurator with pre-selected window | URL-serialisable state |
/fly | Mission arc — personal mission | Reads from shared state |
/fly?id=curiosity | Mission arc — historical mission | Direct link |
/missions | Mission library | |
/missions?dest=moon | Library filtered to Moon | |
/missions?id=apollo11 | Library with Apollo 11 open |
4.2 Router implementation
Hash-based routing (/#/fly?id=curiosity) in Phase 1 — works without server-side configuration and is compatible with static nginx serving. History API routing in Phase 2 when server-side redirects can be configured.
// src/router.js
const ROUTES = {
'/': () => import('./screens/explorer/index.js'),
'/earth': () => import('./screens/earth/index.js'),
'/moon': () => import('./screens/moon/index.js'),
'/plan': () => import('./screens/configurator/index.js'),
'/fly': () => import('./screens/arc/index.js'),
'/missions': () => import('./screens/library/index.js'),
};
function route() {
const path = location.hash.replace('#', '') || '/';
const [base, query] = path.split('?');
const params = new URLSearchParams(query);
const loader = ROUTES[base] || ROUTES['/'];
loader().then(mod => mod.mount(document.getElementById('app'), params));
}
window.addEventListener('hashchange', route);
route(); // initial load4.3 State handoff — configurator to arc
The mission configurator (P02) must pass a trajectory solution to the mission arc (P03). In Phase 1 this uses sessionStorage. In Phase 2 it is URL-serialised.
// When user confirms a trajectory in P02:
sessionStorage.setItem('orrery:planned-mission', JSON.stringify({
dep: '2026-10-15', // ISO date
tof: 280, // days
dv: 5.82, // km/s
vehicle: 'falcon-heavy',
payload: 2500, // kg
name: 'ORRERY-1',
}));
location.hash = '/fly';
// P03 reads on mount:
const mission = JSON.parse(sessionStorage.getItem('orrery:planned-mission'));5. Responding to 05 design decisions
05 section 10 identified eight design decisions with technical implications. Each is addressed below.
5.1 Lambert solver — Web Worker
05 finding: The porkchop plot computes 11,200 Lambert solutions on the main thread, blocking rendering for ~2 seconds at startup.
Architecture response: The Lambert solver moves to a dedicated Web Worker in production. The main thread sends grid parameters; the worker returns the completed heatmap as a Float32Array of delta-v values; the main thread renders it.
// src/workers/lambert.worker.js
self.onmessage = ({ data: { depDays, tofDays, gridW, gridH } }) => {
const dvGrid = new Float32Array(gridW * gridH);
for (let i = 0; i < gridW; i++) {
for (let j = 0; j < gridH; j++) {
dvGrid[j * gridW + i] = solveLambert(depDays[i], tofDays[j]);
}
}
self.postMessage({ dvGrid }, [dvGrid.buffer]); // transferable
};
// src/screens/configurator/index.js
const worker = new Worker(new URL('../../workers/lambert.worker.js', import.meta.url));
worker.postMessage({ depDays, tofDays, gridW: 140, gridH: 80 });
worker.onmessage = ({ data: { dvGrid } }) => renderHeatmap(dvGrid);The main thread shows a loading state ("Computing 11,200 trajectories…") while the worker runs. This is accurate and educational — it tells the user what is happening.
5.2 Three.js — local bundle
05 finding: All 3D screens load Three.js from Cloudflare CDN. Offline use fails.
Architecture response: npm install three@0.128.0 (exact version). Vite bundles it. The CDN script tags in the prototype files are replaced with ES module imports.
// Before (prototype):
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
// After (production):
import * as THREE from 'three'; // resolved by Vite to node_modules/threeBundle size impact: Three.js r128 minified is ~600 KB. With Vite tree-shaking, expected reduction to ~300–400 KB for the modules actually used. Split per-screen so only the screens that need Three.js load it.
5.3 Fonts — self-hosted
05 finding: Google Fonts are loaded via CDN. Offline deployments will fall back to system monospace/serif — which breaks the typographic character of the product.
Architecture response: Download all four weights at build time and serve from public/fonts/. Use @font-face with font-display: swap so text renders immediately in the system fallback and transitions when the custom font loads.
@font-face {
font-family: 'Space Mono';
font-weight: 400;
font-display: swap;
src: url('/fonts/space-mono-400.woff2') format('woff2');
}Script to download fonts at project setup time (runs once, not at build time):
# scripts/download-fonts.sh
npx google-fonts-helper \
--fonts "Space Mono:400,700" \
--fonts "Bebas Neue:400" \
--fonts "Crimson Pro:400italic" \
--output public/fonts/5.4 Agency logos — local hosting
05 finding: Agency logos are hotlinked from upload.wikimedia.org. The text abbreviation fallback is always visible, so this degrades gracefully, but production should not depend on Wikimedia availability.
Architecture response: Download SVG/PNG logos to public/logos/ at project setup. The logoHTML() component references /logos/nasa.svg instead of the Wikimedia URL. The text fallback remains as a loading state and offline fallback.
// src/components/logo.js
const LOGOS = {
NASA: { src: '/logos/nasa.svg', bg: '#0B3D91', abbr: 'NASA' },
ESA: { src: '/logos/esa.svg', bg: '#1C3C8A', abbr: 'ESA' },
CNSA: { src: '/logos/cnsa.svg', bg: '#DE2910', abbr: 'CNSA' },
ISRO: { src: '/logos/isro.svg', bg: '#1a1a2e', abbr: 'ISRO' },
ROSCOSMOS: { src: '/logos/roscosmos.svg', bg: '#0d0d1a', abbr: 'RSC' },
JAXA: { src: '/logos/jaxa.svg', bg: '#0062AC', abbr: 'JAXA' },
SpaceX: { src: '/logos/spacex.svg', bg: '#000000', abbr: 'SX' },
};Trademark notices are added to every panel footer per 03 credit format reference.
5.5 NASA Images API — CORS open, graceful degradation
05 finding: The gallery tab fetches from images-api.nasa.gov at runtime. CORS is open. Offline deployments fail gracefully.
Architecture response: No change to the API call pattern. The gallery already shows a placeholder when the fetch fails. In production, add a 5-second timeout and a try/catch that renders the placeholder — both are already partially implemented in the prototypes.
// src/lib/images.js
export async function fetchNASAImages(query, count = 9) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(
`https://images-api.nasa.gov/search?q=${encodeURIComponent(query)}&media_type=image&page_size=${count}`,
{ signal: controller.signal }
);
const data = await res.json();
return (data.collection?.items || []).slice(0, count);
} catch {
return []; // caller renders placeholder
} finally {
clearTimeout(timeout);
}
}For non-NASA missions, curated static image URLs are defined in the mission data objects (gallery_imgs field per 03 schema). These load as regular <img> tags with onerror fallback.
5.6 Client-side router and shared state
05 finding: No shared state between screens. Mission planned in P02 is not passed to P03 automatically.
Architecture response: The router (section 4) and sessionStorage state handoff (section 4.3) solve this for Phase 1. Shared state that persists across sessions is a Phase 2 requirement.
// src/state.js — Phase 1 (session-only)
export const state = {
get plannedMission() {
return JSON.parse(sessionStorage.getItem('orrery:planned-mission') || 'null');
},
set plannedMission(v) {
sessionStorage.setItem('orrery:planned-mission', JSON.stringify(v));
},
get selectedWindow() {
return JSON.parse(sessionStorage.getItem('orrery:window') || 'null');
},
set selectedWindow(v) {
sessionStorage.setItem('orrery:window', JSON.stringify(v));
},
};5.7 URL-serialisable state — mission sharing
05 finding: simT is local state. Mission sharing requires URL-serialisable parameters.
Architecture response: The URL schema in section 4.1 defines the serialisation format. A planned mission can be encoded as:
/fly?dep=2026-10-15&tof=280&dv=5.82&vehicle=falcon-heavy&payload=2500&name=ORRERY-1The arc screen reads these parameters on mount and reconstructs the trajectory. This enables direct sharing of a mission link. Implementation is Phase 2 — Phase 1 uses sessionStorage handoff.
5.8 Scale functions — design constants in code
05 finding: The auToPx() and altToVis() functions contain magic numbers that are design constants, documented in 03 but not isolated in code.
Architecture response: Both functions live in src/lib/scale.js with explicit documentation linking to 03.
// src/lib/scale.js
/**
* Maps an orbital radius in AU to a visual pixel radius.
* Uses manually compressed scale anchors to keep all planets
* visible on screen while preserving relative ordering.
* See 03 section 1.7 for the full anchor table.
*/
export function auToPx(au) {
const ANCHORS = [
[0.387, 52], // Mercury
[0.723, 83], // Venus
[1.000, 113], // Earth (reference)
[1.524, 155], // Mars
[5.203, 248], // Jupiter
[9.537, 320], // Saturn
[19.19, 378], // Uranus
[30.07, 430], // Neptune
];
// Linear interpolation between anchors
for (let i = 0; i < ANCHORS.length - 1; i++) {
const [a0, p0] = ANCHORS[i], [a1, p1] = ANCHORS[i + 1];
if (au >= a0 && au <= a1) return p0 + (au - a0) / (a1 - a0) * (p1 - p0);
}
return au < ANCHORS[0][0] ? ANCHORS[0][1] : ANCHORS.at(-1)[1];
}
/**
* Maps an orbital altitude in km to a visual pixel radius for the
* Earth orbit viewer. Logarithmic scale to show ISS through JWST
* on the same screen (3,750× range).
* Formula: EARTH_VIS_R + LOG_K × log₁₀(1 + alt_km / 100)
* See 03 section 1.8 for derivation.
*/
const EARTH_VIS_R = 38; // px — visual Earth radius
const LOG_K = 54; // px per decade of altitude
export function altToVis(alt_km) {
return EARTH_VIS_R + LOG_K * Math.log10(1 + alt_km / 100);
}6. Data layer
Data and code are separate things. Mission records, orbital elements, rocket specs, and Earth orbit objects all live as plain JSON files, served statically by nginx alongside the application bundle. No database. No backend. A contributor who wants to add a mission or correct a date edits a JSON file — they do not touch JavaScript, do not understand the build, and do not need to trigger a rebuild.
This is the right approach for a curated catalogue of 30 missions that changes slowly and deliberately. It will remain correct even as the catalogue grows to 100 missions.
6.1 Data directory structure
data/
├── missions/
│ ├── index.json ← Lightweight manifest — card grid data only
│ ├── mars/
│ │ ├── curiosity.json
│ │ ├── perseverance.json
│ │ ├── mars-3.json
│ │ ├── mars-express.json
│ │ ├── hope-probe.json
│ │ ├── tianwen-1.json
│ │ ├── maven.json
│ │ ├── insight.json
│ │ ├── viking-1.json
│ │ ├── pathfinder.json
│ │ ├── spirit.json
│ │ ├── opportunity.json
│ │ ├── mangalyaan.json
│ │ ├── mariner-4.json
│ │ ├── mars-sample-return.json
│ │ └── starship-mars.json
│ └── moon/
│ ├── apollo-11.json
│ ├── apollo-17.json
│ ├── luna-9.json
│ ├── lunokhod-1.json
│ ├── luna-24.json
│ ├── clementine.json
│ ├── lro.json
│ ├── chandrayaan-1.json
│ ├── chandrayaan-3.json
│ ├── change-4.json
│ ├── change-5.json
│ ├── change-6.json
│ ├── slim.json
│ └── artemis-3.json
├── planets.json ← Orbital elements + physical constants
├── rockets.json ← Launch vehicle specs
└── earth-objects.json ← ISS, JWST, Hubble, etc.6.2 The index manifest
missions/index.json contains only the fields needed to render the card grid and power filtering. Full mission detail is fetched on demand when a card is clicked.
[
{
"id": "curiosity",
"name": "Curiosity",
"agency": "NASA",
"dest": "MARS",
"status": "ACTIVE",
"year": 2011,
"type": "ROVER · ACTIVE",
"sector": "gov",
"color": "#0B3D91",
"first": "First nuclear-powered Mars rover · Still active after 12 years"
},
{
"id": "apollo-11",
"name": "Apollo 11",
"agency": "NASA",
"dest": "MOON",
"status": "FLOWN",
"year": 1969,
"type": "CREWED LANDER",
"sector": "gov",
"color": "#0B3D91",
"first": "First humans on the Moon"
}
]This keeps the initial page load fast. Fetching 30 lightweight index entries is trivial; fetching 30 full mission objects with descriptions, link arrays, and gallery data on every page load is wasteful.
6.3 Individual mission file
Each mission file is the full record conforming to the 03 schema. Example:
{
"id": "curiosity",
"name": "Curiosity",
"agency": "NASA",
"agency_full": "NASA / JPL-Caltech",
"sector": "gov",
"dest": "MARS",
"color": "#0B3D91",
"year": 2011,
"type": "ROVER · ACTIVE",
"status": "ACTIVE",
"dep": "Nov 26, 2011",
"arr": "Aug 6, 2012",
"tof": 253,
"j2000": 4347,
"vehicle": "Atlas V 541",
"payload": "899 kg",
"dv": "~6.1 km/s",
"collabs": [],
"first": "First nuclear-powered Mars rover · Still active after 12 years",
"description": "The only nuclear-powered rover on Mars. Has driven 31+ km across Gale Crater, climbed 700 m up Mount Sharp, and confirmed that Mars once had conditions suitable for microbial life. Still active after 12 years — the longest-running Mars surface mission ever.",
"data_quality": "good",
"credit": "© NASA/JPL-Caltech — U.S. Government work · Public domain",
"gallery_query": "Curiosity rover Mars Gale Crater",
"links": [
{ "l": "Curiosity — Wikipedia", "u": "https://en.wikipedia.org/wiki/Curiosity_(rover)", "t": "intro" },
{ "l": "Mars Science Laboratory — NASA", "u": "https://mars.nasa.gov/msl/", "t": "intro" },
{ "l": "MSL science results (JGR 2014)", "u": "https://doi.org/10.1002/2014JE004612", "t": "deep" }
]
}Adding a new mission means creating one new JSON file and adding one entry to index.json. No JavaScript is touched. No rebuild is required — nginx serves the new file immediately on next request.
6.4 Orbital and reference data
Physics constants and planetary elements are also JSON, not JavaScript. They change only when IAU updates a constant — which is rare and deliberate.
// data/planets.json
{
"constants": {
"mu_sun_au3_yr2": 39.4784,
"au_to_km": 149597870.7,
"au_to_light_minutes": 8.3167,
"aupyr_to_kms": 4.7404
},
"planets": [
{ "name": "Mercury", "a": 0.387, "T": 87.97, "L0": 0.5, "incl": 7.00 },
{ "name": "Venus", "a": 0.723, "T": 224.70, "L0": 1.2, "incl": 3.39 },
{ "name": "Earth", "a": 1.000, "T": 365.25, "L0": 1.753, "incl": 0.00 },
{ "name": "Mars", "a": 1.524, "T": 686.97, "L0": 6.203, "incl": 1.85 },
{ "name": "Jupiter", "a": 5.203, "T": 4332.59,"L0": 0.6, "incl": 1.30 },
{ "name": "Saturn", "a": 9.537, "T": 10759.2,"L0": 1.2, "incl": 2.49 },
{ "name": "Uranus", "a": 19.19, "T": 30688.5,"L0": 2.8, "incl": 0.77 },
{ "name": "Neptune", "a": 30.07, "T": 60182.0,"L0": 3.1, "incl": 1.77 }
]
}6.5 Data client — thin fetch layer
The front end has a single data module that knows how to fetch and cache. It does not transform data — it returns it as-is from the JSON files.
// src/lib/data.js
const cache = new Map();
async function get(url) {
if (cache.has(url)) return cache.get(url);
const data = await fetch(url).then(r => {
if (!r.ok) throw new Error(`Data fetch failed: ${url}`);
return r.json();
});
cache.set(url, data);
return data;
}
// Mission index — used by card grid and filters
export async function getMissionIndex() {
return get('/data/missions/index.json');
}
// Full mission record — fetched on card click
export async function getMission(id, dest) {
return get(`/data/missions/${dest.toLowerCase()}/${id}.json`);
}
// Filter on the client — index is small enough
export async function filterMissions({ dest, status, agency } = {}) {
const index = await getMissionIndex();
return index.filter(m =>
(!dest || m.dest === dest) &&
(!status || m.status === status) &&
(!agency || m.agency === agency)
);
}
// Reference data
export const planets = () => get('/data/planets.json');
export const rockets = () => get('/data/rockets.json');
export const earthObjects = () => get('/data/earth-objects.json');The cache map means each JSON file is fetched at most once per session. No library needed — browser fetch plus a Map is sufficient.
6.6 Nginx — serving data files
The data directory is mounted directly into the nginx container alongside the built app. No rebuild is needed to update data.
# docker-compose.yml
services:
orrery:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- ./dist:/usr/share/nginx/html:ro ← built JS/CSS/assets
- ./data:/usr/share/nginx/html/data:ro ← JSON files, live-mounted
- ./nginx.conf:/etc/nginx/conf.d/default.conf:roThe key detail: data/ is a separate volume from dist/. Updating a mission JSON file and running docker compose restart (or nothing, if nginx serves the file fresh on each request with no-cache headers for JSON) takes effect immediately. No rebuild, no redeploy of the application bundle.
Add this to the nginx config:
# Serve JSON data files — short cache so updates are visible quickly
location /data/ {
expires 5m;
add_header Cache-Control "public, must-revalidate";
add_header Content-Type "application/json";
}6.7 Contributing a mission — workflow
This is what the data layer makes possible. From the contributor's perspective:
# 1. Copy the template
cp data/missions/mars/curiosity.json data/missions/moon/my-new-mission.json
# 2. Edit the JSON file with the mission details
# (No JavaScript knowledge required)
# 3. Add one line to the index
# data/missions/index.json — add the lightweight entry
# 4. Done. Restart or the next request picks it up automatically.
docker compose restartA pull request for a new mission is a diff of two JSON files. It can be reviewed by anyone who knows the mission, regardless of whether they can read JavaScript.
7. Docker Compose deployment
7.1 Nginx configuration
# nginx.conf
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# SPA routing — all paths serve index.html
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets aggressively
location ~* \.(js|css|woff2|jpg|png|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# No cache for HTML (so deployments take effect immediately)
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-store";
}
# JSON data files — short cache so mission updates are visible quickly
# data/ is a live-mounted volume, separate from the built app bundle
location /data/ {
expires 5m;
add_header Cache-Control "public, must-revalidate";
}
# Security headers
add_header X-Frame-Options SAMEORIGIN;
add_header X-Content-Type-Options nosniff;
add_header Referrer-Policy strict-origin-when-cross-origin;
}The critical detail: data/ is mounted as a separate volume from dist/. This means updating a mission JSON file and running docker compose restart takes effect immediately — no application rebuild required.
# docker-compose.yml
services:
orrery:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- ./dist:/usr/share/nginx/html:ro # built JS/CSS/assets — needs rebuild to update
- ./data:/usr/share/nginx/html/data:ro # JSON data — edit and restart, no rebuild
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro7.2 Build pipeline
# One-time setup
npm install
bash scripts/download-fonts.sh
bash scripts/download-logos.sh
bash scripts/download-textures.sh
# Development
npm run dev # Vite dev server at localhost:5173
# data/ files served directly — no build needed for data changes
# Production build
npm run build # Outputs to dist/ — only needed for JS/CSS/asset changes
# Deploy
docker compose up
# Update mission data without rebuilding
vim data/missions/mars/curiosity.json # edit the JSON
docker compose restart # nginx picks up changes immediately7.3 Environment variables
The NASA Images API requires no key. No environment variables are needed for Phase 1. If a future API requires authentication, variables are injected at build time via Vite's import.meta.env:
# .env (not committed)
VITE_NASA_API_KEY=DEMO_KEY8. Phase 1 vs Phase 2 scope
Phase 1 — what gets built first
Phase 1 is the six prototype screens, connected by a router, bundled for offline use, deployable with Docker Compose.
| Deliverable | Description |
|---|---|
| Router | Hash-based, six routes, URL params for mission IDs |
| Shared components | Nav, panel, toggle, logo, links — extracted from prototypes |
| JSON data layer | 30 mission files + index + planets/rockets/earth-objects — no JS |
| Data client | src/lib/data.js — fetch, cache, filter |
| Lambert worker | Solver moved off main thread |
| Asset bundling | Three.js, fonts, logos, textures — all local |
| Docker deployment | Single docker compose up installs and runs |
Phase 2 — what comes after
Phase 2 is explicitly out of scope for the initial build. It is listed here so architectural decisions in Phase 1 do not accidentally prevent it.
| Feature | Screen | Technical prerequisite |
|---|---|---|
| Mission sharing via URL | P03 | URL serialisation of arc state (section 5.7) |
| Rocket configurator | P02 | Form state, validation, Tsiolkovsky solver (already in P02) |
| Moon arc screen | New | Earth-Moon Lambert variant, shorter timescale telemetry |
| User-saved missions | All | LocalStorage or server-side persistence; requires auth design |
| History API routing | All | Server-side redirect config added to nginx |
| Mobile layout | All | CSS breakpoints, touch-first interaction redesign |
| Accessibility | All | ARIA roles, keyboard navigation, prefers-reduced-motion |
| Three.js upgrade | All 3D | Full audit of four 3D screens, breaking change testing |
| Launch Sequence | P09 | Deferred — schematic version scoped below |
| Merged into P01 — TECHNICAL tab in planet detail panel | ||
| Merged into P03 — CAPCOM toggle in mission arc |
P07 — Planet Technical Mode (merged into P01)
The TECHNICAL tab is now part of the P01 detail panel — no separate screen needed. Clicking any planet opens OVERVIEW / TECHNICAL / LEARN / SIZES tabs. The TECHNICAL tab shows the full Keplerian element set (a, e, T, inclination, axial tilt, rotation period), a live vis-viva velocity readout, an eccentricity shape visualiser, and per-planet axial tilt callouts. A floating tooltip shows velocity and distance data on hover in both 2D and 3D. The Sun is now clickable with its own panel covering solar physics and galaxy context.
P08 — CAPCOM Mission Arc (merged into P03)
CAPCOM is now a toggle button in the P03 nav bar — no separate screen needed. The mission scenario was changed to a free-return Mars flyby (analogous to Artemis II's lunar free-return): 259 days outbound, Mars closest approach at ~300 km, 250 days return. Total 509 days. No MOI burn. The Keplerian arc is real — both legs computed from orbital elements, not Bezier curves. CAPCOM mode shows a 13-event mission ticker, signal delay in light-minutes, and an anomaly monitor.
P09 — Launch Sequence
A new screen between the mission configurator (P02) and the interplanetary arc (P03), covering the part of spaceflight that Orrery currently skips: the first 12 minutes from the launch pad to orbit insertion.
What it shows:
A schematic side-view of the launch sequence — educational in character, not cinematic. The rocket climbs out of atmosphere with CAPCOM-style event markers overlaid: T+0 IGNITION, T+76s MAX-Q, T+2:36 MECO, T+2:38 STAGE SEP, T+8:30 FAIRING SEP, T+9:00 ORBIT INSERTION. Altitude and velocity plot against a reference atmosphere profile. Stage separation shown as the first stage peeling away, the second stage continuing, and — for Falcon Heavy / Starship — the booster return arc.
Why this completes the narrative:
Orrery currently goes: configure mission → fly interplanetary arc. The jump from "launch window selected" to "spacecraft cruising at 31 km/s" skips the most dramatic 12 minutes of any space mission. The launch sequence screen closes that gap: configure → launch → cruise → arrive. The user sees how the rocket gets from the ground to the interplanetary trajectory they just computed.
Cinematic quality — open question:
A schematic launch sequence (diagram quality, event callouts, altitude/velocity telemetry) is achievable within the current Three.js / Canvas stack. Cinematic quality — rocket plumes, atmospheric glow, stage separation with physics — requires either purpose-built 3D models and a more capable renderer, or integration with an on-demand cloud rendering API. This is an open architectural question for Phase 2. Candidates include Spline (browser-native 3D with asset hosting), cloud-hosted WebGL scenes via a CDN, or procedural generation using Three.js with custom shader materials. The schematic version ships first; the cinematic upgrade is an enhancement that can be layered on without changing the data or navigation architecture.
9. Performance targets
| Metric | Target | How achieved |
|---|---|---|
| First contentful paint | < 1.5s | Vite code splitting, font display: swap |
| Time to interactive | < 3s | Lambert solver in Web Worker, non-blocking |
| 3D frame rate | 60 fps | devicePixelRatio capped at 2×, hit mesh optimisation |
| 2D canvas frame rate | 60 fps | Canvas cleared and redrawn per frame, no DOM diff |
| Porkchop computation | < 100ms (worker) | Off main thread, typed arrays, 52 iterations |
| Bundle size (initial) | < 500 KB gzipped | Three.js tree-shaking, per-screen code splitting |
| Offline availability | Full (minus gallery) | All assets local; NASA Images API degrades gracefully |
10. Open questions for Phase 2
These are not blockers for Phase 1 but must be resolved before Phase 2 begins:
Moon arc telemetry model. The mission arc (P03) uses a solar-system-scale vis-viva model. A Moon arc needs an Earth-Moon scale, different Δv ranges, and a 3-day transit rather than 259 days. It is a new screen, not a variation of P03.
Mission sharing format. URL parameters work for simple missions. A complex configured mission (custom vehicle, custom payload, multi-burn trajectory) needs a more compact serialisation — possibly base64-encoded JSON.
Offline gallery strategy. The NASA Images API requires internet. For a fully offline deployment, a curated subset of images (5 per mission, pre-downloaded) could be bundled. This is a content decision, not a technical one — but it requires a policy choice.
Legal review for production. Agency logo nominative use and NASA public domain claims are well-established. CNSA and ISRO imagery is less clearly licensed. A legal review of the credit format before any public launch is recommended.
Data update cadence. Static mission data goes stale when missions launch, land, or are cancelled. A lightweight data update mechanism — even a manually edited JSON file outside the main bundle — would allow mission status updates without a full rebuild.
Orrery · 04 Technical Architecture · April 2026 · Living document← 03 Data Catalog · 05 Design System →