# Observation Visual Elements Catalog of the animated visual elements Observation Mode produces. Everything here is drawn procedurally at runtime — canvas 2D in `js/observation-engine.js`, inline SVG generated in `js/app.js` and `js/observation-bezels.js`, and CSS keyframes in `css/style.css`. There are no image assets in this project. --- ## The two rendering paths Observation Mode composites **two independent stacks**, and which one a universe uses is the single most important fact when deciding where a new element goes. | | Canvas path | SVG path | | --- | --- | --- | | Owner | `ObservationEngine.render()` | `getObservationSvg()` / `buildObservationSceneMarkup()` in `js/app.js` | | DOM node | `#observation-canvas` | `#observation-stage` (art) + `#observation-sim-layer` (motion), z-index 1 and 2 | | Motion | per-frame in a `requestAnimationFrame` loop | CSS animations + JS spline morphing | | Used by | universes with `canvas: true` | every universe, but it is the *only* path for `canvas: false` ones | `OBSERVATION_CANVAS` (top of `js/observation-engine.js`) is the manifest. A universe not declared `canvas: true` draws nothing on the canvas at all — three separate barriers enforce this (`canvasEnabled()` in `render()`, canvas `visibility: hidden` in `start()`, and the stage SVG's own opaque background rect). Its observation display is entirely bespoke SVG art. ### Layer declarations | Layer | starflight | whataverse | deepspace | spacestations | others | | --- | :-: | :-: | :-: | :-: | :-: | | `celestial` | ● | ● | | | | | `starfield` | ● | | ● | | | | `deepSpaceField` | | | ● | | | | `constellations` | | | ● | | | | `shootingStars` | | | ● | ● | | | `stationPanorama` | | | | ● | | | `traffic` | | | | ● | | | `events` | | | ● | ● | | | `reticles` | | | | ● | | | `cinematics` | | | | ● | | | canvas enabled | ● | ● | ● | ● | — | `industrial`, `bioships`, `retrofuture`, `military`, `outlaw` and `comedy` are all `canvas: false` with no layers. **Dormant layer:** `renderNebulae()` exists and is fully implemented with a per-universe palette, but no universe currently declares `nebulae`. It is available for reuse without new code. Draw order in `render()` is fixed and should not be reshuffled: stationPanorama → nebulae → celestial → deepSpaceField → starfield → constellations → shootingStars → traffic/events → reticles. --- ## Canvas celestial elements | Element | Where drawn | Description | Where used | | --- | --- | --- | --- | | **Time Vortex** | `renderCelestialObjects()` | 14 nested ellipses receding on a z-cycle, alternating cyan and gold strokes, counter-rotating by ring parity. Deliberately drawn on pure black with no starfield. | `whataverse` | | **Class-M Planet** | `renderCelestialObjects()` | Large body with a Rayleigh-scattering atmosphere halo, a radial terminator gradient, and a bright rim arc on the lit limb. | `starflight` | | **Orbiting Moon** | `renderCelestialObjects()` | Small grey disc on a squashed ellipse (×1.7 / ×0.6) around the Class-M planet, with an offset shadow disc. | `starflight` | | **Deep-Space Planet** | `regenerateDeepSpaceField()` / `renderDeepSpaceField()` | 2–4 per session from 6 distinct world palettes (gas giant, ice world, ion-storm giant, seismic rocky, biosignature rocky, frozen dormant). 50% chance of rings, 0–2 moons each, independent slow drift. | `deepspace` | | **Nebula Cloud** | `renderDeepSpaceField()` | 3–5 soft morphing blobs, radius 220–620 px, alpha 0.035–0.09, each with its own drift, pulse and morph seed. | `deepspace` | | **Dust Lane** | `renderDeepSpaceField()` | 4–7 thin angled streaks, 260–620 px long, for texture distinct from the soft blobs. | `deepspace` | | **Derelict Silhouette** | `renderDeepSpaceField()` | 70% chance of 1–2 segmented megastructure hulks with a blinking beacon and a `DERELICT // NO SIGNAL` callout. | `deepspace` | | **Anomaly** | `renderDeepSpaceField()` | 1–2 per session, kind `lensing` or `radiation`, each gated behind its own random `minActivity` threshold of 0.15–0.55. | `deepspace` | | **Computer Callout** | `drawComputerCallout()` | Shared annotation primitive: scanning bracket, periodic ping ring, leader line and label. Attached to every planet, derelict and anomaly. | `deepspace` | | **Generic Nebulae** | `renderNebulae()` | 4 ambient morphing blobs with a per-universe palette (`getNebulaPalette()` covers whataverse, industrial, bioships and a default). | *dormant — no universe declares it* | ### Station panorama elements The `spacestations` sky is a **360° panorama in azimuth/elevation space**, not a forward-motion starfield: the station rotates in place, one full revolution every 240 s, with a ~22% field of view. Generated once per page load. | Element | Description | | --- | --- | | **Panorama Starfield** | 900 stars around the full circle, power-curve size distribution (mostly pinpricks), per-star twinkle speed and phase. | | **Panorama Nebulae** | 3–5 patches, own palette, slow pulse. | | **Panorama Planet** | 1–2 bodies, banded, 45% rings, 0–2 moons, own light angle. | | **Distant Sun** | One, small radius, warm or cool tint. | | **Sister Station** | 1–2 far-off structures with optional ring and blinking beacons. | | **Asteroid Cluster** | 9–18 procedurally faceted rocks (5–8 vertices each) with individual rotation. | Major features are **slot-spread** around the circle rather than randomly placed, so one notable object drifts through the window every so often instead of everything clumping into one bearing. --- ## Canvas motion elements | Element | Method | Description | Where used | | --- | --- | --- | --- | | **Parallax Starfield** | `renderStarfield()` | 3D projected star volume with per-universe profile (count, size range, color set, warp-streak flag). Starflight gets a bespoke calm 150-star cool profile; everything else falls back to `DEFAULT_STARFIELD_PROFILE` (340 stars, 10 colors). | `starflight`, `deepspace` | | **Warp Streaks** | `renderStarfield()` + `render()` | In `warp` flight mode stars elongate and the frame clears to a translucent fill instead of solid, producing a motion-blur trail. Station view never smears. | starfield universes | | **Shooting Star** | `spawnShootingStar()` / `updateShootingStars()` / `renderShootingStars()` | Occasional fast streak with a fading tail. | `deepspace`, `spacestations` | | **Constellation Lines** | `regenerateConstellation()` / `renderConstellations()` | Faint synthetic point set with connecting lines that fade in, hold, and fade out. Independent of the main starfield. | `deepspace` | | **Traffic Vessel** | `spawnTraffic()` / `drawShipVessel()` | A ship crossing the frame on a linear path over 12–26 s, with an engine particle trail. 12% chance each of a warp-flash entry and/or warp-jump exit. | `spacestations` (and any universe declaring `traffic`) | | **Comet** | `spawnEvent('comet')` | 5 s drifting event with lateral velocity. | `deepspace`, `spacestations`, cinematics | | **Warp Flash** | `spawnEvent('warp-flash')` | 1.4 s stationary flash, also used as the arrival marker for warp-entry traffic. | `deepspace`, `spacestations`, cinematics | | **Target Reticle** | `renderTargetReticles()` | LCARD corner brackets around each traffic vessel with a leader line and text tag. | `spacestations` | ### Traffic vessel types `drawShipVessel()` draws each by type. `spawnTraffic()` picks the type and label from the active universe: | Type | Label pattern | Universe | | --- | --- | --- | | `shuttle` | `SHUTTLE // TYPE-9` | default / starflight | | `cruiser` | `USS GIBRALTAR // NCC-#####` | starflight, military | | `runabout` | `RUNABOUT YANGTZE // NCC-72452` | starflight | | `tardix` | `TYPE 4D TIME CAPSULE // DRIFT` | whataverse | | `freighter` | `HEAVY HAULER // CLASS IV` | industrial, outlaw | | `fighterwing` | `VIPER WING // FLIGHT n` | military | | `bioshippod` | `SPAWN POD // DRIFTING` | bioships | | `retrosaucer` | `ATOMIC CRUISER // SAUCER CLASS` | retrofuture | Note that most of these universes are `canvas: false`, so their vessel type is defined but only reachable if `traffic` is later declared for them. The drawing code already exists. --- ## SVG scene elements `buildObservationSceneMarkup(theme)` populates `#observation-sim-layer` with motion entities over the bespoke stage art. Three builders cover every case: | Builder | Motion | Parameters | | --- | --- | --- | | `obsFlightMarkup` | Travel along a cubic Bézier | `x0..x3, y0..y3, duration, delay, mode` (`arrival` / `cruise` / `depart`), `scale0→scale1`, `minActivity`, `opacity` | | `obsOrbitMarkup` | Elliptical orbit | `cx, cy, rx, ry, duration, phase, scale0→scale1`, `minActivity`, `opacity` | | `obsFloatMarkup` | Bounded drift around a point | `cx, cy, ampX, ampY, duration, phase`, `minActivity`, `opacity` | Per-theme casts: | Theme | Entities | | --- | --- | | `starflight` | 2 flights (scout arrival, shuttle cruise), 2 orbits (sensor blip, diamond marker) | | `whataverse` | 3 orbiting glyphs (◎ ∆ ∞), 2 floating vortex fragments | | `industrial` | 4 camera-feed-clipped groups: bay drone, 3 embers, airlock tell-tale, corridor lamp — each parented to a clip path matching one feed's picture area | | `bioships` | 3 flowing particles, 1 orbiting node, 1 breathing membrane | | `retrofuture` | 2 vector-outline ships, 1 orbiting scope blip, 1 Lissajous figure | | `military` | 1 four-ship formation, 1 hostile contact, 1 orbiting CAP marker | | `deepspace` | 1 very slow distant ship, 1 comet, 1 large drifting ringed planet | | `outlaw` | 1 runner, 1 pursuer, 1 floating needle gauge | | `spacestations` | 5 clipped entities: arrival, departure, freighter, holding pattern, near pass | | `comedy` | 1 tour ship, 1 orbiting `?`, 1 tumbling cube | **Clipping is a rule, not a detail.** The sim layer sits *above* the stage art (z-index 2 vs 1), so any entity that should appear inside a viewport, camera feed or window must be wrapped in a matching `clipPath`. The industrial theme's comment records exactly what went wrong when entities floated free in screen space. ### Spline morphing `initializeObservationSplineMorphs()` and `updateObservationSplineMorphs()` parse the stage SVG's path data and continuously re-target control points, so static-looking bespoke art breathes. `obsThemeSplineScale(theme)` sets the per-theme amplitude; `pulseObservationSplineMorphs()` kicks it on activity. --- ## Transient activity effects `triggerObservationActivity(source)` spawns short-lived generative overlays. Each universe has its own six-effect vocabulary: | Universe | Effect vocabulary | | --- | --- | | `starflight` | contact, vector, data, ring, diagnostic, streak | | `whataverse` | echo, glyphs, coordinate, warp, rings | | `industrial` | signal, dropout, vapour, motion, gain, warning | | `bioships` | neural, spores, ripple, tendril, organ, metric | | `retrofuture` | blip, scope, counter, vector, bloom, reel | | `military` | contact, intercept, formation, sector, status, sweep | | `deepspace` | anomaly, comet, lens, spectral, planet, signal | | `outlaw` | glitch, route, contact, signal, gauge, rear | | `spacestations` | dock, depart, guidance, traffic, beacon, queue | | `comedy` | route, oddity, planet, status, contact, geometry | Shared primitives every generator can reuse: `obsTextCard()`, `obsContact()`, `obsExpandingRing()`, plus `obsRand` / `obsInt` / `obsPick` / `obsHex` and the easing helpers. **Two trigger sources:** - `'telemetry'` — driven by the `scifi-telemetry-activity` audio event. Gated by `responseChance = 0.18 + activity * 0.82`, so at low activity many audio pulses pass silently and at maximum every pulse gets a visible response. - `'ambient'` — a self-rescheduling timer whose interval curves from 12.5 s at minimum activity down to 1.5 s at maximum, plus 25–75% jitter. Above activity 0.55 (telemetry) and 0.82 (any source) a second and third concurrent effect can spawn, staggered 120–380 ms apart. --- ## Frame, chrome and post effects | Element | Where | Description | Where used | | --- | --- | --- | --- | | **Viewport Bezel** | `ObservationBezels.getViewportFrameSvg()` | Procedural SVG window frame, keyed by universe and — for starflight — by the active preset's *era*, so TOS gets a hexagonal amber bridge bezel and TNG/Wayfarer get their own. | all except `spacestations`, which has its own native station window in the stage art | | **Support Pillars** | inside each bezel | Optional vertical struts across the viewport. **Default off.** | bezel universes | | **Glass Sheen** | `.observation-glass-sheen` | Static specular sheen over the viewport. | all | | **Scanlines** | `.observation-scanlines` + `updateScanlineBreathing()` | CRT scanline overlay whose opacity breathes with audio energy (base 0.14). | all | | **Vignette** | `.observation-vignette` | Edge darkening. | all | | **Alert Wash** | `.observation-alert-wash.alert-red` / `.alert-yellow` | Full-screen color wash driven by `AlertSynth` state via `updateAlertState()`. | all | | **Camera Wobble** | `cameraWobble` in `update()` | Slow sway of the projection center, so the canvas never feels locked to the frame. | canvas universes | | **Viewport Vibration** | `updateViewportVibration()` | Short high-frequency shake driven by bass energy and warp pulses. | canvas universes | | **Lighting Cycle** | `updateLightingCycle()` / `getLightingModifiers()` | A 25-minute ambient cycle that modulates overall brightness and tint — the slowest animation in the app. | canvas universes | | **Waveform** | `renderWaveform()` | 30-bar HUD spectrum on its own small canvas, colored from the live `--primary-accent` CSS variable. | all | | **Status Ticker** | `getTickerMessages()` / `updateStatusTicker()` | Scrolling HUD line, per-universe message sets. | all | | **Cinematic Caption** | `queueCinematicAction('flash-status')` | Transient headline text driven by the cinematic director. | cinematic universes | ### Cinematic sequences `updateCinematicDirector()` fires a timed multi-step sequence every 45–75 s initially, then 90–180 s after each one completes. | Sequence | Steps | Universes | | --- | --- | --- | | **First Contact** | comet → unknown vessel + emphasis flyby → hailing frequencies → warp flash | starflight, deepspace, spacestations | | **Hull Breach** | warp pulse + stress caption → red alert → damage control → stabilized + alert off | military, outlaw, industrial | | **Temporal Anomaly** | flux detected → random warp flash → vortex stabilizing | whataverse | | **Bio Resonance** | resonant pulse → comet → dissipating | bioships | | **Close Flyby** | one emphasized flyby (×1.6 scale, ×0.55 duration) | **every** universe | Note that Hull Breach reaches into `AlertSynth` — a cinematic can change audio state, and it restores it in its final step. ### Reusable CSS animation classes Apply these to any SVG element instead of writing new keyframes: | Class | Effect | | --- | --- | | `.obs-spin` | 18 s linear rotation | | `.obs-spin-slow` | 42 s linear rotation | | `.obs-spin-rev` | 28 s reverse rotation | | `.obs-pulse` | 3.8 s ease-in-out scale pulse | | `.obs-breathe` | 5.5 s ease-in-out soft swell | | `.obs-flicker` | 8 s stepped flicker | | `.obs-blink` | 3.5 s stepped blink | | `.obs-dashflow` | flowing dash offset (20/12 dasharray, 7 s) | All of them set `transform-box: fill-box` and a center origin where relevant. A `prefers-reduced-motion` block collapses every animation inside `.observation-overlay` to a single 1 ms iteration. --- ## Cross-cutting rules **Coordinate systems — three of them, do not mix:** 1. **SVG stage space** — a fixed `viewBox="0 0 1600 900"` with `preserveAspectRatio` slice behavior, so it lands like a CSS `cover` background. All stage and sim-layer coordinates are in this space. 2. **Canvas normalized space** — deep-space objects store `nx` / `ny` as screen fractions from the center and are projected each frame, so they survive window resizes. 3. **Panorama azimuth/elevation** — station features store `a` (0–1 of a full revolution) and `e` (elevation), projected through `stationProjectX/Y()`. `getStationViewportRect()` reproduces the stage SVG's slice math so canvas objects transit through the *actual* window opening rather than the full canvas. **Session-seeded vs. per-frame.** The station panorama and the deep-space field are generated **once per page load** in the constructor and retained for the whole session — they survive entering and leaving Observation Mode, preset switches and window resizes. Only a page reload rolls a new sky. Everything else (traffic, events, shooting stars, constellations, transients) is spawned and discarded continuously. Respect this contract: regenerating a seeded field mid-session is a visible glitch, not a refresh. **Placement clearance.** `placeAway()` in `regenerateDeepSpaceField()` biases new objects away from already-placed ones, measuring in real pixels (converting normalized offsets through canvas width/height, because a canvas is wider than it is tall) and requiring clearance from **both** sides of a pair. The station panorama does the equivalent with even bearing slots. Any new annotated object must go through the same placement, or its callout will collide with another's. **Activity gating.** Nearly every non-essential element carries a `minActivity` threshold and is skipped when the observation activity slider sits below it. This is how one scene serves both "calm ambient wallpaper" and "busy bridge". Give every new element a threshold; 0.05 for things that should almost always be present, 0.5+ for things that should feel like a rare event. --- ## Reuse or invent **Reuse** when the need is structural rather than thematic. The three SVG motion builders, `drawComputerCallout()`, `obsContact()` / `obsExpandingRing()` / `obsTextCard()`, the eight CSS animation classes, the existing eight vessel types, and the dormant `renderNebulae()` layer all cover a wide range of needs with no new code. Declaring an existing layer for another universe in `OBSERVATION_CANVAS` is the cheapest possible addition — the drawing code already runs. **Invent** when a universe needs identity no existing element carries. Then: 1. Decide the path first — canvas or SVG — from the universe's `OBSERVATION_CANVAS` entry. Putting a canvas element in a `canvas: false` universe produces nothing at all. 2. For a canvas layer: add it to the manifest, add a `hasLayer()` guard in `render()` at the right point in the draw order, and never assume a layer is universal. 3. For an SVG entity: use one of the three builders, give it a `minActivity`, and clip it if it belongs inside a framed area. 4. Seed anything expensive once and store it on the engine, following the panorama / deep-space contract. 5. Add a `minActivity` threshold and, for annotated objects, route placement through the clearance helper. 6. Prefer the existing CSS animation classes over new keyframes, and confirm the result still reads correctly under `prefers-reduced-motion`.