feat(viewer): add Vue-based HUD, ops strip, and history window

This commit is contained in:
2026-03-19 13:49:56 -04:00
parent 710addf1f5
commit 3ca568c05d
36 changed files with 2648 additions and 1017 deletions

View File

@@ -17,7 +17,6 @@ const itemTransportById = new Map<string, string>(
import { describeCelestialPathWithinSystem, describeOrbitalParent, describeSelectable, describeShipBehavior, describeShipCurrentAction, describeShipOrder, describeShipState, getSelectionGroup, renderSystemDetails } from "./viewerSelection";
import type {
CameraMode,
HistoryWindowState,
NodeVisual,
OrbitalAnchor,
Selectable,
@@ -39,9 +38,6 @@ interface DetailPanelParams {
interface SystemPanelParams {
world: WorldState;
activeSystemId?: string;
systemTitleEl: HTMLHeadingElement;
systemBodyEl: HTMLDivElement;
systemPanelEl: HTMLDivElement;
cameraMode: CameraMode;
cameraTargetShipId?: string;
}
@@ -70,7 +66,15 @@ function formatModuleListWithConstruction(
world: WorldState,
stationId: string,
installedModules: string[],
currentProcesses: { lane: string; label: string; progress: number; timeRemainingSeconds: number }[],
currentProcesses: {
lane: string;
label: string;
progress: number;
timeRemainingSeconds: number;
cycleSeconds: number;
inputs: { itemId: string; amount: number }[];
outputs: { itemId: string; amount: number }[];
}[],
): string {
const processByModule = new Map<string, { label: string; progress: number; timeRemainingSeconds: number; cycleSeconds: number; inputs: { itemId: string; amount: number }[]; outputs: { itemId: string; amount: number }[] }[]>();
for (const process of currentProcesses) {
@@ -175,11 +179,7 @@ function renderSystemOwnership(world: WorldState, systemId: string): string {
.join("<br>");
}
export function updateDetailPanel(
detailTitleEl: HTMLHeadingElement,
detailBodyEl: HTMLDivElement,
params: DetailPanelParams,
) {
export function buildDetailPanelState(params: DetailPanelParams) {
const {
world,
selectedItems,
@@ -191,35 +191,37 @@ export function updateDetailPanel(
} = params;
if (selectedItems.length === 0) {
detailTitleEl.textContent = worldLabel;
detailBodyEl.innerHTML = `
Zoom ${povLevel}<br>
Systems ${world.systems.size}<br>
Celestials ${world.celestials.size}<br>
Stations ${world.stations.size}<br>
Claims ${world.claims.size}<br>
Construction ${world.constructionSites.size}<br>
Ships ${world.ships.size}<br>
Recent events ${world.recentEvents.length}
`;
return;
return {
title: worldLabel,
bodyHtml: `
Zoom ${povLevel}<br>
Systems ${world.systems.size}<br>
Celestials ${world.celestials.size}<br>
Stations ${world.stations.size}<br>
Claims ${world.claims.size}<br>
Construction ${world.constructionSites.size}<br>
Ships ${world.ships.size}<br>
Recent events ${world.recentEvents.length}
`,
};
}
if (selectedItems.length > 1) {
const group = getSelectionGroup(selectedItems[0]);
detailTitleEl.textContent = `${selectedItems.length} selected`;
detailBodyEl.innerHTML = `
Type ${group}<br>
${selectedItems.slice(0, 8).map((item) => describeSelectable(world, item)).join("<br>")}
`;
return;
return {
title: `${selectedItems.length} selected`,
bodyHtml: `
Type ${group}<br>
${selectedItems.slice(0, 8).map((item) => describeSelectable(world, item)).join("<br>")}
`,
};
}
const selected = selectedItems[0];
if (selected.kind === "ship") {
const ship = world.ships.get(selected.id);
if (!ship) {
return;
return { title: "Missing ship", bodyHtml: "" };
}
const parent = describeSelectionParent(selected);
const cargoUsed = ship.inventory.reduce((sum, e) => sum + e.amount, 0);
@@ -227,36 +229,37 @@ export function updateDetailPanel(
const shipBehavior = describeShipBehavior(ship);
const shipOrder = describeShipOrder(ship);
const shipAction = describeShipCurrentAction(ship);
detailTitleEl.textContent = ship.label;
detailBodyEl.innerHTML = `
<p>Parent ${parent}</p>
<p>Behavior ${shipBehavior}</p>
<p>State ${shipState}</p>
<p>Order ${shipOrder}</p>
<p>Task ${ship.controllerTaskKind}</p>
${shipAction ? `
<div class="detail-progress">
<div class="detail-progress-label">
<span>${shipAction.label}</span>
<span>${Math.round(shipAction.progress * 100)}%</span>
return {
title: ship.label,
bodyHtml: `
<p>Parent ${parent}</p>
<p>Behavior ${shipBehavior}</p>
<p>State ${shipState}</p>
<p>Order ${shipOrder}</p>
<p>Task ${ship.controllerTaskKind}</p>
${shipAction ? `
<div class="detail-progress">
<div class="detail-progress-label">
<span>${shipAction.label}</span>
<span>${Math.round(shipAction.progress * 100)}%</span>
</div>
<div class="detail-progress-track">
<div class="detail-progress-fill" style="width: ${(shipAction.progress * 100).toFixed(1)}%"></div>
</div>
</div>
<div class="detail-progress-track">
<div class="detail-progress-fill" style="width: ${(shipAction.progress * 100).toFixed(1)}%"></div>
</div>
</div>
` : ""}
<p>Cargo ${cargoUsed.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)}</p>
<p>Inventory ${formatInventory(ship.inventory)}</p>
<p>Speed ${formatShipSpeed(ship)}</p>
<p>Camera ${cameraMode === "follow" && cameraTargetShipId === ship.id ? "camera-follow" : "tactical"}<br>Press C to toggle follow</p>
`;
return;
` : ""}
<p>Cargo ${cargoUsed.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)}</p>
<p>Inventory ${formatInventory(ship.inventory)}</p>
<p>Speed ${formatShipSpeed(ship)}</p>
<p>Camera ${cameraMode === "follow" && cameraTargetShipId === ship.id ? "camera-follow" : "tactical"}<br>Press C to toggle follow</p>
`,
};
}
if (selected.kind === "station") {
const station = world.stations.get(selected.id);
if (!station) {
return;
return { title: "Missing station", bodyHtml: "" };
}
const parent = describeSelectionParent(selected);
const moduleList = formatModuleListWithConstruction(world, station.id, station.installedModules, station.currentProcesses);
@@ -264,110 +267,116 @@ export function updateDetailPanel(
? station.dockedShipIds.map((shipId) => world.ships.get(shipId)?.label ?? shipId).join("<br>")
: "none";
const stationStorage = formatStorageWithInventory(station.storageUsage, station.inventory);
detailTitleEl.textContent = station.label;
detailBodyEl.innerHTML = `
<p>${station.category} · ${station.systemId}</p>
<p>Parent ${parent}</p>
<p>Docked ${station.dockedShips} / ${station.dockingPads}
<br>
${dockedShipLabels}</p>
<p>Modules ${moduleList}</p>
<p>Storage ${stationStorage}</p>
`;
return;
return {
title: station.label,
bodyHtml: `
<p>${station.category} · ${station.systemId}</p>
<p>Parent ${parent}</p>
<p>Docked ${station.dockedShips} / ${station.dockingPads}
<br>
${dockedShipLabels}</p>
<p>Modules ${moduleList}</p>
<p>Storage ${stationStorage}</p>
`,
};
}
if (selected.kind === "node") {
const node = world.nodes.get(selected.id);
if (!node) {
return;
return { title: "Missing node", bodyHtml: "" };
}
const parent = describeSelectionParent(selected);
const nodeLevel = node.maxOre > 0
? Math.max(0, Math.min(node.oreRemaining / node.maxOre, 1))
: 0;
detailTitleEl.textContent = `Node ${node.id}`;
detailBodyEl.innerHTML = `
<p>${node.systemId}</p>
<p>Parent ${parent}</p>
<p>Source ${node.sourceKind}<br>Resource ${node.itemId}</p>
<div class="detail-progress">
<div class="detail-progress-label">
<span>Level</span>
<span>${Math.round(nodeLevel * 100)}%</span>
return {
title: `Node ${node.id}`,
bodyHtml: `
<p>${node.systemId}</p>
<p>Parent ${parent}</p>
<p>Source ${node.sourceKind}<br>Resource ${node.itemId}</p>
<div class="detail-progress">
<div class="detail-progress-label">
<span>Level</span>
<span>${Math.round(nodeLevel * 100)}%</span>
</div>
<div class="detail-progress-track">
<div class="detail-progress-fill" style="width: ${(nodeLevel * 100).toFixed(1)}%"></div>
</div>
</div>
<div class="detail-progress-track">
<div class="detail-progress-fill" style="width: ${(nodeLevel * 100).toFixed(1)}%"></div>
</div>
</div>
<p>Stock ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}</p>
`;
return;
<p>Stock ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}</p>
`,
};
}
if (selected.kind === "celestial") {
const celestial = world.celestials.get(selected.id);
if (!celestial) {
return;
return { title: "Missing celestial", bodyHtml: "" };
}
detailTitleEl.textContent = `${celestial.kind} celestial`;
detailBodyEl.innerHTML = `
<p>${celestial.systemId}</p>
<p>Parent ${celestial.parentNodeId ?? "none"}<br>Orbit ref ${celestial.orbitReferenceId ?? "none"}</p>
<p>Occupying structure ${celestial.occupyingStructureId ?? "none"}</p>
<p>Local space radius ${celestial.localSpaceRadius.toFixed(0)} km</p>
`;
return;
return {
title: `${celestial.kind} celestial`,
bodyHtml: `
<p>${celestial.systemId}</p>
<p>Parent ${celestial.parentNodeId ?? "none"}<br>Orbit ref ${celestial.orbitReferenceId ?? "none"}</p>
<p>Occupying structure ${celestial.occupyingStructureId ?? "none"}</p>
<p>Local space radius ${celestial.localSpaceRadius.toFixed(0)} km</p>
`,
};
}
if (selected.kind === "claim") {
const claim = world.claims.get(selected.id);
if (!claim) {
return;
return { title: "Missing claim", bodyHtml: "" };
}
detailTitleEl.textContent = `Claim ${claim.id}`;
detailBodyEl.innerHTML = `
<p>${claim.systemId}</p>
<p>Celestial ${claim.celestialId}</p>
<p>State ${claim.state}<br>Health ${claim.health.toFixed(0)}</p>
<p>Activates ${new Date(claim.activatesAtUtc).toLocaleTimeString()}</p>
`;
return;
return {
title: `Claim ${claim.id}`,
bodyHtml: `
<p>${claim.systemId}</p>
<p>Celestial ${claim.celestialId}</p>
<p>State ${claim.state}<br>Health ${claim.health.toFixed(0)}</p>
<p>Activates ${new Date(claim.activatesAtUtc).toLocaleTimeString()}</p>
`,
};
}
if (selected.kind === "construction-site") {
const site = world.constructionSites.get(selected.id);
if (!site) {
return;
return { title: "Missing construction", bodyHtml: "" };
}
const orderCount = [...world.marketOrders.values()].filter((order) => order.constructionSiteId === site.id).length;
detailTitleEl.textContent = `Construction ${site.id}`;
detailBodyEl.innerHTML = `
<p>${site.systemId}</p>
<p>Celestial ${site.celestialId}</p>
<p>${site.targetKind} ${site.targetDefinitionId}</p>
<p>State ${site.state}<br>Progress ${(site.progress * 100).toFixed(0)}%</p>
<p>Orders ${orderCount}<br>Assigned constructors ${site.assignedConstructorShipIds.length}</p>
`;
return;
return {
title: `Construction ${site.id}`,
bodyHtml: `
<p>${site.systemId}</p>
<p>Celestial ${site.celestialId}</p>
<p>${site.targetKind} ${site.targetDefinitionId}</p>
<p>State ${site.state}<br>Progress ${(site.progress * 100).toFixed(0)}%</p>
<p>Orders ${orderCount}<br>Assigned constructors ${site.assignedConstructorShipIds.length}</p>
`,
};
}
if (selected.kind === "planet") {
const system = world.systems.get(selected.systemId);
const planet = system?.planets[selected.planetIndex];
if (!system || !planet) {
return;
return { title: "Missing planet", bodyHtml: "" };
}
const parent = describeSelectionParent(selected);
detailTitleEl.textContent = planet.label;
detailBodyEl.innerHTML = `
<p>${system.label}</p>
<p>Parent ${parent}</p>
<p>${planet.planetType} · ${planet.shape} · Moons ${planet.moons.length}</p>
<p>Orbit ${formatSystemDistance(planet.orbitRadius)}<br>Speed ${planet.orbitSpeed.toFixed(3)}<br>Ecc ${planet.orbitEccentricity.toFixed(3)}<br>Inc ${planet.orbitInclination.toFixed(1)}°</p>
<p>Phase ${planet.orbitPhaseAtEpoch.toFixed(1)}°</p>
`;
return;
return {
title: planet.label,
bodyHtml: `
<p>${system.label}</p>
<p>Parent ${parent}</p>
<p>${planet.planetType} · ${planet.shape} · Moons ${planet.moons.length}</p>
<p>Orbit ${formatSystemDistance(planet.orbitRadius)}<br>Speed ${planet.orbitSpeed.toFixed(3)}<br>Ecc ${planet.orbitEccentricity.toFixed(3)}<br>Inc ${planet.orbitInclination.toFixed(1)}°</p>
<p>Phase ${planet.orbitPhaseAtEpoch.toFixed(1)}°</p>
`,
};
}
if (selected.kind === "moon") {
@@ -375,51 +384,57 @@ export function updateDetailPanel(
const planet = system?.planets[selected.planetIndex];
const moon = planet?.moons[selected.moonIndex];
if (moon) {
detailTitleEl.textContent = moon.label;
detailBodyEl.innerHTML = `
<p>${system?.label ?? selected.systemId} / ${planet?.label ?? `planet ${selected.planetIndex + 1}`}</p>
<p>Orbit ${formatSystemDistance(moon.orbitRadius)}<br>Inc ${moon.orbitInclination.toFixed(1)}°</p>
`;
return {
title: moon.label,
bodyHtml: `
<p>${system?.label ?? selected.systemId} / ${planet?.label ?? `planet ${selected.planetIndex + 1}`}</p>
<p>Orbit ${formatSystemDistance(moon.orbitRadius)}<br>Inc ${moon.orbitInclination.toFixed(1)}°</p>
`,
};
}
return;
return { title: "Moon", bodyHtml: "" };
}
const system = world.systems.get(selected.id);
if (!system) {
return;
return {
title: "Unknown selection",
bodyHtml: "",
};
}
detailTitleEl.textContent = system.label;
detailBodyEl.innerHTML = `
<p>Parent galaxy</p>
${renderSystemDetails(world, system, false, cameraMode, cameraTargetShipId)}
`;
return {
title: system.label,
bodyHtml: `
<p>Parent galaxy</p>
${renderSystemDetails(world, system, false, cameraMode, cameraTargetShipId)}
`,
};
}
export function updateSystemPanel(params: SystemPanelParams) {
export function buildSystemPanelState(params: SystemPanelParams) {
const {
world,
activeSystemId,
systemTitleEl,
systemBodyEl,
systemPanelEl,
cameraMode,
cameraTargetShipId,
} = params;
const activeSystem = activeSystemId ? world.systems.get(activeSystemId) : undefined;
systemPanelEl.hidden = !activeSystem;
if (!activeSystem) {
systemTitleEl.textContent = "Deep Space";
systemBodyEl.innerHTML = "";
return;
return {
hidden: true,
title: "Deep Space",
bodyHtml: "",
};
}
systemTitleEl.textContent = activeSystem.label;
systemBodyEl.innerHTML = `
<p>${renderSystemOwnership(world, activeSystem.id)}</p>
`;
return {
hidden: false,
title: activeSystem.label,
bodyHtml: `
<p>${renderSystemOwnership(world, activeSystem.id)}</p>
`,
};
}
export function describeSelectionParent(