960 lines
38 KiB
Vue
960 lines
38 KiB
Vue
<script setup lang="ts">
|
|
import { computed, h, 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 GmPlayerFactionPanel from "./GmPlayerFactionPanel.vue";
|
|
import GmGeopoliticsPanel from "./GmGeopoliticsPanel.vue";
|
|
import { useGmStore } from "../../ui/stores/gmStore";
|
|
import { usePlayerFactionStore } from "../../ui/stores/playerFactionStore";
|
|
import { useViewerSelectionStore } from "../../ui/stores/viewerSelection";
|
|
import type { ShipSnapshot } from "../../contractsShips";
|
|
import type { StationSnapshot } from "../../contractsInfrastructure";
|
|
import type { FactionSnapshot } from "../../contractsFactions";
|
|
import type { MarketOrderSnapshot } from "../../contractsEconomy";
|
|
|
|
// ── 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" | "player" | "geopolitics";
|
|
const activeTab = ref<TabId>("ships");
|
|
|
|
const gmStore = useGmStore();
|
|
const playerFactionStore = usePlayerFactionStore();
|
|
const selectionStore = useViewerSelectionStore();
|
|
const { selectedEntityId, selectedEntityKind } = storeToRefs(selectionStore);
|
|
|
|
// Faction name lookup
|
|
const factionMap = computed(() =>
|
|
new Map(gmStore.factions.map((f) => [f.id, f.label])),
|
|
);
|
|
|
|
const factionColorMap = computed(() =>
|
|
new Map(gmStore.factions.map((f) => [f.id, f.color])),
|
|
);
|
|
|
|
function renderColorCell(color: string | null | undefined) {
|
|
const resolved = color && color !== "—" ? color : "#6b7280";
|
|
return h("div", { class: "flex items-center justify-center" }, [
|
|
h("span", {
|
|
class: "inline-block h-3 w-3 rounded-full border border-white/20",
|
|
style: { backgroundColor: resolved },
|
|
}),
|
|
]);
|
|
}
|
|
|
|
function titleCaseToken(value: string | null | undefined) {
|
|
if (!value) return "—";
|
|
return value
|
|
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
.replace(/[-_]+/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim()
|
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
}
|
|
|
|
function compactNumber(value: number | null | undefined, digits = 0) {
|
|
if (value == null || Number.isNaN(value)) return "—";
|
|
return value.toFixed(digits);
|
|
}
|
|
|
|
function compactRate(value: number | null | undefined) {
|
|
if (value == null || Number.isNaN(value)) return "—";
|
|
const sign = value > 0.001 ? "+" : "";
|
|
return `${sign}${value.toFixed(2)}/s`;
|
|
}
|
|
|
|
function formatCargoAmount(value: number | null | undefined) {
|
|
if (value == null || Number.isNaN(value)) return "—";
|
|
const rounded = Math.round(value);
|
|
if (Math.abs(value - rounded) < 0.005) return String(rounded);
|
|
return value.toFixed(2).replace(/\.?0+$/, "");
|
|
}
|
|
|
|
function formatPercent(value: number | null | undefined) {
|
|
if (value == null || Number.isNaN(value)) return "—";
|
|
return `${Math.round(value * 100)}%`;
|
|
}
|
|
|
|
function getLeadCampaign(faction: FactionSnapshot) {
|
|
return [...faction.strategicState.campaigns]
|
|
.sort((left, right) => right.priority - left.priority)
|
|
.find((campaign) => campaign.status !== "completed" && campaign.status !== "cancelled")
|
|
?? faction.strategicState.campaigns[0];
|
|
}
|
|
|
|
function getLeadObjective(faction: FactionSnapshot) {
|
|
return [...faction.strategicState.objectives]
|
|
.sort((left, right) => right.priority - left.priority)
|
|
.find((objective) => objective.status !== "completed" && objective.status !== "cancelled")
|
|
?? faction.strategicState.objectives[0];
|
|
}
|
|
|
|
function getLatestDecision(faction: FactionSnapshot) {
|
|
return [...faction.decisionLog]
|
|
.sort((left, right) => right.occurredAtUtc.localeCompare(left.occurredAtUtc))[0];
|
|
}
|
|
|
|
function describeCommodityState(faction: FactionSnapshot, itemId: string, shortLabel: string) {
|
|
const signal = faction.strategicState.economicAssessment.commoditySignals.find((entry) => entry.itemId === itemId);
|
|
if (!signal) return `${shortLabel} —`;
|
|
return `${shortLabel} ${titleCaseToken(signal.level)} ${compactRate(signal.projectedNetRatePerSecond)}`;
|
|
}
|
|
|
|
function describeFactionStrategicState(faction: FactionSnapshot) {
|
|
const campaign = getLeadCampaign(faction);
|
|
const objective = getLeadObjective(faction);
|
|
if (!campaign && !objective) return "No campaigns";
|
|
if (!campaign) return `${titleCaseToken(objective?.kind)} · ${titleCaseToken(objective?.status)}`;
|
|
return `${titleCaseToken(campaign.kind)} · ${titleCaseToken(campaign.status)}`;
|
|
}
|
|
|
|
function describeFactionLeadTask(faction: FactionSnapshot) {
|
|
const objective = getLeadObjective(faction);
|
|
if (!objective) return "No objectives";
|
|
const target = objective.itemId ?? objective.targetEntityId ?? objective.targetSystemId ?? objective.homeStationId;
|
|
return target
|
|
? `${titleCaseToken(objective.kind)} · ${titleCaseToken(objective.status)} · ${target}`
|
|
: `${titleCaseToken(objective.kind)} · ${titleCaseToken(objective.status)}`;
|
|
}
|
|
|
|
function describeFactionEconomy(faction: FactionSnapshot) {
|
|
return [
|
|
describeCommodityState(faction, "refinedmetals", "RM"),
|
|
describeCommodityState(faction, "hullparts", "HP"),
|
|
describeCommodityState(faction, "claytronics", "CL"),
|
|
].join(" | ");
|
|
}
|
|
|
|
function describeFactionThreat(faction: FactionSnapshot) {
|
|
const threat = faction.strategicState.threatAssessment;
|
|
return `Enemy ships ${threat.enemyShipCount} · stations ${threat.enemyStationCount}`;
|
|
}
|
|
|
|
function describeFactionCommitments(faction: FactionSnapshot) {
|
|
const economic = faction.strategicState.economicAssessment;
|
|
return `Mil ${economic.militaryShipCount}/${economic.targetMilitaryShipCount} · Min ${economic.minerShipCount}/${economic.targetMinerShipCount} · Tr ${economic.transportShipCount}/${economic.targetTransportShipCount}`;
|
|
}
|
|
|
|
function describeFactionReserves(faction: FactionSnapshot) {
|
|
const budget = faction.strategicState.budget;
|
|
return `Assets ${budget.reservedMilitaryAssets}/${budget.reservedLogisticsAssets}/${budget.reservedConstructionAssets} · Credits ${compactNumber(budget.reservedCredits, 0)}`;
|
|
}
|
|
|
|
function describeFactionBottleneck(faction: FactionSnapshot) {
|
|
const economic = faction.strategicState.economicAssessment;
|
|
if (!economic.industrialBottleneckItemId) {
|
|
return `None · sustain ${formatPercent(economic.sustainmentScore)}`;
|
|
}
|
|
return `${economic.industrialBottleneckItemId} · sustain ${formatPercent(economic.sustainmentScore)} · replace ${formatPercent(economic.replacementPressure)}`;
|
|
}
|
|
|
|
function describeFactionIntent(faction: FactionSnapshot) {
|
|
const latestDecision = getLatestDecision(faction);
|
|
const leadCampaign = getLeadCampaign(faction);
|
|
if (!leadCampaign) return latestDecision?.summary ?? "—";
|
|
const pause = leadCampaign.pauseReason ? ` · ${leadCampaign.pauseReason}` : "";
|
|
return `${titleCaseToken(leadCampaign.kind)} · ${titleCaseToken(leadCampaign.status)}${pause}`;
|
|
}
|
|
|
|
function describeFactionMemory(faction: FactionSnapshot) {
|
|
const topSystem = [...faction.memory.systems]
|
|
.sort((left, right) => (right.frontierPressure + right.routeRisk + right.historicalShortagePressure)
|
|
- (left.frontierPressure + left.routeRisk + left.historicalShortagePressure))[0];
|
|
const topCommodity = [...faction.memory.commodities]
|
|
.sort((left, right) => right.historicalShortageScore - left.historicalShortageScore)[0];
|
|
if (!topSystem && !topCommodity) return "—";
|
|
return `${topSystem ? `${topSystem.systemId} fp ${compactNumber(topSystem.frontierPressure, 1)}` : "no-front"}${topCommodity ? ` · ${topCommodity.itemId} hs ${compactNumber(topCommodity.historicalShortageScore, 1)}` : ""}`;
|
|
}
|
|
|
|
function describeFactionDecision(faction: FactionSnapshot) {
|
|
const latestDecision = getLatestDecision(faction);
|
|
return latestDecision ? `${titleCaseToken(latestDecision.kind)} · ${latestDecision.summary}` : "—";
|
|
}
|
|
|
|
function describeFactionFronts(faction: FactionSnapshot) {
|
|
const activeTheaters = faction.strategicState.theaters.filter((theater) => theater.status === "active");
|
|
const defense = activeTheaters.filter((theater) => theater.kind.includes("defense")).length;
|
|
const offense = activeTheaters.filter((theater) => theater.kind.includes("offense")).length;
|
|
const economy = activeTheaters.filter((theater) => theater.kind.includes("economic")).length;
|
|
return `${activeTheaters.length} active · D ${defense} · O ${offense} · E ${economy}`;
|
|
}
|
|
|
|
// ── Ships table ────────────────────────────────────────────────────────────
|
|
|
|
type ShipRow = {
|
|
id: string;
|
|
label: string;
|
|
class: string;
|
|
factionColor: string;
|
|
faction: string;
|
|
system: string;
|
|
state: string;
|
|
assignment: string;
|
|
behavior: string;
|
|
orders: string;
|
|
plan: string;
|
|
step: string;
|
|
subtask: string;
|
|
cargo: number;
|
|
health: number;
|
|
};
|
|
|
|
const shipRows = computed<ShipRow[]>(() =>
|
|
gmStore.ships.map((s) => {
|
|
const topOrder = [...s.orderQueue]
|
|
.sort((left, right) => right.priority - left.priority || left.createdAtUtc.localeCompare(right.createdAtUtc))[0];
|
|
const currentStep = s.activePlan?.steps[s.activePlan.currentStepIndex];
|
|
const currentSubTask = s.activeSubTasks[0];
|
|
return {
|
|
id: s.id,
|
|
label: s.label,
|
|
class: s.class,
|
|
factionColor: factionColorMap.value.get(s.factionId) ?? "—",
|
|
faction: factionMap.value.get(s.factionId) ?? s.factionId,
|
|
system: s.systemId,
|
|
state: titleCaseToken(s.state),
|
|
assignment: s.assignment ? titleCaseToken(s.assignment.kind) : "—",
|
|
behavior: titleCaseToken(s.defaultBehavior.kind),
|
|
orders: topOrder ? `${titleCaseToken(topOrder.kind)} · ${s.orderQueue.length}` : "—",
|
|
plan: s.activePlan ? `${titleCaseToken(s.activePlan.kind)} · ${titleCaseToken(s.activePlan.status)}` : "—",
|
|
step: currentStep ? `${titleCaseToken(currentStep.kind)} · ${titleCaseToken(currentStep.status)}` : "—",
|
|
subtask: currentSubTask ? `${titleCaseToken(currentSubTask.kind)} ${Math.round(currentSubTask.progress * 100)}%` : "—",
|
|
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("factionColor", {
|
|
id: "factionColor",
|
|
header: "Color",
|
|
cell: (info) => renderColorCell(info.getValue()),
|
|
}),
|
|
shipColumnHelper.accessor("faction", { header: "Faction" }),
|
|
shipColumnHelper.accessor("system", { header: "System" }),
|
|
shipColumnHelper.accessor("state", { header: "Ship State" }),
|
|
shipColumnHelper.accessor("assignment", { header: "Assignment" }),
|
|
shipColumnHelper.accessor("behavior", { header: "Behavior" }),
|
|
shipColumnHelper.accessor("orders", { header: "Orders" }),
|
|
shipColumnHelper.accessor("plan", { header: "Plan" }),
|
|
shipColumnHelper.accessor("step", { header: "Current Step" }),
|
|
shipColumnHelper.accessor("subtask", { header: "SubTask" }),
|
|
shipColumnHelper.accessor("cargo", {
|
|
header: "Cargo",
|
|
cell: (info) => formatCargoAmount(info.getValue()),
|
|
}),
|
|
shipColumnHelper.accessor("health", { header: "HP" }),
|
|
];
|
|
|
|
const shipFilter = ref("");
|
|
const shipSorting = ref<SortingState>([]);
|
|
const shipOrder = useColumnOrder(["label", "class", "factionColor", "faction", "system", "state", "assignment", "behavior", "orders", "plan", "step", "subtask", "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;
|
|
objective: string;
|
|
factionColor: string;
|
|
faction: string;
|
|
system: string;
|
|
process: string;
|
|
workforce: string;
|
|
docked: string;
|
|
orders: number;
|
|
orderDetails: MarketOrderSnapshot[];
|
|
cargo: number;
|
|
modules: number;
|
|
};
|
|
|
|
const marketOrderMap = computed(() =>
|
|
new Map(gmStore.marketOrders.map((o) => [o.id, o])),
|
|
);
|
|
|
|
const stationRows = computed<StationRow[]>(() =>
|
|
gmStore.stations.map((s) => ({
|
|
id: s.id,
|
|
label: s.label,
|
|
category: s.category,
|
|
objective: titleCaseToken(s.objective),
|
|
factionColor: factionColorMap.value.get(s.factionId) ?? "—",
|
|
faction: factionMap.value.get(s.factionId) ?? s.factionId,
|
|
system: s.systemId,
|
|
process: s.currentProcesses.length > 0
|
|
? s.currentProcesses.map((process) => `${process.label} ${Math.round(process.progress * 100)}%`).join(" | ")
|
|
: "Idle",
|
|
workforce: `${Math.round(s.population)} / ${Math.round(s.populationCapacity)} · ${Math.round(s.workforceEffectiveRatio * 100)}%`,
|
|
docked: `${s.dockedShips} / ${s.dockingPads}`,
|
|
orders: s.marketOrderIds.length,
|
|
orderDetails: s.marketOrderIds.flatMap((id) => {
|
|
const order = marketOrderMap.value.get(id);
|
|
return order ? [order] : [];
|
|
}),
|
|
cargo: s.inventory.reduce((sum, e) => sum + e.amount, 0),
|
|
modules: s.installedModules.length,
|
|
})),
|
|
);
|
|
|
|
const stationColumnHelper = createColumnHelper<StationRow>();
|
|
const stationColumns = [
|
|
stationColumnHelper.accessor("label", { header: "Name" }),
|
|
stationColumnHelper.accessor("category", { header: "Category" }),
|
|
stationColumnHelper.accessor("objective", { header: "Objective" }),
|
|
stationColumnHelper.accessor("factionColor", {
|
|
id: "factionColor",
|
|
header: "Color",
|
|
cell: (info) => renderColorCell(info.getValue()),
|
|
}),
|
|
stationColumnHelper.accessor("faction", { header: "Faction" }),
|
|
stationColumnHelper.accessor("system", { header: "System" }),
|
|
stationColumnHelper.accessor("process", { header: "Production" }),
|
|
stationColumnHelper.accessor("workforce", { header: "Workforce" }),
|
|
stationColumnHelper.accessor("docked", { header: "Docked" }),
|
|
stationColumnHelper.accessor("orders", { header: "Orders" }),
|
|
stationColumnHelper.accessor("cargo", {
|
|
header: "Cargo",
|
|
cell: (info) => formatCargoAmount(info.getValue()),
|
|
}),
|
|
stationColumnHelper.accessor("modules", { header: "Modules" }),
|
|
];
|
|
|
|
const stationFilter = ref("");
|
|
const stationSorting = ref<SortingState>([]);
|
|
const stationOrder = useColumnOrder(["label", "category", "objective", "factionColor", "faction", "system", "process", "workforce", "docked", "orders", "cargo", "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;
|
|
color: string;
|
|
planCycle: number;
|
|
posture: string;
|
|
fronts: string;
|
|
leadCampaign: string;
|
|
leadObjective: string;
|
|
commitments: string;
|
|
reserves: string;
|
|
bottleneck: string;
|
|
intent: string;
|
|
decision: string;
|
|
memory: string;
|
|
economy: string;
|
|
threat: string;
|
|
systems: string;
|
|
credits: number;
|
|
population: number;
|
|
shipsBuilt: number;
|
|
shipsLost: number;
|
|
};
|
|
|
|
const factionRows = computed<FactionRow[]>(() =>
|
|
gmStore.factions.map((f) => ({
|
|
id: f.id,
|
|
label: f.label,
|
|
color: f.color,
|
|
planCycle: f.strategicState.planCycle,
|
|
posture: `${titleCaseToken(f.doctrine.strategicPosture)} · ${titleCaseToken(f.doctrine.militaryPosture)} · ${titleCaseToken(f.doctrine.economicPosture)}`,
|
|
fronts: describeFactionFronts(f),
|
|
leadCampaign: describeFactionStrategicState(f),
|
|
leadObjective: describeFactionLeadTask(f),
|
|
commitments: describeFactionCommitments(f),
|
|
reserves: describeFactionReserves(f),
|
|
bottleneck: describeFactionBottleneck(f),
|
|
intent: describeFactionIntent(f),
|
|
decision: describeFactionDecision(f),
|
|
memory: describeFactionMemory(f),
|
|
economy: describeFactionEconomy(f),
|
|
threat: describeFactionThreat(f),
|
|
systems: `${f.strategicState.economicAssessment.controlledSystemCount} / ${f.doctrine.desiredControlledSystems}`,
|
|
credits: Math.round(f.credits),
|
|
population: Math.round(f.populationTotal),
|
|
shipsBuilt: f.shipsBuilt,
|
|
shipsLost: f.shipsLost,
|
|
})),
|
|
);
|
|
|
|
const factionColumnHelper = createColumnHelper<FactionRow>();
|
|
const factionColumns = [
|
|
factionColumnHelper.accessor("label", { header: "Faction" }),
|
|
factionColumnHelper.accessor("color", {
|
|
header: "Color",
|
|
cell: (info) => renderColorCell(info.getValue()),
|
|
}),
|
|
factionColumnHelper.accessor("planCycle", { header: "Cycle" }),
|
|
factionColumnHelper.accessor("posture", { header: "Posture" }),
|
|
factionColumnHelper.accessor("fronts", { header: "Fronts" }),
|
|
factionColumnHelper.accessor("leadCampaign", { header: "Lead Campaign" }),
|
|
factionColumnHelper.accessor("leadObjective", { header: "Lead Objective" }),
|
|
factionColumnHelper.accessor("commitments", { header: "Commitments" }),
|
|
factionColumnHelper.accessor("reserves", { header: "Reserves" }),
|
|
factionColumnHelper.accessor("bottleneck", { header: "Bottleneck" }),
|
|
factionColumnHelper.accessor("intent", { header: "Strategic Intent" }),
|
|
factionColumnHelper.accessor("decision", { header: "Recent Decision" }),
|
|
factionColumnHelper.accessor("memory", { header: "Memory" }),
|
|
factionColumnHelper.accessor("economy", { header: "Economy" }),
|
|
factionColumnHelper.accessor("threat", { header: "Threat" }),
|
|
factionColumnHelper.accessor("systems", { header: "Systems" }),
|
|
factionColumnHelper.accessor("credits", { header: "Credits" }),
|
|
factionColumnHelper.accessor("population", { header: "Pop" }),
|
|
factionColumnHelper.accessor("shipsBuilt", { header: "Built" }),
|
|
factionColumnHelper.accessor("shipsLost", { header: "Lost" }),
|
|
];
|
|
|
|
const factionFilter = ref("");
|
|
const factionSorting = ref<SortingState>([]);
|
|
const factionOrder = useColumnOrder(["label", "color", "planCycle", "posture", "fronts", "leadCampaign", "leadObjective", "commitments", "reserves", "bottleneck", "intent", "decision", "memory", "economy", "threat", "systems", "credits", "population", "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: "player", label: "Player" },
|
|
{ id: "geopolitics", label: "Geopolitics" },
|
|
{ id: "ships", label: "Ships" },
|
|
{ id: "stations", label: "Stations" },
|
|
{ id: "factions", label: "Factions" },
|
|
];
|
|
|
|
const activeFilter = computed({
|
|
get: () => {
|
|
if (activeTab.value === "player") return "";
|
|
if (activeTab.value === "geopolitics") return "";
|
|
if (activeTab.value === "ships") return shipFilter.value;
|
|
if (activeTab.value === "stations") return stationFilter.value;
|
|
return factionFilter.value;
|
|
},
|
|
set: (v: string) => {
|
|
if (activeTab.value === "player") return;
|
|
if (activeTab.value === "geopolitics") return;
|
|
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 === "player") {
|
|
return (playerFactionStore.playerFaction?.assetRegistry.shipIds.length ?? 0)
|
|
+ (playerFactionStore.playerFaction?.assetRegistry.stationIds.length ?? 0);
|
|
}
|
|
if (activeTab.value === "geopolitics") {
|
|
const geopolitics = gmStore.geopolitics;
|
|
return (geopolitics?.diplomacy.relations.length ?? 0)
|
|
+ (geopolitics?.territory.controlStates.length ?? 0)
|
|
+ (geopolitics?.economyRegions.regions.length ?? 0);
|
|
}
|
|
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 === "player") return activeRowCount.value;
|
|
if (activeTab.value === "geopolitics") return activeRowCount.value;
|
|
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;
|
|
}
|
|
|
|
// ── Orders tooltip ─────────────────────────────────────────────────────────
|
|
|
|
const ordersTooltip = ref<{
|
|
visible: boolean;
|
|
x: number;
|
|
y: number;
|
|
orders: MarketOrderSnapshot[];
|
|
}>({ visible: false, x: 0, y: 0, orders: [] });
|
|
|
|
function showOrdersTooltip(e: MouseEvent, orders: MarketOrderSnapshot[]) {
|
|
if (orders.length === 0) return;
|
|
ordersTooltip.value = { visible: true, x: e.clientX, y: e.clientY, orders };
|
|
}
|
|
|
|
function moveOrdersTooltip(e: MouseEvent) {
|
|
if (ordersTooltip.value.visible) {
|
|
ordersTooltip.value.x = e.clientX;
|
|
ordersTooltip.value.y = e.clientY;
|
|
}
|
|
}
|
|
|
|
function hideOrdersTooltip() {
|
|
ordersTooltip.value.visible = false;
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<GmWindow
|
|
title="Empire / AI States"
|
|
: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 v-if="activeTab !== 'player' && activeTab !== 'geopolitics'" 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>
|
|
<div v-else class="flex-1 text-xs opacity-60">
|
|
{{ activeTab === "player" ? "Player empire control, policy, and observability." : "Diplomacy, territory, and regional economy observability." }}
|
|
</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 === 'player'"
|
|
class="gm-table-container min-h-0 flex-1 overflow-auto"
|
|
>
|
|
<GmPlayerFactionPanel />
|
|
</div>
|
|
|
|
<div
|
|
v-show="activeTab === 'geopolitics'"
|
|
class="gm-table-container min-h-0 flex-1 overflow-auto"
|
|
>
|
|
<GmGeopoliticsPanel />
|
|
</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>
|
|
<span
|
|
v-else-if="cell.column.id === 'orders'"
|
|
:class="row.original.orderDetails.length > 0 ? 'gm-orders-trigger' : ''"
|
|
@mouseenter="showOrdersTooltip($event, row.original.orderDetails)"
|
|
@mousemove="moveOrdersTooltip"
|
|
@mouseleave="hideOrdersTooltip"
|
|
>
|
|
<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>
|
|
|
|
<Teleport to="body">
|
|
<div
|
|
v-if="ordersTooltip.visible && ordersTooltip.orders.length > 0"
|
|
class="gm-orders-tooltip"
|
|
:style="{ left: `${ordersTooltip.x + 14}px`, top: `${ordersTooltip.y + 14}px` }"
|
|
>
|
|
<table class="gm-orders-tooltip-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Item</th>
|
|
<th>Kind</th>
|
|
<th>Amount</th>
|
|
<th>Remaining</th>
|
|
<th>Valuation</th>
|
|
<th>State</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="order in ordersTooltip.orders" :key="order.id">
|
|
<td>{{ order.itemId }}</td>
|
|
<td>{{ titleCaseToken(order.kind) }}</td>
|
|
<td class="tabular-nums">{{ order.amount }}</td>
|
|
<td class="tabular-nums">{{ order.remainingAmount }}</td>
|
|
<td class="tabular-nums">{{ order.valuation }}</td>
|
|
<td>{{ titleCaseToken(order.state) }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Teleport>
|
|
</template>
|