ui/phase-27: mass-based circles + cloud cluster + height fit

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>
This commit is contained in:
Ilia Denisov
2026-05-13 15:51:31 +02:00
parent b23649059f
commit 8c260f8715
10 changed files with 544 additions and 77 deletions
+74 -4
View File
@@ -2,11 +2,16 @@
Phase 27 — active-view wrapper around the BattleViewer. Loads the
BattleReport for the supplied `gameId`/`turn`/`battleId` and either
shows the radial playback (BattleViewer), a loading skeleton, or a
not-found state. The viewer itself is a logically isolated
component that takes a `BattleReport` prop — this wrapper owns
loading and routing concerns.
not-found state.
This wrapper also bridges the surrounding GameReport's ship-class
tables into a `(race, className) → ShipClassRef` lookup the viewer
needs to size class circles by ship mass. The viewer remains
prop-driven; we just resolve the lookup once here so the lower
component does not have to know about `RenderedReportSource`.
-->
<script lang="ts">
import { getContext } from "svelte";
import { goto } from "$app/navigation";
import {
@@ -15,6 +20,15 @@ loading and routing concerns.
type BattleReport,
} from "../../api/battle-fetch";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import {
MapShipClassLookup,
type ShipClassLookup,
type ShipClassRef,
} from "../battle-player/mass";
import BattleViewer from "../battle-player/battle-viewer.svelte";
@@ -28,6 +42,42 @@ loading and routing concerns.
battleId: string;
} = $props();
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
// shipClassLookup turns `(race, className)` into the five class
// parameters required by `calc.EmptyMass`. Local classes belong
// to the report recipient (`report.race`); foreign classes carry
// their own `race` field. Lookup is cheap to rebuild whenever the
// report changes — the active-view-host re-renders on turn flips
// anyway.
const shipClassLookup = $derived.by<ShipClassLookup>(() => {
const map = new Map<string, ShipClassRef>();
const report = rendered?.report;
if (report) {
for (const cls of report.localShipClass) {
map.set(`${report.race}::${cls.name}`, {
drive: cls.drive,
weapons: cls.weapons,
armament: cls.armament,
shields: cls.shields,
cargo: cls.cargo,
});
}
for (const cls of report.otherShipClass) {
map.set(`${cls.race}::${cls.name}`, {
drive: cls.drive,
weapons: cls.weapons,
armament: cls.armament,
shields: cls.shields,
cargo: cls.cargo,
});
}
}
return new MapShipClassLookup(map);
});
let state = $state<
| { kind: "loading" }
| { kind: "ready"; report: BattleReport }
@@ -86,7 +136,7 @@ loading and routing concerns.
{i18n.t("game.battle.loading")}
</p>
{:else if state.kind === "ready"}
<BattleViewer report={state.report} />
<BattleViewer report={state.report} shipClassLookup={shipClassLookup} />
{:else if state.kind === "not_found"}
<p class="status" data-testid="battle-not-found">
{i18n.t("game.battle.not_found")}
@@ -98,7 +148,26 @@ loading and routing concerns.
<style>
.active-view {
display: flex;
flex-direction: column;
/*
* The in-game shell renders this active view inside an
* `.active-view-host` with `flex: 1; overflow-y: auto`, but
* the surrounding `.game-shell` uses `min-height: 100vh`,
* so without a hard upper bound the viewer pushes the
* whole shell past the viewport. We pin the active view to
* `100dvh` minus a small allowance for the header chrome
* (in-game Header + optional HistoryBanner = ~66 px on
* desktop) so the internal flex chain can split the
* remaining height between the scene and the always-
* visible log without forcing a page-level scroll.
*/
height: calc(100dvh - 80px);
max-height: calc(100dvh - 80px);
min-height: 0;
overflow: hidden;
padding: 1rem;
box-sizing: border-box;
font-family: system-ui, sans-serif;
color: #d6dcf2;
}
@@ -107,6 +176,7 @@ loading and routing concerns.
gap: 0.5rem;
max-width: 880px;
margin: 0 auto 1rem;
flex: 0 0 auto;
}
.back-btn {
appearance: none;