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