feat(ui): F8-10 — tables planets / ship-groups / fleets, ship-classes delete guard (#53) #68
@@ -467,15 +467,23 @@ preference the store already manages.
|
|||||||
if (canvasEl === null || containerEl === null) return;
|
if (canvasEl === null || containerEl === null) return;
|
||||||
// Capture camera state before disposing so a remount inside
|
// Capture camera state before disposing so a remount inside
|
||||||
// the same game (e.g. cargo-route overlay change) keeps the
|
// the same game (e.g. cargo-route overlay change) keeps the
|
||||||
// user's pan/zoom. A new game / first mount has no prior
|
// user's pan / zoom. On a cold mount with no live `handle` we
|
||||||
// camera, so `previousCamera` stays null and the default
|
// fall back to the per-game `store.lastCamera` snapshot, so
|
||||||
// centring path runs.
|
// leaving the map for a table / report and coming back also
|
||||||
|
// restores the prior view. A new game / first mount has no
|
||||||
|
// prior camera in either source, so `previousCamera` stays
|
||||||
|
// null and the default centring path runs.
|
||||||
const previousGameId = mountedGameId;
|
const previousGameId = mountedGameId;
|
||||||
const targetGameId = store?.gameId ?? "";
|
const targetGameId = store?.gameId ?? "";
|
||||||
const previousCamera =
|
let previousCamera: ReturnType<RendererHandle["getCamera"]> | null = null;
|
||||||
handle !== null && previousGameId === targetGameId
|
if (handle !== null && previousGameId === targetGameId) {
|
||||||
? handle.getCamera()
|
previousCamera = handle.getCamera();
|
||||||
: null;
|
} else if (handle === null && store?.lastCamera) {
|
||||||
|
previousCamera = store.lastCamera;
|
||||||
|
}
|
||||||
|
if (handle !== null && store !== undefined) {
|
||||||
|
store.lastCamera = handle.getCamera();
|
||||||
|
}
|
||||||
if (detachClick !== null) {
|
if (detachClick !== null) {
|
||||||
detachClick();
|
detachClick();
|
||||||
detachClick = null;
|
detachClick = null;
|
||||||
@@ -809,6 +817,9 @@ preference the store already manages.
|
|||||||
detachDebugSurface = null;
|
detachDebugSurface = null;
|
||||||
}
|
}
|
||||||
if (handle !== null) {
|
if (handle !== null) {
|
||||||
|
// Persist the camera snapshot to the per-game store so the
|
||||||
|
// next mount (active-view switch back to map) restores it.
|
||||||
|
if (store !== undefined) store.lastCamera = handle.getCamera();
|
||||||
handle.dispose();
|
handle.dispose();
|
||||||
handle = null;
|
handle = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// F8-10 fleets table — module-level filter / sort rune.
|
||||||
|
// Mirrors `table-planets-state.svelte.ts`. See that file for the
|
||||||
|
// rationale.
|
||||||
|
|
||||||
|
export type FleetsSortColumn = "name" | "groupCount" | "state" | "location" | "speed";
|
||||||
|
export type FleetsSortDirection = "asc" | "desc";
|
||||||
|
|
||||||
|
export interface FleetsTableState {
|
||||||
|
sortColumn: FleetsSortColumn;
|
||||||
|
sortDirection: FleetsSortDirection;
|
||||||
|
planetFilter: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_STATE: FleetsTableState = {
|
||||||
|
sortColumn: "name",
|
||||||
|
sortDirection: "asc",
|
||||||
|
planetFilter: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fleetsTableState: FleetsTableState = $state({ ...DEFAULT_STATE });
|
||||||
|
|
||||||
|
export function resetFleetsTableState(): void {
|
||||||
|
Object.assign(fleetsTableState, DEFAULT_STATE);
|
||||||
|
}
|
||||||
@@ -30,14 +30,10 @@ Click semantics:
|
|||||||
} from "$lib/selection.svelte";
|
} from "$lib/selection.svelte";
|
||||||
import ViewState from "$lib/ui/view-state.svelte";
|
import ViewState from "$lib/ui/view-state.svelte";
|
||||||
import { formatFloat, formatInt } from "$lib/util/number-format";
|
import { formatFloat, formatInt } from "$lib/util/number-format";
|
||||||
|
import {
|
||||||
type SortColumn =
|
fleetsTableState as persistent,
|
||||||
| "name"
|
type FleetsSortColumn as SortColumn,
|
||||||
| "groupCount"
|
} from "./table-fleets-state.svelte";
|
||||||
| "state"
|
|
||||||
| "location"
|
|
||||||
| "speed";
|
|
||||||
type SortDirection = "asc" | "desc";
|
|
||||||
|
|
||||||
const COLUMN_LABELS: Record<SortColumn, TranslationKey> = {
|
const COLUMN_LABELS: Record<SortColumn, TranslationKey> = {
|
||||||
name: "game.table.fleets.column.name",
|
name: "game.table.fleets.column.name",
|
||||||
@@ -60,9 +56,8 @@ Click semantics:
|
|||||||
);
|
);
|
||||||
const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
|
const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
|
||||||
|
|
||||||
let sortColumn: SortColumn = $state("name");
|
// `persistent` (module-level rune above) drives the dropdown
|
||||||
let sortDirection: SortDirection = $state("asc");
|
// selection so the user's planet filter survives navigation.
|
||||||
let planetFilter: string = $state("");
|
|
||||||
|
|
||||||
const reportLoaded = $derived(
|
const reportLoaded = $derived(
|
||||||
rendered?.report !== null && rendered?.report !== undefined,
|
rendered?.report !== null && rendered?.report !== undefined,
|
||||||
@@ -85,7 +80,8 @@ Click semantics:
|
|||||||
});
|
});
|
||||||
|
|
||||||
const filtered = $derived.by(() => {
|
const filtered = $derived.by(() => {
|
||||||
const planet = planetFilter === "" ? null : Number(planetFilter);
|
const planet =
|
||||||
|
persistent.planetFilter === "" ? null : Number(persistent.planetFilter);
|
||||||
return fleets.filter((f) => {
|
return fleets.filter((f) => {
|
||||||
if (planet === null) return true;
|
if (planet === null) return true;
|
||||||
return f.destination === planet || f.origin === planet;
|
return f.destination === planet || f.origin === planet;
|
||||||
@@ -94,8 +90,8 @@ Click semantics:
|
|||||||
|
|
||||||
const sorted = $derived.by(() => {
|
const sorted = $derived.by(() => {
|
||||||
const list = [...filtered];
|
const list = [...filtered];
|
||||||
const dir = sortDirection === "asc" ? 1 : -1;
|
const dir = persistent.sortDirection === "asc" ? 1 : -1;
|
||||||
list.sort((a, b) => compare(a, b, sortColumn) * dir);
|
list.sort((a, b) => compare(a, b, persistent.sortColumn) * dir);
|
||||||
return list;
|
return list;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -123,17 +119,17 @@ Click semantics:
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleSort(column: SortColumn): void {
|
function toggleSort(column: SortColumn): void {
|
||||||
if (sortColumn === column) {
|
if (persistent.sortColumn === column) {
|
||||||
sortDirection = sortDirection === "asc" ? "desc" : "asc";
|
persistent.sortDirection = persistent.sortDirection === "asc" ? "desc" : "asc";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sortColumn = column;
|
persistent.sortColumn = column;
|
||||||
sortDirection = "asc";
|
persistent.sortDirection = "asc";
|
||||||
}
|
}
|
||||||
|
|
||||||
function ariaSort(column: SortColumn): "ascending" | "descending" | "none" {
|
function ariaSort(column: SortColumn): "ascending" | "descending" | "none" {
|
||||||
if (sortColumn !== column) return "none";
|
if (persistent.sortColumn !== column) return "none";
|
||||||
return sortDirection === "asc" ? "ascending" : "descending";
|
return persistent.sortDirection === "asc" ? "ascending" : "descending";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isInSpace(f: ReportLocalFleet): boolean {
|
function isInSpace(f: ReportLocalFleet): boolean {
|
||||||
@@ -189,7 +185,7 @@ Click semantics:
|
|||||||
<span>{i18n.t("game.table.fleets.filter.planet")}</span>
|
<span>{i18n.t("game.table.fleets.filter.planet")}</span>
|
||||||
<select
|
<select
|
||||||
data-testid="fleets-filter-planet"
|
data-testid="fleets-filter-planet"
|
||||||
bind:value={planetFilter}
|
bind:value={persistent.planetFilter}
|
||||||
>
|
>
|
||||||
<option value=""
|
<option value=""
|
||||||
>{i18n.t("game.table.fleets.filter.planet.all")}</option
|
>{i18n.t("game.table.fleets.filter.planet.all")}</option
|
||||||
@@ -233,9 +229,9 @@ Click semantics:
|
|||||||
onclick={() => toggleSort(column)}
|
onclick={() => toggleSort(column)}
|
||||||
>
|
>
|
||||||
{i18n.t(COLUMN_LABELS[column])}
|
{i18n.t(COLUMN_LABELS[column])}
|
||||||
{#if sortColumn === column}
|
{#if persistent.sortColumn === column}
|
||||||
<span class="sort-indicator" aria-hidden="true">
|
<span class="sort-indicator" aria-hidden="true">
|
||||||
{sortDirection === "asc" ? "▲" : "▼"}
|
{persistent.sortDirection === "asc" ? "▲" : "▼"}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// F8-10 planets table — module-level filter / sort rune.
|
||||||
|
//
|
||||||
|
// Held outside the component so the user's filter selections survive
|
||||||
|
// the component being unmounted (e.g. row click → map → back to the
|
||||||
|
// table). Held in memory only; an F5 reloads the report and the
|
||||||
|
// defaults take over.
|
||||||
|
|
||||||
|
export type PlanetSortColumn =
|
||||||
|
| "number"
|
||||||
|
| "name"
|
||||||
|
| "kind"
|
||||||
|
| "owner"
|
||||||
|
| "size"
|
||||||
|
| "resources";
|
||||||
|
|
||||||
|
export type PlanetSortDirection = "asc" | "desc";
|
||||||
|
|
||||||
|
export interface PlanetsTableState {
|
||||||
|
sortColumn: PlanetSortColumn;
|
||||||
|
sortDirection: PlanetSortDirection;
|
||||||
|
showLocal: boolean;
|
||||||
|
showOther: boolean;
|
||||||
|
showUninhabited: boolean;
|
||||||
|
showUnknown: boolean;
|
||||||
|
ownerFilter: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_STATE: PlanetsTableState = {
|
||||||
|
sortColumn: "number",
|
||||||
|
sortDirection: "asc",
|
||||||
|
showLocal: true,
|
||||||
|
showOther: true,
|
||||||
|
showUninhabited: true,
|
||||||
|
showUnknown: true,
|
||||||
|
ownerFilter: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const planetsTableState: PlanetsTableState = $state({ ...DEFAULT_STATE });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* resetPlanetsTableState restores every field to its default value.
|
||||||
|
* Production code never calls it; the Vitest harness uses it from
|
||||||
|
* `beforeEach` to keep cases independent (the rune is a module-level
|
||||||
|
* singleton that otherwise carries state across test boundaries).
|
||||||
|
*/
|
||||||
|
export function resetPlanetsTableState(): void {
|
||||||
|
Object.assign(planetsTableState, DEFAULT_STATE);
|
||||||
|
}
|
||||||
@@ -25,15 +25,11 @@ fetching here.
|
|||||||
} from "$lib/selection.svelte";
|
} from "$lib/selection.svelte";
|
||||||
import ViewState from "$lib/ui/view-state.svelte";
|
import ViewState from "$lib/ui/view-state.svelte";
|
||||||
import { formatFloat, formatInt } from "$lib/util/number-format";
|
import { formatFloat, formatInt } from "$lib/util/number-format";
|
||||||
|
import {
|
||||||
|
planetsTableState as persistent,
|
||||||
|
type PlanetSortColumn as SortColumn,
|
||||||
|
} from "./table-planets-state.svelte";
|
||||||
|
|
||||||
type SortColumn =
|
|
||||||
| "number"
|
|
||||||
| "name"
|
|
||||||
| "kind"
|
|
||||||
| "owner"
|
|
||||||
| "size"
|
|
||||||
| "resources";
|
|
||||||
type SortDirection = "asc" | "desc";
|
|
||||||
type PlanetKind = ReportPlanet["kind"];
|
type PlanetKind = ReportPlanet["kind"];
|
||||||
|
|
||||||
const COLUMN_LABELS: Record<SortColumn, TranslationKey> = {
|
const COLUMN_LABELS: Record<SortColumn, TranslationKey> = {
|
||||||
@@ -73,19 +69,13 @@ fetching here.
|
|||||||
);
|
);
|
||||||
const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
|
const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
|
||||||
|
|
||||||
let sortColumn: SortColumn = $state("number");
|
// `persistent` (declared in the module script above) is the live
|
||||||
let sortDirection: SortDirection = $state("asc");
|
// source for every filter / sort selection so leaving the table
|
||||||
|
// and coming back restores the prior state. Kind toggles default
|
||||||
// Kind toggles default to all-on; owner narrows the `other` slice
|
// to all-on; owner narrows the `other` slice only — "" means
|
||||||
// only — "" means "all owners". The dropdown is rendered as soon
|
// "all owners". The owner dropdown is conditional on
|
||||||
// as at least one foreign planet exists in the report, regardless
|
// `persistent.showOther`, so toggling foreign off hides the
|
||||||
// of the foreign toggle, so the user can re-enable foreign and
|
// chooser; the stored owner value is preserved across that flip.
|
||||||
// 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 planets = $derived<ReportPlanet[]>(rendered?.report?.planets ?? []);
|
||||||
const reportLoaded = $derived(
|
const reportLoaded = $derived(
|
||||||
@@ -104,12 +94,12 @@ fetching here.
|
|||||||
|
|
||||||
const filtered = $derived.by(() => {
|
const filtered = $derived.by(() => {
|
||||||
return planets.filter((p) => {
|
return planets.filter((p) => {
|
||||||
if (p.kind === "local" && !showLocal) return false;
|
if (p.kind === "local" && !persistent.showLocal) return false;
|
||||||
if (p.kind === "uninhabited" && !showUninhabited) return false;
|
if (p.kind === "uninhabited" && !persistent.showUninhabited) return false;
|
||||||
if (p.kind === "unidentified" && !showUnknown) return false;
|
if (p.kind === "unidentified" && !persistent.showUnknown) return false;
|
||||||
if (p.kind === "other") {
|
if (p.kind === "other") {
|
||||||
if (!showOther) return false;
|
if (!persistent.showOther) return false;
|
||||||
if (ownerFilter !== "" && p.owner !== ownerFilter) return false;
|
if (persistent.ownerFilter !== "" && p.owner !== persistent.ownerFilter) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -117,8 +107,8 @@ fetching here.
|
|||||||
|
|
||||||
const sorted = $derived.by(() => {
|
const sorted = $derived.by(() => {
|
||||||
const list = [...filtered];
|
const list = [...filtered];
|
||||||
const dir = sortDirection === "asc" ? 1 : -1;
|
const dir = persistent.sortDirection === "asc" ? 1 : -1;
|
||||||
list.sort((a, b) => compare(a, b, sortColumn) * dir);
|
list.sort((a, b) => compare(a, b, persistent.sortColumn) * dir);
|
||||||
return list;
|
return list;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -143,17 +133,17 @@ fetching here.
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleSort(column: SortColumn): void {
|
function toggleSort(column: SortColumn): void {
|
||||||
if (sortColumn === column) {
|
if (persistent.sortColumn === column) {
|
||||||
sortDirection = sortDirection === "asc" ? "desc" : "asc";
|
persistent.sortDirection = persistent.sortDirection === "asc" ? "desc" : "asc";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sortColumn = column;
|
persistent.sortColumn = column;
|
||||||
sortDirection = "asc";
|
persistent.sortDirection = "asc";
|
||||||
}
|
}
|
||||||
|
|
||||||
function ariaSort(column: SortColumn): "ascending" | "descending" | "none" {
|
function ariaSort(column: SortColumn): "ascending" | "descending" | "none" {
|
||||||
if (sortColumn !== column) return "none";
|
if (persistent.sortColumn !== column) return "none";
|
||||||
return sortDirection === "asc" ? "ascending" : "descending";
|
return persistent.sortDirection === "asc" ? "ascending" : "descending";
|
||||||
}
|
}
|
||||||
|
|
||||||
function openOnMap(planet: ReportPlanet): void {
|
function openOnMap(planet: ReportPlanet): void {
|
||||||
@@ -179,23 +169,15 @@ fetching here.
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
data-testid="planets-filter-own"
|
data-testid="planets-filter-own"
|
||||||
bind:checked={showLocal}
|
bind:checked={persistent.showLocal}
|
||||||
/>
|
/>
|
||||||
<span>{i18n.t("game.table.planets.kind.own")}</span>
|
<span>{i18n.t("game.table.planets.kind.own")}</span>
|
||||||
</label>
|
</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">
|
<label class="check">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
data-testid="planets-filter-uninhabited"
|
data-testid="planets-filter-uninhabited"
|
||||||
bind:checked={showUninhabited}
|
bind:checked={persistent.showUninhabited}
|
||||||
/>
|
/>
|
||||||
<span>{i18n.t("game.table.planets.kind.uninhabited")}</span>
|
<span>{i18n.t("game.table.planets.kind.uninhabited")}</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -203,16 +185,24 @@ fetching here.
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
data-testid="planets-filter-unknown"
|
data-testid="planets-filter-unknown"
|
||||||
bind:checked={showUnknown}
|
bind:checked={persistent.showUnknown}
|
||||||
/>
|
/>
|
||||||
<span>{i18n.t("game.table.planets.kind.unknown")}</span>
|
<span>{i18n.t("game.table.planets.kind.unknown")}</span>
|
||||||
</label>
|
</label>
|
||||||
{#if owners.length > 0}
|
<label class="check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-testid="planets-filter-foreign"
|
||||||
|
bind:checked={persistent.showOther}
|
||||||
|
/>
|
||||||
|
<span>{i18n.t("game.table.planets.kind.foreign")}</span>
|
||||||
|
</label>
|
||||||
|
{#if persistent.showOther && owners.length > 0}
|
||||||
<label class="owner">
|
<label class="owner">
|
||||||
<span>{i18n.t("game.table.planets.filter.owner")}</span>
|
<span>{i18n.t("game.table.planets.filter.owner")}</span>
|
||||||
<select
|
<select
|
||||||
data-testid="planets-filter-owner"
|
data-testid="planets-filter-owner"
|
||||||
bind:value={ownerFilter}
|
bind:value={persistent.ownerFilter}
|
||||||
>
|
>
|
||||||
<option value=""
|
<option value=""
|
||||||
>{i18n.t("game.table.planets.filter.owner.all")}</option
|
>{i18n.t("game.table.planets.filter.owner.all")}</option
|
||||||
@@ -256,9 +246,9 @@ fetching here.
|
|||||||
onclick={() => toggleSort(column)}
|
onclick={() => toggleSort(column)}
|
||||||
>
|
>
|
||||||
{i18n.t(COLUMN_LABELS[column])}
|
{i18n.t(COLUMN_LABELS[column])}
|
||||||
{#if sortColumn === column}
|
{#if persistent.sortColumn === column}
|
||||||
<span class="sort-indicator" aria-hidden="true">
|
<span class="sort-indicator" aria-hidden="true">
|
||||||
{sortDirection === "asc" ? "▲" : "▼"}
|
{persistent.sortDirection === "asc" ? "▲" : "▼"}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -359,7 +359,12 @@ data fetching is performed here — the layout is responsible.
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.delete:hover {
|
.delete:not(:disabled):hover {
|
||||||
border-color: var(--color-danger);
|
border-color: var(--color-danger);
|
||||||
}
|
}
|
||||||
|
.delete:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// F8-10 ship-groups table — module-level filter / sort rune.
|
||||||
|
// Mirrors `table-planets-state.svelte.ts`. See that file for the
|
||||||
|
// rationale.
|
||||||
|
|
||||||
|
export type ShipGroupsSortColumn =
|
||||||
|
| "owner"
|
||||||
|
| "class"
|
||||||
|
| "count"
|
||||||
|
| "race"
|
||||||
|
| "location"
|
||||||
|
| "mass"
|
||||||
|
| "speed";
|
||||||
|
|
||||||
|
export type ShipGroupsSortDirection = "asc" | "desc";
|
||||||
|
|
||||||
|
export interface ShipGroupsTableState {
|
||||||
|
sortColumn: ShipGroupsSortColumn;
|
||||||
|
sortDirection: ShipGroupsSortDirection;
|
||||||
|
showOwn: boolean;
|
||||||
|
showForeign: boolean;
|
||||||
|
planetFilter: string;
|
||||||
|
classFilter: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_STATE: ShipGroupsTableState = {
|
||||||
|
sortColumn: "owner",
|
||||||
|
sortDirection: "asc",
|
||||||
|
showOwn: true,
|
||||||
|
showForeign: true,
|
||||||
|
planetFilter: "",
|
||||||
|
classFilter: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const shipGroupsTableState: ShipGroupsTableState = $state({
|
||||||
|
...DEFAULT_STATE,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function resetShipGroupsTableState(): void {
|
||||||
|
Object.assign(shipGroupsTableState, DEFAULT_STATE);
|
||||||
|
}
|
||||||
@@ -38,16 +38,11 @@ categories.
|
|||||||
} from "$lib/selection.svelte";
|
} from "$lib/selection.svelte";
|
||||||
import ViewState from "$lib/ui/view-state.svelte";
|
import ViewState from "$lib/ui/view-state.svelte";
|
||||||
import { formatFloat, formatInt } from "$lib/util/number-format";
|
import { formatFloat, formatInt } from "$lib/util/number-format";
|
||||||
|
import {
|
||||||
|
shipGroupsTableState as persistent,
|
||||||
|
type ShipGroupsSortColumn as SortColumn,
|
||||||
|
} from "./table-ship-groups-state.svelte";
|
||||||
|
|
||||||
type SortColumn =
|
|
||||||
| "owner"
|
|
||||||
| "class"
|
|
||||||
| "count"
|
|
||||||
| "race"
|
|
||||||
| "location"
|
|
||||||
| "mass"
|
|
||||||
| "speed";
|
|
||||||
type SortDirection = "asc" | "desc";
|
|
||||||
type OwnerKind = "own" | "foreign";
|
type OwnerKind = "own" | "foreign";
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -90,12 +85,9 @@ categories.
|
|||||||
);
|
);
|
||||||
const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
|
const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
|
||||||
|
|
||||||
let sortColumn: SortColumn = $state("owner");
|
// `persistent` (module-level rune above) drives every filter /
|
||||||
let sortDirection: SortDirection = $state("asc");
|
// sort selection so the user's choices survive table↔map round
|
||||||
let showOwn = $state(true);
|
// trips.
|
||||||
let showForeign = $state(true);
|
|
||||||
let planetFilter: string = $state("");
|
|
||||||
let classFilter: string = $state("");
|
|
||||||
|
|
||||||
const reportLoaded = $derived(
|
const reportLoaded = $derived(
|
||||||
rendered?.report !== null && rendered?.report !== undefined,
|
rendered?.report !== null && rendered?.report !== undefined,
|
||||||
@@ -145,15 +137,27 @@ categories.
|
|||||||
return acc;
|
return acc;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// rowsByOwner is the slice surviving just the owner checkboxes,
|
||||||
|
// reused for the planet / class dropdown options so toggling
|
||||||
|
// "foreign" off prunes the choices to only own-group planets and
|
||||||
|
// classes (and vice versa).
|
||||||
|
const rowsByOwner = $derived.by(() => {
|
||||||
|
return rows.filter((r) => {
|
||||||
|
if (r.owner === "own" && !persistent.showOwn) return false;
|
||||||
|
if (r.owner === "foreign" && !persistent.showForeign) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const classes = $derived.by(() => {
|
const classes = $derived.by(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
for (const r of rows) if (r.class !== "") set.add(r.class);
|
for (const r of rowsByOwner) if (r.class !== "") set.add(r.class);
|
||||||
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||||
});
|
});
|
||||||
|
|
||||||
const planets = $derived.by(() => {
|
const planets = $derived.by(() => {
|
||||||
const set = new Set<number>();
|
const set = new Set<number>();
|
||||||
for (const r of rows) {
|
for (const r of rowsByOwner) {
|
||||||
set.add(r.destination);
|
set.add(r.destination);
|
||||||
if (r.origin !== null) set.add(r.origin);
|
if (r.origin !== null) set.add(r.origin);
|
||||||
}
|
}
|
||||||
@@ -161,22 +165,23 @@ categories.
|
|||||||
});
|
});
|
||||||
|
|
||||||
const filtered = $derived.by(() => {
|
const filtered = $derived.by(() => {
|
||||||
const planet = planetFilter === "" ? null : Number(planetFilter);
|
const planet =
|
||||||
return rows.filter((r) => {
|
persistent.planetFilter === "" ? null : Number(persistent.planetFilter);
|
||||||
if (r.owner === "own" && !showOwn) return false;
|
return rowsByOwner.filter((r) => {
|
||||||
if (r.owner === "foreign" && !showForeign) return false;
|
|
||||||
if (planet !== null && r.destination !== planet && r.origin !== planet) {
|
if (planet !== null && r.destination !== planet && r.origin !== planet) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (classFilter !== "" && r.class !== classFilter) return false;
|
if (persistent.classFilter !== "" && r.class !== persistent.classFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const sorted = $derived.by(() => {
|
const sorted = $derived.by(() => {
|
||||||
const list = [...filtered];
|
const list = [...filtered];
|
||||||
const dir = sortDirection === "asc" ? 1 : -1;
|
const dir = persistent.sortDirection === "asc" ? 1 : -1;
|
||||||
list.sort((a, b) => compare(a, b, sortColumn) * dir);
|
list.sort((a, b) => compare(a, b, persistent.sortColumn) * dir);
|
||||||
return list;
|
return list;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -204,17 +209,17 @@ categories.
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleSort(column: SortColumn): void {
|
function toggleSort(column: SortColumn): void {
|
||||||
if (sortColumn === column) {
|
if (persistent.sortColumn === column) {
|
||||||
sortDirection = sortDirection === "asc" ? "desc" : "asc";
|
persistent.sortDirection = persistent.sortDirection === "asc" ? "desc" : "asc";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sortColumn = column;
|
persistent.sortColumn = column;
|
||||||
sortDirection = "asc";
|
persistent.sortDirection = "asc";
|
||||||
}
|
}
|
||||||
|
|
||||||
function ariaSort(column: SortColumn): "ascending" | "descending" | "none" {
|
function ariaSort(column: SortColumn): "ascending" | "descending" | "none" {
|
||||||
if (sortColumn !== column) return "none";
|
if (persistent.sortColumn !== column) return "none";
|
||||||
return sortDirection === "asc" ? "ascending" : "descending";
|
return persistent.sortDirection === "asc" ? "ascending" : "descending";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isInSpace(row: Row): boolean {
|
function isInSpace(row: Row): boolean {
|
||||||
@@ -263,7 +268,7 @@ categories.
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
data-testid="ship-groups-filter-own"
|
data-testid="ship-groups-filter-own"
|
||||||
bind:checked={showOwn}
|
bind:checked={persistent.showOwn}
|
||||||
/>
|
/>
|
||||||
<span>{i18n.t("game.table.ship_groups.owner.own")}</span>
|
<span>{i18n.t("game.table.ship_groups.owner.own")}</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -271,7 +276,7 @@ categories.
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
data-testid="ship-groups-filter-foreign"
|
data-testid="ship-groups-filter-foreign"
|
||||||
bind:checked={showForeign}
|
bind:checked={persistent.showForeign}
|
||||||
/>
|
/>
|
||||||
<span>{i18n.t("game.table.ship_groups.owner.foreign")}</span>
|
<span>{i18n.t("game.table.ship_groups.owner.foreign")}</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -280,7 +285,7 @@ categories.
|
|||||||
<span>{i18n.t("game.table.ship_groups.filter.planet")}</span>
|
<span>{i18n.t("game.table.ship_groups.filter.planet")}</span>
|
||||||
<select
|
<select
|
||||||
data-testid="ship-groups-filter-planet"
|
data-testid="ship-groups-filter-planet"
|
||||||
bind:value={planetFilter}
|
bind:value={persistent.planetFilter}
|
||||||
>
|
>
|
||||||
<option value=""
|
<option value=""
|
||||||
>{i18n.t("game.table.ship_groups.filter.planet.all")}</option
|
>{i18n.t("game.table.ship_groups.filter.planet.all")}</option
|
||||||
@@ -298,7 +303,7 @@ categories.
|
|||||||
<span>{i18n.t("game.table.ship_groups.filter.class")}</span>
|
<span>{i18n.t("game.table.ship_groups.filter.class")}</span>
|
||||||
<select
|
<select
|
||||||
data-testid="ship-groups-filter-class"
|
data-testid="ship-groups-filter-class"
|
||||||
bind:value={classFilter}
|
bind:value={persistent.classFilter}
|
||||||
>
|
>
|
||||||
<option value=""
|
<option value=""
|
||||||
>{i18n.t("game.table.ship_groups.filter.class.all")}</option
|
>{i18n.t("game.table.ship_groups.filter.class.all")}</option
|
||||||
@@ -342,9 +347,9 @@ categories.
|
|||||||
onclick={() => toggleSort(column)}
|
onclick={() => toggleSort(column)}
|
||||||
>
|
>
|
||||||
{i18n.t(COLUMN_LABELS[column])}
|
{i18n.t(COLUMN_LABELS[column])}
|
||||||
{#if sortColumn === column}
|
{#if persistent.sortColumn === column}
|
||||||
<span class="sort-indicator" aria-hidden="true">
|
<span class="sort-indicator" aria-hidden="true">
|
||||||
{sortDirection === "asc" ? "▲" : "▼"}
|
{persistent.sortDirection === "asc" ? "▲" : "▼"}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -117,6 +117,17 @@ export class GameStateStore {
|
|||||||
* `RendererHandle.setHiddenPrimitiveIds`.
|
* `RendererHandle.setHiddenPrimitiveIds`.
|
||||||
*/
|
*/
|
||||||
mapToggles: MapToggles = $state({ ...DEFAULT_MAP_TOGGLES });
|
mapToggles: MapToggles = $state({ ...DEFAULT_MAP_TOGGLES });
|
||||||
|
/**
|
||||||
|
* lastCamera is the most recent map camera snapshot (centre + zoom)
|
||||||
|
* captured before the renderer was disposed — either because the
|
||||||
|
* map view unmounted (active-view switch) or because it remounted
|
||||||
|
* inside the same session. `map.svelte` reads it on cold mount so
|
||||||
|
* leaving the map for a table / report and coming back keeps the
|
||||||
|
* user's prior pan / zoom. Resetting to null falls back to the
|
||||||
|
* default world-centre + minScale fit. Held in memory only; an F5
|
||||||
|
* re-loads the report and the default fit takes over.
|
||||||
|
*/
|
||||||
|
lastCamera: { centerX: number; centerY: number; scale: number } | null = $state(null);
|
||||||
error: string | null = $state(null);
|
error: string | null = $state(null);
|
||||||
/**
|
/**
|
||||||
* notFound is the distinct "this game is not in the player's list"
|
* notFound is the distinct "this game is not in the player's list"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
SelectionStore,
|
SelectionStore,
|
||||||
} from "../src/lib/selection.svelte";
|
} from "../src/lib/selection.svelte";
|
||||||
import { activeView } from "../src/lib/app-nav.svelte";
|
import { activeView } from "../src/lib/app-nav.svelte";
|
||||||
|
import { resetFleetsTableState } from "../src/lib/active-view/table-fleets-state.svelte";
|
||||||
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||||
|
|
||||||
const pageMock = vi.hoisted(() => ({
|
const pageMock = vi.hoisted(() => ({
|
||||||
@@ -49,6 +50,7 @@ beforeEach(() => {
|
|||||||
pageMock.params = { id: "g1" };
|
pageMock.params = { id: "g1" };
|
||||||
gotoMock.mockClear();
|
gotoMock.mockClear();
|
||||||
activeView.reset();
|
activeView.reset();
|
||||||
|
resetFleetsTableState();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
SelectionStore,
|
SelectionStore,
|
||||||
} from "../src/lib/selection.svelte";
|
} from "../src/lib/selection.svelte";
|
||||||
import { activeView } from "../src/lib/app-nav.svelte";
|
import { activeView } from "../src/lib/app-nav.svelte";
|
||||||
|
import { resetPlanetsTableState } from "../src/lib/active-view/table-planets-state.svelte";
|
||||||
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||||
|
|
||||||
const pageMock = vi.hoisted(() => ({
|
const pageMock = vi.hoisted(() => ({
|
||||||
@@ -45,6 +46,7 @@ beforeEach(() => {
|
|||||||
pageMock.params = { id: "g1" };
|
pageMock.params = { id: "g1" };
|
||||||
gotoMock.mockClear();
|
gotoMock.mockClear();
|
||||||
activeView.reset();
|
activeView.reset();
|
||||||
|
resetPlanetsTableState();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -189,6 +191,25 @@ describe("planets table", () => {
|
|||||||
expect(activeView.view).toBe("map");
|
expect(activeView.view).toBe("map");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("filter checkboxes survive an unmount/remount cycle", async () => {
|
||||||
|
const report = makeReport([
|
||||||
|
planet({ number: 1, kind: "local" }),
|
||||||
|
planet({ number: 2, kind: "other", owner: "Klingon" }),
|
||||||
|
]);
|
||||||
|
const first = mount(report);
|
||||||
|
await fireEvent.click(first.getByTestId("planets-filter-own"));
|
||||||
|
first.unmount();
|
||||||
|
const second = mount(report);
|
||||||
|
const ownCheckbox = second.getByTestId(
|
||||||
|
"planets-filter-own",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(ownCheckbox.checked).toBe(false);
|
||||||
|
const kinds = second
|
||||||
|
.getAllByTestId("planets-row")
|
||||||
|
.map((r) => r.getAttribute("data-kind"));
|
||||||
|
expect(kinds).toEqual(["other"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("number column sorts ascending then descending", async () => {
|
test("number column sorts ascending then descending", async () => {
|
||||||
const ui = mount(
|
const ui = mount(
|
||||||
makeReport([
|
makeReport([
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
SelectionStore,
|
SelectionStore,
|
||||||
} from "../src/lib/selection.svelte";
|
} from "../src/lib/selection.svelte";
|
||||||
import { activeView } from "../src/lib/app-nav.svelte";
|
import { activeView } from "../src/lib/app-nav.svelte";
|
||||||
|
import { resetShipGroupsTableState } from "../src/lib/active-view/table-ship-groups-state.svelte";
|
||||||
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||||
|
|
||||||
const pageMock = vi.hoisted(() => ({
|
const pageMock = vi.hoisted(() => ({
|
||||||
@@ -50,6 +51,7 @@ beforeEach(() => {
|
|||||||
pageMock.params = { id: "g1" };
|
pageMock.params = { id: "g1" };
|
||||||
gotoMock.mockClear();
|
gotoMock.mockClear();
|
||||||
activeView.reset();
|
activeView.reset();
|
||||||
|
resetShipGroupsTableState();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -287,6 +289,37 @@ describe("ship-groups table", () => {
|
|||||||
expect(activeView.view).toBe("map");
|
expect(activeView.view).toBe("map");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("planet/class dropdowns narrow to the owner-checkbox cut", async () => {
|
||||||
|
const ui = mount(
|
||||||
|
makeReport({
|
||||||
|
planets: [planet(1), planet(2), planet(3)],
|
||||||
|
local: [localGroup({ id: "L1", class: "Cruiser", destination: 1 })],
|
||||||
|
other: [
|
||||||
|
otherGroup({ class: "Hunter", destination: 3, race: "Klingon" }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// All four planet options available when both owner kinds are on
|
||||||
|
const planetSelect = ui.getByTestId(
|
||||||
|
"ship-groups-filter-planet",
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
const valuesFull = Array.from(planetSelect.options).map((o) => o.value);
|
||||||
|
expect(valuesFull).toContain("1");
|
||||||
|
expect(valuesFull).toContain("3");
|
||||||
|
// Hide foreign rows; planet 3 (only touched by Klingon) disappears
|
||||||
|
await fireEvent.click(ui.getByTestId("ship-groups-filter-foreign"));
|
||||||
|
const valuesNarrowed = Array.from(planetSelect.options).map((o) => o.value);
|
||||||
|
expect(valuesNarrowed).toContain("1");
|
||||||
|
expect(valuesNarrowed).not.toContain("3");
|
||||||
|
// Class dropdown narrows too: Hunter disappears
|
||||||
|
const classSelect = ui.getByTestId(
|
||||||
|
"ship-groups-filter-class",
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
const classValues = Array.from(classSelect.options).map((o) => o.value);
|
||||||
|
expect(classValues).not.toContain("Hunter");
|
||||||
|
expect(classValues).toContain("Cruiser");
|
||||||
|
});
|
||||||
|
|
||||||
test("click on in-space foreign group focuses other variant by index", async () => {
|
test("click on in-space foreign group focuses other variant by index", async () => {
|
||||||
const ui = mount(
|
const ui = mount(
|
||||||
makeReport({
|
makeReport({
|
||||||
|
|||||||
Reference in New Issue
Block a user