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
+11 -3
View File
@@ -712,10 +712,18 @@ which forwards verbatim to the engine's
Visual model is radial: the planet sits at the centre, races are Visual model is radial: the planet sits at the centre, races are
placed at equal angular spacing on an outer ring, and each race is placed at equal angular spacing on an outer ring, and each race is
rendered as a horizontal cluster of small ship-class circles rendered as a cloud of ship-class circles arranged on a Vogel
labelled `<className>:<numLeft>`. Observer groups (`inBattle: sunflower spiral biased toward the planet (the largest group by
NumberLeft sits closest to the planet, lighter buckets fan behind).
Tech-variants of the same `(race, className)` collapse into one
visual bucket labelled `<className>:<numLeft>`; per-class detail
stays available in the Reports view. Circle radius scales with
per-ship FullMass (range `[6, 24] px`, per-battle normalisation)
so heavy ships visually dominate. Observer groups (`inBattle:
false`) are not drawn. Eliminated races drop out and the survivors false`) are not drawn. Eliminated races drop out and the survivors
re-spread on the next frame. re-spread on the next frame. The viewer is pinned to the viewport
(scene grows, log scrolls internally) so no page-level scroll
appears.
Each frame is one protocol entry; the shot is drawn as a thin line Each frame is one protocol entry; the shot is drawn as a thin line
from attacker to defender, red on `destroyed`, green otherwise. from attacker to defender, red on `destroyed`, green otherwise.
+13 -5
View File
@@ -729,11 +729,19 @@ Battle Viewer — отдельное представление, заменяю
`GET /api/v1/battle/:turn/:uuid`. `GET /api/v1/battle/:turn/:uuid`.
Визуальная модель — радиальная: планета в центре, расы по внешней Визуальная модель — радиальная: планета в центре, расы по внешней
окружности на равных угловых интервалах, внутри расы — горизонтальный окружности на равных угловых интервалах, внутри расы — облако
кластер маленьких кружков по классам кораблей с подписями кружков по классам кораблей, выложенное Vogel-спиралью с биасом к
`<className>:<numLeft>` под каждым. Наблюдатели (`inBattle: false`) планете (самая многочисленная группа по NumberLeft — ближе к
не рисуются. Выбывшие расы убираются из сцены, оставшиеся планете, остальные раскручиваются спиралью позади). Tech-варианты
перераспределяются на следующем кадре. одного `(race, className)` схлопываются в один визуальный нод
`<className>:<numLeft>`; детали по тех-уровням остаются в Reports.
Радиус кружка масштабируется по FullMass корабля (диапазон
`[6, 24] px`, нормировка на самую тяжёлую группу в битве), так что
тяжёлые корабли визуально доминируют. Наблюдатели (`inBattle:
false`) не рисуются. Выбывшие расы убираются из сцены, оставшиеся
перераспределяются на следующем кадре. Viewer закреплён по высоте
viewport-а: сцена растягивается, лог скроллит внутри — никаких
скроллов на уровне страницы.
Каждый кадр — одна запись протокола; выстрел рисуется тонкой линией Каждый кадр — одна запись протокола; выстрел рисуется тонкой линией
от атакующего к защитнику, красной при `destroyed`, зелёной иначе. от атакующего к защитнику, красной при `destroyed`, зелёной иначе.
+36 -4
View File
@@ -32,12 +32,33 @@ and the engine never crosses these wires.
The scene (`lib/battle-player/battle-scene.svelte`, SVG) places the The scene (`lib/battle-player/battle-scene.svelte`, SVG) places the
planet at the centre and arrays the still-active races on an outer planet at the centre and arrays the still-active races on an outer
ring at equal angular spacing. Each race anchor is a horizontal ring at equal angular spacing. Each race anchor hosts a *cloud* of
cluster of small class circles, one per `(race, className)` pair, class circles arranged on a Vogel sunflower spiral biased toward the
labelled `<className>:<numLeft>` underneath. When a race is wiped planet (the cluster anchor is pushed inward by a quarter step so the
out, it drops out of the active list and the survivors are 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. 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 `<className>:<numLeft>` sums NumberLeft
across the underlying groups; per-tech detail is available in the
Reports view (Foreign Ship Classes / My Ship Types).
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 The current frame's shot is drawn as a thin line from the attacker's
class circle to the defender's class circle. Colour: class circle to the defender's class circle. Colour:
@@ -74,6 +95,17 @@ the log instead of watching the SVG. The list is always present
and never hidden, satisfying the original Phase 27 acceptance "the and never hidden, satisfying the original Phase 27 acceptance "the
same data is accessible as a static text log". same data is accessible as a static text log".
## Height fit
The viewer is pinned to the viewport: `.active-view` uses
`calc(100dvh 80px)` so the in-game-shell header + optional
HistoryBanner do not push the scene below the fold. Inside the
viewer, the scene grows (`flex: 1`) and the log shrinks to a
30 dvh ceiling with its own internal scroll, so the page itself
never scrolls vertically. The 80 px allowance maps to the current
Header + HistoryBanner total on desktop; mobile breakpoints reuse
the same calc because dvh tracks the dynamic viewport.
## Map markers ## Map markers
`map/battle-markers.ts` emits two marker kinds per `map/battle-markers.ts` emits two marker kinds per
+6
View File
@@ -5,6 +5,12 @@
<link rel="icon" href="%sveltekit.assets%/favicon.svg" /> <link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Galaxy</title> <title>Galaxy</title>
<style>
html,
body {
margin: 0;
}
</style>
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
+74 -4
View File
@@ -2,11 +2,16 @@
Phase 27 — active-view wrapper around the BattleViewer. Loads the Phase 27 — active-view wrapper around the BattleViewer. Loads the
BattleReport for the supplied `gameId`/`turn`/`battleId` and either BattleReport for the supplied `gameId`/`turn`/`battleId` and either
shows the radial playback (BattleViewer), a loading skeleton, or a shows the radial playback (BattleViewer), a loading skeleton, or a
not-found state. The viewer itself is a logically isolated not-found state.
component that takes a `BattleReport` prop — this wrapper owns
loading and routing concerns. 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"> <script lang="ts">
import { getContext } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { import {
@@ -15,6 +20,15 @@ loading and routing concerns.
type BattleReport, type BattleReport,
} from "../../api/battle-fetch"; } from "../../api/battle-fetch";
import { i18n } from "$lib/i18n/index.svelte"; 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"; import BattleViewer from "../battle-player/battle-viewer.svelte";
@@ -28,6 +42,42 @@ loading and routing concerns.
battleId: string; battleId: string;
} = $props(); } = $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< let state = $state<
| { kind: "loading" } | { kind: "loading" }
| { kind: "ready"; report: BattleReport } | { kind: "ready"; report: BattleReport }
@@ -86,7 +136,7 @@ loading and routing concerns.
{i18n.t("game.battle.loading")} {i18n.t("game.battle.loading")}
</p> </p>
{:else if state.kind === "ready"} {:else if state.kind === "ready"}
<BattleViewer report={state.report} /> <BattleViewer report={state.report} shipClassLookup={shipClassLookup} />
{:else if state.kind === "not_found"} {:else if state.kind === "not_found"}
<p class="status" data-testid="battle-not-found"> <p class="status" data-testid="battle-not-found">
{i18n.t("game.battle.not_found")} {i18n.t("game.battle.not_found")}
@@ -98,7 +148,26 @@ loading and routing concerns.
<style> <style>
.active-view { .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; padding: 1rem;
box-sizing: border-box;
font-family: system-ui, sans-serif; font-family: system-ui, sans-serif;
color: #d6dcf2; color: #d6dcf2;
} }
@@ -107,6 +176,7 @@ loading and routing concerns.
gap: 0.5rem; gap: 0.5rem;
max-width: 880px; max-width: 880px;
margin: 0 auto 1rem; margin: 0 auto 1rem;
flex: 0 0 auto;
} }
.back-btn { .back-btn {
appearance: none; appearance: none;
@@ -2,16 +2,33 @@
BattleScene — radial SVG visualisation of one battle frame. BattleScene — radial SVG visualisation of one battle frame.
Layout: planet at the centre, race anchors equally spaced on an Layout: planet at the centre, race anchors equally spaced on an
outer ring, each race rendered as a cluster of small class circles outer ring, each race rendered as a *cloud* of class circles
labelled `<className>:<numLeft>` underneath. The shot line for the arranged on a Vogel sunflower spiral biased toward the planet.
current frame's `lastAction` is drawn from attacker group to Tech-variant groups of the same (race, className) collapse to one
defender group; red when the shot destroyed the defender, green visual node — the per-tech detail lives in Reports. Each circle's
otherwise. Observer groups (`inBattle === false`) are filtered out radius scales with the per-ship FullMass (sqrt) so heavy ships
by `buildFrames`, so they never appear here. 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"> <script lang="ts">
import { getContext } from "svelte";
import type { BattleReport } from "../../api/battle-fetch"; import type { BattleReport } from "../../api/battle-fetch";
import {
CORE_CONTEXT_KEY,
type CoreHandle,
} from "$lib/core-context.svelte";
import { layoutRaces } from "./radial-layout"; import { layoutRaces } from "./radial-layout";
import {
computeBattleGroupMass,
radiusForMass,
MAX_RADIUS,
type ShipClassLookup,
} from "./mass";
import { import {
buildGroupRaceMap, buildGroupRaceMap,
normaliseGroups, normaliseGroups,
@@ -21,50 +38,113 @@ by `buildFrames`, so they never appear here.
let { let {
report, report,
frame, frame,
shipClassLookup,
}: { }: {
report: BattleReport; report: BattleReport;
frame: Frame; frame: Frame;
shipClassLookup?: ShipClassLookup;
} = $props(); } = $props();
const VIEW_BOX = 800; const VIEW_BOX = 800;
const CENTER = { x: VIEW_BOX / 2, y: VIEW_BOX / 2 }; const CENTER = { x: VIEW_BOX / 2, y: VIEW_BOX / 2 };
const PLANET_RADIUS = 60; const PLANET_RADIUS = 60;
const RACE_RING_RADIUS = 280; const RACE_RING_RADIUS = 280;
const CLASS_CIRCLE_RADIUS = 24; // Vogel sunflower step + half-circle bias toward planet.
const CLASS_SPACING = 64; 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 groupRace = $derived(buildGroupRaceMap(report.protocol));
const allGroups = $derived(normaliseGroups(report)); const allGroups = $derived(normaliseGroups(report));
type ClusterEntry = { type ClusterEntry = {
key: number; bucketKey: string;
className: string; className: string;
race: string;
raceId: number;
groupKeys: number[];
numLeft: 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 clustersByRace = $derived.by(() => {
const core = coreHandle?.core ?? null;
// First pass: build the bucket list per race.
const out = new Map<number, ClusterEntry[]>(); const out = new Map<number, ClusterEntry[]>();
const bucketIndex = new Map<string, ClusterEntry>();
for (const g of allGroups) { for (const g of allGroups) {
const numLeft = frame.remaining.get(g.key) ?? 0; const bucketKey = `${g.raceId}::${g.group.className}`;
const list = out.get(g.raceId) ?? []; let bucket = bucketIndex.get(bucketKey);
list.push({ if (bucket === undefined) {
key: g.key, 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, className: g.group.className,
numLeft, 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); out.set(g.raceId, list);
} }
// Stable cluster order: by classname then key. bucket.groupKeys.push(g.key);
bucket.numLeft += frame.remaining.get(g.key) ?? 0;
}
// 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()) { for (const list of out.values()) {
list.sort((a, b) => { list.sort((a, b) => {
const byName = a.className.localeCompare(b.className); if (b.numLeft !== a.numLeft) return b.numLeft - a.numLeft;
if (byName !== 0) return byName; return a.className.localeCompare(b.className);
return a.key - b.key;
}); });
} }
return out; 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( const raceLayout = $derived(
layoutRaces(frame.activeRaceIds, { layoutRaces(frame.activeRaceIds, {
center: CENTER, center: CENTER,
@@ -72,23 +152,62 @@ by `buildFrames`, so they never appear here.
}), }),
); );
function classCircleX(index: number, count: number): number { type ClusterBasis = {
const span = (count - 1) * CLASS_SPACING; anchorX: number;
return -span / 2 + index * CLASS_SPACING; 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) { function findClassCircleCenter(groupKey: number) {
const raceId = groupRace.get(groupKey); const bucket = bucketByGroupKey.get(groupKey);
if (raceId === undefined) return null; if (!bucket) return null;
const anchor = raceLayout.find((a) => a.raceId === raceId); const basis = clusterBasisById.get(bucket.raceId);
if (!anchor) return null; if (!basis) return null;
const cluster = clustersByRace.get(raceId) ?? []; const cluster = clustersByRace.get(bucket.raceId) ?? [];
const idx = cluster.findIndex((c) => c.key === groupKey); const rank = cluster.indexOf(bucket);
if (idx === -1) return null; if (rank === -1) return null;
return { return nodePosition(basis, rank);
x: anchor.x + classCircleX(idx, cluster.length),
y: anchor.y,
};
} }
const shotLine = $derived.by(() => { const shotLine = $derived.by(() => {
@@ -105,6 +224,13 @@ by `buildFrames`, so they never appear here.
for (const g of allGroups) { for (const g of allGroups) {
out.set(g.raceId, g.group.race); 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; return out;
}); });
</script> </script>
@@ -112,6 +238,7 @@ by `buildFrames`, so they never appear here.
<svg <svg
class="battle-scene" class="battle-scene"
viewBox="0 0 {VIEW_BOX} {VIEW_BOX}" viewBox="0 0 {VIEW_BOX} {VIEW_BOX}"
preserveAspectRatio="xMidYMid meet"
role="img" role="img"
aria-label="battle scene" aria-label="battle scene"
data-testid="battle-scene" data-testid="battle-scene"
@@ -132,6 +259,7 @@ by `buildFrames`, so they never appear here.
{#each raceLayout as anchor (anchor.raceId)} {#each raceLayout as anchor (anchor.raceId)}
{@const cluster = clustersByRace.get(anchor.raceId) ?? []} {@const cluster = clustersByRace.get(anchor.raceId) ?? []}
{@const basis = clusterBasisById.get(anchor.raceId)}
<g <g
class="race-cluster" class="race-cluster"
data-testid="battle-race-cluster" data-testid="battle-race-cluster"
@@ -139,30 +267,29 @@ by `buildFrames`, so they never appear here.
> >
<text <text
x={anchor.x} x={anchor.x}
y={anchor.y - CLASS_CIRCLE_RADIUS - 12} y={anchor.y - MAX_RADIUS - 12}
text-anchor="middle" text-anchor="middle"
class="race-label" class="race-label"
>{raceLabelById.get(anchor.raceId) ?? `race ${anchor.raceId}`}</text> >{raceLabelById.get(anchor.raceId) ?? `race ${anchor.raceId}`}</text>
{#each cluster as entry, i (entry.key)} {#if basis}
{@const cx = anchor.x + classCircleX(i, cluster.length)} {#each cluster as entry, rank (entry.bucketKey)}
{@const pos = nodePosition(basis, rank)}
<g <g
class="class-marker" class="class-marker"
data-testid="battle-class-marker" data-testid="battle-class-marker"
data-group-key={entry.key} data-bucket-key={entry.bucketKey}
data-class-name={entry.className}
> >
<circle <circle cx={pos.x} cy={pos.y} r={entry.radius} />
cx={cx}
cy={anchor.y}
r={CLASS_CIRCLE_RADIUS}
/>
<text <text
x={cx} x={pos.x}
y={anchor.y + CLASS_CIRCLE_RADIUS + 16} y={pos.y + entry.radius + 12}
text-anchor="middle" text-anchor="middle"
class="class-label" class="class-label"
>{entry.className}:{entry.numLeft}</text> >{entry.className}:{entry.numLeft}</text>
</g> </g>
{/each} {/each}
{/if}
</g> </g>
{/each} {/each}
@@ -183,7 +310,7 @@ by `buildFrames`, so they never appear here.
<style> <style>
.battle-scene { .battle-scene {
width: 100%; width: 100%;
height: auto; height: 100%;
background: #0a0d1a; background: #0a0d1a;
display: block; display: block;
} }
@@ -210,7 +337,7 @@ by `buildFrames`, so they never appear here.
} }
.class-label { .class-label {
fill: #b8c0e6; fill: #b8c0e6;
font-size: 12px; font-size: 11px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
} }
.shot { .shot {
@@ -10,12 +10,19 @@ is logically isolated: feed it any `BattleReport` matching
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
import type { BattleReport } from "../../api/battle-fetch"; import type { BattleReport } from "../../api/battle-fetch";
import type { ShipClassLookup } from "./mass";
import BattleScene from "./battle-scene.svelte"; import BattleScene from "./battle-scene.svelte";
import PlaybackControls from "./playback-controls.svelte"; import PlaybackControls from "./playback-controls.svelte";
import { buildFrames } from "./timeline"; import { buildFrames } from "./timeline";
let { report }: { report: BattleReport } = $props(); let {
report,
shipClassLookup,
}: {
report: BattleReport;
shipClassLookup?: ShipClassLookup;
} = $props();
const frames = $derived(buildFrames(report)); const frames = $derived(buildFrames(report));
let frameIndex = $state(0); let frameIndex = $state(0);
@@ -81,7 +88,7 @@ is logically isolated: feed it any `BattleReport` matching
</header> </header>
<div class="scene"> <div class="scene">
<BattleScene {report} {frame} /> <BattleScene {report} {frame} {shipClassLookup} />
</div> </div>
<PlaybackControls <PlaybackControls
@@ -113,6 +120,9 @@ is logically isolated: feed it any `BattleReport` matching
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;
max-width: 880px; max-width: 880px;
width: 100%;
flex: 1 1 auto;
min-height: 0;
margin: 0 auto; margin: 0 auto;
padding: 1rem; padding: 1rem;
color: #d6dcf2; color: #d6dcf2;
@@ -122,6 +132,7 @@ is logically isolated: feed it any `BattleReport` matching
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: baseline; align-items: baseline;
flex: 0 0 auto;
} }
.header h2 { .header h2 {
margin: 0; margin: 0;
@@ -139,6 +150,16 @@ is logically isolated: feed it any `BattleReport` matching
border: 1px solid #1e264a; border: 1px solid #1e264a;
border-radius: 4px; border-radius: 4px;
overflow: hidden; 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 { .log h3 {
margin: 0 0 0.4rem; margin: 0 0 0.4rem;
@@ -146,15 +167,17 @@ is logically isolated: feed it any `BattleReport` matching
font-size: 0.8rem; font-size: 0.8rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
flex: 0 0 auto;
} }
.log ol { .log ol {
list-style: decimal inside; list-style: decimal inside;
margin: 0; margin: 0;
padding: 0; padding: 0;
font-size: 0.85rem; font-size: 0.85rem;
max-height: 14rem;
overflow-y: auto; overflow-y: auto;
color: #c6cdf0; color: #c6cdf0;
flex: 1 1 auto;
min-height: 0;
} }
.log li { .log li {
padding: 0.15rem 0; 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;
}
}
+31
View File
@@ -7,6 +7,11 @@ import { describe, expect, it } from "vitest";
import type { BattleReport } from "../src/api/battle-fetch"; import type { BattleReport } from "../src/api/battle-fetch";
import { layoutRaces } from "../src/lib/battle-player/radial-layout"; import { layoutRaces } from "../src/lib/battle-player/radial-layout";
import {
MAX_RADIUS,
MIN_RADIUS,
radiusForMass,
} from "../src/lib/battle-player/mass";
import { import {
buildFrames, buildFrames,
buildGroupRaceMap, buildGroupRaceMap,
@@ -144,3 +149,29 @@ describe("buildFrames", () => {
expect(frames[4].activeRaceIds).toEqual([0]); expect(frames[4].activeRaceIds).toEqual([0]);
}); });
}); });
describe("radiusForMass", () => {
it("returns MAX_RADIUS when mass is zero", () => {
expect(radiusForMass(0, 100)).toBe(MAX_RADIUS);
});
it("returns MAX_RADIUS when maxMassInBattle is zero", () => {
expect(radiusForMass(50, 0)).toBe(MAX_RADIUS);
});
it("returns MAX_RADIUS at the per-battle ceiling", () => {
expect(radiusForMass(100, 100)).toBeCloseTo(MAX_RADIUS, 5);
});
it("scales by sqrt(mass / maxMass): one quarter of max mass = halfway", () => {
const r = radiusForMass(25, 100);
const expected = MIN_RADIUS + (MAX_RADIUS - MIN_RADIUS) * 0.5;
expect(r).toBeCloseTo(expected, 5);
});
it("never returns a radius below MIN_RADIUS or above MAX_RADIUS", () => {
expect(radiusForMass(1, Number.MAX_SAFE_INTEGER)).toBeGreaterThanOrEqual(MIN_RADIUS);
expect(radiusForMass(Number.MAX_SAFE_INTEGER, 1)).toBeLessThanOrEqual(MAX_RADIUS);
});
});
@@ -129,6 +129,28 @@ async function mockGatewayAndBattle(page: Page): Promise<void> {
}, },
], ],
battles: [{ id: BATTLE_ID, planet: 1, shots: 4 }], battles: [{ id: BATTLE_ID, planet: 1, shots: 4 }],
localShipClass: [
{
name: "Cruiser",
drive: 10,
armament: 2,
weapons: 5,
shields: 5,
cargo: 2,
},
],
otherShipClass: [
{
race: "Bajori",
name: "Hawk",
drive: 12,
armament: 1,
weapons: 4,
shields: 2,
cargo: 0,
mass: 75,
},
],
}); });
break; break;
} }
@@ -249,4 +271,35 @@ test.describe("Phase 27 battle viewer", () => {
await expect(page.getByTestId("battle-not-found")).toBeVisible(); await expect(page.getByTestId("battle-not-found")).toBeVisible();
}); });
test("viewer fits the desktop viewport without a vertical scroll", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"desktop-only height-fit check",
);
await page.setViewportSize({ width: 1280, height: 720 });
await mockGatewayAndBattle(page);
await bootSession(page);
await page.goto(`/games/${GAME_ID}/battle/${BATTLE_ID}?turn=1`);
await expect(page.getByTestId("battle-viewer")).toBeVisible();
await expect(page.getByTestId("battle-scene")).toBeVisible();
// Phase 27 refinement: viewer + log fit the viewport; the
// internal log scrolls inside its own pane rather than
// growing the page. Allow a small tolerance for fractional
// pixel rounding around flex math, but reject any
// scrollable overflow beyond a couple of pixels.
// Phase 27 refinement: viewer + log fit the viewport; the
// internal log scrolls inside its own pane rather than
// growing the page. Allow a small tolerance for fractional
// pixel rounding around flex math.
const overflow = await page.evaluate(
() => document.documentElement.scrollHeight - window.innerHeight,
);
expect(overflow).toBeLessThanOrEqual(4);
});
}); });