From e2aba856b59719a443092afe7267292b767960be Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 13 May 2026 17:38:46 +0200 Subject: [PATCH] ui/phase-27: viewer layout pass + static cluster + duel layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layout reshuffle so the scene captures the maximum viewer area: - Header collapses three rows into one: `back to map` / `back to report` on the left, the centred title `Battle on planet (#)` (new i18n key `game.battle.header_title`), and the frame counter on the right. The wrapper `.active-view` no longer renders its own back-row; routes flow through props. - Viewer drops the `max-width: 880px` cap so on a wide monitor the scene scales up across the full active-view-host. - A drag-seek `` sits between the scene and the controls; dragging pauses playback and lands `frameIndex` on the chosen shot. - Speed control is one cycling button: `1x → 2x → 4x → 6x → 1x`. The label shows the current speed; the new 6x adds a 67 ms frame interval for skimming a long timeline. - The text protocol log is now collapsible behind a `Log ▲▼` toggle in the controls bar. The toggle is its own button; the default state stays expanded. Collapsing the log hands the remaining height to the scene. - Numerical list markers (`1. 2. 3.`) are dropped from the log; `list-style: none` keeps each row visually clean. Static cluster + visibility filter: - `staticBucketsByRace` now locks bucket order, mass, radius and local Vogel-spiral positions for the lifetime of the viewer; it only re-derives when `report` or the wasm `core` change. - `renderedByRace` overlays the per-frame `remaining` map and drops buckets whose `numLeft` hits zero. The surviving buckets keep their slots, so a class emptying never reshuffles the cluster — the empty bucket simply disappears. - A shot whose attacker or defender bucket is no longer visible draws no line (phantom shots into already-empty buckets are silently skipped, matching the user expectation that pup at 0 should stop attracting fire visually). - Race label clamps to a minimum y inside the SVG viewport so three-or-more-race layouts with a north anchor never clip the top race name off-canvas. Duel layout (user suggestion): - `layoutRaces` rotates the radial start angle by 90° when only two participants remain, so race 0 lands at 9 o'clock and race 1 at 3 o'clock. The pair faces off horizontally; neither label pushes against the SVG top edge. The existing test for two-race positions is updated accordingly. Tests: the existing `layoutRaces` two-race case is rewritten for the horizontal duel; the `game-shell-stubs` battle case checks the loading placeholder (back buttons now live in the loaded viewer, not the wrapper). 644 Vitest cases stay green; 4 Playwright battle-viewer cases stay green. Docs: `ui/docs/battle-viewer-ux.md` documents the static cluster / visibility filter, the duel layout, the scrubber, the cycling speed button and the collapsible log. Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/docs/battle-viewer-ux.md | 58 +++-- ui/frontend/src/lib/active-view/battle.svelte | 78 ++---- .../src/lib/battle-player/battle-scene.svelte | 226 +++++++++--------- .../lib/battle-player/battle-viewer.svelte | 187 +++++++++++---- .../battle-player/playback-controls.svelte | 74 +++--- .../src/lib/battle-player/radial-layout.ts | 23 +- ui/frontend/src/lib/i18n/locales/en.ts | 4 + ui/frontend/src/lib/i18n/locales/ru.ts | 4 + ui/frontend/tests/battle-player.test.ts | 13 +- ui/frontend/tests/game-shell-stubs.test.ts | 16 +- 10 files changed, 397 insertions(+), 286 deletions(-) diff --git a/ui/docs/battle-viewer-ux.md b/ui/docs/battle-viewer-ux.md index 956933a..4a4d7e2 100644 --- a/ui/docs/battle-viewer-ux.md +++ b/ui/docs/battle-viewer-ux.md @@ -48,13 +48,22 @@ across the underlying groups; per-tech detail is available in the Reports view (Foreign Ship Classes / My Ship Types). Bucket order inside a cluster is **locked at battle start** by the -initial ship count (`num` summed across tech variants, descending). -As ships die during playback only the label number changes — every -bucket keeps its slot in the Vogel spiral, so the user does not see -the cluster reshuffle when a class empties. Vogel positions are -then reassigned per rank by their inward distance toward the -planet, so the rank-0 bucket (the largest at battle start) always -sits at the most-inward spiral slot. +initial ship count (`num` summed across tech variants, descending), +together with mass, radius and local position. The static layout +lives in `staticBucketsByRace`; the per-frame derivation +`renderedByRace` overlays the live `NumberLeft` and drops buckets +once they hit zero. The remaining buckets keep their slots in the +cloud, so the cluster does not reshuffle when a class empties — the +empty bucket simply disappears. + +Vogel positions are reassigned per rank by their inward distance +toward the planet, so the rank-0 bucket (the largest by initial +ship count) always sits at the most-inward spiral slot. + +When two races remain in battle the radial layout switches to the +horizontal duel: race 0 at 9 o'clock, race 1 at 3 o'clock. This +keeps both race labels clear of the SVG top edge and reads as the +two sides facing off naturally. Circle radius scales with per-ship FullMass (Empty + Carrying via the per-ship `LoadQuantity`). The viewer resolves a @@ -80,20 +89,25 @@ produces the "shot-shot-shot" pulse the user wanted. ## Playback controls -`lib/battle-player/playback-controls.svelte` ships the full set: +`lib/battle-player/playback-controls.svelte` ships: -| Control | Effect | -| ------------- | ------------------------------------------ | -| ⏮ rewind | Stop, jump to frame 0 | -| ◀︎ step back | Stop, frame ← frame − 1 | -| ▶︎ / ⏸ play | Toggle continuous playback | -| ▶︎▶︎ step fwd | Stop, frame ← frame + 1 | -| 1x / 2x / 4x | Speed switch: 400 / 200 / 100 ms per frame | +| Control | Effect | +| ------------------ | ------------------------------------------------------- | +| ⏮ rewind | Stop, jump to frame 0 | +| ◀︎◀︎ step back | Stop, frame ← frame − 1 | +| ▶︎ / ⏸ play | Toggle continuous playback | +| ▶︎▶︎ step forward | Stop, frame ← frame + 1 | +| `Nx` cycle speed | Single button, cycles 1x → 2x → 4x → 6x → 1x; the label shows the current speed (400 / 200 / 100 / 67 ms per frame) | +| `Log ▲▼` toggle | Collapses / expands the always-visible text protocol so the user can give the scene the full viewer height | When the timeline is at its end and the user hits play, the frame counter wraps to 0 and continues. Step buttons disable themselves at their boundary. +A drag-seek slider sits between the scene and the controls. Dragging +pauses playback and lands `frameIndex` on the chosen shot — handy +for jumping to the moment a particular race started losing ground. + ## Accessibility Below the scene the viewer renders a static `
    ` text protocol — @@ -130,12 +144,24 @@ the scene until its actual ships are gone. The phantom shots still draw a line during the frame they belong to; only the running counters are protected. +## Header + layout + +The viewer header carries three rows of chrome in a single line: +the back-navigation buttons (`back to map` / `back to report`) on +the left, a centred title — `Battle on planet (<#number>)`, +i18n key `game.battle.header_title` — and the frame counter on the +right. Pulling navigation into the header frees the entire viewer +area for the scene; the `.viewer` container has no `max-width` cap, +so on wide monitors the scene scales up while the log keeps its +internal 30 dvh scroll. + ## 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 +viewer, the scene grows (`flex: 1`), the scrubber + controls hold +their natural height, and the log (when expanded) 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 diff --git a/ui/frontend/src/lib/active-view/battle.svelte b/ui/frontend/src/lib/active-view/battle.svelte index c0c9028..7393ece 100644 --- a/ui/frontend/src/lib/active-view/battle.svelte +++ b/ui/frontend/src/lib/active-view/battle.svelte @@ -6,9 +6,10 @@ 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`. +needs to size class circles by ship mass. The back-navigation +buttons (`back to map` / `back to report`) live INSIDE the viewer +header now — we just hand the routes down as callbacks so the +viewer keeps its prop-driven contract. --> -
    - - +
    {#if state.kind === "loading"}

    {i18n.t("game.battle.loading")}

    {:else if state.kind === "ready"} - + {:else if state.kind === "not_found"}

    {i18n.t("game.battle.not_found")} @@ -153,44 +142,23 @@ component does not have to know about `RenderedReportSource`. /* * 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. + * 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, scrubber, controls and 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; } - .back-row { - display: flex; - gap: 0.5rem; - max-width: 880px; - margin: 0 auto 1rem; - flex: 0 0 auto; - } - .back-btn { - appearance: none; - background: #1f2748; - color: #d6dcf2; - border: 1px solid #2c3568; - padding: 0.35rem 0.7rem; - border-radius: 3px; - cursor: pointer; - font-size: 0.85rem; - } - .back-btn:hover { - background: #2a3463; - } .status { margin: 2rem auto; max-width: 880px; diff --git a/ui/frontend/src/lib/battle-player/battle-scene.svelte b/ui/frontend/src/lib/battle-player/battle-scene.svelte index 206727d..bcc2292 100644 --- a/ui/frontend/src/lib/battle-player/battle-scene.svelte +++ b/ui/frontend/src/lib/battle-player/battle-scene.svelte @@ -5,19 +5,20 @@ Layout: planet at the centre, race anchors equally spaced on an outer ring, each race rendered as a *cloud* of class circles arranged on a Vogel sunflower spiral. Spiral positions are reassigned per rank by their inward distance toward the planet so -the rank-0 bucket (heaviest by NumberLeft) always sits at the -most-inward Vogel slot — the cloud visually leans toward the -planet without the cluster anchor needing a manual offset. +the rank-0 bucket (the bucket with the largest initial ship count) +always sits at the most-inward Vogel slot. Tech-variant groups of the same `(race, className)` collapse to one -visual node — the per-tech detail lives in Reports. Each circle's +visual node — per-tech detail lives in Reports. Each circle's radius scales with the per-ship FullMass (sqrt) so heavy ships -visually dominate. +visually dominate. Order, position, radius and mass are locked at +battle start; only NumberLeft (the label number) and per-bucket +visibility change per frame. Empty buckets are hidden so the +remaining ones keep their original spots without reshuffling. 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. +`buildFrames`. Same-race opponents are forbidden by the engine's +combat filter, so a shot never collapses to a single visual node. -->

    @@ -77,25 +92,25 @@ already at its end. - {i18n.t("game.battle.controls.speed_label")} + class="speed-btn" + onclick={cycleSpeed} + title={i18n.t("game.battle.controls.speed_label")} + aria-label={i18n.t("game.battle.controls.speed_label")} + data-testid="battle-control-speed" + data-speed={speed} + >{speedLabel} + - + class="log-toggle" + class:active={logOpen} + onclick={toggleLog} + aria-pressed={logOpen} + aria-label={i18n.t("game.battle.controls.log_toggle")} + data-testid="battle-control-log-toggle" + >{i18n.t("game.battle.controls.log_toggle")} {logOpen ? "▲" : "▼"}
    diff --git a/ui/frontend/src/lib/battle-player/radial-layout.ts b/ui/frontend/src/lib/battle-player/radial-layout.ts index 161e9c0..25591f4 100644 --- a/ui/frontend/src/lib/battle-player/radial-layout.ts +++ b/ui/frontend/src/lib/battle-player/radial-layout.ts @@ -1,11 +1,16 @@ // Radial layout for the BattleViewer. // // Places race anchors on a circle of radius `radius` around `center` -// at equal angular spacing. The first anchor sits at the top (12 -// o'clock); subsequent anchors march clockwise. When a race is -// eliminated mid-battle, the caller filters it out of `activeRaceIds` -// and the survivors are re-spaced on the next frame. The same helper -// drives both the initial layout and that re-distribution. +// at equal angular spacing. For three or more races the first anchor +// sits at the top (12 o'clock) and subsequent anchors march +// clockwise. For exactly two races the pair is rotated 90° so they +// face each other horizontally (3 o'clock vs 9 o'clock) — that keeps +// every race label clear of the SVG top edge when only two clusters +// remain, and reads as "the two sides facing off" naturally. +// +// When a race is eliminated mid-battle the caller filters it out of +// `activeRaceIds` and the survivors are re-spaced on the next frame +// through the same helper. export interface RaceAnchor { raceId: number; @@ -35,10 +40,14 @@ export function layoutRaces( if (count === 0) return []; const { center, radius } = options; const out: RaceAnchor[] = []; + // For two participants we want a horizontal duel layout: race 0 + // at 9 o'clock, race 1 at 3 o'clock. For any other count the + // first anchor lands at the top (12 o'clock) and the rest march + // clockwise at equal spacing. + const startAngle = count === 2 ? Math.PI : -Math.PI / 2; for (let i = 0; i < count; i++) { - // 12 o'clock = -PI/2 in math convention; clockwise → +i*step. const step = (2 * Math.PI) / count; - const angle = -Math.PI / 2 + i * step; + const angle = startAngle + i * step; out.push({ raceId: activeRaceIds[i], x: center.x + radius * Math.cos(angle), diff --git a/ui/frontend/src/lib/i18n/locales/en.ts b/ui/frontend/src/lib/i18n/locales/en.ts index 86c3ce8..ee65623 100644 --- a/ui/frontend/src/lib/i18n/locales/en.ts +++ b/ui/frontend/src/lib/i18n/locales/en.ts @@ -484,6 +484,7 @@ const en = { "game.report.section.battles.empty": "no battles last turn", "game.report.section.battles.id_label": "battle", "game.battle.title": "battle", + "game.battle.header_title": "Battle on planet {planet_name} (#{planet_number})", "game.battle.loading": "loading battle…", "game.battle.not_found": "battle not found", "game.battle.back_to_report": "back to report", @@ -497,6 +498,9 @@ const en = { "game.battle.controls.speed_1x": "1x", "game.battle.controls.speed_2x": "2x", "game.battle.controls.speed_4x": "4x", + "game.battle.controls.speed_6x": "6x", + "game.battle.controls.scrub": "scrub battle timeline", + "game.battle.controls.log_toggle": "Log", "game.battle.log.destroyed": "{attacker_race}'s {attacker_class} destroyed {defender_race}'s {defender_class}", "game.battle.log.shielded": "{attacker_race}'s {attacker_class} hit {defender_race}'s {defender_class}, shields held", "game.battle.accessibility.protocol_heading": "battle log", diff --git a/ui/frontend/src/lib/i18n/locales/ru.ts b/ui/frontend/src/lib/i18n/locales/ru.ts index ef547a2..eafd66c 100644 --- a/ui/frontend/src/lib/i18n/locales/ru.ts +++ b/ui/frontend/src/lib/i18n/locales/ru.ts @@ -485,6 +485,10 @@ const ru: Record = { "game.report.section.battles.empty": "сражений в этом ходу не было", "game.report.section.battles.id_label": "сражение", "game.battle.title": "сражение", + "game.battle.header_title": "Битва на планете {planet_name} (#{planet_number})", + "game.battle.controls.speed_6x": "6x", + "game.battle.controls.scrub": "перемотать таймлайн битвы", + "game.battle.controls.log_toggle": "Лог", "game.battle.loading": "загрузка сражения…", "game.battle.not_found": "сражение не найдено", "game.battle.back_to_report": "к отчёту", diff --git a/ui/frontend/tests/battle-player.test.ts b/ui/frontend/tests/battle-player.test.ts index b2572b0..c735c05 100644 --- a/ui/frontend/tests/battle-player.test.ts +++ b/ui/frontend/tests/battle-player.test.ts @@ -34,13 +34,16 @@ describe("layoutRaces", () => { expect(result[0].y).toBeCloseTo(center.y - radius, 5); }); - it("places two races at opposite poles (180° apart)", () => { + it("places two races on the horizontal axis (9 vs 3 o'clock)", () => { + // Special-case duel layout: two anchors face each other on + // the horizontal axis so neither cluster's race label clips + // against the SVG top edge. const result = layoutRaces([0, 1], { center, radius }); expect(result).toHaveLength(2); - expect(result[0].x).toBeCloseTo(center.x, 5); - expect(result[0].y).toBeCloseTo(center.y - radius, 5); - expect(result[1].x).toBeCloseTo(center.x, 5); - expect(result[1].y).toBeCloseTo(center.y + radius, 5); + expect(result[0].x).toBeCloseTo(center.x - radius, 5); + expect(result[0].y).toBeCloseTo(center.y, 5); + expect(result[1].x).toBeCloseTo(center.x + radius, 5); + expect(result[1].y).toBeCloseTo(center.y, 5); }); it("places three races at 120° intervals", () => { diff --git a/ui/frontend/tests/game-shell-stubs.test.ts b/ui/frontend/tests/game-shell-stubs.test.ts index 0d38f6a..28ad925 100644 --- a/ui/frontend/tests/game-shell-stubs.test.ts +++ b/ui/frontend/tests/game-shell-stubs.test.ts @@ -76,20 +76,20 @@ describe("active-view stubs", () => { ); }); - test("battle view stamps the battleId and renders the back-to-map link", () => { + test("battle view stamps the battleId and shows the loading placeholder", () => { // Phase 27 replaces the Phase 10 stub with the Battle Viewer - // wrapper. The wrapper mounts the loading copy until the - // fetcher resolves (component test runs in jsdom without a - // network); the back buttons and the data-battle-id stamp are - // rendered unconditionally so the orchestrator scaffold is the - // stable hook the active-view shell relies on. + // wrapper. The latest layout iteration moved the back- + // navigation buttons inside `BattleViewer` so they only mount + // once the BattleReport finishes loading. The wrapper itself + // always renders the `active-view-battle` host with the + // `data-battle-id` stamp and a localized loading copy until + // the fetcher resolves. const ui = render(BattleView, { props: { gameId: "synthetic-test", turn: 0, battleId: "b-42" }, }); const node = ui.getByTestId("active-view-battle"); expect(node).toHaveAttribute("data-battle-id", "b-42"); - expect(ui.getByTestId("battle-back-to-map")).toBeInTheDocument(); - expect(ui.getByTestId("battle-back-to-report")).toBeInTheDocument(); + expect(ui.getByTestId("battle-loading")).toBeInTheDocument(); }); test("battle view surfaces the not-found state for an empty battleId", () => {