Files
space-game/apps/viewer/src/viewerPanels.ts

493 lines
17 KiB
TypeScript

import {
formatInventory,
formatLocalDistance,
formatShipSpeed,
formatSystemDistance,
inventoryAmount,
} from "./viewerMath";
import modulesData from "../../../shared/data/modules.json";
import itemsData from "../../../shared/data/items.json";
const moduleNameById = new Map<string, string>(
(modulesData as { id: string; name: string }[]).map((m) => [m.id, m.name]),
);
const itemTransportById = new Map<string, string>(
(itemsData as { id: string; transport: string }[]).map((item) => [item.id, item.transport]),
);
import { describeCelestialPathWithinSystem, describeOrbitalParent, describeSelectable, describeShipBehavior, describeShipCurrentAction, describeShipOrder, describeShipState, getSelectionGroup, renderSystemDetails } from "./viewerSelection";
import type {
CameraMode,
NodeVisual,
OrbitalAnchor,
Selectable,
ShipVisual,
StructureVisual,
WorldState,
} from "./viewerTypes";
interface DetailPanelParams {
world: WorldState;
selectedItems: Selectable[];
povLevel: string;
cameraMode: CameraMode;
cameraTargetShipId?: string;
worldLabel: string;
describeSelectionParent: (selection: Selectable) => string;
}
interface SystemPanelParams {
world: WorldState;
activeSystemId?: string;
cameraMode: CameraMode;
cameraTargetShipId?: string;
}
function formatDuration(seconds: number): string {
if (seconds < 60) return `${Math.ceil(seconds)}s`;
const m = Math.floor(seconds / 60);
const s = Math.ceil(seconds % 60);
return s > 0 ? `${m}m ${s}s` : `${m}m`;
}
function escapeAttr(str: string): string {
return str
.replaceAll("&", "&amp;")
.replaceAll("\"", "&quot;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function renderProgressBar(progress: number): string {
return `<div class="detail-progress"><div class="detail-progress-track"><div class="detail-progress-fill" style="width: ${(progress * 100).toFixed(1)}%"></div></div></div>`;
}
function formatModuleListWithConstruction(
world: WorldState,
stationId: string,
installedModules: string[],
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) {
const existing = processByModule.get(process.lane) ?? [];
existing.push({ label: process.label, progress: process.progress, timeRemainingSeconds: process.timeRemainingSeconds, cycleSeconds: process.cycleSeconds, inputs: process.inputs, outputs: process.outputs });
processByModule.set(process.lane, existing);
}
const renderedProcessCount = new Map<string, number>();
const moduleHtmlParts = installedModules.map((moduleId) => {
const processIndex = renderedProcessCount.get(moduleId) ?? 0;
const processes = processByModule.get(moduleId) ?? [];
const process = processes[processIndex];
renderedProcessCount.set(moduleId, processIndex + 1);
const moduleName = moduleNameById.get(moduleId) ?? moduleId;
if (!process) {
return moduleName;
}
const inputLines = process.inputs.map((e) => ` ${e.itemId}: ${e.amount.toFixed(0)}`).join("\n");
const outputLines = process.outputs.map((e) => ` ${e.itemId}: ${e.amount.toFixed(0)}`).join("\n");
const tooltip = `Cycle: ${formatDuration(process.cycleSeconds)}\nInputs:\n${inputLines || " none"}\nOutputs:\n${outputLines || " none"}`;
const rightLabel = `${Math.round(process.progress * 100)}% · ${formatDuration(process.timeRemainingSeconds)}`;
return `<div class="detail-progress-label" title="${escapeAttr(tooltip)}"><span>${moduleName}</span><span>${rightLabel}</span></div>${renderProgressBar(process.progress)}`;
});
const activeSites = [...world.constructionSites.values()]
.filter((site) => site.stationId === stationId && site.state !== "completed")
.sort((left, right) => left.targetDefinitionId.localeCompare(right.targetDefinitionId));
for (const site of activeSites) {
const moduleId = site.blueprintId ?? site.targetDefinitionId;
const moduleName = moduleNameById.get(moduleId) ?? moduleId;
const progress = site.progress;
const constructionTooltip = site.requiredItems.length > 0
? site.requiredItems
.map((entry) => `${entry.itemId}: ${entry.amount.toFixed(0)} required / ${inventoryAmount(site.stationId ? (world.stations.get(site.stationId)?.inventory ?? []) : site.deliveredItems, entry.itemId).toFixed(0)} available`)
.join("\n")
: "No material requirements";
moduleHtmlParts.push(`<div class="detail-progress-label" title="${escapeAttr(constructionTooltip)}"><span>${moduleName}</span><span>${Math.round(progress * 100)}% constructing</span></div>${renderProgressBar(progress)}`);
}
return moduleHtmlParts.length > 0 ? moduleHtmlParts.join("<br>") : "none";
}
function formatStorageClassLabel(storageClass: string): string {
return storageClass
.split("-")
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
.join(" ");
}
function formatStorageWithInventory(
storageUsage: { storageClass: string; used: number; capacity: number }[],
inventory: { itemId: string; amount: number }[],
): string {
if (storageUsage.length === 0) {
return inventory.length === 0 ? "none" : inventory.map((e) => `${e.itemId} ${e.amount.toFixed(0)}`).join("<br>");
}
const itemsByClass = new Map<string, { itemId: string; amount: number }[]>();
for (const entry of inventory) {
const cls = itemTransportById.get(entry.itemId) ?? "unknown";
const list = itemsByClass.get(cls) ?? [];
list.push(entry);
itemsByClass.set(cls, list);
}
return storageUsage
.map((entry) => {
const percentUsed = entry.capacity > 0 ? Math.round((entry.used / entry.capacity) * 100) : 0;
const header = `${formatStorageClassLabel(entry.storageClass)} ${percentUsed}% (${entry.used.toFixed(0)} / ${entry.capacity.toFixed(0)})`;
const items = itemsByClass.get(entry.storageClass) ?? [];
if (items.length === 0) {
return `${header}<br><span style="padding-left:1em">empty</span>`;
}
return `${header}<br>${items.map((e) => `<span style="padding-left:1em">${e.itemId} ${e.amount.toFixed(0)}</span>`).join("<br>")}`;
})
.join("<br>");
}
function renderSystemOwnership(world: WorldState, systemId: string): string {
const claims = [...world.claims.values()].filter((claim) =>
claim.systemId === systemId && claim.state !== "destroyed");
if (claims.length === 0) {
return "Ownership none";
}
const ownershipByFaction = new Map<string, number>();
for (const claim of claims) {
ownershipByFaction.set(claim.factionId, (ownershipByFaction.get(claim.factionId) ?? 0) + 1);
}
return [...ownershipByFaction.entries()]
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
.map(([factionId, count]) => {
const faction = world.factions.get(factionId);
const label = faction?.label ?? factionId;
const share = Math.round((count / claims.length) * 100);
return `${label} ${count}/${claims.length} (${share}%)`;
})
.join("<br>");
}
export function buildDetailPanelState(params: DetailPanelParams) {
const {
world,
selectedItems,
povLevel,
cameraMode,
cameraTargetShipId,
worldLabel,
describeSelectionParent,
} = params;
if (selectedItems.length === 0) {
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]);
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 { title: "Missing ship", bodyHtml: "" };
}
const parent = describeSelectionParent(selected);
const cargoUsed = ship.inventory.reduce((sum, e) => sum + e.amount, 0);
const shipState = describeShipState(world, ship);
const shipBehavior = describeShipBehavior(ship);
const shipOrder = describeShipOrder(ship);
const shipAction = describeShipCurrentAction(ship);
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>
` : ""}
<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 { title: "Missing station", bodyHtml: "" };
}
const parent = describeSelectionParent(selected);
const moduleList = formatModuleListWithConstruction(world, station.id, station.installedModules, station.currentProcesses);
const dockedShipLabels = station.dockedShipIds.length > 0
? station.dockedShipIds.map((shipId) => world.ships.get(shipId)?.label ?? shipId).join("<br>")
: "none";
const stationStorage = formatStorageWithInventory(station.storageUsage, station.inventory);
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 { title: "Missing node", bodyHtml: "" };
}
const parent = describeSelectionParent(selected);
const nodeLevel = node.maxOre > 0
? Math.max(0, Math.min(node.oreRemaining / node.maxOre, 1))
: 0;
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>
<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 { title: "Missing celestial", bodyHtml: "" };
}
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 { title: "Missing claim", bodyHtml: "" };
}
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 { title: "Missing construction", bodyHtml: "" };
}
const orderCount = [...world.marketOrders.values()].filter((order) => order.constructionSiteId === site.id).length;
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 { title: "Missing planet", bodyHtml: "" };
}
const parent = describeSelectionParent(selected);
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") {
const system = world.systems.get(selected.systemId);
const planet = system?.planets[selected.planetIndex];
const moon = planet?.moons[selected.moonIndex];
if (moon) {
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 { title: "Moon", bodyHtml: "" };
}
const system = world.systems.get(selected.id);
if (!system) {
return {
title: "Unknown selection",
bodyHtml: "",
};
}
return {
title: system.label,
bodyHtml: `
<p>Parent galaxy</p>
${renderSystemDetails(world, system, false, cameraMode, cameraTargetShipId)}
`,
};
}
export function buildSystemPanelState(params: SystemPanelParams) {
const {
world,
activeSystemId,
} = params;
const activeSystem = activeSystemId ? world.systems.get(activeSystemId) : undefined;
if (!activeSystem) {
return {
hidden: true,
title: "Deep Space",
bodyHtml: "",
};
}
return {
hidden: false,
title: activeSystem.label,
bodyHtml: `
<p>${renderSystemOwnership(world, activeSystem.id)}</p>
`,
};
}
export function describeSelectionParent(
world: WorldState | undefined,
selection: Selectable,
stationVisuals: Map<string, StructureVisual>,
nodeVisuals: Map<string, NodeVisual>,
) {
if (!world) {
return "unknown";
}
if (selection.kind === "system") {
return "galaxy";
}
if (selection.kind === "planet") {
const system = world.systems.get(selection.systemId);
return system ? `${system.label} star` : selection.systemId;
}
if (selection.kind === "ship") {
const ship = world.ships.get(selection.id);
if (!ship) {
return "unknown";
}
const system = world.systems.get(ship.systemId);
return system ? `${system.label} system` : ship.systemId;
}
if (selection.kind === "station") {
const station = world.stations.get(selection.id);
if (!station) {
return "unknown";
}
return station.celestialId
? describeCelestialPathWithinSystem(world, station.systemId, station.celestialId) ?? `${station.systemId} network`
: "unknown";
}
if (selection.kind === "node") {
const node = world.nodes.get(selection.id);
const visual = node ? nodeVisuals.get(selection.id) : undefined;
return describeOrbitalParent(world, node?.systemId, visual?.anchor);
}
if (selection.kind === "celestial") {
const celestial = world.celestials.get(selection.id);
return celestial?.parentNodeId ?? `${celestial?.systemId ?? "unknown"} network`;
}
if (selection.kind === "claim") {
return world.claims.get(selection.id)?.celestialId ?? "unknown";
}
if (selection.kind === "construction-site") {
return world.constructionSites.get(selection.id)?.celestialId ?? "unknown";
}
return "unknown";
}