feat(viewer): add GM Ops Console window replacing ops strip

Introduces a floating, draggable, resizable Game Master console as the
first of a planned series of GM/debug windows. Replaces the horizontal
ops-strip card layout with proper data tables using TanStack Table v8.

- GmWindow.vue: reusable draggable+resizable floating window base;
  snapshots offsetWidth/Height on drag start so resize is preserved
- GmOpsWindow.vue: Ships / Stations / Factions tabs with global filter,
  column sorting, and drag-to-reorder columns (useColumnOrder composable)
- gmStore.ts: Pinia store fed from ViewerWorldLifecycle.rebuildFactions
  with raw world arrays (ships, stations, factions)
- Removes opsStripEl binding (was stored but never read by controller)
- GM Console toggle button replaces the bottom ops strip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-20 00:24:32 -04:00
parent cd1fe776a5
commit 892d069b92
10 changed files with 977 additions and 14 deletions

View File

@@ -8,6 +8,7 @@
"name": "space-game-viewer",
"version": "0.1.0",
"dependencies": {
"@tanstack/vue-table": "^8.21.3",
"pinia": "^3.0.3",
"three": "^0.179.1",
"vue": "^3.5.21"
@@ -1118,6 +1119,36 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/vue-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/vue-table/-/vue-table-8.21.3.tgz",
"integrity": "sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==",
"dependencies": {
"@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"vue": ">=3.2"
}
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",

View File

@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@tanstack/vue-table": "^8.21.3",
"pinia": "^3.0.3",
"three": "^0.179.1",
"vue": "^3.5.21"

View File

@@ -5,13 +5,12 @@ import { GameViewer } from "./GameViewer";
import CollapsibleHudPanel from "./components/CollapsibleHudPanel.vue";
import HtmlInfoPanel from "./components/HtmlInfoPanel.vue";
import ViewerHistoryLayer from "./components/ViewerHistoryLayer.vue";
import ViewerOpsStrip from "./components/ViewerOpsStrip.vue";
import GmOpsWindow from "./components/gm/GmOpsWindow.vue";
import { createViewerHudState } from "./viewerHudState";
import { useViewerSelectionStore } from "./ui/stores/viewerSelection";
import type { Selectable } from "./viewerTypes";
const canvasHostEl = ref<HTMLDivElement | null>(null);
const opsStripHostEl = ref<HTMLDivElement | null>(null);
const historyLayerHostEl = ref<HTMLDivElement | null>(null);
const marqueeEl = ref<HTMLDivElement | null>(null);
const hoverLabelEl = ref<HTMLDivElement | null>(null);
@@ -22,11 +21,12 @@ const selectionStore = useViewerSelectionStore();
const { selectedEntityId, selectedEntityLabel } = storeToRefs(selectionStore);
let viewer: GameViewer | undefined;
const gmOpsOpen = ref(false);
onMounted(async () => {
await nextTick();
if (
!canvasHostEl.value
|| !opsStripHostEl.value
|| !historyLayerHostEl.value
|| !marqueeEl.value
|| !hoverLabelEl.value
@@ -38,7 +38,6 @@ onMounted(async () => {
viewer = new GameViewer(canvasHostEl.value, {
state: hudState,
selectionStore,
opsStripEl: opsStripHostEl.value,
historyLayerEl: historyLayerHostEl.value,
marqueeEl: marqueeEl.value,
hoverLabelEl: hoverLabelEl.value,
@@ -146,13 +145,19 @@ function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactic
/>
</div>
<div ref="opsStripHostEl">
<ViewerOpsStrip
:state="hudState.opsStrip"
@history="onOpenHistory"
@focus="onFocusSelection"
/>
</div>
<button
type="button"
class="gm-console-toggle"
@click="gmOpsOpen = !gmOpsOpen"
>
{{ gmOpsOpen ? "Close" : "GM Console" }}
</button>
<GmOpsWindow
v-if="gmOpsOpen"
@close="gmOpsOpen = false"
@focus="(id, kind) => onFocusSelection({ kind, id }, kind === 'ship' ? 'follow' : 'tactical')"
/>
<div
ref="marqueeEl"

View File

@@ -68,7 +68,6 @@ export class ViewerAppController {
readonly hudState: ViewerHudState;
readonly selectionStore: ViewerSelectionStore;
private readonly opsStripEl: HTMLDivElement;
private readonly historyLayerEl: HTMLDivElement;
private readonly marqueeEl: HTMLDivElement;
private readonly hoverLabelEl: HTMLDivElement;
@@ -122,7 +121,6 @@ export class ViewerAppController {
this.container = container;
this.hudState = hud.state;
this.selectionStore = hud.selectionStore;
this.opsStripEl = hud.opsStripEl;
this.historyLayerEl = hud.historyLayerEl;
this.marqueeEl = hud.marqueeEl;
this.hoverLabelEl = hud.hoverLabelEl;

View File

@@ -0,0 +1,617 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import {
useVueTable,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
createColumnHelper,
FlexRender,
type SortingState,
type ColumnOrderState,
type Updater,
} from "@tanstack/vue-table";
import { storeToRefs } from "pinia";
import GmWindow from "./GmWindow.vue";
import { useGmStore } from "../../ui/stores/gmStore";
import { useViewerSelectionStore } from "../../ui/stores/viewerSelection";
import type { ShipSnapshot } from "../../contractsShips";
import type { StationSnapshot } from "../../contractsInfrastructure";
import type { FactionSnapshot } from "../../contractsFactions";
// ── Column ordering composable ─────────────────────────────────────────────
function useColumnOrder(initialIds: string[]) {
const columnOrder = ref<ColumnOrderState>([...initialIds]);
const draggingId = ref<string | null>(null);
const overId = ref<string | null>(null);
function onColumnOrderChange(updater: Updater<ColumnOrderState>) {
columnOrder.value = typeof updater === "function" ? updater(columnOrder.value) : updater;
}
function onDragStart(e: DragEvent, id: string) {
draggingId.value = id;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", id);
}
}
function onDragOver(e: DragEvent, id: string) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
overId.value = id;
}
function onDragLeave() {
overId.value = null;
}
function onDrop(targetId: string) {
if (!draggingId.value || draggingId.value === targetId) return;
const order = [...columnOrder.value];
const from = order.indexOf(draggingId.value);
const to = order.indexOf(targetId);
order.splice(from, 1);
order.splice(to, 0, draggingId.value);
columnOrder.value = order;
draggingId.value = null;
overId.value = null;
}
function onDragEnd() {
draggingId.value = null;
overId.value = null;
}
return { columnOrder, draggingId, overId, onColumnOrderChange, onDragStart, onDragOver, onDragLeave, onDrop, onDragEnd };
}
const emit = defineEmits<{
close: [];
focus: [id: string, kind: "ship" | "station"];
}>();
type TabId = "ships" | "stations" | "factions";
const activeTab = ref<TabId>("ships");
const gmStore = useGmStore();
const selectionStore = useViewerSelectionStore();
const { selectedEntityId, selectedEntityKind } = storeToRefs(selectionStore);
// Faction name lookup
const factionMap = computed(() =>
new Map(gmStore.factions.map((f) => [f.id, f.label])),
);
// ── Ships table ────────────────────────────────────────────────────────────
type ShipRow = {
id: string;
label: string;
class: string;
faction: string;
system: string;
state: string;
behavior: string;
task: string;
cargo: number;
health: number;
};
const shipRows = computed<ShipRow[]>(() =>
gmStore.ships.map((s) => ({
id: s.id,
label: s.label,
class: s.class,
faction: factionMap.value.get(s.factionId) ?? s.factionId,
system: s.systemId,
state: s.state,
behavior: s.defaultBehaviorKind + (s.behaviorPhase ? ` · ${s.behaviorPhase}` : ""),
task: s.controllerTaskKind,
cargo: s.inventory.reduce((sum, e) => sum + e.amount, 0),
health: Math.round(s.health),
})),
);
const shipColumnHelper = createColumnHelper<ShipRow>();
const shipColumns = [
shipColumnHelper.accessor("label", { header: "Name" }),
shipColumnHelper.accessor("class", { header: "Class" }),
shipColumnHelper.accessor("faction", { header: "Faction" }),
shipColumnHelper.accessor("system", { header: "System" }),
shipColumnHelper.accessor("state", { header: "State" }),
shipColumnHelper.accessor("behavior", { header: "Behavior" }),
shipColumnHelper.accessor("task", { header: "Task" }),
shipColumnHelper.accessor("cargo", { header: "Cargo" }),
shipColumnHelper.accessor("health", { header: "HP" }),
];
const shipFilter = ref("");
const shipSorting = ref<SortingState>([]);
const shipOrder = useColumnOrder(["label", "class", "faction", "system", "state", "behavior", "task", "cargo", "health"]);
const shipTable = useVueTable({
get data() { return shipRows.value; },
columns: shipColumns,
state: {
get globalFilter() { return shipFilter.value; },
get sorting() { return shipSorting.value; },
get columnOrder() { return shipOrder.columnOrder.value; },
},
onGlobalFilterChange: (v) => { shipFilter.value = String(v); },
onSortingChange: (updater) => {
shipSorting.value = typeof updater === "function" ? updater(shipSorting.value) : updater;
},
onColumnOrderChange: shipOrder.onColumnOrderChange,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
});
// ── Stations table ─────────────────────────────────────────────────────────
type StationRow = {
id: string;
label: string;
category: string;
faction: string;
system: string;
docked: string;
cargo: number;
population: number;
modules: number;
};
const stationRows = computed<StationRow[]>(() =>
gmStore.stations.map((s) => ({
id: s.id,
label: s.label,
category: s.category,
faction: factionMap.value.get(s.factionId) ?? s.factionId,
system: s.systemId,
docked: `${s.dockedShips} / ${s.dockingPads}`,
cargo: s.inventory.reduce((sum, e) => sum + e.amount, 0),
population: Math.round(s.population),
modules: s.installedModules.length,
})),
);
const stationColumnHelper = createColumnHelper<StationRow>();
const stationColumns = [
stationColumnHelper.accessor("label", { header: "Name" }),
stationColumnHelper.accessor("category", { header: "Category" }),
stationColumnHelper.accessor("faction", { header: "Faction" }),
stationColumnHelper.accessor("system", { header: "System" }),
stationColumnHelper.accessor("docked", { header: "Docked" }),
stationColumnHelper.accessor("cargo", { header: "Cargo" }),
stationColumnHelper.accessor("population", { header: "Pop" }),
stationColumnHelper.accessor("modules", { header: "Modules" }),
];
const stationFilter = ref("");
const stationSorting = ref<SortingState>([]);
const stationOrder = useColumnOrder(["label", "category", "faction", "system", "docked", "cargo", "population", "modules"]);
const stationTable = useVueTable({
get data() { return stationRows.value; },
columns: stationColumns,
state: {
get globalFilter() { return stationFilter.value; },
get sorting() { return stationSorting.value; },
get columnOrder() { return stationOrder.columnOrder.value; },
},
onGlobalFilterChange: (v) => { stationFilter.value = String(v); },
onSortingChange: (updater) => {
stationSorting.value = typeof updater === "function" ? updater(stationSorting.value) : updater;
},
onColumnOrderChange: stationOrder.onColumnOrderChange,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
});
// ── Factions table ─────────────────────────────────────────────────────────
type FactionRow = {
id: string;
label: string;
credits: number;
population: number;
military: number;
miners: number;
transport: number;
constructors: number;
systems: string;
ore: number;
shipsBuilt: number;
shipsLost: number;
};
const factionRows = computed<FactionRow[]>(() =>
gmStore.factions.map((f) => {
const gs = f.goapState;
return {
id: f.id,
label: f.label,
credits: Math.round(f.credits),
population: Math.round(f.populationTotal),
military: gs?.militaryShipCount ?? 0,
miners: gs?.minerShipCount ?? 0,
transport: gs?.transportShipCount ?? 0,
constructors: gs?.constructorShipCount ?? 0,
systems: gs ? `${gs.controlledSystemCount} / ${gs.targetSystemCount}` : "—",
ore: gs ? Math.round(gs.oreStockpile) : 0,
shipsBuilt: f.shipsBuilt,
shipsLost: f.shipsLost,
};
}),
);
const factionColumnHelper = createColumnHelper<FactionRow>();
const factionColumns = [
factionColumnHelper.accessor("label", { header: "Faction" }),
factionColumnHelper.accessor("credits", { header: "Credits" }),
factionColumnHelper.accessor("population", { header: "Pop" }),
factionColumnHelper.accessor("military", { header: "Military" }),
factionColumnHelper.accessor("miners", { header: "Miners" }),
factionColumnHelper.accessor("transport", { header: "Transport" }),
factionColumnHelper.accessor("constructors", { header: "Constructors" }),
factionColumnHelper.accessor("systems", { header: "Systems" }),
factionColumnHelper.accessor("ore", { header: "Ore" }),
factionColumnHelper.accessor("shipsBuilt", { header: "Built" }),
factionColumnHelper.accessor("shipsLost", { header: "Lost" }),
];
const factionFilter = ref("");
const factionSorting = ref<SortingState>([]);
const factionOrder = useColumnOrder(["label", "credits", "population", "military", "miners", "transport", "constructors", "systems", "ore", "shipsBuilt", "shipsLost"]);
const factionTable = useVueTable({
get data() { return factionRows.value; },
columns: factionColumns,
state: {
get globalFilter() { return factionFilter.value; },
get sorting() { return factionSorting.value; },
get columnOrder() { return factionOrder.columnOrder.value; },
},
onGlobalFilterChange: (v) => { factionFilter.value = String(v); },
onSortingChange: (updater) => {
factionSorting.value = typeof updater === "function" ? updater(factionSorting.value) : updater;
},
onColumnOrderChange: factionOrder.onColumnOrderChange,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
});
// ── Row counts ─────────────────────────────────────────────────────────────
const tabs: { id: TabId; label: string }[] = [
{ id: "ships", label: "Ships" },
{ id: "stations", label: "Stations" },
{ id: "factions", label: "Factions" },
];
const activeFilter = computed({
get: () => {
if (activeTab.value === "ships") return shipFilter.value;
if (activeTab.value === "stations") return stationFilter.value;
return factionFilter.value;
},
set: (v: string) => {
if (activeTab.value === "ships") shipFilter.value = v;
else if (activeTab.value === "stations") stationFilter.value = v;
else factionFilter.value = v;
},
});
const activeRowCount = computed(() => {
if (activeTab.value === "ships") return shipTable.getFilteredRowModel().rows.length;
if (activeTab.value === "stations") return stationTable.getFilteredRowModel().rows.length;
return factionTable.getFilteredRowModel().rows.length;
});
const activeTotalCount = computed(() => {
if (activeTab.value === "ships") return gmStore.ships.length;
if (activeTab.value === "stations") return gmStore.stations.length;
return gmStore.factions.length;
});
// ── Row interaction ────────────────────────────────────────────────────────
function onShipClick(row: ShipRow) {
selectionStore.selectSelection({ id: row.id, kind: "ship", label: row.label }, "ui");
}
function onShipDblClick(row: ShipRow) {
selectionStore.selectSelection({ id: row.id, kind: "ship", label: row.label }, "ui");
emit("focus", row.id, "ship");
}
function onStationClick(row: StationRow) {
selectionStore.selectSelection({ id: row.id, kind: "station", label: row.label }, "ui");
}
function onStationDblClick(row: StationRow) {
selectionStore.selectSelection({ id: row.id, kind: "station", label: row.label }, "ui");
emit("focus", row.id, "station");
}
function isShipSelected(id: string) {
return selectedEntityKind.value === "ship" && selectedEntityId.value === id;
}
function isStationSelected(id: string) {
return selectedEntityKind.value === "station" && selectedEntityId.value === id;
}
</script>
<template>
<GmWindow
title="Ships"
:initial-width="980"
:initial-height="560"
:initial-x="80"
:initial-y="80"
@close="emit('close')"
>
<div class="flex h-full flex-col">
<!-- Tab bar + search -->
<div class="gm-toolbar flex shrink-0 items-center gap-3 px-3 py-2">
<div class="flex gap-1">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
class="gm-tab-btn rounded px-3 py-1 text-xs font-semibold uppercase tracking-widest transition"
:class="activeTab === tab.id ? 'gm-tab-btn--active' : ''"
@click="activeTab = tab.id"
>
{{ tab.label }}
</button>
</div>
<div class="relative flex-1">
<input
v-model="activeFilter"
class="gm-search-input w-full rounded border py-1 pl-7 pr-7 text-xs"
placeholder="Filter…"
type="search"
/>
<svg class="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 opacity-40" width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001q.044.06.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1 1 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0" />
</svg>
<button
v-if="activeFilter"
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] opacity-40 hover:opacity-80"
@click="activeFilter = ''"
>
</button>
</div>
<span class="gm-row-count shrink-0 font-mono text-xs tabular-nums opacity-60">
{{ activeRowCount }} / {{ activeTotalCount }}
</span>
</div>
<!-- Ships table -->
<div
v-show="activeTab === 'ships'"
class="gm-table-container min-h-0 flex-1 overflow-auto"
>
<table class="gm-table w-full min-w-max border-separate border-spacing-0 text-xs">
<thead class="sticky top-0 z-10">
<tr
v-for="headerGroup in shipTable.getHeaderGroups()"
:key="headerGroup.id"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
class="gm-th whitespace-nowrap px-3 py-2 text-left"
:class="[
header.column.getCanSort() ? 'cursor-pointer' : '',
shipOrder.draggingId.value === header.column.id ? 'gm-th--dragging' : '',
shipOrder.overId.value === header.column.id ? 'gm-th--dragover' : '',
]"
draggable="true"
@dragstart="shipOrder.onDragStart($event, header.column.id)"
@dragover="shipOrder.onDragOver($event, header.column.id)"
@dragleave="shipOrder.onDragLeave()"
@drop="shipOrder.onDrop(header.column.id)"
@dragend="shipOrder.onDragEnd()"
@click="header.column.getToggleSortingHandler()?.($event)"
>
<span class="flex select-none items-center gap-1">
<FlexRender
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
<span class="text-[10px] opacity-60">
{{ header.column.getIsSorted() === "asc" ? "↑" : header.column.getIsSorted() === "desc" ? "↓" : "" }}
</span>
</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in shipTable.getRowModel().rows"
:key="row.id"
class="gm-tr cursor-pointer"
:class="isShipSelected(row.original.id) ? 'gm-tr--selected' : ''"
@click="onShipClick(row.original)"
@dblclick="onShipDblClick(row.original)"
>
<td
v-for="cell in row.getVisibleCells()"
:key="cell.id"
class="gm-td whitespace-nowrap px-3 py-1.5"
>
<span
v-if="cell.column.id === 'class'"
class="gm-badge rounded px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider"
>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</span>
<FlexRender
v-else
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
</td>
</tr>
<tr v-if="shipTable.getRowModel().rows.length === 0">
<td :colspan="shipColumns.length" class="gm-td px-3 py-6 text-center opacity-40">
No ships match the filter.
</td>
</tr>
</tbody>
</table>
</div>
<!-- Stations table -->
<div
v-show="activeTab === 'stations'"
class="gm-table-container min-h-0 flex-1 overflow-auto"
>
<table class="gm-table w-full min-w-max border-separate border-spacing-0 text-xs">
<thead class="sticky top-0 z-10">
<tr
v-for="headerGroup in stationTable.getHeaderGroups()"
:key="headerGroup.id"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
class="gm-th whitespace-nowrap px-3 py-2 text-left"
:class="[
header.column.getCanSort() ? 'cursor-pointer' : '',
stationOrder.draggingId.value === header.column.id ? 'gm-th--dragging' : '',
stationOrder.overId.value === header.column.id ? 'gm-th--dragover' : '',
]"
draggable="true"
@dragstart="stationOrder.onDragStart($event, header.column.id)"
@dragover="stationOrder.onDragOver($event, header.column.id)"
@dragleave="stationOrder.onDragLeave()"
@drop="stationOrder.onDrop(header.column.id)"
@dragend="stationOrder.onDragEnd()"
@click="header.column.getToggleSortingHandler()?.($event)"
>
<span class="flex select-none items-center gap-1">
<FlexRender
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
<span class="text-[10px] opacity-60">
{{ header.column.getIsSorted() === "asc" ? "↑" : header.column.getIsSorted() === "desc" ? "↓" : "" }}
</span>
</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in stationTable.getRowModel().rows"
:key="row.id"
class="gm-tr cursor-pointer"
:class="isStationSelected(row.original.id) ? 'gm-tr--selected' : ''"
@click="onStationClick(row.original)"
@dblclick="onStationDblClick(row.original)"
>
<td
v-for="cell in row.getVisibleCells()"
:key="cell.id"
class="gm-td whitespace-nowrap px-3 py-1.5"
>
<span
v-if="cell.column.id === 'category'"
class="gm-badge rounded px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider"
>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</span>
<FlexRender
v-else
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
</td>
</tr>
<tr v-if="stationTable.getRowModel().rows.length === 0">
<td :colspan="stationColumns.length" class="gm-td px-3 py-6 text-center opacity-40">
No stations match the filter.
</td>
</tr>
</tbody>
</table>
</div>
<!-- Factions table -->
<div
v-show="activeTab === 'factions'"
class="gm-table-container min-h-0 flex-1 overflow-auto"
>
<table class="gm-table w-full min-w-max border-separate border-spacing-0 text-xs">
<thead class="sticky top-0 z-10">
<tr
v-for="headerGroup in factionTable.getHeaderGroups()"
:key="headerGroup.id"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
class="gm-th whitespace-nowrap px-3 py-2 text-left"
:class="[
header.column.getCanSort() ? 'cursor-pointer' : '',
factionOrder.draggingId.value === header.column.id ? 'gm-th--dragging' : '',
factionOrder.overId.value === header.column.id ? 'gm-th--dragover' : '',
]"
draggable="true"
@dragstart="factionOrder.onDragStart($event, header.column.id)"
@dragover="factionOrder.onDragOver($event, header.column.id)"
@dragleave="factionOrder.onDragLeave()"
@drop="factionOrder.onDrop(header.column.id)"
@dragend="factionOrder.onDragEnd()"
@click="header.column.getToggleSortingHandler()?.($event)"
>
<span class="flex select-none items-center gap-1">
<FlexRender
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
<span class="text-[10px] opacity-60">
{{ header.column.getIsSorted() === "asc" ? "↑" : header.column.getIsSorted() === "desc" ? "↓" : "" }}
</span>
</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in factionTable.getRowModel().rows"
:key="row.id"
class="gm-tr"
>
<td
v-for="cell in row.getVisibleCells()"
:key="cell.id"
class="gm-td whitespace-nowrap px-3 py-1.5"
>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</td>
</tr>
<tr v-if="factionTable.getRowModel().rows.length === 0">
<td :colspan="factionColumns.length" class="gm-td px-3 py-6 text-center opacity-40">
No factions match the filter.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</GmWindow>
</template>

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
const props = withDefaults(defineProps<{
title: string;
initialX?: number;
initialY?: number;
initialWidth?: number;
initialHeight?: number;
}>(), {
initialX: 80,
initialY: 80,
initialWidth: 960,
initialHeight: 580,
});
const emit = defineEmits<{
close: [];
}>();
const windowEl = ref<HTMLDivElement | null>(null);
const x = ref(props.initialX);
const y = ref(props.initialY);
const w = ref(props.initialWidth);
const h = ref(props.initialHeight);
const isDragging = ref(false);
let dragOffsetX = 0;
let dragOffsetY = 0;
function onTitleMouseDown(e: MouseEvent) {
if ((e.target as HTMLElement).closest("button")) return;
// Snapshot current rendered size so resize isn't lost on drag
if (windowEl.value) {
w.value = windowEl.value.offsetWidth;
h.value = windowEl.value.offsetHeight;
}
isDragging.value = true;
dragOffsetX = e.clientX - x.value;
dragOffsetY = e.clientY - y.value;
e.preventDefault();
}
function onMouseMove(e: MouseEvent) {
if (!isDragging.value) return;
x.value = e.clientX - dragOffsetX;
y.value = e.clientY - dragOffsetY;
}
function onMouseUp() {
isDragging.value = false;
}
onMounted(() => {
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
});
onUnmounted(() => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
});
</script>
<template>
<div
ref="windowEl"
class="gm-window pointer-events-auto fixed flex flex-col overflow-hidden rounded-xl border"
:style="{
left: `${x}px`,
top: `${y}px`,
width: `${w}px`,
height: `${h}px`,
cursor: isDragging ? 'grabbing' : 'default',
zIndex: 200,
}"
>
<!-- Title bar -->
<div
class="gm-window-titlebar flex shrink-0 cursor-grab select-none items-center gap-2 px-4 py-2.5"
:style="{ cursor: isDragging ? 'grabbing' : 'grab' }"
@mousedown="onTitleMouseDown"
>
<span class="gm-window-title-badge mr-1 rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-widest">GM</span>
<h2 class="flex-1 font-[Space_Grotesk] text-sm font-semibold tracking-wide">{{ title }}</h2>
<button
type="button"
class="gm-window-close-btn flex h-6 w-6 items-center justify-center rounded text-xs opacity-60 transition hover:opacity-100"
aria-label="Close window"
@click="emit('close')"
>
</button>
</div>
<!-- Content -->
<div class="min-h-0 flex-1 overflow-hidden">
<slot />
</div>
</div>
</template>

View File

@@ -386,3 +386,181 @@ canvas {
min-height: 120px;
}
}
/* ── GM Windows ──────────────────────────────────────────────────────────── */
.gm-window {
backdrop-filter: blur(18px);
background: rgba(7, 14, 27, 0.92);
border-color: rgba(132, 196, 255, 0.18);
box-shadow:
0 0 0 1px rgba(127, 214, 255, 0.06) inset,
0 32px 72px rgba(0, 0, 0, 0.52);
color: var(--viewer-text);
resize: both;
min-width: 480px;
min-height: 240px;
overflow: hidden;
}
.gm-window-titlebar {
background: rgba(127, 214, 255, 0.04);
border-bottom: 1px solid rgba(132, 196, 255, 0.12);
}
.gm-window-title-badge {
background: rgba(127, 214, 255, 0.14);
color: var(--viewer-accent);
font-family: "IBM Plex Mono", monospace;
}
.gm-window-close-btn {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--viewer-text);
font-family: inherit;
cursor: pointer;
}
.gm-window-close-btn:hover {
background: rgba(255, 80, 60, 0.18);
border-color: rgba(255, 80, 60, 0.3);
}
.gm-toolbar {
background: rgba(127, 214, 255, 0.03);
border-bottom: 1px solid rgba(132, 196, 255, 0.1);
}
.gm-tab-btn {
background: transparent;
border: 1px solid transparent;
color: var(--viewer-muted);
cursor: pointer;
font-family: "IBM Plex Mono", monospace;
}
.gm-tab-btn:hover {
color: var(--viewer-text);
background: rgba(127, 214, 255, 0.06);
}
.gm-tab-btn--active {
background: rgba(127, 214, 255, 0.12);
border-color: rgba(127, 214, 255, 0.22);
color: var(--viewer-accent);
}
.gm-search-input {
background: rgba(127, 214, 255, 0.05);
border-color: rgba(132, 196, 255, 0.18);
color: var(--viewer-text);
font-family: "IBM Plex Mono", monospace;
outline: none;
}
.gm-search-input:focus {
border-color: rgba(127, 214, 255, 0.4);
background: rgba(127, 214, 255, 0.08);
}
.gm-search-input::placeholder {
color: var(--viewer-muted);
}
.gm-row-count {
color: var(--viewer-muted);
font-family: "IBM Plex Mono", monospace;
}
.gm-table-container {
scrollbar-width: thin;
scrollbar-color: rgba(127, 214, 255, 0.2) transparent;
}
.gm-table {
font-family: "IBM Plex Mono", monospace;
}
.gm-th {
background: rgba(7, 14, 27, 0.98);
color: var(--viewer-accent);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
border-bottom: 1px solid rgba(132, 196, 255, 0.14);
}
.gm-th:hover {
color: var(--viewer-text);
}
.gm-tr {
border-bottom: 1px solid transparent;
transition: background 80ms ease;
}
.gm-tr:nth-child(even) .gm-td {
background: rgba(127, 214, 255, 0.025);
}
.gm-tr:hover .gm-td {
background: rgba(127, 214, 255, 0.07);
}
.gm-tr--selected .gm-td {
background: rgba(255, 191, 105, 0.1) !important;
border-bottom-color: rgba(255, 191, 105, 0.14);
}
.gm-td {
color: var(--viewer-muted);
font-size: 0.72rem;
border-bottom: 1px solid rgba(132, 196, 255, 0.06);
}
.gm-td:first-child {
color: var(--viewer-text);
font-weight: 500;
}
.gm-badge {
background: rgba(127, 214, 255, 0.1);
color: var(--viewer-accent);
}
.gm-th--dragging {
opacity: 0.35;
}
.gm-th--dragover {
background: rgba(127, 214, 255, 0.14);
box-shadow: inset 2px 0 0 var(--viewer-accent);
}
.gm-console-toggle {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
pointer-events: auto;
z-index: 100;
padding: 7px 18px;
border-radius: 999px;
background: rgba(127, 214, 255, 0.1);
border: 1px solid rgba(127, 214, 255, 0.24);
color: var(--viewer-accent);
font-family: "IBM Plex Mono", monospace;
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
cursor: pointer;
transition: background 140ms ease, border-color 140ms ease;
backdrop-filter: blur(8px);
}
.gm-console-toggle:hover {
background: rgba(127, 214, 255, 0.18);
border-color: rgba(127, 214, 255, 0.4);
}

View File

@@ -0,0 +1,23 @@
import { defineStore } from "pinia";
import type { ShipSnapshot } from "../../contractsShips";
import type { StationSnapshot } from "../../contractsInfrastructure";
import type { FactionSnapshot } from "../../contractsFactions";
export const useGmStore = defineStore("gm", {
state: () => ({
ships: [] as ShipSnapshot[],
stations: [] as StationSnapshot[],
factions: [] as FactionSnapshot[],
}),
actions: {
updateWorld(
ships: ShipSnapshot[],
stations: StationSnapshot[],
factions: FactionSnapshot[],
) {
this.ships = ships;
this.stations = stations;
this.factions = factions;
},
},
});

View File

@@ -112,7 +112,6 @@ export interface ViewerHudState {
export interface ViewerHudBindings {
state: ViewerHudState;
selectionStore: ViewerSelectionStore;
opsStripEl: HTMLDivElement;
historyLayerEl: HTMLDivElement;
marqueeEl: HTMLDivElement;
hoverLabelEl: HTMLDivElement;

View File

@@ -1,6 +1,8 @@
import { fetchWorldSnapshot, openWorldStream } from "./api";
import type { ViewerHudState } from "./viewerHudState";
import { buildOpsStripState } from "./viewerOpsStrip";
import { useGmStore } from "./ui/stores/gmStore";
import { viewerPinia } from "./ui/stores/pinia";
import { buildDetailPanelState } from "./viewerPanels";
import { applyDeltaToWorld, cloneFactions, createWorldState, recordDeltaStats } from "./viewerState";
import type {
@@ -195,6 +197,15 @@ export class ViewerWorldLifecycle {
this.context.getPovLevel(),
this.context.getActiveSystemId(),
);
const world = this.context.getWorld();
if (world) {
useGmStore(viewerPinia).updateWorld(
[...world.ships.values()],
[...world.stations.values()],
[...world.factions.values()],
);
}
}
updatePanels() {