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