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
@@ -2,16 +2,33 @@
BattleScene — radial SVG visualisation of one battle frame.
Layout: planet at the centre, race anchors equally spaced on an
outer ring, each race rendered as a cluster of small class circles
labelled `<className>:<numLeft>` underneath. The shot line for the
current frame's `lastAction` is drawn from attacker group to
defender group; red when the shot destroyed the defender, green
otherwise. Observer groups (`inBattle === false`) are filtered out
by `buildFrames`, so they never appear here.
outer ring, each race rendered as a *cloud* of class circles
arranged on a Vogel sunflower spiral biased toward the planet.
Tech-variant groups of the same (race, className) collapse to one
visual node — the per-tech detail lives in Reports. Each circle's
radius scales with the per-ship FullMass (sqrt) so heavy ships
visually dominate.
Observer groups (`inBattle === false`) are filtered out by
`buildFrames`, so they never appear here. Same-race opponents are
forbidden by the engine's combat filter, so a shot can never
collapse to a single visual node.
-->
<script lang="ts">
import { getContext } from "svelte";
import type { BattleReport } from "../../api/battle-fetch";
import {
CORE_CONTEXT_KEY,
type CoreHandle,
} from "$lib/core-context.svelte";
import { layoutRaces } from "./radial-layout";
import {
computeBattleGroupMass,
radiusForMass,
MAX_RADIUS,
type ShipClassLookup,
} from "./mass";
import {
buildGroupRaceMap,
normaliseGroups,
@@ -21,50 +38,113 @@ by `buildFrames`, so they never appear here.
let {
report,
frame,
shipClassLookup,
}: {
report: BattleReport;
frame: Frame;
shipClassLookup?: ShipClassLookup;
} = $props();
const VIEW_BOX = 800;
const CENTER = { x: VIEW_BOX / 2, y: VIEW_BOX / 2 };
const PLANET_RADIUS = 60;
const RACE_RING_RADIUS = 280;
const CLASS_CIRCLE_RADIUS = 24;
const CLASS_SPACING = 64;
// Vogel sunflower step + half-circle bias toward planet.
const BASE_STEP = 1.8 * MAX_RADIUS;
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
const ANCHOR_BIAS = 0.25; // fraction of step pushed toward planet
const MAX_CLUSTER_RADIUS = 0.6 * (RACE_RING_RADIUS - PLANET_RADIUS);
const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY);
const groupRace = $derived(buildGroupRaceMap(report.protocol));
const allGroups = $derived(normaliseGroups(report));
type ClusterEntry = {
key: number;
bucketKey: string;
className: string;
race: string;
raceId: number;
groupKeys: number[];
numLeft: number;
mass: number;
radius: number;
};
// Aggregate every `(raceId, className)` into a single bucket and
// compute per-bucket NumberLeft (sum across tech-variants) and
// per-ship FullMass via the wasm bridge. mass=0 when the class
// either doesn't resolve in the lookup or the calc rejects its
// params; downstream `radiusForMass` falls back to MAX_RADIUS for
// those nodes.
const clustersByRace = $derived.by(() => {
const core = coreHandle?.core ?? null;
// First pass: build the bucket list per race.
const out = new Map<number, ClusterEntry[]>();
const bucketIndex = new Map<string, ClusterEntry>();
for (const g of allGroups) {
const numLeft = frame.remaining.get(g.key) ?? 0;
const list = out.get(g.raceId) ?? [];
list.push({
key: g.key,
className: g.group.className,
numLeft,
});
out.set(g.raceId, list);
const bucketKey = `${g.raceId}::${g.group.className}`;
let bucket = bucketIndex.get(bucketKey);
if (bucket === undefined) {
const classDef =
shipClassLookup?.get(g.group.race, g.group.className) ?? null;
const mass = core
? computeBattleGroupMass(g.group, classDef, core)
: 0;
bucket = {
bucketKey,
className: g.group.className,
race: g.group.race,
raceId: g.raceId,
groupKeys: [],
numLeft: 0,
mass,
radius: MAX_RADIUS,
};
bucketIndex.set(bucketKey, bucket);
const list = out.get(g.raceId) ?? [];
list.push(bucket);
out.set(g.raceId, list);
}
bucket.groupKeys.push(g.key);
bucket.numLeft += frame.remaining.get(g.key) ?? 0;
}
// Stable cluster order: by classname then key.
// Per-battle mass normalisation: the heaviest visual node
// renders at MAX_RADIUS; lighter ones scale by sqrt(m/max).
let maxMass = 0;
for (const bucket of bucketIndex.values()) {
if (bucket.mass > maxMass) maxMass = bucket.mass;
}
for (const bucket of bucketIndex.values()) {
bucket.radius = radiusForMass(bucket.mass, maxMass);
}
// Sort buckets in each cluster by NumberLeft desc → rank 0 is
// the biggest group (will be placed closest to planet).
for (const list of out.values()) {
list.sort((a, b) => {
const byName = a.className.localeCompare(b.className);
if (byName !== 0) return byName;
return a.key - b.key;
if (b.numLeft !== a.numLeft) return b.numLeft - a.numLeft;
return a.className.localeCompare(b.className);
});
}
return out;
});
// bucketByGroupKey lets shot endpoints look up the aggregated
// node by any of its constituent ship-group keys.
const bucketByGroupKey = $derived.by(() => {
const out = new Map<number, ClusterEntry>();
for (const list of clustersByRace.values()) {
for (const bucket of list) {
for (const key of bucket.groupKeys) {
out.set(key, bucket);
}
}
}
return out;
});
const raceLayout = $derived(
layoutRaces(frame.activeRaceIds, {
center: CENTER,
@@ -72,23 +152,62 @@ by `buildFrames`, so they never appear here.
}),
);
function classCircleX(index: number, count: number): number {
const span = (count - 1) * CLASS_SPACING;
return -span / 2 + index * CLASS_SPACING;
type ClusterBasis = {
anchorX: number;
anchorY: number;
ux: number;
uy: number;
vx: number;
vy: number;
step: number;
};
// clusterBasisById carries the local frame for every active
// race's cluster: `u` points from the race anchor toward the
// planet, `v` is `u` rotated 90° clockwise, and `step` is the
// adaptive Vogel-spiral step (scaled down when many class
// buckets share a cluster to stay inside MAX_CLUSTER_RADIUS).
const clusterBasisById = $derived.by(() => {
const out = new Map<number, ClusterBasis>();
for (const anchor of raceLayout) {
const dx = CENTER.x - anchor.x;
const dy = CENTER.y - anchor.y;
const len = Math.hypot(dx, dy) || 1;
const ux = dx / len;
const uy = dy / len;
const vx = uy; // (ux, uy) rotated 90° clockwise → (uy, -ux);
const vy = -ux; // use mirror to keep label below cluster.
const count = (clustersByRace.get(anchor.raceId) ?? []).length;
const baseStep = BASE_STEP;
const denom = Math.max(1, Math.sqrt(Math.max(count, 1)));
const step = Math.min(baseStep, MAX_CLUSTER_RADIUS / denom);
const anchorX = anchor.x + ANCHOR_BIAS * step * ux;
const anchorY = anchor.y + ANCHOR_BIAS * step * uy;
out.set(anchor.raceId, { anchorX, anchorY, ux, uy, vx, vy, step });
}
return out;
});
function nodePosition(basis: ClusterBasis, rank: number) {
const radius = basis.step * Math.sqrt(rank);
const angle = rank * GOLDEN_ANGLE;
const offsetU = radius * Math.cos(angle);
const offsetV = radius * Math.sin(angle);
return {
x: basis.anchorX + offsetU * basis.ux + offsetV * basis.vx,
y: basis.anchorY + offsetU * basis.uy + offsetV * basis.vy,
};
}
function findClassCircleCenter(groupKey: number) {
const raceId = groupRace.get(groupKey);
if (raceId === undefined) return null;
const anchor = raceLayout.find((a) => a.raceId === raceId);
if (!anchor) return null;
const cluster = clustersByRace.get(raceId) ?? [];
const idx = cluster.findIndex((c) => c.key === groupKey);
if (idx === -1) return null;
return {
x: anchor.x + classCircleX(idx, cluster.length),
y: anchor.y,
};
const bucket = bucketByGroupKey.get(groupKey);
if (!bucket) return null;
const basis = clusterBasisById.get(bucket.raceId);
if (!basis) return null;
const cluster = clustersByRace.get(bucket.raceId) ?? [];
const rank = cluster.indexOf(bucket);
if (rank === -1) return null;
return nodePosition(basis, rank);
}
const shotLine = $derived.by(() => {
@@ -105,6 +224,13 @@ by `buildFrames`, so they never appear here.
for (const g of allGroups) {
out.set(g.raceId, g.group.race);
}
// `groupRace` covers protocol-derived races even when no group
// is left to read from `allGroups` (shouldn't happen with
// `inBattle: true` rosters, but keeps the label resolver
// defensive).
for (const [, raceId] of groupRace) {
if (!out.has(raceId)) out.set(raceId, `race ${raceId}`);
}
return out;
});
</script>
@@ -112,6 +238,7 @@ by `buildFrames`, so they never appear here.
<svg
class="battle-scene"
viewBox="0 0 {VIEW_BOX} {VIEW_BOX}"
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label="battle scene"
data-testid="battle-scene"
@@ -132,6 +259,7 @@ by `buildFrames`, so they never appear here.
{#each raceLayout as anchor (anchor.raceId)}
{@const cluster = clustersByRace.get(anchor.raceId) ?? []}
{@const basis = clusterBasisById.get(anchor.raceId)}
<g
class="race-cluster"
data-testid="battle-race-cluster"
@@ -139,30 +267,29 @@ by `buildFrames`, so they never appear here.
>
<text
x={anchor.x}
y={anchor.y - CLASS_CIRCLE_RADIUS - 12}
y={anchor.y - MAX_RADIUS - 12}
text-anchor="middle"
class="race-label"
>{raceLabelById.get(anchor.raceId) ?? `race ${anchor.raceId}`}</text>
{#each cluster as entry, i (entry.key)}
{@const cx = anchor.x + classCircleX(i, cluster.length)}
<g
class="class-marker"
data-testid="battle-class-marker"
data-group-key={entry.key}
>
<circle
cx={cx}
cy={anchor.y}
r={CLASS_CIRCLE_RADIUS}
/>
<text
x={cx}
y={anchor.y + CLASS_CIRCLE_RADIUS + 16}
text-anchor="middle"
class="class-label"
>{entry.className}:{entry.numLeft}</text>
</g>
{/each}
{#if basis}
{#each cluster as entry, rank (entry.bucketKey)}
{@const pos = nodePosition(basis, rank)}
<g
class="class-marker"
data-testid="battle-class-marker"
data-bucket-key={entry.bucketKey}
data-class-name={entry.className}
>
<circle cx={pos.x} cy={pos.y} r={entry.radius} />
<text
x={pos.x}
y={pos.y + entry.radius + 12}
text-anchor="middle"
class="class-label"
>{entry.className}:{entry.numLeft}</text>
</g>
{/each}
{/if}
</g>
{/each}
@@ -183,7 +310,7 @@ by `buildFrames`, so they never appear here.
<style>
.battle-scene {
width: 100%;
height: auto;
height: 100%;
background: #0a0d1a;
display: block;
}
@@ -210,7 +337,7 @@ by `buildFrames`, so they never appear here.
}
.class-label {
fill: #b8c0e6;
font-size: 12px;
font-size: 11px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.shot {
@@ -10,12 +10,19 @@ is logically isolated: feed it any `BattleReport` matching
import { i18n } from "$lib/i18n/index.svelte";
import type { BattleReport } from "../../api/battle-fetch";
import type { ShipClassLookup } from "./mass";
import BattleScene from "./battle-scene.svelte";
import PlaybackControls from "./playback-controls.svelte";
import { buildFrames } from "./timeline";
let { report }: { report: BattleReport } = $props();
let {
report,
shipClassLookup,
}: {
report: BattleReport;
shipClassLookup?: ShipClassLookup;
} = $props();
const frames = $derived(buildFrames(report));
let frameIndex = $state(0);
@@ -81,7 +88,7 @@ is logically isolated: feed it any `BattleReport` matching
</header>
<div class="scene">
<BattleScene {report} {frame} />
<BattleScene {report} {frame} {shipClassLookup} />
</div>
<PlaybackControls
@@ -113,6 +120,9 @@ is logically isolated: feed it any `BattleReport` matching
flex-direction: column;
gap: 0.75rem;
max-width: 880px;
width: 100%;
flex: 1 1 auto;
min-height: 0;
margin: 0 auto;
padding: 1rem;
color: #d6dcf2;
@@ -122,6 +132,7 @@ is logically isolated: feed it any `BattleReport` matching
display: flex;
justify-content: space-between;
align-items: baseline;
flex: 0 0 auto;
}
.header h2 {
margin: 0;
@@ -139,6 +150,16 @@ is logically isolated: feed it any `BattleReport` matching
border: 1px solid #1e264a;
border-radius: 4px;
overflow: hidden;
flex: 1 1 auto;
min-height: 0;
}
.log {
flex: 0 1 auto;
min-height: 4rem;
max-height: 30vh;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.log h3 {
margin: 0 0 0.4rem;
@@ -146,15 +167,17 @@ is logically isolated: feed it any `BattleReport` matching
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
flex: 0 0 auto;
}
.log ol {
list-style: decimal inside;
margin: 0;
padding: 0;
font-size: 0.85rem;
max-height: 14rem;
overflow-y: auto;
color: #c6cdf0;
flex: 1 1 auto;
min-height: 0;
}
.log li {
padding: 0.15rem 0;
+109
View File
@@ -0,0 +1,109 @@
// Mass helpers for the Battle Viewer.
//
// Phase 27 refinement: ship-class circles are sized by per-ship
// FullMass (Empty + carrying), with a 6..24 px range. The viewer
// resolves a `(race, className) → ShipClassRef` lookup from the
// surrounding GameReport's `localShipClass` / `otherShipClass`
// tables and feeds it through the wasm bridge to `pkg/calc/ship.go`.
//
// Pure utilities live here; the Svelte components consume them.
import type { Core } from "../../platform/core/index";
import type { BattleReportGroup } from "../../api/battle-fetch";
/** Smallest visible ship circle. Picked so the `<class>:<n>` label
* stays legible on every viewport. */
export const MIN_RADIUS = 6;
/** Largest ship circle. Matches the Phase-27 baseline so heavy
* ships keep their previous visual prominence. */
export const MAX_RADIUS = 24;
/**
* ShipClassRef is the minimum slice of a ship class needed to
* compute its mass. Mirrors the relevant fields of
* `ShipClassSummary` (own classes) and `ReportOtherShipClass`
* (foreign classes) without coupling the viewer to either type.
*/
export interface ShipClassRef {
drive: number;
weapons: number;
armament: number;
shields: number;
cargo: number;
}
/**
* ShipClassLookup resolves `(race, className)` to a ship-class
* descriptor. Returns `null` when the class is not in the parent
* report — happens with legacy-mode foreign races that lack a
* `<Race> Ship Types` block.
*/
export interface ShipClassLookup {
get(race: string, className: string): ShipClassRef | null;
}
/**
* computeBattleGroupMass returns the per-ship FullMass for a given
* battle group. Mass=0 means "unknown" — either the wasm bridge
* rejected the ship-class params (degenerate weapons/armament pair)
* or the class did not resolve in the lookup. Either way the
* caller's downstream `radiusForMass` falls back to MAX_RADIUS so
* the node stays visible.
*
* Cargo never changes during a battle, so this can be cached per
* `(race, className)` bucket for the lifetime of the viewer
* session.
*/
export function computeBattleGroupMass(
group: BattleReportGroup,
classDef: ShipClassRef | null,
core: Core,
): number {
if (classDef === null) return 0;
const empty = core.emptyMass({
drive: classDef.drive,
weapons: classDef.weapons,
armament: classDef.armament,
shields: classDef.shields,
cargo: classDef.cargo,
});
if (empty === null) return 0;
const cargoTech = classDef.cargo * (group.tech.CARGO ?? 0);
const carrying = core.carryingMass({
load: group.loadQuantity,
cargoTech,
});
return core.fullMass({ emptyMass: empty, carryingMass: carrying });
}
/**
* radiusForMass maps an absolute ship mass to a circle radius via
* a per-battle normalisation: the heaviest visual node always
* renders at MAX_RADIUS, lighter ones scale by sqrt(mass /
* maxMassInBattle) so the smallest ships don't disappear and the
* heaviest ones don't dominate the scene at >MAX_RADIUS. mass<=0
* falls back to MAX_RADIUS so unresolved/invalid classes stay
* visible.
*/
export function radiusForMass(mass: number, maxMassInBattle: number): number {
if (maxMassInBattle <= 0 || mass <= 0) return MAX_RADIUS;
const scaled = MIN_RADIUS + (MAX_RADIUS - MIN_RADIUS) * Math.sqrt(mass / maxMassInBattle);
if (scaled < MIN_RADIUS) return MIN_RADIUS;
if (scaled > MAX_RADIUS) return MAX_RADIUS;
return scaled;
}
/**
* MapShipClassLookup is a `Map<string, ShipClassRef>`-backed
* implementation of `ShipClassLookup`. Key encoding mirrors the
* one battle.svelte uses when populating the lookup from the
* parent GameReport.
*/
export class MapShipClassLookup implements ShipClassLookup {
constructor(private readonly map: Map<string, ShipClassRef>) {}
get(race: string, className: string): ShipClassRef | null {
return this.map.get(`${race}::${className}`) ?? null;
}
}