From 8c260f8715abbad57263808c7653d71dda915bcb Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 13 May 2026 15:51:31 +0200 Subject: [PATCH] ui/phase-27: mass-based circles + cloud cluster + height fit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/FUNCTIONAL.md | 14 +- docs/FUNCTIONAL_ru.md | 18 +- ui/docs/battle-viewer-ux.md | 40 ++- ui/frontend/src/app.html | 6 + ui/frontend/src/lib/active-view/battle.svelte | 78 +++++- .../src/lib/battle-player/battle-scene.svelte | 243 +++++++++++++----- .../lib/battle-player/battle-viewer.svelte | 29 ++- ui/frontend/src/lib/battle-player/mass.ts | 109 ++++++++ ui/frontend/tests/battle-player.test.ts | 31 +++ ui/frontend/tests/e2e/battle-viewer.spec.ts | 53 ++++ 10 files changed, 544 insertions(+), 77 deletions(-) create mode 100644 ui/frontend/src/lib/battle-player/mass.ts diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 0cfb69e..1a2eb36 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -712,10 +712,18 @@ which forwards verbatim to the engine's 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 -rendered as a horizontal cluster of small ship-class circles -labelled `:`. Observer groups (`inBattle: +rendered as a cloud of ship-class circles arranged on a Vogel +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 `:`; 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 -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 from attacker to defender, red on `destroyed`, green otherwise. diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index f0706c1..2a48ba0 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -729,11 +729,19 @@ Battle Viewer — отдельное представление, заменяю `GET /api/v1/battle/:turn/:uuid`. Визуальная модель — радиальная: планета в центре, расы по внешней -окружности на равных угловых интервалах, внутри расы — горизонтальный -кластер маленьких кружков по классам кораблей с подписями -`:` под каждым. Наблюдатели (`inBattle: false`) -не рисуются. Выбывшие расы убираются из сцены, оставшиеся -перераспределяются на следующем кадре. +окружности на равных угловых интервалах, внутри расы — облако +кружков по классам кораблей, выложенное Vogel-спиралью с биасом к +планете (самая многочисленная группа по NumberLeft — ближе к +планете, остальные раскручиваются спиралью позади). Tech-варианты +одного `(race, className)` схлопываются в один визуальный нод +`:`; детали по тех-уровням остаются в Reports. +Радиус кружка масштабируется по FullMass корабля (диапазон +`[6, 24] px`, нормировка на самую тяжёлую группу в битве), так что +тяжёлые корабли визуально доминируют. Наблюдатели (`inBattle: +false`) не рисуются. Выбывшие расы убираются из сцены, оставшиеся +перераспределяются на следующем кадре. Viewer закреплён по высоте +viewport-а: сцена растягивается, лог скроллит внутри — никаких +скроллов на уровне страницы. Каждый кадр — одна запись протокола; выстрел рисуется тонкой линией от атакующего к защитнику, красной при `destroyed`, зелёной иначе. diff --git a/ui/docs/battle-viewer-ux.md b/ui/docs/battle-viewer-ux.md index 54761d6..c82212a 100644 --- a/ui/docs/battle-viewer-ux.md +++ b/ui/docs/battle-viewer-ux.md @@ -32,12 +32,33 @@ and the engine never crosses these wires. 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 is a horizontal -cluster of small class circles, one per `(race, className)` pair, -labelled `:` underneath. When a race is wiped -out, it drops out of the active list and the survivors are +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). + +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: @@ -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 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/battle-markers.ts` emits two marker kinds per diff --git a/ui/frontend/src/app.html b/ui/frontend/src/app.html index 9dbc70a..26c5627 100644 --- a/ui/frontend/src/app.html +++ b/ui/frontend/src/app.html @@ -5,6 +5,12 @@ Galaxy + %sveltekit.head% diff --git a/ui/frontend/src/lib/active-view/battle.svelte b/ui/frontend/src/lib/active-view/battle.svelte index 7167c74..c0c9028 100644 --- a/ui/frontend/src/lib/active-view/battle.svelte +++ b/ui/frontend/src/lib/active-view/battle.svelte @@ -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`. --> @@ -112,6 +238,7 @@ by `buildFrames`, so they never appear here. {raceLabelById.get(anchor.raceId) ?? `race ${anchor.raceId}`} - {#each cluster as entry, i (entry.key)} - {@const cx = anchor.x + classCircleX(i, cluster.length)} - - - {entry.className}:{entry.numLeft} - - {/each} + {#if basis} + {#each cluster as entry, rank (entry.bucketKey)} + {@const pos = nodePosition(basis, rank)} + + + {entry.className}:{entry.numLeft} + + {/each} + {/if} {/each} @@ -183,7 +310,7 @@ by `buildFrames`, so they never appear here.
- +
:` 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 + * ` 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`-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) {} + + get(race: string, className: string): ShipClassRef | null { + return this.map.get(`${race}::${className}`) ?? null; + } +} diff --git a/ui/frontend/tests/battle-player.test.ts b/ui/frontend/tests/battle-player.test.ts index ab0ddd6..ecd0d37 100644 --- a/ui/frontend/tests/battle-player.test.ts +++ b/ui/frontend/tests/battle-player.test.ts @@ -7,6 +7,11 @@ import { describe, expect, it } from "vitest"; import type { BattleReport } from "../src/api/battle-fetch"; import { layoutRaces } from "../src/lib/battle-player/radial-layout"; +import { + MAX_RADIUS, + MIN_RADIUS, + radiusForMass, +} from "../src/lib/battle-player/mass"; import { buildFrames, buildGroupRaceMap, @@ -144,3 +149,29 @@ describe("buildFrames", () => { 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); + }); +}); + diff --git a/ui/frontend/tests/e2e/battle-viewer.spec.ts b/ui/frontend/tests/e2e/battle-viewer.spec.ts index 9d465a6..e410b2d 100644 --- a/ui/frontend/tests/e2e/battle-viewer.spec.ts +++ b/ui/frontend/tests/e2e/battle-viewer.spec.ts @@ -129,6 +129,28 @@ async function mockGatewayAndBattle(page: Page): Promise { }, ], 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; } @@ -249,4 +271,35 @@ test.describe("Phase 27 battle viewer", () => { 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); + }); });