This document describes how the current code actually works. It is the reference a developer or agent should read before modifying the application.
Scope of ownership:
- This document owns current implementation truth: entry points, control flow, module responsibilities, and the concrete behaviour of each subsystem.
ARCHITECTURE.mdowns the durable boundaries and invariants that this implementation is required to respect. Where an invariant is relevant here, it is linked rather than restated.docs/PROJECT_STRATEGY.mdowns product intent.docs/STATE.mdowns current development state. This document deliberately contains no status, phase, or scheduling information.
Everything below was established by reading the source at the time of writing. Where the code contains a transitional or surprising arrangement, that is described as it is, not as it ought to be.
Libration is a single-page browser application. The stack is React 19, TypeScript, and Vite 7. Rendering is done with the Canvas 2D API onto one full-window canvas element. Persistence is browser localStorage.
npm install # once
npm run dev # Vite dev server, http://localhost:1420
npm test # Vitest, run mode
npm run build # tsc && vite buildThe dev server port is fixed at 1420 with strictPort: true in vite.config.ts, because a Tauri desktop shell expects a known port.
The repository contains a configured Tauri 2 shell in src-tauri/ (Cargo manifest, tauri.conf.json, capabilities, icons, build script), and @tauri-apps/api plus @tauri-apps/plugin-opener are declared as dependencies.
No file under src/ imports anything from @tauri-apps. The application does not call Tauri APIs, does not use a Tauri-provided filesystem or HTTP path, and stores its state in browser localStorage. Network acquisition uses fetch.
The accurate statement is therefore: the shell exists and is configured for desktop packaging, but it is not load-bearing in the current application architecture. The application behaves identically whether loaded through Vite in a browser or through the Tauri webview. Whether the shell should become load-bearing — for filesystem-backed caching, native menus, or packaged distribution — is an open product question, not a settled one. See ADR 0006.
The npm package name is libration. index.html and tauri.conf.json (productName and window title) identify the application as Libration. The Tauri Rust crate name (tauri-app) and bundle identifier (com.user.tauri-app) remain scaffold leftovers; they are not architectural signals.
The application is usable with no network. All base-map rasters, the emissive night-lights raster, and the font assets are bundled and served from public/. Clouds, earthquakes, and ISS hide when their live sources are unavailable; they never present fixture data as live. DEV ?scenario=clouds may paint a labeled fixture. Nothing in the render path requires the network.
index.html loads src/main.tsx, which mounts <App /> into #root.
src/App.tsx is the application shell. On first render it establishes several refs that hold the authoritative runtime state outside React's render cycle:
| Ref | Holds |
|---|---|
workingV2Ref |
The normalized LibrationConfigV2 document — the authoritative persisted configuration. |
derivedAppConfigRef |
The runtime AppConfig derived from it via v2ToAppConfig. |
registryRef |
The LayerRegistry built from the derived config. |
canvasRef |
The single canvas element. |
demoPlaybackRef |
Demo-time playback state (transport position, pause flag). |
dynamicLifecycleHostRef |
The process-local dynamic data lifecycle host. |
productInstantMsRef |
The most recent canonical product instant. |
Configuration is seeded by resolveStartupWorkingV2(storage, buildFallback) in src/config/v2/workingV2Persistence.ts:
- If no
Storageis available, normalize and use the fallback document. - Otherwise attempt
loadPersistedWorkingV2(storage), which reads, parses, and validates the persisted document. - If loading yields nothing (absent, unparseable, or invalid), normalize and use the fallback.
Normalization is unconditional on both paths, so the working document is always in canonical form before anything else reads it.
In the Vite development server only (import.meta.env.DEV), src/main.tsx may apply ?scenario=<id> before mounting <App />. Detection is centralized in src/dev/visualScenarios.ts. The registry builds a normalized LibrationConfigV2 from defaultLibrationConfigV2() plus named overrides, with demo time enabled at a documented UTC instant and live dynamic feeds forced off. iss-presentation is the exception that turns orbitalTracks back on and installs a process-local prepared ISS view (recorded TLE, in-process SGP4, no network) so Space objects presentation controls can be inspected. Production ISS provenance is unchanged: the hatch never runs outside that DEV scenario, and fixture-as-live remains suppressed. earthquake-presentation turns Earthquakes on and installs a process-local recorded USGS-shaped point view (fixture origin, never labeled live) so Layers → Earthquakes filters and hover labels can be inspected without USGS. clouds turns Clouds on and installs a process-local IR-derived PNG (fixture origin, status Clouds (DEV fixture)) so Layers → Weather can be inspected without GIBS. planetary-objects turns the Planets master on with all eight bodies plus Pluto enabled at a frozen UTC so Space objects planetary glyphs, tracks, and loci can be inspected; it uses the bundled offline ephemeris, not a live feed. milky-way turns the Milky Way master on at a frozen UTC so the Galactic-plane zenith ribbon, approximate band, ribs, Galactic center, and Galactic-center altitude contours can be inspected; it uses offline IAU Galactic geometry, not a star field or live catalog.
src/App.tsx (the shell, not the renderer) then:
- Passes
nullstorage intoresolveStartupWorkingV2so persisted user configuration cannot contaminate the fixture. - Seeds
demoPlaybackRefwithcreatePausedDemoPlaybackState, so the product instant is the scenario UTC and stays frozen across reloads. - Shows a small HTML banner with the scenario id and UTC (or a visible unknown-id error). Banner CSS is imported from the same DEV-only module. The Canvas backend, layers, and
RenderPlando not see the query string.
While a scenario is applied, persistWorkingV2 is a no-op (setWorkingV2PersistenceSuppressed), so fixture edits do not overwrite libration.workingConfigV2.v1. Unknown ids do not suppress persistence and do not substitute another scenario.
A DEV scenario may also install a process-local extra overlay builder (setVisualScenarioExtraOverlayBuilder in src/dev/visualScenarioRuntime.ts). The shell appends an upstream-resolved resolvedRenderPlan vector layer (drawn below the sublunar marker) when a builder is present. Production never installs a builder. lunar-locus enables the production Lunar locus scene row rather than that extra-overlay path. moon-libration uses the production Moon glyph (no extra overlay). twilight-presentation freezes full-world solar shading at a documented Knoxville UTC (twilightCase=a|b|c); optional DEV nightVeilCurve= selects a diagnostic night-veil transfer without persisting it.
Production builds never import the registry (the dynamic import sits inside the DEV branch) and ignore ?scenario=. Procedure: docs/VISUAL_VERIFICATION.md.
The layer registry is then built by createLayerRegistryFromConfig (src/app/bootstrap.ts), which asks planSceneStackComposition(config.scene) for the resolved base-map part and ordered overlay parts, registers the base-map layer, and registers one layer per enabled overlay instance through createLayerForSceneOverlayInstance. Layers do not decide their own stacking; composition order, opacity, and zIndex come from the scene plan.
Two startup effects then run:
syncDynamicLifecycleConsumers()— arms dynamic-data acquisition for whatever the persisted configuration already had enabled, so a saved session resumes without requiring the user to toggle anything. The same helper runs after everyupdateConfigcommit. If the lifecycle host has beendispose()d (the canvas render effect cleans it up; React StrictMode remounts that effect in development),reviveDisposedDynamicLifecycleHostreplaces it before re-arming.ensure*on a disposed host is a no-op.- The render effect, which constructs the backend, waits for
backend.initialize(viewport), and only then starts the animation-frame loop. On setup it also callssyncDynamicLifecycleConsumers()so a StrictMode remount re-arms from current config.
The whole frame lives in renderFrame inside a single useEffect in App.tsx. It is driven by runAnimationFrameLoop (src/app/renderLoop.ts), and is additionally invoked on resize and whenever the backend reports that a deferred resource (such as a decoded image) became available.
The sequence is:
1. Resolve the canonical instant.
realNowMs = Date.now(). If demo time is active, pending transport actions (reset, resume) are applied, computeEffectiveRenderTimeMs produces the simulated instant, and a pending pause is applied afterwards. The frame then commits to exactly one value:
const clockNowMs = demoActive ? effectiveNowMs : realNowMs;
productInstantMsRef.current = clockNowMs;Everything downstream in the frame uses clockNowMs. There is no second clock. deltaMs is derived from the previous frame's clock value and is clamped to be non-negative; it resets to zero when demo mode is toggled, so a mode change cannot inject a spurious jump.
2. Compute the overlay-readability frame.
The shell resolves the effective base-map presentation for the current base map (resolveEffectiveBaseMapPresentation against the catalog entry) and the catalog's capabilities hint, then calls computeOverlayReadabilityFrameFromTimeMs with the instant, the emissive night-lights policy, those substrate inputs, and the scene's readability presentation. This produces one OverlayReadabilityFrame per frame that all participating layers share instead of each recomputing solar samples.
3. Attach the dynamic-data view.
dynamicLifecycleHostRef.current.attachForProductInstant(clockNowMs) produces a read-only attachment: resolve-by-source-id and prepared-view accessors bound to the product instant. It performs store reads only. It never fetches.
4. Build TimeContext.
createTimeContext(clockNowMs, deltaMs, simulated, { overlayReadabilityFrame, dynamicDataLifecycle }). This object is the single carrier of per-frame product time and per-frame derived context into the layer system.
5. Evaluate layers.
registry.update(time) advances every registered layer, then buildRenderableLayerStates(registry, time) collects time-resolved layer states in composition order.
6. Build chrome state.
buildDisplayChromeState({ time, viewport, frame, displayTime, geography, displayChromeLayout }) computes the complete screen-space chrome layout, including chromeState.topBand.height.
7. Build scene input and render the scene.
buildSceneRenderInput({ frame, viewport, layers, scene, topChromeReservedHeightPx: chromeState.topBand.height }) — note that the reserved chrome height is an input to the scene viewport, not something the scene discovers afterwards. backend.render(input) then executes the scene.
8. Render chrome over the same canvas.
renderDisplayChrome(ctx2d, chromeState, viewport) draws directly on the same 2D context.
There is one <canvas>. The frame paints it twice: the backend paints the scene into the region below the reserved top band, and chrome is then painted in screen space over the whole surface. This is the concrete expression of the chrome/scene separation.
The ordering constraint is not stylistic. Chrome must produce its height before the scene viewport is computed, because the scene's usable rectangle is full viewport minus reserved top height (sceneLayerViewportRectPx in src/renderer/sceneViewportLayout.ts). Reversing that order would make the map's vertical extent depend on content that has not been measured yet. See the chrome invariants in ARCHITECTURE.md.
Config + Time + Assets
→ Resolvers (turn persisted config into effective values)
→ Semantic planning (what the instrument means: markers, ticks, labels)
→ Layout (where things go, in CSS pixels)
→ Realization adapters (how a semantic thing becomes drawable)
→ RenderPlan (backend-neutral primitives)
→ Executor (walks the plan)
→ Canvas backend (issues Canvas 2D calls)
RenderPlan (src/renderer/renderPlan/renderPlanTypes.ts) is the hard boundary between product meaning and drawing. It is a flat list:
export interface RenderPlan {
/** Drawn in array order (painter's algorithm). */
items: RenderPlanItem[];
}There are nine primitive kinds:
| Kind | Purpose |
|---|---|
text |
Straight text with a resolved font descriptor |
curvedText |
Text along a path |
rect |
Filled or stroked rectangle |
line |
Straight segment |
path2d |
Descriptor-backed or backend-native path |
linearGradientRect |
Linear gradient fill in a rect |
radialGradientFill |
Radial gradient fill |
rasterPatch |
Direct RGBA pixel data (used by planetary illumination) |
imageBlit |
A decoded image drawn into a destination rect, optionally with a CSS filter string |
Draw order is array order. There is no z-sorting inside the executor and no compositor. Anything that needs to be beneath something else must be emitted earlier. Layer ordering is resolved upstream by the scene composition planner, which is why the executor can be this simple.
Plan builders live in src/renderer/renderPlan/, one per product concern (sceneBaseRasterMapPlan, sceneCityPinsPlan, sceneSolarShadingIlluminationPlan, topBandTickRailPlan, timezoneLetterRowPlan, and so on).
CanvasRenderBackend (src/renderer/canvasRenderBackend.ts) executes plans through canvasRenderPlanExecutor and a set of narrow bridges in src/renderer/canvas/:
canvasTextFontBridge— resolves a font descriptor to a Canvas font string.canvasPaintBridge— fills and strokes.canvasPathBridge— path construction.bundledFontFaceLoader/bundledFontCanvasFamily— font registration.canvasGammaRasterCache— an offscreen cache keyed by image URL, natural pixel dimensions, and effective gamma, so a gamma-corrected base map is recomputed only when one of those changes.
The backend is mechanical. It decodes images, registers fonts, manages surfaces and caches, and issues draw calls. It does not read SceneConfig, does not know which base-map family is active, does not implement month-aware resolution, and does not interpret illumination modes. Its one upstream-facing signal is resource failure reporting — it can report that an image URL failed to load (addEquirectBaseMapImageLoadFailure), but the decision about what to do instead belongs upstream.
LoggingRenderBackend (src/renderer/loggingRenderBackend.ts) implements the same interface and records plans instead of drawing, which is what makes plan-level testing possible without a canvas.
This section describes the single most misleading part of the codebase. Read it before touching src/renderer/displayChrome.ts (roughly 1,900 lines).
The top band renders several horizontal rows that look like they belong to one ruler. They do not. Two different coordinate models are interleaved, deliberately.
The 24 structural columns are pure geography. Column h spans longitude -180 + 15h to -180 + 15(h+1), converted to x by mapXFromLongitudeDeg. They never move. They are the same grid the equirectangular map uses, which is why the NATO structural-zone letter row lines up with the map beneath it.
Helpers live in src/renderer/structuralLongitudeGrid.ts (LON_PER_UTC_STRUCTURAL_HOUR = 360 / 24, column index from longitude, column-center longitude). UtcTopScaleHourSegment carries this geometry.
The label on a structural column is a meridian-offset grid hour (UTC day plus lon/15, wrapped). It is a structural overlay label. It is not the reference civil clock.
The circular hour markers and the tick rail are not on the structural grid. They slide continuously with civil time. Their x comes from topBandHourMarkerCenterX(...), which is a function of:
- the civil fractional hour-of-day in the reference IANA zone (
referenceFractionalHourOfDay, derived viaderiveCivilProjection), and - an anchor fraction from
resolveTapeAnchorFraction(readPoint, width), which registers the tape against the resolved read-point meridian.
TopBandLongitudeAnchor holds the resolved reference meridian and its exact x on the strip. That x uses the same mapXFromLongitudeDeg as map pins, so the read-point indicator sits at the same place a pin at that longitude would. A selected reference city contributes longitude for spatial registration only; the civil time itself comes from the IANA zone.
Tick geometry uses the same phased formula with fractional hour arguments. The intra-hour cadence is three majors per hour (at 1/4, 1/2, 3/4) and two minors per quarter (at 1/3 and 2/3 along each quarter), giving eight minors per hour.
Structural longitude sectors and civil timezone membership are intentionally decoupled. Civil offsets are not multiples of 15°, political zones do not follow meridians, and the product's thesis is that longitude — not political zoning — is the structural basis of the display. Snapping the phased tape onto the structural columns would destroy the civil reading; anchoring the structural row to civil time would destroy the map registration.
The source says so explicitly in the doc comment on UtcTopScaleCircleMarker:
centerXis not tied toUtcTopScaleHourSegment.centerX; it follows the time-phased band anchored in longitude.
If you find yourself writing a helper that returns "the x for hour h", stop and decide which model you are in.
Because the phased tape moves continuously and the strip is periodic with period widthPx, markers near the edges must be drawn more than once. topBandWrapOffsetsForCenteredExtent(centerX, halfExtent, widthPx) returns the integer offsets k such that centerX + k·widthPx is visible. Any new top-band content wide enough to straddle the seam needs the same treatment, or it will visibly pop at the antimeridian.
The top band stacks three rows, top to bottom: the circle band (dual-hour stack, disks, annotations), the tick rail, and the timezone letter row. Their heights sum to the band height (UtcTopScaleRowMetrics, TopBandLayout).
The circle band height is computed by a fixed-point solve, solveCanonicalHourMarkerDiskBandHeightPx. Intrinsic content height (text ink metrics or glyph head geometry) determines the marker radius, which in turn affects the measured intrinsic height, so the solver iterates to convergence. Two properties are load-bearing and are called out in the source:
- The seed intrinsic height must not be the disk-strip height from the circle stack, or the fixed point becomes self-referential.
- The loop must converge on intrinsic content height only. Terminating on row height would let padding change the iteration count and return different intrinsics near ~1px thresholds — a bug that previously existed.
Marker scale is driven only by the converged intrinsic height. Row height is intrinsic plus resolved padding, and "Auto" padding is proportional to intrinsic height so the row tracks content when size or font changes.
TopBandTimeMode (local 12-hour, local 24-hour, UTC-style) affects numerals and the crown annotation (noon / midnight wording in 12-hour mode, numeric 00 / 12 in 24-hour civil mode, nothing in UTC-style mode). It does not affect geometry. referenceFractionalHourOfDay is documented as unaffected by display mode, and tape positions come from deriveCivilProjection regardless of mode. This is the chrome-level expression of the time invariant in ARCHITECTURE.md.
SceneConfig.viewMode is persisted and currently only fullWorldFixed. projectionId is equirectangular. Plan builders map canonical lon/lat through the scene/map reference frame, then mapXFromLongitudeDeg / matching latitude helpers in src/core/equirectangularProjection.ts (scene-frame coordinates onto the identity full-world strip), and emit marker radii and stroke widths in CSS pixels from the scene viewport size, not a zoom-expanded world width.
A runtime scene camera (src/core/sceneCamera.ts) then maps that identity strip into the scene CSS rect: uniform scale (clamped 1…8) about a normalized projected centre (centerU, centerV). Identity is exactly scale = 1, centerU = 0.5, centerV = 0.5 — not merely scale === 1 — and is the default scene-frame view for Earth-fixed and longitude-lock (bit-for-bit the 2.0.0 full-world view when the frame is Earth-fixed). Anchored position-lock uses a runtime automatic cover policy: the minimum scale such that the origin-centred vertical window lies inside the translated Earth extent (minimumScaleToCoverSceneFrameEarth; 1 / (1 − |anchorLat| / 90) under the current camera model). That policy is not a frame transform and does not write the anchor into centerV. Manual wheel zoom sets an explicit manual override so later latitude ticks do not rewrite scale; Reset view and entering a position-lock kind re-arm auto. centerU is continuous and unwrapped (horizontal world is periodic). centerV is clamped so the viewport stays inside the active frame’s scene-frame Earth extent; at scale 1 that forces centerV = 0.5 even if position-lock has translated Earth so it no longer fills the strip. The camera is attached to SceneRenderInput each frame (src/app/renderBridge.ts) and applied in plan builders (src/renderer/canvasRenderBackend.ts); the Canvas backend still draws CSS primitives without ctx.scale(). Camera state and cover policy are shell runtime refs — not persisted, not viewMode, not URL state.
Wheel zoom applies only when the pointer is in the scene strip (canvasClientPointToSceneCss is null over reserved top chrome). Zoom is pointer-stable, including after pan and when centerU is outside [0,1]. Pointer drag pan (threshold 4 CSS px) starts on the scene strip, not top chrome or DOM overlays; geography follows the pointer; pointer capture continues a trusted drag if the pointer briefly leaves the canvas. Horizontal pan at scale 1 is allowed. A Reset view control restores the frame default camera: identity for Earth-fixed and longitude-lock; the current automatic cover camera for position-lock (which may have scale > 1). The button is disabled at that default, including auto-cover with scale > 1. After manual zoom, Reset is enabled; Reset re-arms auto-cover in position-lock. touch-action: none on the scene canvas lets Pointer Events drive single-finger pan; pinch zoom is not implemented.
Plan builders project canonical lon/lat through the scene/map reference frame, then onto the identity strip, then emit viewport-intersecting horizontal world copies via sceneCameraHorizontalWorldCopyOffsets (rasters: dest-intersection, slop 0; seam-unwrapped vectors: 5% width slop; cap 4). Canonical entity lon/lat is unchanged; extra copies are display instances. Inverse camera + inverse projection + inverse frame mapping plus those copies hit-test wrapped earthquake markers to the canonical id. Chrome uses the same full-width longitude basis and is painted after the scene; it is not camera-transformed. Structural meridians register with the map only at identity camera in Earth-fixed view.
The scene/map reference frame (src/core/sceneReferenceFrame.ts) is a transform before projection, carried on SceneRenderInput.sceneReferenceFrame. Production kinds: Earth-fixed identity (default; exact short-circuit so LIB-081 numbers are unchanged) and anchored (kind: "anchored", target: TrackableMapObjectId, lockMode: "longitude" | "position"). TrackableMapObjectId is "moon" | "sun" | "iss" or a structured city/planet/Milky Way point id ({ kind: "city", id } / { kind: "planet", id } / { kind: "milkyWayPoint", id: "galacticCenter" | "galacticAnticenter" }; LIB-092, ADR 0036, LIB-093, ADR 0037). There is no synthetic "milkyWay" target. Moon/Sun/ISS longitude-lock and position-lock are configurations of that one anchored type (LIB-086, ADR 0030, LIB-088, ADR 0032, LIB-089, ADR 0033, LIB-090, ADR 0034). Runtime only — not persisted, not viewMode, not URL state, and not civil-time reference. User-facing tracking is Tracking target + Tracking mode (src/core/trackingSelection.ts): Earth-fixed is no target; mode is a runtime preference retained across target switches. Combined UI kind strings remain compatibility aliases in src/core/sceneFrameAnchor.ts. SceneCamera is unchanged (scale, centerU, centerV). Anchored frames do not write the Moon, Sun, or ISS into centerU / centerV. The continuous longitude anchor follows src/core/longitudeContinuity.ts after target resolution (src/core/trackableMapObject.ts: Moon from existing sublunarPoint, Sun from existing subsolarPoint, ISS from the existing lifecycle current sample via src/lifecycle/issAuthoritativePosition.ts; cities from the visible city-pins payload; eligible planets from that payload’s current mapped subpoint; Galactic Center and Anticenter from the same Milky Way geometry payload used to paint those glyphs; same nearest-equivalent policy; continuity is value-based and tracking-session-local, not target-typed). Changing target reinitializes continuous longitude; a mode-only switch on the same target preserves it. Latitude has no continuity state: it is the resolved target latitude supplied into the frame from App.tsx. Horizontal camera wrapping is not longitude continuity. Sign convention: sceneLon = nearestEquivalent(canonicalLon, λAnchor_continuous) − λAnchor_continuous; positive scene longitude is east of the anchor. Longitude-lock leaves sceneLat = canonicalLat. Position-lock uses sceneLat = canonicalLat − anchorLat. Scene-frame latitude is not periodic and may leave geographic ±90°; projection maps it linearly. Inverse latitude adds the anchor and clamps only when producing canonical geographic latitude. Sun longitude-lock’s scene origin is the current subsolar meridian, not civil clock noon. ISS tracking is geographic sub-satellite-point anchoring, not heading lock. Transform, raster dest, camera vertical extent, and automatic cover branch on Earth-fixed vs anchored and on lockMode, not on target identity.
Earth-fixed geographic rasters keep their LIB-081 dest mapping. Under longitude-lock the same full-world equirectangular strip is shifted by −λAnchor_continuous / 360 × width and copied with the existing periodic dest machinery so base map, illumination, and Clouds stay registered. Position-lock adds a vertical dest shift of −anchorLat / 180 × height with no vertical copies: the translated Earth is clipped, not wrapped. Illumination samples remain canonical geographic/time physics; only the dest moves with the frame. Tiles and raster reprojection are not part of this layer.
Shared mapping: sceneXFromLongitudeDeg / sceneYFromLatitudeDeg compose frame → projection → camera (optional frame argument defaults to Earth-fixed). The Canvas backend threads the live frame into plan builders. Compact Target and Mode controls sit with Reset view (Target is a native <select> with Earth-fixed ungrouped plus Celestial / Spacecraft / Cities optgroups; Mode is Longitude / Position). Celestial is Moon, Sun, eligible planets, then Galactic Center and Galactic Anticenter when those tagged glyphs are rendered. City targets reuse CityPinEntry.id and the same static pin lon/lat used to paint (visible city-pins payload: nine reference cities plus any custom pins). Eligible planetary targets are Mercury–Neptune plus Pluto when that body's current mapped glyph is painted (showCurrent and a finite current subpoint); Earth is the map, not a target. Planets without a current glyph are omitted, not listed disabled. Galactic Center and Anticenter reuse geometry.galacticCenter / geometry.galacticAnticenter from the visible Milky Way payload (dynamic Earth-relative zenith subpoints at TimeContext.now); factory Center on, Anticenter off. A missing/disabled galactic point is omitted, not listed disabled. If a selected galactic point becomes unavailable, the shell returns to Earth-fixed, keeps remembered mode, and reinitializes camera. Mode is disabled when the target is Earth-fixed and keeps the remembered value. Switching target or effective mode reinitializes camera policy (identity, or automatic cover on the destination position-lock frame) and does not carry a manual zoom override; Reset view resets the camera only and does not change target or mode. Reload returns to Earth-fixed with remembered mode default position. ISS is listed and disabled when no authoritative ISS position exists for the active scene instant (same validity as ISS overlay paint: live/cached-live TLE that is fresh or degraded; never fixture; never excessively stale). If ISS tracking is active and that position becomes unavailable, the shell returns to Earth-fixed, keeps remembered mode, and reinitializes camera. Clicking a visible Moon, Sun, ISS, city-pin, current-planet, Galactic Center, or Galactic Anticenter glyph copy sets Tracking target through setTrackingTarget (src/core/trackingSelection.ts) using scene-space hit targets collected from the same glyph geometry as paint (src/core/trackableMapObjectHit.ts, src/renderer/trackableMapObjectHitTargets.ts, ADR 0035, ADR 0036, ADR 0037). Hit radius is max(paintedRadius + 3px, 8px). Wrapped copies share one TrackableMapObjectId. Remembered mode is retained. Empty geography does not clear tracking. Same-target click is a no-op. A pan that crosses the existing 4px drag threshold does not select. Earthquakes remain hover-only. The galactic-plane band is not click-to-track. Architecture: docs/specs/scene/camera-and-reference-frame.md, ADR 0026, ADR 0027, ADR 0028, ADR 0029, ADR 0030, ADR 0031, ADR 0032, ADR 0033, ADR 0034, ADR 0035, ADR 0036, ADR 0037.
A layer (src/layers/types.ts) declares a LayerType — one of raster, vector, points, tracks, heatmap, text, illumination — and produces a time-resolved state from a TimeContext. RenderPlan builders convert that state into primitives.
Layer type matters at the backend dispatch seam: the Canvas backend routes by type, and a type with no dispatch arm draws nothing silently. (This is not hypothetical; tracks was previously folded under points and the ISS ground track did not paint.)
LayerRegistry (src/layers/LayerRegistry.ts) holds registered layers and drives update(time). createLayerForSceneOverlayInstance (src/layers/sceneOverlayLayerFactory.ts) maps a SceneLayerInstance to a concrete layer. planSceneStackComposition (src/config/sceneStackComposition.ts) resolves the ordered stack.
The registry is rebuilt, not mutated, when composition-relevant configuration changes. See §7.
SCENE_STACK_LAYER_IDS in src/config/v2/sceneConfig.ts defines the sixteen known overlay ids, in canonical order:
solarShading, grid, staticEquirectOverlay, globalCloudsIr, milkyWay, solarEclipse, lunarEclipse,
earthquakes, orbitalTracks, planetaryObjects, cityPins, subsolarMarker, lunarGroundTrack,
lunarLocus, sublunarMarker, solarAnalemma
The base map is separate; it is the foundational part of the composition, not an entry in this list.
| Layer | Module | Notes |
|---|---|---|
| Base map | baseMapLayer.ts |
Resolves the family id to a concrete raster; carries effective presentation. |
| Solar shading / illumination | solarShadingLayer.ts, solarShadingPayload.ts |
Emits the single planetary illumination rasterPatch. During an active solar eclipse, a geographic daylight-transmission field from local disc obscuration is composed into the same raster (ADR 0012). |
| Lat/lon grid | latLonGridLayer.ts, equirectGridPayload.ts |
|
| City pins | cityPinsLayer.ts, cityPinsPayload.ts |
Carries per-pin readability veil. |
| Subsolar / sublunar markers | subsolarMarkerLayer.ts, sublunarMarkerLayer.ts |
The Moon glyph is a symbolic map marker, not an angular-scale Moon. Optical libration (Meeus ch. 53, no physical libration) is computed in lunarOpticalLibration.ts from the same truncated lunar series as sublunarPoint. Payload fields librationLongitudeDeg / librationLatitudeDeg plus appearance drive a displaced internal ring (default) or crosshair. Map-oriented presentation keeps longitude east = right and latitude north = up. Observer-oriented (default) rotates that displacement — and the crosshair axes — by χ = C − q (Meeus lunar-axis position angle minus parallactic angle) for the terrestrial observer. Observer coordinates come only from chrome displayTime.topBandAnchor when it is a known catalog fixedCity (resolveReferenceCityObserverLocation); they are not stored on the Moon row. If orientation is observer-oriented, “use reference city” is on, and no valid city is resolved, presentation falls back to map-oriented (χ = 0) rather than inventing a location. Below-horizon geometry is still computed. Ring geometry stays circular; observer rotation is visible there only as a rotated displacement. Contrast is a two-pass stroke: a slightly wider automatic under-stroke (dark 18,26,40 or light 236,240,246 from WCAG relative luminance of the user color, threshold 0.179) then the user-selected foreground (#c5d4e8 default). The under-stroke is not user-configurable and does not recolor by phase region. Display amplification (librationMotionScale) scales the glyph offset only. Size tokens small / normal / large / extraLarge scale disc, phase, and indicator together; normal is the historical radius (min(7.5, max(3.8, width×0.0046))). Moon size does not change the Sun glyph. Libration defaults on. Phase astronomy is unchanged. During an active lunar eclipse, a separate spatial Earth-shadow overlay (penumbra gradient + clipped umbra + coverage-scaled totality red, rotated by the same observer χ as libration) paints over the phase disc and under the libration mark; it is not a phase rewrite. |
| Lunar ground track | lunarGroundTrackLayer.ts |
Time-windowed trajectory of sublunarPoint around TimeContext.now. Default 24 h past + 24 h future at 10-minute samples; extents persist on source.parameters.pastHours / futureHours (6 / 12 / 24 / 48 / 72). Stroke RGB identities persist as pastColor / futureColor (#rrggbb, default #aacdf0). Past is quieter than future via plan-builder alpha; unlabeled 6-hour ticks. Default off. Independent of the sublunar marker. |
| Lunar locus | lunarLocusLayer.ts |
Compact sublunar figure: sublunarPoint sampled once per mean lunar day (derived from the lunar model’s GMST and mean-longitude rates, ≈24 h 50 m 28.3 s) for 28 points spanning ≈27.3 days, starting at TimeContext.now (k = 0…+27). Residual (δlon, lat) is interpolated with an open centripetal Catmull-Rom whose neighbors outside that window (k = −1 and k = +28) supply real tangents. The displayed path is cropped near one sidereal month after the current Moon (~26.4 mean lunar days). Endpoints are not welded: the locus is approximately periodic, not exactly periodic, and the Moon glyph is the cycle seam. Strokes that fall inside the Moon disc are trimmed as presentation only (trim radius follows the configured Moon size). The plan draws an open polyline of unwrapped longitudes plus ±360° copies so a figure near ±180° stays associated with the Moon. Line-only. Stroke RGB identity persists as source.parameters.strokeColor (default #1c2638); thickness token thin / normal / thick multiplies the veil-aware base width 1.2 + 0.95 × veil. Independent of Solar analemma styling. Non-current samples memoized per 1-second product-time bucket; k = 0 is always live sublunarPoint(now). Default off. Independent of the Moon marker, lunar ground track, and solar analemma. Vertical extent follows the lunar model (major- vs minor-standstill epochs differ without a standstill switch). |
| Solar eclipses | solarEclipseLayer.ts |
NASA-derived solar overlay (E1 live footprint + E2 forecast corridor + E5 alignment beam + E6 labels/styles + live ground-position marker). Default on: geography appears only while an event is relevant. Forecast horizon (0 / live only, 1, 3, 7, 14, 30, 90, 365 days; default 7) is how early upcoming solar geography appears, not eclipse duration. Upcoming events emit an event-path corridor (cached, time-independent) and a representative greatest-eclipse partial region. While the event is globally active the corridor remains as path context (fill ~80% of upcoming strength; limits stay strong) even before/after the umbra is on Earth. The representative forecast partial region is upcoming-only. Active broad partial darkening is physical illumination (local obscuration field), not the former teal live-partial fill; that fill is restored only if Active eclipse shading is off. Active events emit the live E1 umbra/antumbra when that geometry exists. A live ground-position marker sits on the authoritative central point while that intersection exists. An optional alignment ribbon (E5) connects the Sun/Moon glyph cluster to that live umbra/antumbra only while a terrestrial central target exists; partial-only events get a local bloom; central events with no current Earth intersection emit no beam. Partial-only events never fabricate a central corridor or marker. Presentation-only type filters (total / annular / partial / hybrid) default on. Child geography toggles and user style persist on the row. Alignment is scene.eclipseAlignment. Canvas sees no eclipse astronomy. |
| Lunar eclipses | lunarEclipseLayer.ts, Earth-shadow fields on sublunarMarkerLayer.ts |
NASA-derived lunar overlay (E3 + E6 labels/styles + forecast window + LIB-021 spatial Moon shadow + LIB-043/044/046/054 presentation). Default on: event presentation appears only while an event is relevant. Separate lunar forecast horizon (0 / live only, 1, 3, 7, 14, 30, 90, 365 days; default 7). The map paints one event-static visibility footprint: a closed line enclosing every location from which some part of the eclipse is geometrically visible at any time in [globalStartMs, globalEndMs] (P1→P4 when those contacts exist). Factory on; line-only; cached by event id (lunar-visibility-footprint-v1). Stroke RGB identity persists as source.parameters.visibilityFootprintColor (factory #6a9aa8); thickness token thin / normal / thick is independent. Changing color rebuilds overlay presentation only — it does not recompute footprint geometry or reacquire catalog data. It is not the old instantaneous Moon-visible hemisphere (removed in LIB-046), not a moving horizon, not a lunar shadow path, and not a fill. The same geometry appears from forecast-horizon entry through event end and disappears after last contact. The eclipse is also communicated by the Moon glyph, Earth-shadow treatment, Moon-local Earth-shadow cue, physically attenuated moonlight, HUD/placard, and event label. Forecast horizon Live only hides upcoming events; it does not change physical moonlight. Ordinary Moon-above-horizon astronomy remains in illumination (lunarDot ≥ 0), sublunar geometry, and local circumstances. Presentation-only type filters (total / partial / penumbral) default on. Child controls persist on the row. Alignment is scene.eclipseAlignment (lunarEnabled stored boolean; UI copy is Earth-shadow cue). Lunar map labels use glyph-relative candidates at the current Moon and avoid city-name boxes when city pins are on. Canvas sees no eclipse astronomy. |
| Solar analemma | solarAnalemmaLayer.ts |
Derived ground track. Default samples the year-long subsolar locus at the canonical instant’s UTC time-of-day so today’s vertex coincides with the live subsolar point. Optional source.parameters.utcHour freezes that integer hour at :00:00.000. Stroke RGB identity persists as source.parameters.strokeColor (default #ffc878); thickness token thin / normal / thick multiplies the same veil-aware base width as the lunar locus. Independent of Lunar locus styling. |
| Static equirect overlay | staticEquirectRasterOverlayLayer.ts |
Full-viewport raster overlay. |
| Dynamic equirect raster | dynamicEquirectRasterOverlayLayer.ts |
Reads prepared views only. |
| Dynamic point features | dynamicPointFeaturesOverlayLayer.ts |
|
| Dynamic tracks | dynamicTracksOverlayLayer.ts |
|
| Planetary objects | planetaryObjectsLayer.ts |
One derived overlay (planetaryObjects), not eight layer ids. Mercury–Neptune plus Pluto as terrestrial sub-object points from the bundled astronomy-engine apparent-position authority (ADR 0016). Factory off; all bodies factory off. Current glyphs/labels follow shared masters. Optional continuous planet ground tracks (hours/days at 15 min samples; past stronger / future fainter alpha; not orbits around Earth). Optional planetary loci: daily same-UTC-clock subpoint traces over a centered 1/2/5/10-year or 1-synodic-cycle window (analemma-like method; not claimed to be solar figure-eights). Per-body enable, color, and locus toggle; everything else shared. Body off hides glyph, label, track, and locus while keeping the locus preference. Unsupported outside 1600–2500: hide features and show a concise Config status. Not current-only live data; follows TimeContext.now. Earth is not a rendered target. |
| Milky Way | milkyWayLayer.ts |
One derived overlay (milkyWay). IAU 1958 Galactic directions via astronomy-engine GAL→EQJ, then equator-of-date and GAST (ADR 0017). Two line-only map artifacts: (1) a zenith-projection ribbon (Galactic plane b = 0°, approximate band edges, sparse width ribs, Galactic center / optional anticenter subpoints; those two tagged points are production tracking targets when rendered (LIB-093); the galactic-plane band is not) answering where those directions are overhead; (2) Galactic-center altitude contours (small circles of radius 90° − h around the same GC subpoint; default 30/45/60/75°, optional horizon 0°) answering how high the Galactic center is above the geometric horizon. Neither is a star field, photographic texture, world-map shading raster, nor an observing-quality score. Ribbon night-side emphasis uses the subsolar geometric horizon. Contour night emphasis uses solar altitude along the contour (full at Sun ≤ −18°); optional moonlight de-emphasis multiplies alpha from existing phase × incidence × lunar-eclipse transmission. A separate headless Milky Way viewing window family (ADR 0018, ADR 0021, policy milky-way-viewing-v2) enumerates one primary reference-city interval from GC altitude ≥ 15° ∩ ≥ 90% of local nightly max ∩ Sun ≤ −18° ∩ moonlight ≤ 0.08; it does not read contour pixels or RenderPlan. At the window’s peak UTC a static line-only viewing footprint outlines other locations that satisfy the same gates. Factory overlay off. When enabled: plane, band (Normal ±10°), ribs, Galactic center + label, and ribbon night-side emphasis on; anticenter off; altitude contours off until the user enables them. Viewing events default off independently of the overlay master. Unsupported outside 1600–2500. Celestial EQD samples are cached per UTC date and band width; Earth rotation is a GAST longitude shift. |
Illumination is not a stack of blend passes. Solar geometry, continuous twilight, moonlight, and emissive night lights are all resolved upstream, on the CPU, into one RGBA field, which is emitted as a single rasterPatch.
The pieces:
src/core/nightVeilFromSolarAltitude.ts— presentation transfersolarAltitudeDeg → nightVeil01. Solar altitude remains the physical authority. The factory curve is a C1 Fritsch–Carlson monotone cubic through twilight-semantic samples: veil 0 at +4°, 0.10 at the geometric horizon, 0.32 at −6°, 0.70 at −12°, 1 at −18°. Overlay alpha isnightVeil01 × 0.62. This replaced a single-interval smootherstep(+4° → −18°) that concentrated its steepest slope at −7° and read as a narrow dark stripe on the equirectangular map (LIB-056). Astronomy, raster sampling, moonlight policy, and eclipse composition are unchanged. Curve type is not user configuration; a DEV-onlynightVeilCurve=query exists for diagnostics.src/renderer/illuminationShading.ts— the sampling and tuning core. Takes the geometric dot product of surface normal and subsolar direction, converts to solar altitude, and produces attenuation plus atmospheric tint. Civil, nautical, and astronomical thresholds are retained as semantic anchors informing a continuous field, not as banded regions. Composition is non-emissive: it attenuates and tints, it does not glow. OptionaldaylightTransmission01attenuates daylight only:eclipseDaylightFactor = 1 − (1 − nightVeil) × (1 − transmission)thenoverlayAlpha = 1 − (1 − ordinaryOverlayAlpha) × eclipseDaylightFactor.ordinaryOverlayAlphais night-overlay opacity, not a daylight fraction. Settled night (nightVeil = 1) is unchanged.src/core/illuminationFrameState.ts— one product-UTC astronomical state (subsolar, sublunar, phase fraction, lunar-eclipse moonlight transmission) used by the solar-shading layer so a frame cannot mix a new solar geometry with a stale lunar scalar. There is no illumination time bucket; identity follows the exact product instant.src/core/moonlightPolicy.ts,lunarIllumination.ts,lunarPhase.ts,sublunarPoint.ts— moon phase, lunar altitude, and surface incidence produce a bounded directional night-side contribution: cool additive RGB plus a secondary transmittance lift on the darken mask. Strength comes fromscene.illumination.moonlight.mode(off/natural/enhanced/illustrative), resolved into a deterministic policy table upstream. During an active lunar eclipse, ordinary moonlight is further multiplied by a coverage-derived transmission scalar (lunarEclipseMoonlightTransmission): uneclipsed fraction at 1, penumbra-only at 0.78, umbra at 0.05. Composition isordinaryMoonlight × lunarEclipseTransmissioninside the existing illuminationrasterPatch(ADR 0011). The scalar comes from E3 disc/shadow overlap, not contact-state switches, and does not change lunar phase. Moonlight is already spatially gated bylunarDot(zero where the Moon is below the geometric horizon) and by night eligibility (day side is not darkened by a lunar eclipse). Lunar eclipse map presentation no longer paints a terrestrial Moon-visible hemisphere; that overlay was informational only and was replaced by the event-static visibility footprint line (LIB-054). The Earth-shadow cue remains an informational Moon-local decoration. Toggling lunar eclipse overlays does not change physical illumination samples. Reference city does not affect the scalar.src/renderer/renderPlan/sceneSolarShadingIlluminationPlan.ts— samples that state onto a stable full-world half-resolution equirect grid ((i + 0.5) / sw, independent of lifecycle and of the current sublunar point), then the canvas executor upscales with bilinear smoothing. Moonlight is not a moving-bbox overlay.src/core/eclipse/solarEclipseObscuration.ts,solarEclipseObscurationField.ts,solarEclipseDaylightTransmission.ts— during an active solar eclipse, local solar-disc area obscuration is evaluated from the same Besselian observer-plane identities as E4 (Rs,Rm, circle-overlap fraction). E4 visibility (obscuration01) remains 0 when the Sun is geometrically below the horizon. Map illumination storesphysicalObscuration01(overlap while in the penumbra, including just below the horizon) on a stable full-world 288×145 (~1.25°) equirect grid (−180…+180 periodic longitude, +90…−90 latitude), bilinearly interpolated, cached by event id and 250 ms product-time bucket, and mapped withvisualDarkening = maxDarken × obscuration^γ(Normal: maxDarken 0.56, γ 1.45; Subtle 0.34/1.7; Dramatic 0.74/1.22). Cells outside the physical penumbra are 0 because the physics is 0, not because they were skipped. A boolean sun-above-horizon mask is not stored: interpolating that mask produced a scalloped terminator seam (LIB-029). A moving bbox derived from the live penumbra outline is not used (LIB-028). Sampler longitude is wrapped; latitude is clamped. Upcoming events do not contribute. The field follows eclipse truth whenever solar shading is on, even if Solar eclipses overlays are hidden (ADR 0012). This is a visual illumination approximation, not photometric lux.src/renderer/emissiveIlluminationRaster.ts,src/core/emissiveNightLightsPolicy.ts— human-made radiance sampled per texel from a bundled equirectangular raster, gated by solar altitude, coexisting with moonlight, and scaled by mode and presentation (intensity,driverExponent). The asset is chosen by durableassetIdagainst a bundled emissive composition catalog that is separate from the base-map catalog; unknown or blank ids canonicalize to the catalog default.src/lifecycle/dynamicCloudOpacityMaterializer.ts— Model A display-luma → cloud-opacity attenuation. Clouds v3 forcescloudParticipationMode: "off"at the overlay factory; the Illumination topic no longer exposes the control. Enabling Clouds does not change the illumination raster. The materializer remains for tests and possible future optical-depth products.
Polar behaviour (midnight sun, polar night) is not special-cased. It emerges from real solar geometry and seasonal axial tilt.
The backend sees one rasterPatch and knows nothing about any of this. See ADR 0002.
Production flow (E1 live + E2 forecast, LIB-014, LIB-015):
bundled authority JSON
→ EclipseAuthority (parse, provenance, binary-search lookup)
→ EclipseEventService.resolveEclipseFrame(TimeContext.now, { horizonMs })
→ live Besselian footprint at T + cached event corridor (if horizon > 0)
→ solar eclipse presentation lifecycle (upcoming / global-active pre-central / central-active / global-active post-central / completed)
→ solar eclipse layer (semantic lat/lon regions)
→ equirectRegionOverlay RenderPlan (seam unwrap + ±360° copies)
→ Canvas (path fill/stroke only)
- Asset:
src/assets/eclipse/solar-eclipse-authority-v1.json(authorityIdnasa-espenak-meeus-5mcse-solar,authorityVersion1). 454 solar events, 1900-01-01T00:00:00.000Z inclusive through 2101-01-01T00:00:00.000Z exclusive. - Ingest:
npm run eclipse:prepreads the NASA GSFC Besselian CSV (SHA-256 pinned; file is gitignored) and writes the JSON. Runtime never fetches NASA and never parses HTML/PDF. - Lookup:
activeSolarEclipseAt,nextSolarEclipseAfter,solarEclipsesIntersecting, andsolarEclipsesUpcomingInHorizonuse binary search on the sorted catalog. Discovery does not scan all 454 events per frame and does not live in Canvas. - Forecast horizon: scene parameter
forecastHorizonDays(0= Live only, plus 1/3/7/14/30/90/365). Default 7. Live-only preserves E1: active live footprint only, no upcoming corridor. Master Solar eclipses toggle defaults on as of E6; explicit persisted off is preserved. - Lifecycle:
EclipseFramestill distinguishes only authorityupcomingvsactivefrom product UTC + contacts + horizon. Presentation addsresolveSolarEclipsePresentationPhase(LIB-025): upcoming; global-active pre-central (event started, no terrestrial central intersection yet); central-active (livecentralPoint); global-active post-central (event still global, umbra/antumbra has left Earth); global-active (partial-only, no central intersection possible); completed. This is not a second eclipse truth model. Completed events drop out of the selection. Live-only horizon (0) still omits forecast corridor geography. - Live vs corridor: the live umbra/antumbra is the compact footprint at T. The forecast corridor is the geographic strip swept by the central shadow over the event, sampled at 60 s, cached by event id / authority version / algorithm id
solar-event-corridor-v1. See ADR 0009. The corridor is event-scale context and stays visible through upcoming and all globally active phases when the horizon is not live-only. It is independent of the live ground marker and alignment beam. Active fill is ~80% of upcoming fill; corridor limits stay at least as strong as upcoming. Forecast centerline remains during pre- and post-central active phases so the path does not vanish before the umbra is on Earth; during central-active the stronger live centerline is used instead. - Partial forecast: representative greatest-eclipse penumbral outline, not the event-long swept penumbral union. It is drawn only while the event is upcoming. Once globally active, continuous local-obscuration shading in the illumination raster owns current partial darkening. The former teal live-partial fill is not drawn while Active eclipse shading is on. Partial-only events show the forecast region before start, then the physical field, and never a central corridor.
- Outside the span / truncated windows:
EclipseFrame.supportis{ supported: false, reason: "outside-authority-range" }when T itself is outside 1900–2100. That is not the same as a supported instant with no eclipse. If the requested(T, T+H]extends beyond the authority interval,forecastCoverage.truncatedis true and only events in the supported query interval are returned. No ambient fallback. When Solar or Lunar eclipses are enabled, the event-information surface and optional chrome line say “Eclipse data unavailable outside 1900–2100.” They do not say that no eclipse exists. - Product time: every evaluation uses the frame’s canonical UTC (
TimeContext.now/eclipseFrame). NoDate.now()in eclipse math. Pause freezes geometry; accelerated demo and direct UTC jumps re-evaluate selection immediately and reuse cached corridors. - Wrap:
src/renderer/renderPlan/equirectSeamRegion.tsprojects closed fill rings by folding longitudes into the smallest containing arc, then emits ±360° world copies so a dateline-crossing oval does not span the map. Sequential path unwrap is still used for polylines. Polar caps (circular longitude span > 270°) close through the nearer pole. World copies whose visible x-spans overlap are dropped so one semantic translucent fill cannot alpha-stack on itself (LIB-026). - Visual families (LIB-026 / LIB-027): Event path — static violet/lilac corridor (
rgba(72, 48, 140, …)fill, lilac limits, active stroke0.62). Active partial darkening — physical illumination field (charcoal daylight attenuation from local obscuration), not a teal polygon. Forecast partial — informational teal-slate (rgba(47, 109, 120, 0.11)), upcoming only. Live central — compact indigo umbra (rgba(40, 24, 72, 0.50)) or warm antumbra. Alignment — warm gold ribbon. Ground marker — vermilion locator. Ordinary night — the same illuminationrasterPatch, unmodified on the night side. - Illumination raster is not the eclipse overlay. Compact umbra/antumbra remain overlay markers. Broad active obscuration is composed into the same
rasterPatchas ordinary solar shading (ADR 0012); Canvas still sees pixels.
See ADR 0008, ADR 0009, and docs/specs/scene/eclipse-system.md.
Production flow (E3, LIB-016):
bundled lunar authority JSON
→ EclipseAuthority (same family as solar; lunar parse, provenance, binary-search lookup)
→ EclipseEventService.resolveEclipseFrame(TimeContext.now)
→ circular Earth-shadow geometry at the Moon
→ cached event-static visibility footprint (selected lunar event)
→ Moon glyph earthShadowOverlay + lunar eclipse layer (footprint line + labels)
→ existing Moon RenderPlan / equirectRegionOverlay
→ Canvas (stroke + text; no lunar-eclipse region fill)
- Asset:
src/assets/eclipse/lunar-eclipse-authority-v1.json(authorityIdnasa-espenak-meeus-5mcle-lunar,authorityVersion1). 459 lunar events (166 total, 122 partial, 171 penumbral), same 1900-01-01T00:00:00.000Z inclusive through 2101-01-01T00:00:00.000Z exclusive span as solar. - Ingest:
npm run eclipse:prepalso reads NASA GSFC5MKLEcatalog.txt(SHA-256 pinned; file is gitignored) and writes the lunar JSON. Runtime never fetches NASA and never parses HTML/PDF. EclipseWise is not an authority. - Lookup:
activeLunarEclipseAt,getLunarEclipseEventById,nextLunarEclipseAfter,lunarEclipsesIntersecting, andlunarEclipsesUpcomingInHorizonuse binary search. The sameEclipseEventServiceforecast model used for solar also returns upcoming lunar events whenlunarHorizonMs > 0. Live only (0) keeps E3 active-only presentation. - Contacts: P1/U1/U2/greatest/U3/U4/P4 by symmetry about greatest eclipse from NASA durations. Invalid contacts are
null(no U2/U3 on partial; no U1–U4 on penumbral). Not derived from the ambient Moon model. - Earth-shadow at the Moon: recover penumbral/umbral radii from catalog magnitudes + |γ| with IAU
k = 0.2725076; interpolate along-track separation at constant speed from P1/P4. Phases:none/penumbral/partial-umbral/total-umbral. Totality styling is used only when the Moon is inside the umbra. - Moon glyph: existing size, phase, optical libration, and observer orientation are unchanged. Eclipse overlay is independent of phase shading. Draw order: glow → Earth-shadow directional cue (if active) → phase disc → phase shadow → spatial Earth-shadow (penumbra gradient + clipped umbra + coverage-scaled totality red) → libration mark → outline. The cue is a short tapered wedge that originates on the Earth-shadow side and terminates at the disc; it uses the same east/north shadow offsets rotated by observer χ. Shadow offsets are map-oriented east/north in Moon radii. Geometry scales with Moon size. Totality red/brown emerges from umbral coverage rather than a whole-disc state switch.
- Moon-above-horizon astronomy: geometric lunar altitude = 0 on a spherical Earth (same unit-sphere dot product as illumination
lunarDot). No refraction. This geometry still gates physical moonlight (lunarDot ≥ 0) and local circumstances. Event-static visibility footprint (LIB-054): the union of those Moon-up hemispheres over[globalStartMs, globalEndMs], drawn as one closed line. Cached by event id / authority version / algorithmlunar-visibility-footprint-v1/ 2-minute sampling; not keyed by product time. Appears with the existing lunar forecast horizon, stays invariant through the event, and disappears after last contact. Presentation stroke comes fromvisibilityFootprintColor(factory#6a9aa8) viaresolveLunarEclipsePaint; color is not part of the cache identity. Distinct from the removed instantaneous Moon-visible hemisphere fill/horizon (LIB-046). Placard: Visibility footprint — some part of this lunar eclipse is visible inside the boundary. The reference city never selects global eclipse presentation. - Wrap: solar eclipse overlays still use generic
equirectRegionOverlaypolar-close / world-copy path construction. The lunar visibility footprint is a line-only ring around the visible band (typically larger than a hemisphere); sequential polyline unwrap plus ±360° copies handle dateline crossings. Horizon-ring helpers remain for subset tests and leftover GE event-information geometry. - Glyph depth: when Sun and Moon glyphs overlap, Moon paints above Sun. Default stack order (
sublunarMarkeraftersubsolarMarker) assigns the higher z-index; Canvas sorts by that z-index. The rule is global, not eclipse-gated. - Penumbral-only events: subtype and negative umbral magnitude are preserved. When such an event is active, the Moon receives the penumbral overlay only (no umbra/totality fill). There is no dedicated penumbral UI emphasis.
- Outside the span: the shared
EclipseFrame.support{ supported: false, reason: "outside-authority-range" }applies. No ambient fallback. - Product time:
TimeContext.now/eclipseFrame. Pause freezes geometry; accelerated demo and direct UTC jumps reconstruct immediately.
See docs/specs/scene/eclipse-system.md §10 and §22.
Production flow (E4, LIB-017):
EclipseFrame (global events + geography)
+ resolveReferenceCityObserverLocation(displayTime.topBandAnchor)
→ ReferenceCityEclipseCircumstances resolver (cached by event id / authority version / lat/lon)
→ presentation (lower-right eclipse information panel; optional bottom-HUD chrome line)
GLOBAL ECLIPSE TRUTH IS NEVER FILTERED BY REFERENCE CITY. Changing the city updates only the derived circumstances. Event identity, solar live footprint, solar forecast corridor, lunar Earth-shadow state, and lunar visibility footprint are unchanged. No catalog city (auto, fixed longitude, unknown id) leaves global eclipses intact and omits circumstances — there is no Knoxville fallback.
- Observer: the same chrome
displayTime.topBandAnchorcatalog city used by top-band time and LIB-011 libration. No second selector. - Solar local contacts: Besselian reduction of the same NASA elements as E1/E2. Root functions
m²−L1'²(C1/C4),m²−|L2'|²(C2/C3), andu u̇ + v v̇(maximum). 30 s sampling, bisection + Newton, 1 ms tolerance. C2/C3 only when the observer is locally total or annular. Contacts are UTC instants in domain state. - Magnitude is NASA diameter fraction
(L1'−m)/(L1'+L2')at local maximum. Obscuration is circle-overlap area fraction from apparent Sun/Moon radii; it is not magnitude. - Geometric horizon: Sun/Moon center altitude with no refraction, topography, or station elevation. A contact is below the horizon when center altitude < 0°.
- Lunar: global contacts from E3; Moon altitude at each contact; geometric moonrise/moonset inside the event interval; local-visible maximum is global GE only when the Moon is up.
- Caching: solar C1–C4 solutions are cached per event+observer and are not recomputed every frame. Product time only selects the relevant event and formats live status.
- Presentation: compact rows in the lower-right eclipse information panel when Event information is on; optional compact bottom-HUD line (date-style, subordinate). HUD copy is local/reference-city only. Local wall times use the city’s IANA zone via existing
formatWallClockInTimeZone. Copy says “not visible from {city}”, never that the global event is absent. Layers remains controls-only. OneEclipsePresentationStateprojection (src/core/eclipse/eclipsePresentationState.ts) feeds HUD, placard rows, and map-label text. - Config:
scene.eclipseCircumstances.detailsEnabledandchromeStatusEnabled, both default on. Disabling either does not disable the global eclipse map. Old configs missing the keys normalize to on. - Upcoming events: local contacts are available for forecast-horizon solar and lunar events, not only active ones. A future lunar eclipse still appears globally when the reference city cannot see it.
See ADR 0010.
Production flow (E5, LIB-018):
EclipseFrame (active event + live geometry)
+ ambient subsolar/sublunar glyph positions
+ scene.eclipseAlignment
→ solar: buildEclipseAlignmentPresentation → extra equirectRegionOverlay fills/strokes
→ lunar: lunarEarthShadowCue on the Moon glyph (no geographic ribbon)
→ Canvas (generic path fill/stroke only)
The beam is presentation, not eclipse truth. It does not recompute Besselian or lunar-shadow astronomy. It does not replace the E1 live footprint, the E2 forecast corridor, or the E3 Moon-up region.
- Active-only. Upcoming forecast events emit no beam. After last contact the effect disappears. Same product UTC yields the same geometry; pause freezes it; direct jumps reconstruct immediately. No
Date.now(), no independent animation clock. - Solar. For total/annular/hybrid events with a live central point, a tapered translucent ribbon runs from the Sun/Moon glyph cluster (ambient midpoint) to the live umbra (total) or antumbra (annular). The beam target is that same central point as the live ground-position marker. Partial-only events get a local alignment bloom around the glyphs — no fabricated terrestrial target. Central events before/after the umbra/antumbra is on Earth emit no beam (and no glyph-field bloom); the event corridor remains as independent path context. The beam is not aimed along the forecast corridor.
- Lunar. LIB-043 removed the geographic Sun→Earth→Moon map ribbon (it read as a beam emitted by the Moon and contributed blocky, rotating large-area shading). The lunar control now draws a short Moon-local Earth-shadow directional cue on the glyph: cool gray/blue-gray tapered wedge, origin on the Earth-shadow side, terminal at the disc, behind Moon details. Active eclipse only; strength is
1 − moonlightTransmissionfrom E3 coverage. It is not a terrestrial path and not a solar-style beam. - Strength. Solar
alignmentStrength01comes from the same Besselian evaluation as the live footprint (axis distance vs penumbra / central presence). Lunar strength comes from E3 phase and magnitudes. Reference-city magnitude is not used. - Independence. The builder does not take an observer. Changing the reference city does not change beam geometry, strength, target, or event identity.
- Config.
scene.eclipseAlignment:enabled(master, default on),solarEnabled/lunarEnabled(default on),intensitysubtle|normal|dramatic(default normal). UI copy: Solar alignment beam; Lunar Earth-shadow cue. The stored lunar boolean is unchanged. Disabling the cue leaves eclipse geography intact. Disabling the solar or lunar eclipse layer suppresses the corresponding alignment/cue. The cue cannot appear when the lunar eclipse layer is off. - Layering. Within the solar eclipse overlay, generic
drawOrderon equirect fills/strokes yields: forecast representative partial (upcoming only) → corridor fill → corridor limit strokes → alignment bands → live umbra/antumbra → alignment axis → live/forecast centerline → ground-position marker → labels. Corridor limits stay above the path fill so the route remains readable over the moving dark field. Alignment bands draw before the compact central footprint. Glyphs stay above the eclipse layers. Ordinary solar/day-night shading plus active eclipse daylight attenuation is the illuminationrasterPatchunder the overlay. The solar ribbon is a directional beam (origin half-width ~5.4°, Dramatic ~6.4°), not a map-scale shading region. - Active eclipse shading config.
activeEclipseShadingEnabled(default on) andactiveEclipseShadingIntensitysubtle|normal|dramatic(default normal) persist on the solar eclipse row. The physical field follows solar shading, not the Solar eclipses overlay master. - Map semantics. The effect is a 2D geographic alignment visualization, not a literal 3D ray through screen space.
See docs/specs/scene/eclipse-system.md §11.
Production surface (E6, LIB-019). E6 does not add astronomy. It groups the E1–E5 controls, adds inspectable event information, restrained labels, presentation-only type filters, user styling, honest unsupported-range copy, and reviewed defaults. LIB-021 moved that inspectable event information out of Layers onto a lower-right map panel.
- Defaults. Factory Solar eclipses and Lunar eclipses are on. Geography still appears only while an event is relevant, so an ordinary date stays visually clean. Named presets
minimal/celestial/featuredCitiesremain explicitly off. An old persisted document with an explicitenabled: falsestays off; a missing key now normalizes on. Forecast horizon remains 7 days. Alignment, observer details/chrome, event information, labels, and all supported event types default on. - Type filters. Solar: total / annular / partial / hybrid. Lunar: total / partial / penumbral. Filters hide map geometry, labels, event information, and chrome for that subtype. They do not change
EclipseFrameor authority lookup. Default: all on. - Event information. Compact lower-right map panel for the presented upcoming solar, upcoming lunar, active solar, or active lunar event. Global rows (Global event, lifecycle, current shadow only while active, geography legend) sit above the E4 reference-city block (Reference city, Local type, contacts). The Event information toggle shows or hides that panel. It auto-opens when a relevant event appears and can be dismissed to a small chip. When Config is open, the panel offsets left of the Config shell. The primary event is active solar, then active lunar, then the nearest upcoming of either kind. Hidden when no relevant event exists. Layers/Config no longer contain live Event/Date/magnitude rows.
- Labels. At most one restrained map label for that same primary event: global identity plus
upcomingoractive(no local magnitude, no countdown).scene.eclipseInfo.labelsEnabledis captured into the solar/lunar layers at construction; toggling it rebuilds the layer registry so the map label appears or disappears immediately. Solar labels prefer the Moon (Sun/Moon cluster) as the geographic anchor. After projection, screen-space placement prefers the side opposite the nearest visible central corridor / live central point / forecast centerline sample (wrapped copies included); partial-only events fall back to a glyph-relative offset without fabricating a central path. Candidates reject glyph discs, a ~12 px path clearance, and screen edges; last resort may drop path clearance then clamp. Lunar labels use Moon-glyph-relative candidates (right, left, above, below, diagonals, farther radial; ~40–64 px) and do not use the solar path-opposite rule. When city pins are labeled, lunar placement also rejects those city-name boxes (minimal read-only handoff; not a general layout manager). Lunar labels do not avoid an unpainted geometric lunar horizon. Event information and persistent chrome status are independent and do not require a registry rebuild. - HUD copy. Persistent event notices answer what is upcoming or active at the reference city. Eclipse lines keep existing local meaning (obscuration, begins/max/ends, not-visible). Milky Way lines are compact (
Milky Way viewing/Milky Way viewing · tonight). Solar HUD percent is obscuration (Sun area covered), not magnitude: integer below 99%; one decimal from 99% to <100% that never rounds a partial to"100%";"100%"only when the value is truly 1.0. Upcoming with local C1:begins {time}. Upcoming without C1: keep relativein 50m. Active before local max:max {time}. After local max with C4:ends {time}. Not-visible copy never hides the global event. - Unsupported range. When Solar or Lunar eclipses are enabled and product UTC is outside 1900–2100, copy is “Eclipse data unavailable outside 1900–2100.” That is not the same as a supported date with no eclipse. Features off: no empty furniture.
- Styling. Independent color / thickness / fill-opacity families for solar forecast, solar live, and optional alignment base colors. Fill opacity is clamped 0.04–0.55. Defaults keep the verified E1–E5 tokens. Changing one family does not leak into another. Alignment intensity still does not change event truth.
- Config groups in Layers: Eclipses (information / labels); Solar (horizon, types, live/forecast geography); Lunar (horizon, types, Moon Earth-shadow); Alignment; Reference city; Eclipse appearance. Child controls disable when the parent layer or master is off. Solar forecast corridor/partial disable when the solar horizon is Live only.
- HUD layout. Date/time remain the primary two-line block. Eligible solar, lunar, and Milky Way notices are collected upstream and ranked (active before upcoming, then start time, then family, then id). At most two event-notice rows plus overflow (
+1 more event). Eclipse persistent-status (chromeStatusEnabled) still gates eclipse lines; Milky Way notices follow Layers viewing-event enablement. Data playback checkboxes do not control HUD or map presentation. Turning persistent status off removes eclipse rows; MW rows remain if viewing events are on. Event labels and Event information stay independently visible. - No event browser and no map click-inspector (scene pointer inspection remains unapproved Phase 11).
Post-E6 reconciliation (LIB-020) added lunar forecasting on the existing EclipseAuthority / EclipseEventService path. LIB-021 moved event information onto the map, attenuates moonlight from lunar coverage, and replaced whole-disc Moon tints with spatial Earth-shadow geometry. LIB-025 keeps the solar event corridor visible through globally active pre- and post-central phases, hands active partial shading to the live footprint, and limits the targeted alignment beam to instants with a terrestrial central target. LIB-026 separates those layers into stable visual families and prevents wrap-copy alpha stacking. LIB-027 replaces the active teal live-partial fill with continuous local-obscuration daylight attenuation in the illumination raster. LIB-042 is a presentation-only reconciliation: one EclipsePresentationState projection, HUD local vs placard global+local vs map-label global+lifecycle, and solar labels anchored at the Sun/Moon cluster opposite the path. LIB-043 keeps that split: lunar labels around the Moon glyph with city-name clearance; one physical moonlight pipeline; no geographic lunar “beam”; a short Earth-shadow cue on the Moon. LIB-044 / LIB-045 had kept map Moon-visible geography on the current product instant under one region/boundary pair. LIB-046 removes that eclipse-specific terrestrial fill and horizon stroke entirely; ordinary Moon-above-horizon mechanics remain for illumination and other non-eclipse behaviour. None of these is an E7 program phase. Moonlight attenuation is recorded in ADR 0011. Solar-eclipse daylight attenuation is recorded in ADR 0012.
See docs/specs/scene/eclipse-system.md §13 and §18 E6.
Overlays must stay legible over eleven visually different substrates and across the full illumination range. The mechanism is derived entirely upstream — it never samples the rendered raster.
Per frame the shell computes one OverlayReadabilityFrame (src/core/overlayReadabilityFrame.ts) from four inputs:
- Solar field —
nightVeil01At/globalNightVeil01, aligned withilluminationNightVeil01FromSolarAltitudeDeg. - Emissive policy pressure —
globalEmissiveLegibilityPressure01, derived from the emissive night-lights policy (mode, intensity, driver exponent). The policy, not the texture. No emissive raster is sampled in the readability path. - Substrate lift scale —
substrateOverlayReadabilityLiftScale01(range 0.35–1) fromsrc/core/substrateOverlayReadabilityLiftScale.ts, computed from the effective base-map presentation and optional catalogcapabilities.overlayOptimizedanddarkFriendlyscale the presentation-derived penalty. Eight optional intrinsic hints —reliefShaded,boundaryDense,chromaticDense,bathymetryShaded,fineScaleTexture,labelDense,etchedReliefDense,sunGlintDense— add small bounded penalties at neutral presentation, subject to a combined cap. Brightness below default reduces the penalty, so a dimmed base keeps its overlay lift. - Scene presentation — normalized
scene.overlayReadability.presentation(readabilityVeilScale01,overlayLiftMultiplier01) post-processes the derived veil and lift.
Optional scene.overlayReadability.perLayer entries exist for six default stack rows (grid, solarAnalemma, subsolarMarker, sublunarMarker, cityPins, staticEquirectOverlay) and apply the same two scalars again inside the layer constructor, via applySceneOverlayReadabilityPresentationToFrame. Identity values are dropped during normalization, so the persisted document stays clean.
Consumers receive derived hints (OverlayReadabilityHints) and adjust stroke widths and RGBA alphas using effectiveOverlayReadabilityLiftVeil01 (veil × lift scale). City pins carry the signal per pin. Static full-viewport raster overlays carry a global veil on the payload, and buildBaseRasterMapRenderPlan merges overlayReadabilityCssFilterAppend with the presentation-derived cssFilter into the single imageBlit.
getOverlayReadabilityFrameOrCompute exists as a fallback for callers with no attached frame; it computes a subsolar-only frame from now. Production always uses the shell-attached frame, so the fallback path does not see emissive, substrate, or presentation inputs. Do not treat the fallback as equivalent.
LibrationConfigV2 (src/config/v2/librationConfig.ts) is the authoritative persisted application configuration. SceneConfig (src/config/v2/sceneConfig.ts) is the scene portion of it and is authoritative for scene content: projectionId, viewMode, orderingMode, baseMap, ordered layers[], illumination, and overlayReadability.
AppConfig (src/config/appConfig.ts) is a derived runtime view, produced by v2ToAppConfig. It is not persisted and is not a second source of truth.
| Key | Contents |
|---|---|
libration.workingConfigV2.v1 |
The working LibrationConfigV2 document |
libration.userPresets.v1 |
Saved user presets |
All mutation goes through commitWorkingV2Update in src/app/workingV2Commit.ts:
- Clone-and-normalize the current document.
- Run the caller's
updater(draft). - Normalize again.
- Apply the committed result.
Normalizing on both sides of the updater means a caller cannot leave the document in a non-canonical state, and normalization is idempotent by construction. replaceWorkingV2FromSnapshot takes the same path for preset loads.
Applying a commit does four things: swap the working document, re-derive AppConfig, conditionally rebuild the layer registry, and persist to localStorage. Persistence is skipped when a DEV visual scenario is active (see §2).
The registry is rebuilt when any of the following changed:
sceneRuntimeAffectingEqual(prev.scene, next.scene)is false — the authoritative trigger. It compares projection, view mode, ordering mode, base-map identity/visibility/opacity/variant/presentation, illumination modes and emissive presentation, overlay-readability presentation and per-layer entries, and every runtime-affecting field of each layer instance. FordynamicTracksrows that includessource.parameters(ISS presentation: track toggles, horizons, colors, thickness, glyph, label). Those values are captured whencreateDynamicTracksOverlayLayeris constructed; omitting them from the predicate leaves a stale overlay that only refreshes when something else rebuilds the registry (LIB-039). Horizon changes also locally re-propagate the ISS track from the already-acquired TLE (LIB-041) without a network fetch. For derivedplanetaryObjectsrows the same predicate deep-comparessource.parameters(body enable/color/locus, glyph, tracks, loci) becausecreatePlanetaryObjectsLayercaptures presentation at construction. Color/glyph-only edits therefore rebuild the layer; locus and ground-track geometry stay in module-level caches keyed by authority, body, window, and time bucket, so a paint change does not recompute years of ephemerides. DerivedmilkyWayrows likewise deep-compare presentation parameters; celestial EQD samples stay in a date/band-width cache and Earth rotation is a GAST shift.- Legacy
LayerEnableFlagsdiffer. - The visible city id set or the custom pin list differ.
- Pin presentation, the default product font, or the top-band mode changed and the city-pins layer is registered in either the old or new config — because that layer captures those values at construction time.
- The top-band reference-city anchor (
displayTime.topBandAnchor) changed and the Moon (sublunarMarker) layer is registered — because that layer captures the resolved observer location at construction. Anchor-only chrome edits do not rebuild when the Moon layer is off.
That last group is worth understanding: several layers read configuration once, at construction. Changing such a value therefore requires a rebuild rather than an update. If you add a layer that captures config in its constructor, you must extend these predicates or the layer will silently keep stale values.
normalizeLibrationConfig backfills defaults, clamps unsupported values, canonicalizes durable ids against their catalogs, drops identity-valued optional entries, and preserves user intent where it is representable. assertIsNormalizedLibrationConfig is used in tests to prove a document is canonical.
Missing keys take the current factory defaults. Explicit persisted values are kept. Factory presentation defaults after LIB-035: bottom HUD chrome.layout.bottomTimeShowSeconds is false; pin labelMode is city (name without time); pin pinDateTimeDisplayMode is time (no seconds when the time line is enabled). An old document that omitted those keys therefore normalizes to the new quieter defaults; a document that stored true / cityAndTime / timeWithSeconds keeps them. Named code presets clone DEFAULT_APP_CONFIG and inherit the same factory values; none pin HUD seconds or pin time as an intentional override.
Config stores durable semantic ids — base-map family ids, emissive asset ids, dynamic sourceIds — never resolved file paths, month-specific rasters, or feed URLs.
AppConfig.layers is a flat LayerEnableFlags record with one boolean per layer id. It is kept in sync with the authoritative scene, and it is still consulted in the rebuild predicates and by some construction paths.
This is a transitional compatibility surface, documented as such in the source: "Scene is authoritative for runtime composition and overlay construction. Legacy layers flags remain as transitional compatibility, but scene deltas are the primary trigger surface for registry rebuilds."
Treat the flags as derived. Do not add new product behaviour that depends on them, and do not remove them opportunistically — several predicates and tests currently read them. Collapsing this surface is a real piece of work, not a cleanup.
ALLOW_PHASE3_MUTATIONS in src/components/config/phase3Flags.ts is a const true. It gates the config-mutation path and the user-presets panel in App.tsx. It reads like a leftover feature flag but it is currently load-bearing in the sense that removing it means editing live call sites; it is not an inert constant with no references.
One canonical UTC instant per frame. clockNowMs is computed once in renderFrame and threaded through TimeContext to everything. There is no Date.now() call downstream of that point in the frame.
Real time is Date.now(). Demo time is a configured alternative source: data.mode === "demo" with demoTime.enabled, a startIsoUtc anchor (default 2030-06-15T12:00:00.000Z), and a speedMultiplier. computeEffectiveRenderTimeMs and the transport helpers in src/app/demoPlayback.ts produce the simulated instant. Pause state lives in the runtime playback ref, not in the configuration document.
Demo mode is the intentional exception to "one clock", and it is intentional precisely because it replaces the source rather than adding a parallel one: downstream code cannot tell the difference apart from the simulated flag on TimeContext.
Time drives content, not just labels. The product instant selects:
- the month raster for month-aware base-map families, through the catalog-backed resolver;
- subsolar and sublunar points, solar altitude, lunar phase and optical libration, lunar altitude, and hence the whole illumination field;
- the analemma ground track (default: UTC time-of-day of the instant; optional frozen
utcHour); - the lunar ground track (past/future window around the instant; same
sublunarPointas the Moon marker); - the lunar locus (mean-lunar-day samples of the same
sublunarPointacross one orbital cycle, with the instant as the cycle seam); - planetary sub-object points, optional continuous planet ground tracks, and daily same-UTC-clock planetary loci for Mercury–Neptune plus Pluto (bundled
astronomy-engineapparent positions; unsupported outside 1600–2500; not current-only live data); - the Milky Way zenith ribbon and optional Galactic-center altitude contours (IAU Galactic plane / approximate band / Galactic center at product time; unsupported outside 1600–2500; contours are altitude geometry, not a visibility forecast);
- solar eclipse authority lookup, live geographic footprint, and forecast-window event selection (NASA Besselian evaluation at that UTC; unsupported outside 1900–2100; forecast windows that extend past the span are truncated);
- lunar eclipse authority lookup, Earth-shadow geometry at the Moon, the event-static terrestrial visibility footprint, and advance forecast of upcoming lunar events (unsupported outside 1900–2100);
- reference-city eclipse circumstances for the chrome catalog city (derived observer projection; does not change global event selection);
- live eclipse alignment presentation for an active solar or lunar event (derived from existing live geometry; does not change event truth);
- eclipse event-information copy, relative time-to-event, and restrained map labels (presentation of the same frame; does not change event truth);
- which dynamic snapshot is resolved for each source.
Display formatting never mutates the instant. Reference zone, reference city, and top-band mode change presentation and — for the phased tape — where a civil hour is read. They do not change what time it is. Time formatting helpers live in src/core/timeFormat.ts, wallTimeInZone.ts, timeZoneOffset.ts, and civilProjection.ts; none of them feed back into the instant.
Current-only live feeds are gated by live-enough product time. isProductTimeLiveEnough in src/core/liveProductTimePolicy.ts compares the product instant to wall-clock now with an inclusive ±5 minute window (not user-configurable). Ordinary current-time operation qualifies; paused Demo still within the window qualifies; 2017/2030 Demo and accelerated playback that has walked outside the window do not. Wall-clock now is the same top-of-frame Date.now() already used to distinguish real vs demo time. It is not a second display clock. See ADR 0013.
Full contract: docs/specs/scene/dynamic-data-lifecycle.md. Summarised here only as it appears in the application.
Runtime lives in src/lifecycle/. createDynamicDataLifecycleHost bundles a versioned snapshot store, a per-source lifecycle state machine (idle / loading / ready / stale / error), a product-time resolver, an acquisition controller, and the materializers.
Acquisition is periodic and runs on an injectable timer, never inside requestAnimationFrame, a layer constructor, or a RenderPlan builder. Refresh cadence is per source: 8 minutes for Clouds (GEO publication ~10–15 min; the EUMET ring GetMap is skipped internally unless 30 min have passed), 5 minutes for earthquakes, 2 hours for the ISS TLE (CelesTrak GP update cycle). Enabling a consumer calls startPeriodic with runImmediately: true, so the first fetch does not wait for the interval tick. ISS re-enable with a still-ready in-memory snapshot starts the interval without an immediate re-download. ISS TLE refresh is background maintenance; the marker moves every frame via local SGP4(product UTC). Clouds GetMap and GetCapabilities attempts are bounded at 15 seconds. Earthquake USGS attempts are bounded at 15 seconds so a hung feed can leave Loading and resolve to stale or unavailable.
Each frame the host attaches a read-only view bound to the product instant. When the shell supplies wall-clock now, getPreparedEquirectRaster / getPreparedCloudOpacity / getPreparedPointFeatures / getPreparedTracks return null for catalog timePolicy: "wallClockCurrent" sources unless product time is live-enough. Store snapshots remain; fixture bytes are not painted as a substitute. Layers with no prepared view contribute nothing (missing-prepared-view).
Three live feeds are wired, each classified timePolicy: "wallClockCurrent" under their present implementations. Clouds, earthquakes, and ISS do not present fixture as live: a failed live fetch with no usable live snapshot leaves the overlay unavailable. Last-good live Clouds components may remain as stale while each sector’s observation age is within its source-local stale band (GIBS GEO ≤ 4 h, MSG FES ≤ 2 h, EUMET ring ≤ 8 h). ISS HTTP attempts are bounded at 8 seconds; timeout is not treated as a user abort, so the secondary provider can run in the same cycle. After all-provider failure, a single 5-minute retry is scheduled if ISS remains enabled, then the 2-hour cadence. ADR 0014.
Clouds v3. Durable sourceId remains global-clouds-ir-v1; the user-facing layer is Clouds. The observational path is: provider observations → independent observation/acquisition times → coverage → geometric quality → source authority → provider-specific canonical IR → shared cloud confidence → one composed cloud raster → one RenderPlan imageBlit. Coverage, quality, and signal remain independent planes; signal does not decide source authority. Live authority is a best-current observational composition: NASA GIBS WMS 1.1.1 Band13 GOES-East_ABI_Band13_Clean_Infrared, GOES-West_ABI_Band13_Clean_Infrared, and Himawari_AHI_Band13_Clean_Infrared each with independent explicit TIME; EUMETView WMS 1.3.0 msg_fes:ir108 (Meteosat FES, PT15M) for Europe/Africa; EUMETView mumi:worldcloudmap_ir108 (PT3H geostationary ring) as coverage backstop, not the sole temporal authority. Each sector keeps its own observation time. Do not force min(latest sectors) as a common GetMap TIME. Product snapshot.validTimeMs is the newest contributing observation (resolver compatibility). body.cloudComposite holds per-component observation/acquisition times; status uses the visible age range only (ring age is included only when the ring owns composed geographic pixels). Each cached sector stores a coverage mask (provider alpha > 0), a quality weight (Uint8 viewing geometry; Earth-fixed, cached outside rAF), and cloud signal (canonical-IR cloud confidence). Regional quality uses that sector’s sub-satellite point. Ring quality is independent of ring coverage and ring signal: it is the max of the shared GEO 55°/75° function over documented ring-component SSPs (Meteosat 0°, IODC Meteosat-9 45.5°E, GOES-16 East 75.2°W, GOES-18 West 137.0°W, Himawari-9 140.7°E), version wx53-ring-geo-q1. That is inferred component geometry, not EUMET per-pixel source-id (the WMS has none). Per-pixel authority is: (1) usable regional (coverage && q>0) using the WEATHER-4.3 lexicographic rule; (2) good ring (coverage && ring q>0); (3) q=0 regional via existing freshness/stable order; (4) poor ring (coverage && ring q==0); (5) no data. Quality 0 remains valid coverage and still paints when no better class exists; it does not punch coverage holes. Usable q>0 regionals still beat any ring, including valid-clear. Cloud signal is replaced, including 0 (authoritative clear); it is not blended. Quality is 1 at viewing zenith ≤55°, 0 at ≥75°, smoothstep between. One composed PNG, one imageBlit. Freshness is source-local: GIBS GEO ≤2 h recent / 2–4 h stale / >4 h suppress (ingest lag ~70 min observed); MSG FES ≤45 min / 45 min–2 h / >2 h; EUMET ring ≤4 h / 4–8 h / >8 h. Catalog poll 8 min; ring GetMap skipped if fetched within 30 min. Probe 128×64 then full 2048×1024 PNG; concurrency 2. Bytes decoded and interpreted outside rAF: GIBS Band13 family uses a checked-in NASA GIBS v1.3 colormap (Clean_Longwave_Infrared_Window_Band.xml) into canonical display IR (0 = warm/surface-like, 1 = cold/high-cloud-like). Chromatic pixels use the existing 64³ nearest-segment LUT; near-gray pixels (max(R,G,B)−min(R,G,B) ≤ 8) invert along the warm-gray legend by integer-average luma, because that visualization reuses grayscale on both a cold branch (−79.6…−70.6 °C) and a warm branch (−18.85…+57 °C) and WMS interpolation can mint isolated gray 102 as a false cold spike. Meteosat IR108 and the EUMET ring both use identity grayscale (luma / 255). The former ring black-point 56 is not production; it suppressed ordinary cloud below the shared 0.30 confidence floor while typical clear (luma ~63–73) already sits below that floor. One shared conservative cloud-confidence curve (knots 0.30/0.40/0.52/0.68/0.82) then yields RGB (248, 250, 252). Transfer version wx55-ring-identity-v1. Highlight is applied per sector before compose so a sector update does not re-transfer unchanged components. Factory overlay opacity is 0.42. Adapter retains about 3 observations per sector; the host keeps about 4 composed snapshots. Layers → Weather holds Cloud opacity, observation-age status (Clouds · observations 5–17 min old), optional per-sector times, and EUMETSAT/GIBS attribution. No source selector, no sync-mode toggle, no calibration controls. Browser fetch is the transport. ADR 0022, ADR 0023, ADR 0024, ADR 0025. Coverage authority is LIB-067. Quality-aware overlap is LIB-069. Canonical IR + cloud confidence is LIB-071. Ring-over-q0 authority is LIB-073. Ring component-geometry quality is LIB-075. Chroma-aware GIBS near-gray inversion is LIB-077. Ring canonical identity grayscale is LIB-079.
Durable sourceId |
Feed | When product time is not live-enough |
|---|---|---|
global-clouds-ir-v1 |
Best-current GEO IR composite: GIBS GOES-East/West + Himawari Band13, EUMETView msg_fes:ir108, EUMET ring mumi:worldcloudmap_ir108 backstop (explicit per-sector TIME) |
Overlay suppressed (Clouds remain current-only; no historical TIME query) |
usgs-earthquakes-v1 |
USGS all_day.geojson |
Presentation suppressed |
iss-orbital-track-v1 |
CelesTrak GP TLE (CATNR 25544) primary, Where the ISS at TLE secondary; SGP4 via satellite.js |
Track and current marker suppressed (current TLE is not a historical reconstruction) |
Physical cloud illumination participation is off for Clouds v3. Enabling the Clouds overlay arms acquisition; a stored cloudParticipation mode does not. The overlay factory forces cloudParticipationMode: "off".
Layer masters checkboxes (globalCloudsIr labeled Clouds, earthquakes, orbitalTracks) are the production enablement path. They mutate SceneConfig through updateConfig → commitWorkingV2Update, rebuild the registry, and call syncDynamicLifecycleConsumers(). Factory defaults keep all three off, so ordinary startup fetches nothing. Durable checked state is not cleared when Demo time is historical; Layers shows “Live-only data is hidden while viewing another product time.” while suppressed.
When every current-only consumer of a source is suppressed, armDynamicLifecycleConsumers stops periodic acquisition. Returning inside the live-enough window re-arms immediately (runImmediately: true) from a React effect driven by an eligibility flag — not from rAF.
Each animation frame re-attaches the host and re-reads prepared views, so a completed acquisition becomes visible on the next frame without a separate React invalidation.
ISS current position. Live acquisition stores TLE lines, an origin stamp (live-tle), and the live provider id (celestrak or wheretheiss-at) on the track properties. Each prepared view computes an explicit SGP4 sample at the product UTC (propagateIssPositionAtTime); the RenderPlan marker and ISS label use that sample, not the first or last track vertex. Acquisition still stores a −60 min past / +30 min future snapshot at 2 min steps when the TLE arrives. Layers → Space objects holds ISS presentation: an orbit-track master (hides trajectory lines, not the current glyph), independent past/future toggles and colors, and past/future horizons as tokens (15m / 30m / 45m / 60m / 1orbit / 2orbits / 3orbits / 6orbits; defaults 60 min past / 30 min future). Orbit counts resolve at runtime as N × (1440 / n) minutes, where n is the active TLE line-2 mean motion in revolutions per day. Changing a horizon locally re-propagates samples from the already-acquired TLE around product UTC (2 min cadence; cached by TLE identity, resolved windows, and a sample-step time bucket). No extra TLE fetch. LIB-038 pastMinutes / futureMinutes migrate onto those tokens; explicit 45 min is kept. Shared line thickness (thin/normal/thick; normal matches the previous 1.6 px trail), orbit base color (on-map label family; past track follows when it still matches the previous base), Dot or ISS-silhouette glyph with size and conditional color, and a Show ISS label toggle (default on). Multi-orbit past/future polylines fade by orbit distance from product UTC (alpha only; user hues unchanged). The ISS silhouette uses a contrasting under-stroke then fill/stroke from glyphColor so the configured color is the visible foreground. Changing paint parameters rebuilds the layer registry immediately because sceneRuntimeAffectingEqual includes dynamicTracks.source.parameters; the already-prepared ISS view is reused and no TLE fetch is started. TLE refresh is 2 hours; the marker moves with product time between fetches. Presentation options do not change provenance, freshness, or historical suppression.
Planetary objects. The same Space objects topic holds Planets after ISS. One Layer masters checkbox (planetaryObjects, factory off) owns the derived overlay. Per-body enable, color, and locus toggles live on the scene row; glyph type/size, current-subpoint/label masters, ground-track horizons, and locus duration/thickness/opacity are shared. Geographic subpoints use apparent equator-of-date RA/Dec plus GAST with the same east-positive ±180° wrap as Sun/Moon (lat = Dec, lon = wrap180(RA − GAST)). Ground tracks answer where the body is overhead as product time advances (Earth rotation dominates; 15 min samples). Planetary loci are daily same-clock samples centered on the product UTC calendar date, cached by authority version, body, duration, date, and hour, with a GAST shift inside the hour so accelerated Demo does not rebuild years every frame. Pluto is included. These features are not classified as wall-clock-current internet sources.
Milky Way. Space objects holds Milky Way after Planets. One Layer masters checkbox (milkyWay, factory off) owns the derived overlay. Two independently configurable line presentations share ADR 0017:
- Reference geometry (zenith ribbon). Where Galactic-plane and approximate-band directions are directly overhead at product time. Conservative controls: Galactic plane, band (Narrow ±5° / Normal ±10° / Wide ±15°), sparse ribs, plane/band color and thickness, Galactic center + label, optional anticenter (factory off), and night-side emphasis (factory on when the master is on). Night emphasis reuses the subsolar geometric horizon.
- Visibility (Galactic-center altitude contours). How high the Galactic center is above the geometric horizon from each terrestrial location. Each contour is a small circle around the same GC subpoint as the marker, radius
90° − h. Factory off even when the master is on. When the user enables contours: 30/45/60/75° on, horizon 0° off, astronomical-night emphasis on (stronger where Sun ≤ −18°, smooth through twilight), moonlight de-emphasis on (existing phase × incidence × eclipse transmission; does not erase geometry). One visibility color/thickness. Show contour values (factory on when contours are on) gates only the numeric30°/45°/60°/75°labels; contour lines are unchanged. Not a brightness, seeing, or light-pollution score, and not an aggregated observing-quality forecast. - Viewing windows (reference-city events). Headless
listMilkyWayViewingWindowsinmilkyWayViewingWindows.tsbuilds one primary UTC interval family for the chrome reference city (ADR 0021, policymilky-way-viewing-v2): GC ≥ 15° and ≥ 90% of local nightly max, Sun ≤ −18°, moonlight ≤ 0.08. Nightly max is90° − |lat − GC Dec|. Peak maximizes GC altitude inside the interval. Event ids aremilky-way:<city-id>:<startUtcMs>. User-facing Viewing / Strong / Prime classes are gone; old class keys migrate so any previously enabled class keeps labels on, and deprecated keys are omitted. Factory Enable Milky Way viewing events off; when on, Layers owns map labels, the static peak-UTC viewing footprint (line-only, factory on, color#c97ba8, thickness normal), and the shared event-label advance horizon (default 2 days) for both. Independent of the overlay master, ribbon, and contours. Map labels (upcoming/active) anchor at the Galactic-center subpoint with city copy such asKnoxville · Milky Way · tonight. Time navigation is Data → Event playback (one MW source, no level subfilters), not Layers. Clouds and light pollution are not inputs. HUD event notices are a separate presentation stack (see below).
IAU 1958 Galactic coordinates transform through EQJ and equator-of-date, then the same RA−GAST wrap as planets (ADR 0017). The topic name “Space objects” is slightly strained for an extended celestial structure; renaming is not part of this overlay.
TLE age is productUtcMs − tleEpochMs (not user-configurable): ≤18 h paints as live; 18–48 h paints as degraded (not labeled live); >48 h suppresses ISS (same visual as unavailable). Last-good live TLE kept by stale-when-cached may still paint in the fresh or degraded band; origin is cached/stale, not live, not fixture. Production does not fall back to fixture. All-provider failure with an empty cache leaves ISS unavailable. Layers shows a concise loading / unavailable / degraded hint when the layer is enabled and product time is live-enough (“ISS orbital track is loading…” while the first acquire is in flight with no usable cache). Historical suppression takes precedence: a current-only source outside the live window is hidden even if a snapshot exists, and no loading hint is shown. Re-enable with a still-usable in-memory live TLE paints immediately and does not start another TLE download until the 2-hour cadence (or a later stale/error retry).
Failure policy is stale-when-cached for all three live consumers. Production Clouds, earthquakes, and ISS do not fall back to fixture. First-ever live failure with an empty cache is unavailable. A later Clouds poll failure keeps prior live sector rasters while each component’s observation age is within its source-local stale band; excessively stale components drop out and the ring may backfill that geography. Observation age is productUtcMs − component validTimeMs. A later earthquake poll failure keeps the prior live snapshot while snapshot age is ≤ 60 min (status stale, not live); snapshot age > 60 min suppresses markers as unavailable. Earthquake snapshot age is productUtcMs − acquiredAtMs (how old the USGS copy is), distinct from per-event age (productUtcMs − properties.time). Aborts do not trigger fixture fallback. Clouds Layers copy: loading / observations age-range / mixed freshness / stale / unavailable / DEV fixture / partial coverage; historical live-only copy still wins. Earthquake Layers copy: loading / live / stale / unavailable / DEV fixture; historical live-only copy still wins.
Earthquakes presentation. Live authority remains USGS all_day.geojson (5 min cadence, immediate fetch on enable). Layers → Earthquakes owns local filters over that snapshot: minimum magnitude (factory 2.5+), maximum event age (factory 24 hours), Earthquakes only (factory on; excludes USGS types other than earthquake), Show earthquake labels (factory on), label minimum magnitude (factory 4.0+), and Show label on hover (factory on). Changing those parameters rebuilds the overlay immediately because sceneRuntimeAffectingEqual includes dynamicPointFeatures.source.parameters; no refetch. Markers stay magnitude-scaled orange discs; labels use a compact M4.6 · place formatter rather than the provider title. Persistent labels follow the label master and label-magnitude threshold. Hover uses the same compact formatter on any already-visible marker: it works when persistent labels are off and when an event is below the persistent threshold, and it does not duplicate a label that is already painted. Hover is session-only (pointer CSS scene coordinates → screen-space hit descriptors → one transient hover id → RenderPlan). Hit radius is max(painted radius + 2 CSS px, 7 CSS px). Overlap pick is nearest center, then larger magnitude, then newer event, then stable id. Hover never fetches, never persists, and is not click/selection. Null magnitude is hidden unless minimum magnitude is All. Event timestamps up to 2 min in the future count as age 0. Depth, clustering, selection, tsunami/alert symbology, and earthquake Event Playback are not implemented.
Base-map inventory is declared in a bundled JSON catalog:
src/assets/maps/base-map-catalog.json
The application does not scan public/maps at runtime and does not fetch a remote catalog. Eleven families are bundled:
| Family id | Kind |
|---|---|
equirect-world-legacy-v1 |
Default reference substrate |
equirect-world-topography-ne-v1 |
Natural Earth topography |
equirect-world-political-v1 |
Natural Earth political |
equirect-world-geology-v1 |
USGS-lineage geology |
equirect-world-bathymetry-etopo-v1 |
NOAA NCEI ETOPO 2022 |
equirect-world-landcover-modis-v1 |
NASA MODIS IGBP land cover |
equirect-world-climate-koppen-beck-v1 |
Beck Köppen–Geiger present-day climate |
equirect-world-population-gpw-v1 |
NASA SEDAC GPWv4 population |
equirect-world-blue-marble-bm-v1 |
Blue Marble, month-aware |
equirect-world-blue-marble-t-v1 |
Blue Marble topography, month-aware |
equirect-world-blue-marble-tb-v1 |
Blue Marble topography+bathymetry, month-aware |
Persisted configuration stores the family id. Concrete rasters are resolved at runtime by baseMapAssetResolve.ts and, for month-aware families, baseMapMonthResolve.ts using the canonical product instant. A month raster path is never persisted. equirect-world-topography-v1 and equirect-world-topo-v1 are legacy resolver aliases for the Blue Marble T family; they are not aliases for the static Natural Earth topography family.
Catalog entries carry previewThumbnailSrc, structured attribution, an optional licenseNote, up to two sourceLinks, and optional capabilities hints consumed by the overlay-readability substrate model (§6). Attribution is catalog-only and is never persisted into SceneConfig; BaseMapStyleControl renders it in a "Source & license" block.
Presentation overrides (brightness, contrast, gamma, saturation) are per-family and resolved by resolveEffectiveBaseMapPresentation. Gamma is applied through the backend's offscreen cache rather than per frame.
Onboarding a new family uses npm run maps:prep -- --update-catalog against a curated source TIFF. Provenance, licensing, dateline-roll handling, and resampling procedure for every asset live in docs/maps/MAP_ASSET_SOURCES.md; the curation policy lives in docs/maps/MAP_ASSET_STRATEGY.md. Do not duplicate provenance elsewhere.
Base maps are substrates. Spatial truth is the projection (src/core/equirectangularProjection.ts), never the image. The 2.0.0 view is the identity camera: full world fills the scene strip. Runtime zoom (LIB-080) maps that strip through SceneCamera at plan construction; see docs/specs/scene/camera-and-reference-frame.md.
The scene fills the window. The configuration UI is an overlay panel.
Opening and closing: press C to toggle, Escape to close, or use the launcher button. The C handler ignores repeats, ignores modified keypresses (Ctrl / Meta / Alt), and ignores the key entirely when focus is in a text-entry element, so typing a lowercase "c" into a field does not close the panel.
Tabs are declared once in src/components/config/configTabs.ts:
| Tab | Owns |
|---|---|
| Layers | Scene stack toggles (Layer masters topic, default): lunar ground track, lunar locus, Solar eclipses, Lunar eclipses, and the other overlay masters. Internal Layers topic selector (UI-only, not persisted; inactive topics unmount; compact selector uses shared .config-topic-nav position: sticky inside the existing .config-tab-panel scroller, not the viewport; changing topic resets that panel’s scrollTop and does not call updateConfig): Map (family, preview, attribution, presentation); Illumination (moonlight, night lights; cloud participation is hidden and non-operative for Clouds v3); Eclipse System (event information / labels; solar and lunar forecast horizons and type filters; lunar eclipse visibility footprint; Moon Earth-shadow; alignment; reference-city details/chrome; independent appearance including lunar footprint color/thickness; active solar eclipse shading enable and Subtle/Normal/Dramatic intensity — presentation only, no event tour); Moon & libration (size, ring/crosshair, map/observer orientation, use-reference-city, color, thickness, motion scale); Astronomy paths (past/future track extents and stroke colors; independent Lunar locus and Solar analemma stroke color/thickness); Space objects (ISS presentation: orbit track, past/future segments, minute/orbit horizons, colors, thickness, glyph, size, label; Planets: Mercury–Neptune plus Pluto body enable/color/locus plus shared current-subpoint, glyph, ground-track, and locus style; Milky Way: zenith-projection Galactic plane / approximate band / ribs / Galactic center, GC altitude contours, viewing-event enable, map labels, and viewing-footprint presentation — not product-time navigation); Earthquakes (local USGS all-day filters: minimum magnitude, maximum age, earthquakes-only, persistent labels and label magnitude, show label on hover, live/stale/unavailable status); Weather (Clouds opacity slider factory 0.42 plus mosaic/observed/stale/unavailable/DEV-fixture/partial-coverage status; no empty radar/wind/lightning sections); Advanced (overlay-readability presentation and per-layer pilots). Optional live overlays (Clouds, earthquakes, ISS) remain Layer masters checkboxes. Planets and Milky Way are also Layer masters (offline astronomy, factory off). Live eclipse event rows live on the map, not in this tab. |
| Pins | Reference cities, custom pins, pin presentation |
| Chrome | Internal Chrome topic selector (UI-only, not persisted; inactive topics unmount; same sticky .config-topic-nav inside .config-tab-panel as Layers; changing topic resets that panel’s scrollTop and does not call updateConfig). Default Reference & clock (hour-label format, civil timezone source, read-point meridian / reference city). Other topics: Bottom HUD; Hour indicators; Tick tape; NATO time zones. There is no nested Chrome-area selector. |
| Geography | Geographic meridian when Chrome read point is Auto (Greenwich vs fixed coordinate) |
| Data | Internal Data topic selector (UI-only, not persisted; same sticky .config-topic-nav as Layers/Chrome). Default Time: pipeline mode and generic Demo-time controls. Event playback: merged solar / lunar / Milky Way sequencing through the shared Demo clock (ADR 0019, ADR 0020). Data owns when the product is viewed; Layers own what is rendered. |
| General | Application-level settings, presets |
Every edit routes through updateConfig → commitWorkingV2Update, so the panel cannot bypass normalization or persistence. Changes are saved immediately; there is no explicit save action.
Demo transport controls in the Data tab post an action (pause / resume / reset) into demoTransportActionRef, which the next frame consumes. Transport state is runtime-only and is not persisted; demoTime.speedMultiplier and startIsoUtc are persisted.
Event playback is a domain sequencer above Demo time, not a second clock (ADR 0015, ADR 0019, ADR 0020). Controls live under Data → Event playback. Layers keep presentation only.
Durable data.eventPlayback stores a shared date range, shared loop / lead-in / post-wait, and enabled event types (solar, lunar, Milky Way). There is no Event family selector and no MW quality-class subfilter. Pre-LIB-052 scene.eclipseTour migrates to solar/lunar-only prefs. LIB-052 { family, eclipse, milkyWay } migrates so the previously selected family remains the enabled set; fresh configs enable all three types. Pre-LIB-057 MW includeViewing / includeStrong / includePrime flags are omitted; if the MW source was enabled it stays enabled. Playback speed is data.demoTime.speedMultiplier only. Runtime phase, current event, and owned Demo start ISO are session-only and start inactive.
On Start, each enabled source reports its first eligible event (MW by incremental chunked search — not a full-range enumeration). The earliest by canonical UTC order becomes the current event; Demo mode/enabled/start jump to its clamped lead-in. At each event’s clamped post-wait the sequencer asks all enabled sources for the next event and jumps Demo start to max(current product time, next lead-in) so forward playback never rewinds except Previous, Reset, loop wrap, or an explicit user date change. Loop wraps to the earliest event in range; loop off pauses at the final allowed instant. Stop deactivates sequencing and leaves product time paused. Reset returns to the current event lead-in. Use current time (and any other manual Demo start edit) deactivates sequencing. Status identifies the navigation event (Event N) without a full matching count.
Solar and lunar use the bundled eclipse catalogs and presentation-coupled subtype filters. Milky Way uses the chrome reference city and one viewing window per opportunity (ADR 0021). Changing enabled types, date range, or lead-in/post-wait while running deactivates playback. A reference-city change deactivates playback only when the Milky Way type is enabled. Map labels and the viewing footprint remain Layers presentation and are independent of Data playback selection.
Forecast horizons and eclipse/Milky Way presentation are not overridden. Enumeration is not performed every frame.
Config-panel time: while the panel is open, configPanelProductInstantMs tracks the render clock. It updates when the UTC calendar month changes (month-aware base-map selector) or when product time jumps by ≥ 1 s (Demo seeks, including event-playback jumps). That avoids per-frame Config re-renders while still keeping viewing-window status on the same product instant as the scene.
Accessibility: the canvas is aria-hidden. Nothing in the scene is represented in the DOM. DOM-based inspection tells you nothing about what is drawn — only pixels do. This matters for any verification approach.
Not a defect list. These are places where the code is doing something subtle for a reason, and where an ordinary-looking change is likely to be wrong.
src/renderer/displayChrome.ts (~1,900 lines). Two coordinate models coexist (§5). The band-height fixed-point solve has two documented traps: the seed must not come from the circle stack, and the loop must converge on intrinsic height rather than row height. Seam wrapping must be applied to anything that can straddle the antimeridian. A change that "simplifies" any of these will produce output that looks nearly right and is wrong.
src/renderer/illuminationShading.ts. The twilight field is continuous, and its constants have been tuned iteratively against visual review (Gaussian sigma for anchor-colour coupling, cooler civil-to-astronomical progression, a capped atmospheric-tint budget, a softened day-side envelope below the daylight-clear cutoff). The constants are interdependent — the tint cap only makes sense against the current sigma and anchor colours. Changing one in isolation shifts the terminator's appearance globally, and there is no pixel baseline to catch it. Treat this file as tuned, and change it only with visual review.
Config normalization and the rebuild predicates. Normalization is idempotent and runs on both sides of every update; the rebuild predicates in workingV2Commit.ts encode which layers capture configuration at construction time. Adding a config field that a layer reads in its constructor without extending sceneRuntimeAffectingEqual or the compatibility predicates produces a stale layer that only refreshes when something else forces a rebuild — an intermittent bug that is hard to attribute.
Illumination resource realization. The illumination field is computed on the CPU per frame and emitted as one rasterPatch. Emissive sampling decodes a bundled raster; cloud participation decodes a JPEG during materialization, outside the frame. The gamma raster cache is keyed on URL, natural dimensions, and gamma. Work added to this path is per-frame, per-texel work — it is the easiest place in the codebase to destroy the frame rate.
Backend layer-type dispatch. The Canvas backend routes by LayerType. A missing dispatch arm is silent: the layer computes state, emits nothing, and nothing appears. A new layer type needs a dispatch arm and a test at the backend boundary.
The legacy layer-flag surface (§7). Transitional and still read. Neither extend it nor delete it casually.
| Path | Responsibility |
|---|---|
src/main.tsx, src/App.tsx |
Entry point and application shell: refs, frame loop, wiring |
src/dev/ |
Development-only visual-scenario registry and process-local session (not a product subsystem) |
src/app/ |
Bootstrap (registry construction), render loop, render bridge, config commit path, demo playback, preset lifecycle |
src/config/ |
Resolvers, defaults, catalogs (base map, presentation), chrome and hour-marker configuration, semantic planning inputs |
src/config/v2/ |
LibrationConfigV2, SceneConfig, normalization, localStorage persistence, user presets |
src/core/ |
Product logic independent of rendering: time and civil projection, live-enough product-time policy, solar and lunar geometry, eclipse authority and Besselian geography, projection maths, illumination policies, overlay-readability frame, substrate lift model |
src/layers/ |
Layer contracts, registry, factory, and one module per layer with its payload type |
src/lifecycle/ |
Dynamic data: contracts, store, manager, resolver, acquisition (live HTTP and fixture), source catalogs, materializers, app-shell host |
src/renderer/ |
Chrome layout and rendering, illumination sampling, realization adapters, scene viewport layout, backend interface |
src/renderer/renderPlan/ |
RenderPlan types, the Canvas executor, and one plan builder per product concern |
src/renderer/canvas/ |
Canvas-specific bridges: fonts, paint, paths, gamma raster cache |
src/glyphs/ |
Procedural glyph geometry for hour markers |
src/typography/ |
Font descriptors, metrics, ink measurement |
src/color/ |
Colour space helpers |
src/components/config/ |
Configuration panel shell, tab strip, and the six tab implementations |
src/assets/ |
Bundled catalogs (base maps, emissive composition, solar and lunar eclipse authority) and the generated font manifest |
src/data/ |
Static reference data (cities) |
tools/ |
maps:prep, fonts:prep, and eclipse:prep asset preparation |
src-tauri/ |
Tauri desktop shell (present, not load-bearing) |
Tests are colocated as *.test.ts / *.test.tsx next to the modules they cover.
ARCHITECTURE.md— the boundaries and invariants this implementation must preserve.docs/VISUAL_VERIFICATION.md— Cursor-native visual verification procedure.docs/decisions/— why the durable choices were made.docs/PROJECT_STRATEGY.md— what the product is for.docs/specs/scene/dynamic-data-lifecycle.md— the dynamic-data contract in full.docs/specs/scene/eclipse-system.md— Eclipse System architecture; E1–E6 are production. Remaining eclipse ideas stay unapproved indocs/FUTURE_FEATURES.md.docs/specs/scene/camera-and-reference-frame.md— scene camera and map reference frame; zoom (LIB-080), pan (LIB-081), Earth-fixed identity (LIB-082), Moon longitude-lock (LIB-083), Moon position-lock (LIB-084), Sun anchoring (LIB-085), the shared anchored production model (LIB-086), position-lock automatic cover zoom (LIB-087), the trackable-map-object target identity (LIB-088), ISS tracking (LIB-089), Tracking target + Tracking mode (LIB-090), click-to-track (LIB-091), city/planet tracking (LIB-092), and Galactic Center/Anticenter tracking (LIB-093) are implemented. A generic target picker remains unscoped.docs/maps/MAP_ASSET_SOURCES.md— asset provenance and licensing.docs/history/— how the system was built, for when the why is not in the code.