# Battle Viewer UX The battle viewer is a dedicated active view for battles (`activeView.view === "battle"`, with `battleId` and `turn` sub-parameters; the app-shell has no per-view URL — see [`navigation.md`](navigation.md)). Bombings are a separate static table in the Reports view (`section-bombings.svelte`). The two domains are deliberately not mixed in any visual surface or click target. ## Data shape The `BattleViewer` component (`lib/battle-player/battle-viewer.svelte`) is logically isolated. It accepts a `BattleReport` matching `pkg/model/report/battle.go`. The fields it uses: - `id`, `planet`, `planetName` — header + the central-planet glyph. - `races: { [raceId]: raceUUID }` — race index space used by the protocol's `a` / `d` fields. - `ships: { [groupKey]: BattleReportGroup }` — ship-group rosters with `race` name, `className`, initial `num`, end-state `numLeft`, and the `inBattle` flag. Observer groups (`inBattle: false`) are never drawn. - `protocol: BattleActionReport[]` — flat list of shots. Each carries attacker `(a, sa)`, defender `(d, sd)`, and `x` (destroyed?). The component asks `timeline.ts.buildFrames(report)` to expand the protocol into `protocol.length + 1` frames; frame 0 is the initial state and frame `N` reflects state after action `protocol[N-1]`. The race index per ship group is derived from the protocol itself — every in-battle group appears at least once as attacker or defender, and the engine never crosses these wires. ## Radial scene The scene (`lib/battle-player/battle-scene.svelte`, SVG) places the planet at the centre and arrays the still-active races on an outer ring at equal angular spacing. Each race anchor hosts a *cloud* of class circles arranged on a Vogel sunflower spiral biased toward the planet (the cluster anchor is pushed inward by a quarter step so the rank-0 node — the heaviest group by NumberLeft — sits closest to the planet, and the spiral fans the rest behind it). When a race is wiped out, it drops out of the active list and the survivors are re-spaced on the next frame. Each class circle is one *bucket* keyed by `(race, className)`: tech-variants of the same class collapse into one node so the scene stays readable when a race fields a dozen tech levels of the same hull. The per-bucket label `:` sums NumberLeft across the underlying groups; per-tech detail is available in the Reports view (Foreign Ship Classes / My Ship Types). Bucket order inside a cluster is **locked at battle start** by the initial ship count (`num` summed across tech variants, descending), together with mass, radius and local position. The static layout lives in `staticBucketsByRace`; the per-frame derivation `renderedByRace` overlays the live `NumberLeft` and drops buckets once they hit zero. The remaining buckets keep their slots in the cloud, so the cluster does not reshuffle when a class empties — the empty bucket simply disappears. Vogel positions are reassigned per rank by their inward distance toward the planet, so the rank-0 bucket (the largest by initial ship count) always sits at the most-inward spiral slot. When two races remain in battle the radial layout switches to the horizontal duel: race 0 at 9 o'clock, race 1 at 3 o'clock. This keeps both race labels clear of the SVG top edge and reads as the two sides facing off naturally. Circle radius scales with per-ship FullMass (Empty + Carrying via the per-ship `LoadQuantity`). The viewer resolves a `(race, className) → ShipClassRef` lookup from the surrounding `GameReport.localShipClass` + `otherShipClass` tables and runs it through the existing wasm bridge to `pkg/calc/ship.go` (`emptyMass` + `carryingMass` + `fullMass`). The radius is then `MIN_RADIUS + (MAX_RADIUS − MIN_RADIUS) × sqrt(mass / maxMassInBattle)` clamped to `[6, 24]` pixels — per-battle normalisation, so the heaviest ship in any given battle renders at the cap. Unknown class or invalid params fall back to MAX_RADIUS so the bucket stays visible. The current frame's shot is drawn as a thin line from the attacker's class circle to the defender's class circle. Colour: - red (`#ee3344`) when the action's `x === true` (the defender ship was destroyed), - green (`#44dd66`) otherwise. Each frame redraws the line in isolation, so continuous playback produces the "shot-shot-shot" pulse the user wanted. ## Playback controls `lib/battle-player/playback-controls.svelte` ships: | Control | Effect | | ------------------ | ------------------------------------------------------- | | ⏮ rewind | Stop, jump to frame 0 | | ◀︎◀︎ step back | Stop, frame ← frame − 1 | | ▶︎ / ⏸ play | Toggle continuous playback | | ▶︎▶︎ step forward | Stop, frame ← frame + 1 | | `Nx` cycle speed | Single button, cycles 1x → 2x → 4x → 6x → 1x; the label shows the current speed (400 / 200 / 100 / 67 ms per frame) | | `Log ▲▼` toggle | Collapses / expands the always-visible text protocol so the user can give the scene the full viewer height | When the timeline is at its end and the user hits play, the frame counter wraps to 0 and continues. Step buttons disable themselves at their boundary. A drag-seek slider sits between the scene and the controls. Dragging pauses playback and lands `frameIndex` on the chosen shot — handy for jumping to the moment a particular race started losing ground. ## Accessibility Below the scene the viewer renders a static `
    ` text protocol — one line per action, formatted from `BattleReportGroup.race` and `BattleReportGroup.className`. The line for the current frame is highlighted so a non-visual reader can follow along by scrolling the log instead of watching the SVG. The list is always present and never hidden; the same data is accessible as a static text log. Each log row is also a `