CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Development
npm install # one-time: pulls Vite, Three.js, ESLint, Prettier
npm run dev # Vite dev server with HMR (default: http://localhost:5173)
npm run build # production build into dist/
npm run preview # serve the built dist/ for verification
npm test # node --test test/*.test.js (unit tests, no bundler)
npm run lint # ESLint
npm run format # Prettier (rewrite in place); format:check for CI
Vite handles dev/prod parity: the same module graph the dev server serves with HMR is what gets bundled and tree-shaken into dist/ at build time. Three.js and its addons are resolved from node_modules/three (no CDN). Locale JSONs and texture images are registered via import.meta.glob so they participate in content-hashing.
Tests run on raw Node 20+ — they cover src/physics/ and src/data/, neither of which imports Three or Vite-specific APIs. To run a single test file: node --test test/physics.test.js.
glab is required for the MR and release workflow (glab mr create, glab release create). Install via brew install glab on macOS or from the official releases elsewhere.
Architecture
docs/architecture.mdis the canonical overview: the conceptual pipeline (scientific Kepler model → reprojected scale model → scene → vcams → UI → animation/voyages), the file-tree layout, and the build-level decisions.docs/camera.mdis the camera cookbook;docs/storytelling.mdis the cross-axis tween convention. The section below is the per-module operational catalog those overviews point back to — one line per module, with non-obvious gotchas called out.
Shared modules (src/)
src/data/ — astronomical data, pure & Node-testable:
constants.js— physical/astronomical constants + default sim speeds; all magic numbers live here.planets.js— 8 planets, NASA-validated ({ id, names, color, radiusKm, orbitKm, orbitalPeriodDays, axialTiltRad }+ optionalrings). Gotcha: no view imports it — each inlines its own table (see Design rules).moons.js— 15 major moons keyed by parent planetid. Gotcha: negativeorbitalPeriodDays= retrograde. (Plural — notmoon.js.)moon.js— Meeus §47 lunar perturbation tables backingphysics/moon.js. (Singular — notmoons.js.)satellites.js— hand-pinned Keplerian elements for ISS / Hubble / Tiangong.stars.js— ~8800-star Hipparcos catalog (STARSflat array) +STAR_NAMESIAU-name map.constellations.js— 88 IAU stick figures (CONSTELLATIONS, derivedZODIAC);validateConstellations(hipSet)prunes defensively. Generated byscripts/build-constellations.js.locations.js— 11 curated cities (lat/lon, ISO-3166).populations.js— asteroid-population statistics +samplePopulation(def, rng)(injected seeded rng → deterministic).
src/physics/ — canonical math, no Three / Vite, Node-testable:
formulas.js— Kepler (3rd law + heliocentric solver), galactic rotation, Lorentz, unit converters (AU↔km↔ly). Never inline or duplicate these.events.js— event scanners (oppositions, conjunctions, transits, alignments).moon.js— Meeus lunar model (phase / illumination / position). Accuracy: minutes over decades.satellites.js— Earth-orbit propagator (Kepler + J2, GMST, ECI↔ECEF), Y-up world frame.seasons.js— solar declination + season-name resolver.tides.js— equilibrium-tide model + tidal-acceleration helpers for force arrows.geo.js— Earth-frame ENU geometry + horizon azimuths. Gotcha: NEGATE longitude when placing markers on a ThreeSphereGeometry(UV wraps the opposite way).
src/camera/ — generic Cinemachine-style layer; writes only a duck-typed controls.target:
vcam.js—createVcam({ id, body, aim, up?, fov?, priority, enabled, extensions?, nearReference? })+onActivate/onDeactivatehooks.brain.js—createBrain(...)picks the highest-priority enabled vcam, blends pos/target/fov/up. Gotcha: adaptive near plane (#183) derived from each vcam’snearReference; inactive vcams are never evaluated.body.js— position producers (transposer,framingTransposer,orbitalTransposer,trackedDolly,atCityMarker,localFrameRide, …). Gotcha: offsets take a live number-or-getter so they track the effective radius (#183).localFrameRide(canonical pose during the blend, then ride the anchor’s rotation preserving user drag) replaced the oldsunRelative*family — Sun-relative framing is nowlocalFrameRideon asunRelativeAnchor.aim.js— target producers (composer,hardLookAt,pov,lookHorizon,lookSunset/Sunrise/Moonrise/Moonset,lookAntisolar, …).povis the only stateful one (toggles OrbitControls).up.js— up-axis primitives (upWorldY,upLocalGravity,upPlanetAxis,upRolled) +slerpUnit.anchors.js— synthetic anchors (surfaceAnchor,overheadAnchor,corotatingFrameAnchor,sunRelativeAnchor,orbitalSidelineOffset).target-group.js—createTargetGroupweighted compound target (works withframingTransposer/groupComposer).noise.js/confiner.js— post-write extensions: seeded position perturbation / sphere+box clamps.easings.js— four named curves.storytelling.js—flyToVcamPromise adapter +lockUserInput/unlockUserInput; uses the reserved 9999 priority band.
src/textures/ — loader.js (async photographic-map loader, procedural fallback) + procedural.js (deterministic Canvas2D fallback textures).
i18n — i18n.js (runtime: t, setLang, getLang, objectName, onLangChange, fmtNumber, mountLangToggle, applyLangToLinks, applyPageMeta, ready; resolution ?lang=→localStorage→navigator→fr), i18n-url.js (pure rewriteHrefForLang, split out for Node tests), locales/{fr,en}.json (dot-path translation tables).
src/ui/ — sidebar.{css,js} (shared sidebar base + initSidebar()), view-bootstrap.js (bootstrapView(...) collapses the per-view boot dance), tabs.js / mode-switcher.js (generic mountTabs / mountModeSwitcher), panel.css / solar-system-overlays.css (panel-system / HUD-overlay styles).
src/widgets/ — cross-view UI:
events.js—mountEventsWidgetscanner-backed event picker.info-panel.{js,css}—mountInfoPanel(...)animated container (tweened size/opacity,prefers-reduced-motionaware). Three instances drive the solar-system overlay slots (title/content/subtitle) viapanelCtx.infoSlots. Import the CSS explicitly so the JS stays Node-importable.
src/voyages/ — voyage engine:
schema.js— pure validator with allowlists (curves, selectors, paramPaths, relative tokens). Runner trusts validated input.runner.js— dispatcher (scripted →voyage.run(ctx); composable → walksphases[]). Cinema-protected, AbortSignal end-to-end.index.js— built-in registry; validates every built-in at module-init (fails fast).library.js— localStorage CRUD; quarantines broken entries on read.url-share.js— base64url?voyage=codec (MAX_VOYAGE_URL_LEN≈2000).welcome-overlay.js— boot-time?voyage=modal.narration.js— sharedsetNarrationhelper.scripted/showcase.js— cinematic showcase orchestrator.composable/paris-sunset-simple.json— first built-in composable; doubles as a loader/validator/runner regression check.
src/views/ + roots — views/{solar-system,galaxy,universe}.js (self-mounting per-page modules), populations.js (asteroid-population Three.js renderer, buildPopulations), version.js (single VERSION export).
src/solar-system/ — solar-system.html’s state, scene, vcams, panel:
index.js— public barrel re-exporting the state surface; external consumers import from here, not./state/*.state/time.js— time axis{ jd, signedSpeed };setTime/setTimeSpeed(instant or tweened),resolveRelativeJD,tick(now).state/scale.js— scale axis{ size, distance, exaggeration, lunarOrbit };effectiveMoonRadius/effectiveMoonDistance,getEffectiveScale,KM_PER_UNIT_AT_TRUE_SCALE. Domains: size/distance[0,1], exaggeration/lunarOrbit[0,2];lunarOrbitis decoupled fromdistance. Every geometric consumer reads these live (scale-axis discipline).state/model.js— model axis: faceted node/aspect tree + a small selector grammar (#186).registerNode,setVisible/show/hide/showOnly/isolate/releaseIsolate/defineSelection,alphaOf/isVisible,definedSelectionNames,resolvePaths(debug resolver),nodesByTag(tag index, single source of truth), plus the unchanged param sub-system (setBodyParam/registerAnimatable, registry-derivedPARAM_ALLOWLIST). Multiplicative alpha cascade (own × ancestors × isolate filters, planet→moon via:). Alpha tweens are batched: onevisibilityevent per frame per direction (not per path), no-op tweens (already at target) short-circuit, and a tweenedsetVisible’s promise resolves once every path it started has settled or been superseded. A starter set of named selections (@labels,@orbits,@aids,@satellites, …) replaces the old 24 layers; keep it in sync withvoyages/schema.jsSELECTION_ALLOWLIST(guarded by a parity test) andblocks/model-inspector-block.js.state/mode.js— mode{ eleve, dev, createur }+ localStorage +?mode=.scene/body-refs.js— the spine: registry every mounter registers into viaregister(path, { kind, tags, aspects })(→registerNode); bridges the faceted model to Three objects by walking each aspect’s subtree: every nested material (arrays included) gets its authored opacity scaled (never replaced) and its authoredtransparent: truepreserved, and every nested CSS2DObject gets its OWN.visible+ element opacity driven (CSS2DRenderer ignores an ancestor Group’s.visible— no per-mounter observer needed). Subtrees that are sibling aspects of the same node are skipped (their own alpha drives them). Aspects whose.visible/opacity a runtime drives per frame opt out viauserData.externalVisibility = trueon the root — the bridge skips the whole subtree and the runtime composes<its gate> && isVisible(path)itself (planet markers in animate(), cardinal rose in its tick).register,patchRefs,getBodiesByTag(delegates to the model’s tag index),applyPaths/applyAll.scene/planets.js—mountPlanets: spheres, parent groups, orbit rings, markers, labels, trails; Earth specials viaonEarthMesh.scene/earth.js—mountEarthFeatures: tilt group, equator/tropics, polar caps, city markers, tide water, force arrows.scene/moons.js—mountMoonSystem: per-planet moon group (follows translation, not rotation).scene/sun.js—mountSun: mesh + corona sprite + scene lighting.scene/satellites.js—mountSatellites: ISS/Hubble/Tiangong glyph + orbit ring + trail + ground track.scene/stars.js—mountStars: Hipparcos starfield atSTAR_SPHERE_RADIUS = 6000(exported) + named-star labels.scene/constellations.js—mountConstellationLines: 88 figures on the star sphere; progressive trace;selectOne/clearSelection.scene/cardinal-rose.js— N/E/S/W horizon labels for city ground vcams (N tinted red). Faceted asearth/cardinals(externally composed: the city-vcam gate stays master, the facet ANDs in).scene/motion-blur.js—attachMotionBlurlongitude-smear material patch +motionBlurUgate.scene/trails.js—createTrailfactory (shared planet+moon trail shader,uTailFractail-fade uniform) +PLANET_TRAIL_LEN. Moon-trail resolution/loops are local toscene/moons.js.vcams/destinations.js— free-orbit + Sun + 8 planets + Moon + per-moon + seasons/tides;activate(id)raises priority. Bodies (localFrameRideon asunRelativeAnchor) read scale live and re-snap via a debouncedbrain.reactivateononScaleChange; only the sun-visible toggle rebuilds bodies.destination-handlers.js—DEST_HANDLERSregistry (destination id →onSelect/onLeave) consulted byblocks/destinations-block.js.onSelectownsctx.activateand runs per-button effects (titles, body visibility, clock jumps); fires on panel click only (notbrain.onActiveChange); optionalonLeaveundoes effects when switching destinations. Pure, Node-testable. Holds the migrated satellite sim-speed + Montréal sunset effects.vcams/cities.js— 121 per-city vcams (11 cities × 11 modes).vcams/satellites.js—dest-iss/dest-hubble/dest-tiangong;onActivateflips thesatelliteslayer on (sticky).vcams/constellations.js— one vcam per figure, parked outside planet space toward the centroid (no Sun in frame).vcams/follow-modes.js— spin-locked / pov-surface / inertial-sideline / tidally-locked frame demos.panel/index.js— orchestrator: mounts the mode switcher + active mode’s tabs into#panel-root; wirespanelCtx.panel/eleve.js— Histoires + Explorer tabs (layer-gated constellation picker).panel/dev.js— placeholder.panel/createur.js— voyage editor + library CRUD.blocks/—mount<Name>Block(target, ctx) → teardown; each builds one.section-block, subscribes to its axis, refreshes ononLangChange, tears down idempotently. (date, destinations, display, events, time, scale [dev] / scale-simple [élève], vcam-tree, model-inspector [dev], info-panel-tester [dev], voyage-editor/library/list, constellations-picker + the pureconstellation-searchhelper.)timeline/— full-width strip + pure helpers (jdToFraction,fractionToJD,zoomRange,eventsInRange).dev/—log.js(ring buffer for the Dev Log tab) +vcam-tree.js(buildVcamTreegrouping helper).readouts.js—mountReadouts: per-frame HUD overlays gated by the active vcam; light-time/Voyager readouts gated on the true-scale endpoint.
Design rules
-
All physics formulas go in
src/physics/formulas.js. Never duplicate or inline them. -
All astronomical data is defined in
src/data/. Today, no view module imports fromsrc/data/planets.js— each maintains its own inlined table with a different shape, all for deliberate visual reasons. Seedocs/scales.mdfor the per-view rationale.View module Inlined table Why it diverges from src/data/planets.jssrc/views/solar-system.jsPLANETSHeavily compressed visual-scale (display radii, distances) for aesthetics. src/views/galaxy.jsPLANETSFrench-named, visually-scaled inner solar system embedded inside the galaxy view; not real values. New planet/moon facts (a constant, an attribute, a moon) belong in
src/data/. New views may inline a table if they need a different shape — but document the divergence here. -
Each view is authoritative for its own Three.js scene setup, UI controls, and animation loop.
-
UI follows a consistent pattern: time-speed slider, pause/reset buttons, label/orbit/trail toggles.
-
Scale-axis discipline (#183). Every geometric value the scene consumes — a mesh scale, a vcam camera offset, a layer’s geometry, a particle position — must read the live effective value through
effectiveRadius(p)/effectiveDistance(p)(view) oreffectiveMoonRadius(md)/effectiveMoonDistance(md)/getEffectiveScale(id)(state/scale.js). Never capturep.radius/p.distance/earthRadius/moonMesh.geometry.parameters.radiusinto a closure and reuse it as a constant — that’s the bug class behind #173 and #175. Three sub-patterns cover every case: (1) read live at consumption time (per-frame body components take a number-or-getter — e.g.atCityMarker, satellite chasekmToWorld()); (2) debouncedbrain.reactivate(activeId)ononScaleChangeso a body that reads scale live (e.g.localFrameRide’sgetScale) re-snaps to its canonical pose at the new scale without thrashing the blend on every frame of a slider drag or voyage tween (seevcams/destinations.js#onScaleChanged) — a mode toggle that swaps a baked offset config rebuilds the affected bodies before reactivating; (3) unit geometry + per-framemesh.scalefor layers whose vertex data depends on the scaled radius and that aren’t parented to the scaled body group (the tide water layer is built at unit radius and scaled each frame from the live effective Earth radius). The camera near plane is the same kind of scale-dependent quantity: it’s managed adaptively by the brain from each vcam’snearReference— never hardcode a fixed near (the old per-vcam0.0001is gone), or a body vanishes into the near plane as it shrinks. The camera/scene always reads the scale axis — a vcam must never write it (nosetBodyScalefrom anonActivatehook); exaggeration stays an explicit user/voyage choice. -
The v1 phase grammar’s allowlists (curves, selectors, paramPaths, relative tokens) live in
src/voyages/schema.jsand should be kept in sync with the runtime modules they mirror; expanding the runtime allowlists is a separate WI from expanding the schema.
For adding a new view, see the step-by-step in docs/architecture.md.
Workflow
See CONTRIBUTING.md for the contributor workflow:
git flow, issue labels, commit-message style, docs-match-code rule,
release process, deployment trigger. Follow it exactly — the rules
about self-assigning, opening the draft MR before writing code, and
treating ready MRs as frozen are the ones most likely to bite if
skipped.
Language note
UI strings are in French. Code, comments, and documentation are in English.
← Astrarium