Three Phase-27 BattleViewer refinements on top of the radial scene:
1. Height fit. The viewer is pinned to `calc(100dvh − 80px)` so it
never pushes the in-game shell past the viewport. `.active-view`
gains `overflow: hidden` + flex column; `.viewer` becomes a
`flex: 1` child; the always-visible text log shrinks to a 30 dvh
ceiling with its own scroll. A global `body { margin: 0 }`
reset (added to `app.html`) plugs the 16 px the browser's
default body margin used to leak.
2. Mass-based ship-class circles. New `lib/battle-player/mass.ts`
carries the radius formula and the per-battle FullMass compute:
`MIN_RADIUS + (MAX_RADIUS − MIN_RADIUS) * sqrt(mass / max)`,
clamped to `[6, 24] px`. FullMass goes through the existing
wasm bridge (`emptyMass` → `carryingMass` → `fullMass`) — no
new wire fields. The viewer page resolves a
`(race, className) → ShipClassRef` lookup from the parent
GameReport's `localShipClass` + `otherShipClass` tables and
passes it to the viewer via context. Unknown class or
degenerate (weapons/armament) params fall back to MAX_RADIUS
so the bucket stays visible.
3. Cloud cluster layout. Cluster key shifts from per-group
`g.key` to `(raceId, className)` so tech-variants of the same
hull collapse into one visual bucket. The horizontal
classCircleX row is replaced by a Vogel sunflower spiral in
the local `(u, v)` basis — `u` points from the race anchor to
the planet, `v` is `u` rotated 90° clockwise. Buckets are
sorted by NumberLeft desc; the cluster anchor is pushed inward
by a quarter step so rank-0 sits closest to the planet. The
step is adaptive (`min(baseStep, MAX_CLUSTER_RADIUS / sqrt(N))`)
so clusters with many classes do not spill into neighbours.
Tests:
- Vitest: `radiusForMass` covering zero / max / quarter-mass /
out-of-range cases (6 cases).
- Playwright: new `battle-viewer.spec.ts` case asserts
`document.documentElement.scrollHeight - window.innerHeight ≤ 4`
at a 1280×720 desktop viewport. The existing fixture gains
`localShipClass` + `otherShipClass` so the lookup has data to
render proportional circles.
Docs: `ui/docs/battle-viewer-ux.md` rewrites the "Radial scene"
section (cloud layout, mass-based radius, height fit) and adds
a "Height fit" subsection. `docs/FUNCTIONAL.md` §6.5 (+ ru
mirror) get the one-line story about per-mass sizing, cluster
aggregation, and the viewport-locked layout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Engine wire change: Report.battle switched from []uuid.UUID to
[]BattleSummary{id, planet, shots} so the map can place battle
markers without N extra fetches. FBS schema + generated Go/TS
regenerated; transcoder + report controller updated; openapi
adds the BattleSummary schema with a freeze test.
Backend gateway forwards engine GET /api/v1/battle/:turn/:uuid as
/api/v1/user/games/{game_id}/battles/{turn}/{battle_id} (handler
plus engineclient.FetchBattle, contract test stub, openapi spec).
UI:
- BattleViewer (lib/battle-player/) is a logically isolated SVG
radial scene that consumes a BattleReport prop. Planet at the
centre, races on the outer ring at equal angular spacing, race
clusters by (race, className) with <class>:<numLeft> labels;
observer groups (inBattle: false) are not drawn; eliminated
races drop out and survivors re-distribute on the next frame.
- Shot line per frame: red on destroyed, green otherwise; erased
on the next frame. Playback controls: play/pause + step ± +
rewind + 1x/2x/4x speed (400/200/100 ms per frame).
- Page wrapper (lib/active-view/battle.svelte) loads BattleReport
via api/battle-fetch.ts; synthetic-gameId prefix routes to a
fixture loader, otherwise REST through the gateway. Always-
visible <ol> text protocol satisfies the accessibility ask.
- section-battles.svelte links every battle UUID into the viewer.
- map/battle-markers.ts: yellow X cross of 2 LinePrim through the
corners of the planet's circumscribed square (stroke width
clamps from 1 px at 1 shot to 5 px at 100+ shots); bombing
marker is a stroke-only ring (yellow when damaged, red when
wiped). Wired into state-binding.ts; click handler dispatches
battle clicks to the viewer and bombing clicks to the matching
Reports row.
- i18n keys for the viewer in en + ru.
Docs: ui/docs/battle-viewer-ux.md, FUNCTIONAL.md §6.5 + ru
mirror, ui/PLAN.md Phase 27 decisions + deferred TODOs (push
event, richer class visuals, animated re-distribution).
Tests: Vitest unit (radial layout + timeline frame builder +
marker stroke formula + marker primitives), Playwright e2e for
the viewer (Reports link → viewer, playback step, not-found),
backend engineclient FetchBattle (200 / 404 / bad input), engine
openapi freezes (BattleReport, BattleReportGroup,
BattleActionReport, BattleSummary, Report.battle items).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>