feat(ui): F8-10 — tables planets / ship-groups / fleets, ship-classes delete guard (#53)
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m45s

Lights up three previously-stubbed table active views and tightens the
existing one:

  - table-planets: 4 kind checkboxes (own / foreign / uninhabited /
    unknown) + race dropdown that filters the foreign slice; row click
    selects + centres the planet on the map.
  - table-ship-groups: local + foreign groups in one grid, owner
    checkboxes, planet dropdown (destination OR origin), class
    dropdown; on-planet click focuses the destination planet, in-space
    click focuses the ship group itself (camera follows interpolated
    position).
  - table-fleets: own fleets only with the shared planet dropdown;
    on-planet click focuses the planet, in-space click centres the
    camera on the interpolated fleet position without altering the
    selection (no fleet variant in Selected).
  - table-ship-classes: per-row Delete is disabled with a count tooltip
    while at least one local ship group references the class. The
    engine refuses the removal anyway; the UI pre-empts the surface.

Wires the click → map flow through a transient `SelectionStore.focus`
/ `focusPoint` channel that `map.svelte` consumes once on mount —
in-memory only, so an F5 does not re-centre.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-27 20:35:38 +02:00
parent ef4cecb4b2
commit 80ed11e3b6
17 changed files with 2537 additions and 22 deletions
+62 -1
View File
@@ -63,8 +63,10 @@ preference the store already manages.
} from "$lib/game-state.svelte";
import {
SELECTION_CONTEXT_KEY,
type Selected,
type SelectionStore,
} from "$lib/selection.svelte";
import { computeInSpacePosition } from "../../map/ship-groups";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
@@ -515,7 +517,27 @@ preference the store already manages.
},
world,
);
if (previousCamera !== null) {
// Consume an F8-10 table click that asked the next map mount
// to centre on a particular target. The store self-clears on
// read, so any later remount inside the same session sees
// null and falls through to the default centring path. The
// coord-only `pendingCenter` is the fleet-row fallback: a
// fleet has no `Selected` variant, but its xy still feeds
// the camera. `pendingFocus` wins when both are queued.
const focusTarget = selection?.consumePendingFocus() ?? null;
const focusPoint =
resolveFocusPoint(focusTarget, report, world.width, world.height)
?? selection?.consumePendingCenter()
?? null;
if (focusPoint !== null) {
handle.viewport.moveCenter(focusPoint.x, focusPoint.y);
handle.viewport.setZoom(
previousCamera === null
? minScale * 1.05
: Math.max(previousCamera.scale, minScale),
true,
);
} else if (previousCamera !== null) {
// Same-game remount — preserve pan/zoom. Clamp zoom
// to `minScale` so a remount that re-derives the
// minimum (e.g. a viewport resize between renderers)
@@ -642,6 +664,45 @@ preference the store already manages.
}
}
// resolveFocusPoint maps an F8-10 table click target to world (x, y)
// for camera centring. Planets resolve via the report; in-space
// ship groups via the shared interpolation helper; on-planet ship
// groups fall back to the destination planet's xy (so a click on a
// group stationed at #5 centres on #5). Returns null when the
// target cannot be resolved — a stale ref after a fresh report,
// or a planet that is no longer in the visible set; the caller
// then falls through to the default centring path.
function resolveFocusPoint(
target: Selected | null,
report: NonNullable<GameStateStore["report"]>,
worldWidth: number,
worldHeight: number,
): { x: number; y: number } | null {
if (target === null) return null;
if (target.kind === "planet") {
const planet = report.planets.find((p) => p.number === target.id);
return planet === undefined ? null : { x: planet.x, y: planet.y };
}
const ref = target.ref;
const group =
ref.variant === "local"
? report.localShipGroups.find((g) => g.id === ref.id)
: ref.variant === "other"
? report.otherShipGroups[ref.index]
: undefined;
if (group === undefined) return null;
const planetIndex = new Map(report.planets.map((p) => [p.number, p]));
const inSpace = computeInSpacePosition(
group,
planetIndex,
worldWidth,
worldHeight,
);
if (inSpace !== null) return inSpace;
const dest = planetIndex.get(group.destination);
return dest === undefined ? null : { x: dest.x, y: dest.y };
}
// handleMapClick translates a renderer click into a selection
// update. A click that misses every primitive (empty space) is a
// deliberate no-op: the selection rule from Phase 13 is that only
@@ -0,0 +1,358 @@
<!--
F8-10 fleets table. The report only carries the player's own fleets
(`localFleets`) — there is no foreign analogue — so the table is a
single block without owner toggles. Filters are reduced to the
shared planet dropdown (matching destination OR origin).
Click semantics:
- on-planet fleet (origin === null && range === null) focuses the
destination planet via `SelectionStore.focus`, identical to the
ship-groups behaviour;
- in-space fleet centres the camera on the interpolated fleet
position via `SelectionStore.focusPoint`, leaving the selection
unchanged (the `Selected` union has no "fleet" variant and
extending it for one table would be needless surface area).
-->
<script lang="ts">
import { getContext } from "svelte";
import type { ReportLocalFleet, ReportPlanet } from "../../api/game-state";
import { activeView } from "$lib/app-nav.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { planetLabel } from "./report/format";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import {
SELECTION_CONTEXT_KEY,
type SelectionStore,
} from "$lib/selection.svelte";
import ViewState from "$lib/ui/view-state.svelte";
import { formatFloat, formatInt } from "$lib/util/number-format";
type SortColumn =
| "name"
| "groupCount"
| "state"
| "location"
| "speed";
type SortDirection = "asc" | "desc";
const COLUMN_LABELS: Record<SortColumn, TranslationKey> = {
name: "game.table.fleets.column.name",
groupCount: "game.table.fleets.column.groups",
state: "game.table.fleets.column.state",
location: "game.table.fleets.column.location",
speed: "game.table.fleets.column.speed",
};
const COLUMNS: readonly SortColumn[] = [
"name",
"groupCount",
"state",
"location",
"speed",
];
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
let sortColumn: SortColumn = $state("name");
let sortDirection: SortDirection = $state("asc");
let planetFilter: string = $state("");
const reportLoaded = $derived(
rendered?.report !== null && rendered?.report !== undefined,
);
const fleets = $derived<ReportLocalFleet[]>(
rendered?.report?.localFleets ?? [],
);
const allPlanets = $derived<ReportPlanet[]>(rendered?.report?.planets ?? []);
const planetIndex = $derived(
new Map(allPlanets.map((p) => [p.number, p])),
);
const planets = $derived.by(() => {
const set = new Set<number>();
for (const f of fleets) {
set.add(f.destination);
if (f.origin !== null) set.add(f.origin);
}
return Array.from(set).sort((a, b) => a - b);
});
const filtered = $derived.by(() => {
const planet = planetFilter === "" ? null : Number(planetFilter);
return fleets.filter((f) => {
if (planet === null) return true;
return f.destination === planet || f.origin === planet;
});
});
const sorted = $derived.by(() => {
const list = [...filtered];
const dir = sortDirection === "asc" ? 1 : -1;
list.sort((a, b) => compare(a, b, sortColumn) * dir);
return list;
});
function compare(
a: ReportLocalFleet,
b: ReportLocalFleet,
column: SortColumn,
): number {
switch (column) {
case "name":
return a.name.localeCompare(b.name);
case "groupCount":
return a.groupCount - b.groupCount;
case "state":
return a.state.localeCompare(b.state);
case "location": {
if (a.destination !== b.destination) return a.destination - b.destination;
const ao = a.origin ?? Number.MAX_SAFE_INTEGER;
const bo = b.origin ?? Number.MAX_SAFE_INTEGER;
return ao - bo;
}
case "speed":
return a.speed - b.speed;
}
}
function toggleSort(column: SortColumn): void {
if (sortColumn === column) {
sortDirection = sortDirection === "asc" ? "desc" : "asc";
return;
}
sortColumn = column;
sortDirection = "asc";
}
function ariaSort(column: SortColumn): "ascending" | "descending" | "none" {
if (sortColumn !== column) return "none";
return sortDirection === "asc" ? "ascending" : "descending";
}
function isInSpace(f: ReportLocalFleet): boolean {
return f.origin !== null && f.range !== null;
}
function locationLabel(f: ReportLocalFleet): string {
if (!isInSpace(f)) {
return `@ ${planetLabel(f.destination, allPlanets)}`;
}
const from = planetLabel(f.origin!, allPlanets);
const to = planetLabel(f.destination, allPlanets);
return `${from}${to} (${formatFloat(f.range!)})`;
}
function openOnMap(f: ReportLocalFleet): void {
if (selection === undefined) {
activeView.select("map");
return;
}
if (!isInSpace(f)) {
selection.focus({ kind: "planet", id: f.destination });
activeView.select("map");
return;
}
const dest = planetIndex.get(f.destination);
const origin = planetIndex.get(f.origin!);
if (dest !== undefined && origin !== undefined) {
const dx = dest.x - origin.x;
const dy = dest.y - origin.y;
const total = Math.hypot(dx, dy);
if (total === 0 || f.range! <= 0) {
selection.focusPoint(dest.x, dest.y);
} else {
const t = Math.min(1, f.range! / total);
selection.focusPoint(dest.x + t * (origin.x - dest.x), dest.y + t * (origin.y - dest.y));
}
}
activeView.select("map");
}
</script>
<section
class="active-view"
data-testid="active-view-table"
data-entity="fleets"
>
<header>
<h2>{i18n.t("game.table.fleets.title")}</h2>
<div class="controls">
{#if planets.length > 0}
<label class="dropdown">
<span>{i18n.t("game.table.fleets.filter.planet")}</span>
<select
data-testid="fleets-filter-planet"
bind:value={planetFilter}
>
<option value=""
>{i18n.t("game.table.fleets.filter.planet.all")}</option
>
{#each planets as n (n)}
<option value={String(n)}
>{planetLabel(n, allPlanets)}</option
>
{/each}
</select>
</label>
{/if}
</div>
</header>
{#if !reportLoaded}
<ViewState
kind="loading"
testid="fleets-loading"
message={i18n.t("game.table.fleets.loading")}
/>
{:else if fleets.length === 0}
<ViewState
kind="empty"
testid="fleets-empty"
message={i18n.t("game.table.fleets.empty")}
/>
{:else}
<table class="grid" data-testid="fleets-table">
<thead>
<tr>
{#each COLUMNS as column (column)}
<th
aria-sort={ariaSort(column)}
class:numeric={column === "groupCount" || column === "speed"}
>
<button
type="button"
class="sort"
data-testid="fleets-column-{column}"
onclick={() => toggleSort(column)}
>
{i18n.t(COLUMN_LABELS[column])}
{#if sortColumn === column}
<span class="sort-indicator" aria-hidden="true">
{sortDirection === "asc" ? "▲" : "▼"}
</span>
{/if}
</button>
</th>
{/each}
</tr>
</thead>
<tbody>
{#each sorted as f (f.name)}
<tr
data-testid="fleets-row"
data-name={f.name}
data-in-space={isInSpace(f)}
onclick={() => openOnMap(f)}
>
<td data-testid="fleets-cell-name">{f.name || "—"}</td>
<td class="numeric" data-testid="fleets-cell-groups">
{formatInt(f.groupCount)}
</td>
<td data-testid="fleets-cell-state">{f.state || "—"}</td>
<td data-testid="fleets-cell-location">{locationLabel(f)}</td>
<td class="numeric" data-testid="fleets-cell-speed">
{formatFloat(f.speed)}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.active-view {
padding: 1.5rem;
font-family: system-ui, sans-serif;
display: flex;
flex-direction: column;
gap: 1rem;
}
header {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
header h2 {
margin: 0;
font-size: 1.1rem;
}
.controls {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.dropdown {
display: inline-flex;
gap: 0.4rem;
align-items: center;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.dropdown select {
font: inherit;
font-size: 0.85rem;
padding: 0.15rem 0.3rem;
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 3px;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid var(--color-border-subtle);
font-size: 0.85rem;
}
.grid th {
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid th.numeric,
.grid td.numeric {
font-family: var(--font-mono);
text-align: right;
}
.grid th.numeric .sort {
justify-content: flex-end;
}
.grid tbody tr {
cursor: pointer;
}
.grid tbody tr:hover {
background: var(--color-surface);
}
.sort {
font: inherit;
font-size: inherit;
text-transform: inherit;
letter-spacing: inherit;
color: inherit;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
display: inline-flex;
gap: 0.3rem;
align-items: baseline;
}
.sort-indicator {
font-size: 0.7em;
}
</style>
@@ -0,0 +1,403 @@
<!--
F8-10 planets table. Lists every planet from the rendered report with
4 kind checkboxes (own / foreign / uninhabited / unknown) and a race
dropdown that filters foreign planets by owner. A row click focuses
the planet on the map (selection + camera centre) via the transient
`SelectionStore.focus` channel — `map.svelte` consumes it on mount.
Lives inside the active-view slot; reads the report and the selection
store from context, matching the surrounding tables. No data
fetching here.
-->
<script lang="ts">
import { getContext } from "svelte";
import type { ReportPlanet } from "../../api/game-state";
import { activeView } from "$lib/app-nav.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import {
SELECTION_CONTEXT_KEY,
type SelectionStore,
} from "$lib/selection.svelte";
import ViewState from "$lib/ui/view-state.svelte";
import { formatFloat, formatInt } from "$lib/util/number-format";
type SortColumn =
| "number"
| "name"
| "kind"
| "owner"
| "size"
| "resources";
type SortDirection = "asc" | "desc";
type PlanetKind = ReportPlanet["kind"];
const COLUMN_LABELS: Record<SortColumn, TranslationKey> = {
number: "game.table.planets.column.number",
name: "game.table.planets.column.name",
kind: "game.table.planets.column.kind",
owner: "game.table.planets.column.owner",
size: "game.table.planets.column.size",
resources: "game.table.planets.column.resources",
};
const COLUMNS: readonly SortColumn[] = [
"number",
"name",
"kind",
"owner",
"size",
"resources",
];
const KIND_LABELS: Record<PlanetKind, TranslationKey> = {
local: "game.table.planets.kind.own",
other: "game.table.planets.kind.foreign",
uninhabited: "game.table.planets.kind.uninhabited",
unidentified: "game.table.planets.kind.unknown",
};
const KIND_ORDER: Record<PlanetKind, number> = {
local: 0,
other: 1,
uninhabited: 2,
unidentified: 3,
};
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
let sortColumn: SortColumn = $state("number");
let sortDirection: SortDirection = $state("asc");
// Kind toggles default to all-on; owner narrows the `other` slice
// only — "" means "all owners". The dropdown is rendered as soon
// as at least one foreign planet exists in the report, regardless
// of the foreign toggle, so the user can re-enable foreign and
// keep their owner pick.
let showLocal = $state(true);
let showOther = $state(true);
let showUninhabited = $state(true);
let showUnknown = $state(true);
let ownerFilter: string = $state("");
const planets = $derived<ReportPlanet[]>(rendered?.report?.planets ?? []);
const reportLoaded = $derived(
rendered?.report !== null && rendered?.report !== undefined,
);
const owners = $derived.by(() => {
const set = new Set<string>();
for (const p of planets) {
if (p.kind === "other" && p.owner !== null && p.owner !== "") {
set.add(p.owner);
}
}
return Array.from(set).sort((a, b) => a.localeCompare(b));
});
const filtered = $derived.by(() => {
return planets.filter((p) => {
if (p.kind === "local" && !showLocal) return false;
if (p.kind === "uninhabited" && !showUninhabited) return false;
if (p.kind === "unidentified" && !showUnknown) return false;
if (p.kind === "other") {
if (!showOther) return false;
if (ownerFilter !== "" && p.owner !== ownerFilter) return false;
}
return true;
});
});
const sorted = $derived.by(() => {
const list = [...filtered];
const dir = sortDirection === "asc" ? 1 : -1;
list.sort((a, b) => compare(a, b, sortColumn) * dir);
return list;
});
function compare(a: ReportPlanet, b: ReportPlanet, column: SortColumn): number {
switch (column) {
case "number":
return a.number - b.number;
case "name":
return a.name.localeCompare(b.name);
case "kind":
return KIND_ORDER[a.kind] - KIND_ORDER[b.kind];
case "owner": {
const ao = a.owner ?? "";
const bo = b.owner ?? "";
return ao.localeCompare(bo);
}
case "size":
return (a.size ?? -1) - (b.size ?? -1);
case "resources":
return (a.resources ?? -1) - (b.resources ?? -1);
}
}
function toggleSort(column: SortColumn): void {
if (sortColumn === column) {
sortDirection = sortDirection === "asc" ? "desc" : "asc";
return;
}
sortColumn = column;
sortDirection = "asc";
}
function ariaSort(column: SortColumn): "ascending" | "descending" | "none" {
if (sortColumn !== column) return "none";
return sortDirection === "asc" ? "ascending" : "descending";
}
function openOnMap(planet: ReportPlanet): void {
selection?.focus({ kind: "planet", id: planet.number });
activeView.select("map");
}
function ownerDisplay(p: ReportPlanet): string {
if (p.kind === "other") return p.owner ?? "";
return "—";
}
</script>
<section
class="active-view"
data-testid="active-view-table"
data-entity="planets"
>
<header>
<h2>{i18n.t("game.table.planets.title")}</h2>
<div class="controls">
<label class="check">
<input
type="checkbox"
data-testid="planets-filter-own"
bind:checked={showLocal}
/>
<span>{i18n.t("game.table.planets.kind.own")}</span>
</label>
<label class="check">
<input
type="checkbox"
data-testid="planets-filter-foreign"
bind:checked={showOther}
/>
<span>{i18n.t("game.table.planets.kind.foreign")}</span>
</label>
<label class="check">
<input
type="checkbox"
data-testid="planets-filter-uninhabited"
bind:checked={showUninhabited}
/>
<span>{i18n.t("game.table.planets.kind.uninhabited")}</span>
</label>
<label class="check">
<input
type="checkbox"
data-testid="planets-filter-unknown"
bind:checked={showUnknown}
/>
<span>{i18n.t("game.table.planets.kind.unknown")}</span>
</label>
{#if owners.length > 0}
<label class="owner">
<span>{i18n.t("game.table.planets.filter.owner")}</span>
<select
data-testid="planets-filter-owner"
bind:value={ownerFilter}
>
<option value=""
>{i18n.t("game.table.planets.filter.owner.all")}</option
>
{#each owners as owner (owner)}
<option value={owner}>{owner}</option>
{/each}
</select>
</label>
{/if}
</div>
</header>
{#if !reportLoaded}
<ViewState
kind="loading"
testid="planets-loading"
message={i18n.t("game.table.planets.loading")}
/>
{:else if planets.length === 0}
<ViewState
kind="empty"
testid="planets-empty"
message={i18n.t("game.table.planets.empty")}
/>
{:else}
<table class="grid" data-testid="planets-table">
<thead>
<tr>
{#each COLUMNS as column (column)}
<th
aria-sort={ariaSort(column)}
class:numeric={column === "number" ||
column === "size" ||
column === "resources"}
>
<button
type="button"
class="sort"
data-testid="planets-column-{column}"
onclick={() => toggleSort(column)}
>
{i18n.t(COLUMN_LABELS[column])}
{#if sortColumn === column}
<span class="sort-indicator" aria-hidden="true">
{sortDirection === "asc" ? "▲" : "▼"}
</span>
{/if}
</button>
</th>
{/each}
<th class="numeric">
{i18n.t("game.table.planets.column.coordinates")}
</th>
</tr>
</thead>
<tbody>
{#each sorted as p (p.number)}
<tr
data-testid="planets-row"
data-number={p.number}
data-kind={p.kind}
onclick={() => openOnMap(p)}
>
<td class="numeric" data-testid="planets-cell-number">
{p.number}
</td>
<td data-testid="planets-cell-name">{p.name || "—"}</td>
<td data-testid="planets-cell-kind">
{i18n.t(KIND_LABELS[p.kind])}
</td>
<td data-testid="planets-cell-owner">{ownerDisplay(p)}</td>
<td class="numeric" data-testid="planets-cell-size">
{p.size === null ? "—" : formatFloat(p.size)}
</td>
<td class="numeric" data-testid="planets-cell-resources">
{p.resources === null ? "—" : formatFloat(p.resources)}
</td>
<td class="numeric" data-testid="planets-cell-coordinates">
{formatInt(p.x)},{formatInt(p.y)}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.active-view {
padding: 1.5rem;
font-family: system-ui, sans-serif;
display: flex;
flex-direction: column;
gap: 1rem;
}
header {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
header h2 {
margin: 0;
font-size: 1.1rem;
}
.controls {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.check {
display: inline-flex;
gap: 0.3rem;
align-items: center;
font-size: 0.85rem;
color: var(--color-text-muted);
cursor: pointer;
}
.check input {
margin: 0;
}
.owner {
display: inline-flex;
gap: 0.4rem;
align-items: center;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.owner select {
font: inherit;
font-size: 0.85rem;
padding: 0.15rem 0.3rem;
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 3px;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid var(--color-border-subtle);
font-size: 0.85rem;
}
.grid th {
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid th.numeric,
.grid td.numeric {
font-family: var(--font-mono);
text-align: right;
}
.grid th.numeric .sort {
justify-content: flex-end;
}
.grid tbody tr {
cursor: pointer;
}
.grid tbody tr:hover {
background: var(--color-surface);
}
.sort {
font: inherit;
font-size: inherit;
text-transform: inherit;
letter-spacing: inherit;
color: inherit;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
display: inline-flex;
gap: 0.3rem;
align-items: baseline;
}
.sort-indicator {
font-size: 0.7em;
}
</style>
@@ -73,6 +73,21 @@ data fetching is performed here — the layout is responsible.
);
const reportLoaded = $derived(rendered?.report !== null && rendered?.report !== undefined);
// inUseCounts is a derived index from class name → number of player
// ship groups currently referencing it. The engine refuses
// `removeShipClass` while any such group exists, so the table
// pre-emptively disables the per-row Delete affordance instead of
// surfacing a server-side rejection. The map is rebuilt whenever
// the rendered report changes; an empty report yields an empty map
// and every Delete stays enabled.
const inUseCounts = $derived.by(() => {
const map = new Map<string, number>();
for (const group of rendered?.report?.localShipGroups ?? []) {
map.set(group.class, (map.get(group.class) ?? 0) + 1);
}
return map;
});
const filtered = $derived.by(() => {
const needle = filter.trim().toLowerCase();
if (needle === "") return localShipClass;
@@ -123,6 +138,13 @@ data fetching is performed here — the layout is responsible.
name,
});
}
function deleteTooltip(count: number): string {
if (count === 0) return "";
return i18n.t("game.table.ship_classes.action.delete.in_use", {
count: String(count),
});
}
</script>
<section
@@ -189,6 +211,7 @@ data fetching is performed here — the layout is responsible.
</thead>
<tbody>
{#each sorted as cls (cls.name)}
{@const inUse = inUseCounts.get(cls.name) ?? 0}
<tr
data-testid="ship-classes-row"
data-name={cls.name}
@@ -215,6 +238,9 @@ data fetching is performed here — the layout is responsible.
type="button"
class="delete"
data-testid="ship-classes-delete"
data-in-use={inUse}
disabled={inUse > 0 || draft === undefined}
title={deleteTooltip(inUse)}
onclick={() => void deleteShipClass(cls.name)}
>
{i18n.t("game.table.ship_classes.action.delete")}
@@ -0,0 +1,485 @@
<!--
F8-10 ship-groups table. Lists own (`localShipGroups`) and foreign
(`otherShipGroups`) ship groups under a single grid, with checkboxes
for the two owner buckets, a planet dropdown that narrows to groups
touching the chosen planet (destination OR origin), and a ship-class
dropdown. A row click focuses the on-map target through the
transient `SelectionStore.focus` channel:
- on-planet groups (origin === null && range === null) focus the
destination planet so the map centres on it and the inspector
opens its planet card;
- in-space groups focus the group itself, so the map centres on
its interpolated position and the inspector shows the group.
`incomingShipGroups` and `unidentifiedShipGroups` are intentionally
out of scope here — the map and inspector already surface those
categories.
-->
<script lang="ts">
import { getContext } from "svelte";
import type {
ReportLocalShipGroup,
ReportOtherShipGroup,
} from "../../api/game-state";
import { activeView } from "$lib/app-nav.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { planetLabel } from "./report/format";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import {
SELECTION_CONTEXT_KEY,
type Selected,
type SelectionStore,
type ShipGroupRef,
} from "$lib/selection.svelte";
import ViewState from "$lib/ui/view-state.svelte";
import { formatFloat, formatInt } from "$lib/util/number-format";
type SortColumn =
| "owner"
| "class"
| "count"
| "race"
| "location"
| "mass"
| "speed";
type SortDirection = "asc" | "desc";
type OwnerKind = "own" | "foreign";
type Row = {
key: string;
owner: OwnerKind;
ref: ShipGroupRef;
class: string;
count: number;
race: string;
mass: number;
speed: number;
destination: number;
origin: number | null;
range: number | null;
state: string;
};
const COLUMN_LABELS: Record<SortColumn, TranslationKey> = {
owner: "game.table.ship_groups.column.owner",
class: "game.table.ship_groups.column.class",
count: "game.table.ship_groups.column.count",
race: "game.table.ship_groups.column.race",
location: "game.table.ship_groups.column.location",
mass: "game.table.ship_groups.column.mass",
speed: "game.table.ship_groups.column.speed",
};
const COLUMNS: readonly SortColumn[] = [
"owner",
"class",
"count",
"race",
"location",
"mass",
"speed",
];
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
let sortColumn: SortColumn = $state("owner");
let sortDirection: SortDirection = $state("asc");
let showOwn = $state(true);
let showForeign = $state(true);
let planetFilter: string = $state("");
let classFilter: string = $state("");
const reportLoaded = $derived(
rendered?.report !== null && rendered?.report !== undefined,
);
const local = $derived<ReportLocalShipGroup[]>(
rendered?.report?.localShipGroups ?? [],
);
const other = $derived<ReportOtherShipGroup[]>(
rendered?.report?.otherShipGroups ?? [],
);
const rows = $derived.by<Row[]>(() => {
const acc: Row[] = [];
for (const g of local) {
acc.push({
key: `local:${g.id}`,
owner: "own",
ref: { variant: "local", id: g.id },
class: g.class,
count: g.count,
race: g.race,
mass: g.mass,
speed: g.speed,
destination: g.destination,
origin: g.origin,
range: g.range,
state: g.state,
});
}
for (let i = 0; i < other.length; i++) {
const g = other[i]!;
acc.push({
key: `other:${i}`,
owner: "foreign",
ref: { variant: "other", index: i },
class: g.class,
count: g.count,
race: g.race,
mass: g.mass,
speed: g.speed,
destination: g.destination,
origin: g.origin,
range: g.range,
state: "",
});
}
return acc;
});
const classes = $derived.by(() => {
const set = new Set<string>();
for (const r of rows) if (r.class !== "") set.add(r.class);
return Array.from(set).sort((a, b) => a.localeCompare(b));
});
const planets = $derived.by(() => {
const set = new Set<number>();
for (const r of rows) {
set.add(r.destination);
if (r.origin !== null) set.add(r.origin);
}
return Array.from(set).sort((a, b) => a - b);
});
const filtered = $derived.by(() => {
const planet = planetFilter === "" ? null : Number(planetFilter);
return rows.filter((r) => {
if (r.owner === "own" && !showOwn) return false;
if (r.owner === "foreign" && !showForeign) return false;
if (planet !== null && r.destination !== planet && r.origin !== planet) {
return false;
}
if (classFilter !== "" && r.class !== classFilter) return false;
return true;
});
});
const sorted = $derived.by(() => {
const list = [...filtered];
const dir = sortDirection === "asc" ? 1 : -1;
list.sort((a, b) => compare(a, b, sortColumn) * dir);
return list;
});
function compare(a: Row, b: Row, column: SortColumn): number {
switch (column) {
case "owner":
return a.owner.localeCompare(b.owner);
case "class":
return a.class.localeCompare(b.class);
case "count":
return a.count - b.count;
case "race":
return a.race.localeCompare(b.race);
case "location": {
if (a.destination !== b.destination) return a.destination - b.destination;
const ao = a.origin ?? Number.MAX_SAFE_INTEGER;
const bo = b.origin ?? Number.MAX_SAFE_INTEGER;
return ao - bo;
}
case "mass":
return a.mass - b.mass;
case "speed":
return a.speed - b.speed;
}
}
function toggleSort(column: SortColumn): void {
if (sortColumn === column) {
sortDirection = sortDirection === "asc" ? "desc" : "asc";
return;
}
sortColumn = column;
sortDirection = "asc";
}
function ariaSort(column: SortColumn): "ascending" | "descending" | "none" {
if (sortColumn !== column) return "none";
return sortDirection === "asc" ? "ascending" : "descending";
}
function isInSpace(row: Row): boolean {
return row.origin !== null && row.range !== null;
}
function openOnMap(row: Row): void {
let target: Selected;
if (isInSpace(row)) {
target = { kind: "shipGroup", ref: row.ref };
} else {
target = { kind: "planet", id: row.destination };
}
selection?.focus(target);
activeView.select("map");
}
function locationLabel(row: Row): string {
const all = rendered?.report?.planets ?? [];
if (!isInSpace(row)) {
return `@ ${planetLabel(row.destination, all)}`;
}
const from = planetLabel(row.origin!, all);
const to = planetLabel(row.destination, all);
return `${from}${to} (${formatFloat(row.range!)})`;
}
function ownerLabel(row: Row): string {
return i18n.t(
row.owner === "own"
? "game.table.ship_groups.owner.own"
: "game.table.ship_groups.owner.foreign",
);
}
</script>
<section
class="active-view"
data-testid="active-view-table"
data-entity="ship-groups"
>
<header>
<h2>{i18n.t("game.table.ship_groups.title")}</h2>
<div class="controls">
<label class="check">
<input
type="checkbox"
data-testid="ship-groups-filter-own"
bind:checked={showOwn}
/>
<span>{i18n.t("game.table.ship_groups.owner.own")}</span>
</label>
<label class="check">
<input
type="checkbox"
data-testid="ship-groups-filter-foreign"
bind:checked={showForeign}
/>
<span>{i18n.t("game.table.ship_groups.owner.foreign")}</span>
</label>
{#if planets.length > 0}
<label class="dropdown">
<span>{i18n.t("game.table.ship_groups.filter.planet")}</span>
<select
data-testid="ship-groups-filter-planet"
bind:value={planetFilter}
>
<option value=""
>{i18n.t("game.table.ship_groups.filter.planet.all")}</option
>
{#each planets as n (n)}
<option value={String(n)}
>{planetLabel(n, rendered?.report?.planets ?? [])}</option
>
{/each}
</select>
</label>
{/if}
{#if classes.length > 0}
<label class="dropdown">
<span>{i18n.t("game.table.ship_groups.filter.class")}</span>
<select
data-testid="ship-groups-filter-class"
bind:value={classFilter}
>
<option value=""
>{i18n.t("game.table.ship_groups.filter.class.all")}</option
>
{#each classes as c (c)}
<option value={c}>{c}</option>
{/each}
</select>
</label>
{/if}
</div>
</header>
{#if !reportLoaded}
<ViewState
kind="loading"
testid="ship-groups-loading"
message={i18n.t("game.table.ship_groups.loading")}
/>
{:else if rows.length === 0}
<ViewState
kind="empty"
testid="ship-groups-empty"
message={i18n.t("game.table.ship_groups.empty")}
/>
{:else}
<table class="grid" data-testid="ship-groups-table">
<thead>
<tr>
{#each COLUMNS as column (column)}
<th
aria-sort={ariaSort(column)}
class:numeric={column === "count" ||
column === "mass" ||
column === "speed"}
>
<button
type="button"
class="sort"
data-testid="ship-groups-column-{column}"
onclick={() => toggleSort(column)}
>
{i18n.t(COLUMN_LABELS[column])}
{#if sortColumn === column}
<span class="sort-indicator" aria-hidden="true">
{sortDirection === "asc" ? "▲" : "▼"}
</span>
{/if}
</button>
</th>
{/each}
</tr>
</thead>
<tbody>
{#each sorted as row (row.key)}
<tr
data-testid="ship-groups-row"
data-key={row.key}
data-owner={row.owner}
data-in-space={isInSpace(row)}
onclick={() => openOnMap(row)}
>
<td data-testid="ship-groups-cell-owner">{ownerLabel(row)}</td>
<td data-testid="ship-groups-cell-class">{row.class || "—"}</td>
<td class="numeric" data-testid="ship-groups-cell-count">
{formatInt(row.count)}
</td>
<td data-testid="ship-groups-cell-race">{row.race || "—"}</td>
<td data-testid="ship-groups-cell-location">
{locationLabel(row)}
</td>
<td class="numeric" data-testid="ship-groups-cell-mass">
{formatFloat(row.mass)}
</td>
<td class="numeric" data-testid="ship-groups-cell-speed">
{formatFloat(row.speed)}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.active-view {
padding: 1.5rem;
font-family: system-ui, sans-serif;
display: flex;
flex-direction: column;
gap: 1rem;
}
header {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
header h2 {
margin: 0;
font-size: 1.1rem;
}
.controls {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.check {
display: inline-flex;
gap: 0.3rem;
align-items: center;
font-size: 0.85rem;
color: var(--color-text-muted);
cursor: pointer;
}
.check input {
margin: 0;
}
.dropdown {
display: inline-flex;
gap: 0.4rem;
align-items: center;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.dropdown select {
font: inherit;
font-size: 0.85rem;
padding: 0.15rem 0.3rem;
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 3px;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid var(--color-border-subtle);
font-size: 0.85rem;
}
.grid th {
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid th.numeric,
.grid td.numeric {
font-family: var(--font-mono);
text-align: right;
}
.grid th.numeric .sort {
justify-content: flex-end;
}
.grid tbody tr {
cursor: pointer;
}
.grid tbody tr:hover {
background: var(--color-surface);
}
.sort {
font: inherit;
font-size: inherit;
text-transform: inherit;
letter-spacing: inherit;
color: inherit;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
display: inline-flex;
gap: 0.3rem;
align-items: baseline;
}
.sort-indicator {
font-size: 0.7em;
}
</style>
+13 -6
View File
@@ -1,16 +1,17 @@
<!--
Active-view router for the per-entity tables. Phase 17 lights up
the ship-classes table; Phase 21 lights up the sciences table;
Phase 22 lights up the races table; the remaining slugs (planets,
ship-groups, fleets) keep the Phase 10 stub copy until their
respective phases land. The wrapper preserves
Active-view router for the per-entity tables. Phase 17 lit up
ship-classes; Phase 21 sciences; Phase 22 races; F8-10 lights up
planets, ship-groups, and fleets. The wrapper preserves
`data-testid="active-view-table"` and `data-entity={entity}` for
every branch (each leaf component mirrors them) so the navigation
e2e specs (`game-shell.spec.ts`, `view-menu`) keep matching.
-->
<script lang="ts">
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import TablePlanets from "./table-planets.svelte";
import TableShipClasses from "./table-ship-classes.svelte";
import TableShipGroups from "./table-ship-groups.svelte";
import TableFleets from "./table-fleets.svelte";
import TableSciences from "./table-sciences.svelte";
import TableRaces from "./table-races.svelte";
@@ -23,8 +24,14 @@ e2e specs (`game-shell.spec.ts`, `view-menu`) keep matching.
}
</script>
{#if entity === "ship-classes"}
{#if entity === "planets"}
<TablePlanets />
{:else if entity === "ship-classes"}
<TableShipClasses />
{:else if entity === "ship-groups"}
<TableShipGroups />
{:else if entity === "fleets"}
<TableFleets />
{:else if entity === "sciences"}
<TableSciences />
{:else if entity === "races"}