Files
galaxy-game/ui/frontend/src/lib/selection.svelte.ts
T
Ilia Denisov 80ed11e3b6
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m45s
feat(ui): F8-10 — tables planets / ship-groups / fleets, ship-classes delete guard (#53)
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>
2026-05-27 20:35:38 +02:00

158 lines
5.6 KiB
TypeScript

// Per-game selection state: which on-map object the user is
// currently inspecting. Phase 13 modelled planets only; Phase 19
// widened the union to ship groups (own / foreign / incoming /
// unidentified).
//
// The store is in-memory only: lifetime matches the in-game shell
// layout instance, which itself is preserved across active-view
// switches inside `/games/:id/*`. Persisting selection across
// reloads is intentionally out of scope — the Phase 13 acceptance
// criterion calls out "across view switches", and survival across a
// reload would be a surprising contrast with the empty-state copy
// users see on first load.
//
// Like `GameStateStore` and `OrderDraftStore`, the store is
// instantiated by the layout and shared with descendants through
// Svelte context. The map view pushes selection events into it; the
// inspector tab and the mobile bottom-sheet read from it.
//
// The store deliberately carries no Svelte component imports so it
// can be tested directly without rendering any UI.
/**
* ShipGroupRef identifies a ship group inside the current report.
* `local` groups carry a stable engine UUID (passed through
* `report.localGroup.id` and used by the upcoming Phase 20 order
* envelopes). The remaining variants do not — they are addressed by
* their position in the matching report array, which is fine for
* the read-only inspector: a new report load reseeds the store and
* any stale index resolves to a missing entry on lookup, collapsing
* the inspector cleanly.
*/
export type ShipGroupRef =
| { variant: "local"; id: string }
| { variant: "other"; index: number }
| { variant: "incoming"; index: number }
| { variant: "unidentified"; index: number };
/**
* Selected describes the currently selected map object. The
* discriminated union is closed: every map-clickable surface maps
* to one of these variants. Future phases (e.g. fleet selection)
* extend by adding a new branch — extension is purely additive.
*/
export type Selected =
| { kind: "planet"; id: number }
| { kind: "shipGroup"; ref: ShipGroupRef };
/**
* SELECTION_CONTEXT_KEY is the Svelte context key the in-game shell
* layout uses to expose its `SelectionStore` instance to descendants.
* Map view, inspector tab, and the mobile bottom-sheet resolve the
* store via `getContext(SELECTION_CONTEXT_KEY)`.
*/
export const SELECTION_CONTEXT_KEY = Symbol("selection");
export class SelectionStore {
selected: Selected | null = $state(null);
// pendingFocus is a transient one-shot request to centre the map
// camera on a selection. F8-10 tables raise it together with the
// view switch to map; map.svelte consumes it once on mount (see
// `consumePendingFocus`) and clears it. Held in memory only — not
// persisted, so an F5 after a click-through does not re-centre.
#pendingFocus: Selected | null = $state(null);
// pendingCenter is the coord-only sibling of pendingFocus: it
// asks map.svelte to centre on a world point without touching
// the selection. F8-10 uses it for in-space fleet rows (the
// `Selected` union has no "fleet" variant, but the user still
// expects the camera to find them). Also one-shot and transient.
#pendingCenter: { x: number; y: number } | null = $state(null);
private destroyed = false;
/**
* selectPlanet sets the active selection to the planet identified
* by its engine `number`. A no-op once the store has been disposed.
*/
selectPlanet(id: number): void {
if (this.destroyed) return;
this.selected = { kind: "planet", id };
}
/**
* selectShipGroup sets the active selection to a ship group. The
* `ref` discriminator carries the variant + the right id shape for
* lookup against the current report.
*/
selectShipGroup(ref: ShipGroupRef): void {
if (this.destroyed) return;
this.selected = { kind: "shipGroup", ref };
}
/**
* focus sets the active selection to `target` and queues a one-shot
* camera-centre request for whichever next mount of the map view
* picks it up via `consumePendingFocus`. Used by the F8-10 tables to
* navigate from a row click to the map. A no-op once the store has
* been disposed.
*/
focus(target: Selected): void {
if (this.destroyed) return;
this.selected = target;
this.#pendingFocus = target;
}
/**
* consumePendingFocus returns the queued focus target (if any) and
* clears it in the same call. Designed for `map.svelte` to invoke
* once after the renderer mounts.
*/
consumePendingFocus(): Selected | null {
const target = this.#pendingFocus;
this.#pendingFocus = null;
return target;
}
/**
* focusPoint queues a one-shot camera-centre on a free-form world
* coordinate, without altering selection. Used by the fleets table
* when the user clicks an in-space fleet (there is no `"fleet"`
* variant in `Selected`, but the camera should still find it).
* A no-op once the store has been disposed.
*/
focusPoint(x: number, y: number): void {
if (this.destroyed) return;
this.#pendingCenter = { x, y };
}
/**
* consumePendingCenter returns the queued centre point (if any)
* and clears it.
*/
consumePendingCenter(): { x: number; y: number } | null {
const point = this.#pendingCenter;
this.#pendingCenter = null;
return point;
}
/**
* clear drops the current selection. The mobile sheet's close
* button calls this; otherwise selection persists across active-
* view switches. Does not affect any queued pending focus — that
* is a transient delivery channel, not selection state.
*/
clear(): void {
if (this.destroyed) return;
this.selected = null;
}
dispose(): void {
this.destroyed = true;
this.selected = null;
this.#pendingFocus = null;
this.#pendingCenter = null;
}
}