Storytelling
Astrarium’s storytelling stack lets the simulation be scripted as a sequence of axis tweens — voyages cinématiques (#83) that compose camera + scale + time + model + geo-anchor moves to tell a story. Each axis exposes the same imperative API:
setX(value, { duration, curve }) // tween, returns Promise
onXChange(cb) // subscription
This document records the cross-axis conventions — rules every axis honors. Per-axis details live in each axis’s source file and its WI’s design spec.
The five axes
| # | Axis | Module | Issue | State |
|---|---|---|---|---|
| 1 | Camera | src/camera/{brain,vcam,body,aim,storytelling,…}.js |
#80 / #96 | shipped |
| 2 | Scale | src/solar-system/state/scale.js |
#91 | planned |
| 3 | Time | src/solar-system/state/time.js |
#94 | this WI |
| 4 | Model | src/solar-system/state/model.js |
#93 | shipped |
| 5 | Geo-anchor | (TBD) | #95 (depends on #92) | planned |
Cancellation policy
(Locked by the 2026-05-03 storytelling-v1 review.)
- Local-only. A user input on an axis cancels only the in-flight tween on that same axis. No cross-axis propagation. A pause on the time axis does not freeze a camera flight in progress.
- Silent resolution. A cancelled tween’s Promise resolves (does not reject) at the value reached at cancellation. No sentinel object, no rejection reason. Voyage authors’
await setX(...)proceeds as if the tween settled normally. - Setter = cancel. A new same-axis setter call during an active tween cancels the previous one and starts a fresh tween from that point. This is the only automatic cancellation; user input cancels only by going through the setter.
Cinema-protected mode (per-voyage, owned by #83): inputs are gated at the source during a voyage, so the cancellation policy never fires from user actions. The axis modules behave identically; #83’s protection layer just intercepts UI events before they reach the setter.
Date resolution
Voyages can reference dates as either an absolute Julian Date or a relative phenomenological token. The time module exposes:
resolveRelativeJD(token, currentJD) → number
Throws RangeError for unknown tokens or malformed planet ids.
Token allowlist v1
| Category | Tokens |
|---|---|
| Seasons | nextWinterSolstice, nextSummerSolstice, nextSpringEquinox, nextAutumnEquinox |
| Eclipses | nextSolarEclipse, nextLunarEclipse |
| Planet events | nextOpposition:<planet>, nextInferiorConjunction:<planet>, nextSuperiorConjunction:<planet>, nextTransit:<planet> |
<planet> is one of mercury, venus, mars, jupiter, saturn, uranus, neptune (Earth excluded — opposition is geocentric). Tokens dispatch to scanners in src/physics/events.js. Scanner-window failures (no event found within the default search window) also raise RangeError.
Season tokens are hemisphere-aware (Northern Hemisphere convention): nextWinterSolstice resolves to the December solstice, nextSummerSolstice to June, nextSpringEquinox to March, nextAutumnEquinox to September. The internal mapping to the hemisphere-neutral scanner kinds in events.js lives in solar-system-time.js.
Voyage authors compose:
await setTime(resolveRelativeJD('nextSolarEclipse', timeState.jd), {
duration: 6000,
curve: 'easeOutCubic',
});
Cross-axis interactions
TBD — fills in as #91 (scale), #93 (model), #95 (geo-anchor), #96 (camera-storytelling adapter) land. Initial topics:
- Camera follow of a body that becomes invisible (model axis) — vcams expose
disableWhenInvisible(decided 2026-05-03; per-vcam opt-in). - Scale change during a camera framing — scale-axis tween updates the world-units denominator the camera reads each frame; framing transposer auto-adjusts via per-frame target query.
- Time tween during a model swap — model setters are instant for v1; no time/model coupling concerns yet.
- Geo-anchor tween + camera follow — TBD pending #95.
dest-city:*:* vcam namespace (camera × geo-anchor)
The 11 curated cities × 11 modes (above / N / S / E / W / sunset /
sunrise / moonrise / moonset / antisolar / panorama) are registered as
dest-city:<city>:<mode> in src/views/solar-system.js. Voyage authors
compose:
await flyToVcam(brain, 'dest-city:tokyo:sunrise', { duration: 6000 });
Modes sunrise/moonrise/moonset/antisolar/panorama have no UI
button — they are voyage-only. The mode-then-city UI exposes
above / N / S / E / W / sunset.
flyToVcam (in src/camera/storytelling.js) is the Promise-grammar
adapter that voyages await — see docs/camera.md § Storytelling
consumption.
Per-body overrides
(Locked by the 2026-05-04 #91 brainstorm.)
The scale axis introduces the canonical pattern for overriding a body-specific value on top of a global:
setBodyScale(id, { size?, distance?, exaggeration? } | null, { duration?, curve? }?)
Conventions every per-body override should honor:
- Absolute, not multiplicative.
setBodyScale('saturn', { size: 0.8 })sets Saturn’s size to0.8, regardless of what global is. Multiplier semantics make voyage scripts hard to reason about. - Partial-update merge. Two successive calls on different axes merge into one override entry.
nullclears the entry across all three axes — body reverts to global. - One tween per
(body, axis)key. Tweens on different keys coexist; tween on the same key cancels-and-replaces silently (cancellation policy applies per-key). - Hierarchy decisions are case-by-case. Saturn’s rings (child geometry) inherit Saturn’s size override. Jupiter’s moons do not inherit Jupiter’s size override (Io shouldn’t grow when Jupiter does). Document the choice for each parent/child relationship in the axis’s spec.
The model axis (#93) is expected to mirror this with setBodyParam(id, …) — same partial-update, same null-to-clear, same per-key cancellation. Diverging would force voyage authors to learn two override grammars.
Roadmap
The Storytelling v1 milestone (#83) is delivered in four phases — see the milestone description on GitLab for current state. The time axis (#94, this WI) is phase 1 alongside the camera axis (#80, shipped) and the mini-PoC voyage in #83.
← Astrarium