# Battle Viewer UX Phase 27 ships a dedicated viewer for battles (`/games//battle/`). Bombings stay where they were in Phase 23 — a 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, satisfying the original Phase 27 acceptance "the same data is accessible as a static text log". Each log row is also a `