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( (modulesData as { id: string; name: string }[]).map((m) => [m.id, m.name]), ); const itemTransportById = new Map( (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("&", "&") .replaceAll("\"", """) .replaceAll("<", "<") .replaceAll(">", ">"); } function renderProgressBar(progress: number): string { return `
`; } 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(); 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(); 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 `
${moduleName}${rightLabel}
${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(`
${moduleName}${Math.round(progress * 100)}% constructing
${renderProgressBar(progress)}`); } return moduleHtmlParts.length > 0 ? moduleHtmlParts.join("
") : "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("
"); } const itemsByClass = new Map(); 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}
empty`; } return `${header}
${items.map((e) => `${e.itemId} ${e.amount.toFixed(0)}`).join("
")}`; }) .join("
"); } 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(); 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("
"); } export function buildDetailPanelState(params: DetailPanelParams) { const { world, selectedItems, povLevel, cameraMode, cameraTargetShipId, worldLabel, describeSelectionParent, } = params; if (selectedItems.length === 0) { return { title: worldLabel, bodyHtml: ` Zoom ${povLevel}
Systems ${world.systems.size}
Celestials ${world.celestials.size}
Stations ${world.stations.size}
Claims ${world.claims.size}
Construction ${world.constructionSites.size}
Ships ${world.ships.size}
Recent events ${world.recentEvents.length} `, }; } if (selectedItems.length > 1) { const group = getSelectionGroup(selectedItems[0]); return { title: `${selectedItems.length} selected`, bodyHtml: ` Type ${group}
${selectedItems.slice(0, 8).map((item) => describeSelectable(world, item)).join("
")} `, }; } 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: `

Parent ${parent}

Behavior ${shipBehavior}

State ${shipState}

Order ${shipOrder}

Task ${ship.controllerTaskKind}

${shipAction ? `
${shipAction.label} ${Math.round(shipAction.progress * 100)}%
` : ""}

Cargo ${cargoUsed.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)}

Inventory ${formatInventory(ship.inventory)}

Speed ${formatShipSpeed(ship)}

Camera ${cameraMode === "follow" && cameraTargetShipId === ship.id ? "camera-follow" : "tactical"}
Press C to toggle follow

`, }; } 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("
") : "none"; const stationStorage = formatStorageWithInventory(station.storageUsage, station.inventory); return { title: station.label, bodyHtml: `

${station.category} · ${station.systemId}

Parent ${parent}

Docked ${station.dockedShips} / ${station.dockingPads}
${dockedShipLabels}

Modules ${moduleList}

Storage ${stationStorage}

`, }; } 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: `

${node.systemId}

Parent ${parent}

Source ${node.sourceKind}
Resource ${node.itemId}

Level ${Math.round(nodeLevel * 100)}%

Stock ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}

`, }; } if (selected.kind === "celestial") { const celestial = world.celestials.get(selected.id); if (!celestial) { return { title: "Missing celestial", bodyHtml: "" }; } return { title: `${celestial.kind} celestial`, bodyHtml: `

${celestial.systemId}

Parent ${celestial.parentNodeId ?? "none"}
Orbit ref ${celestial.orbitReferenceId ?? "none"}

Occupying structure ${celestial.occupyingStructureId ?? "none"}

Local space radius ${celestial.localSpaceRadius.toFixed(0)} km

`, }; } if (selected.kind === "claim") { const claim = world.claims.get(selected.id); if (!claim) { return { title: "Missing claim", bodyHtml: "" }; } return { title: `Claim ${claim.id}`, bodyHtml: `

${claim.systemId}

Celestial ${claim.celestialId}

State ${claim.state}
Health ${claim.health.toFixed(0)}

Activates ${new Date(claim.activatesAtUtc).toLocaleTimeString()}

`, }; } 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: `

${site.systemId}

Celestial ${site.celestialId}

${site.targetKind} ${site.targetDefinitionId}

State ${site.state}
Progress ${(site.progress * 100).toFixed(0)}%

Orders ${orderCount}
Assigned constructors ${site.assignedConstructorShipIds.length}

`, }; } 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: `

${system.label}

Parent ${parent}

${planet.planetType} · ${planet.shape} · Moons ${planet.moons.length}

Orbit ${formatSystemDistance(planet.orbitRadius)}
Speed ${planet.orbitSpeed.toFixed(3)}
Ecc ${planet.orbitEccentricity.toFixed(3)}
Inc ${planet.orbitInclination.toFixed(1)}°

Phase ${planet.orbitPhaseAtEpoch.toFixed(1)}°

`, }; } 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: `

${system?.label ?? selected.systemId} / ${planet?.label ?? `planet ${selected.planetIndex + 1}`}

Orbit ${formatSystemDistance(moon.orbitRadius)}
Inc ${moon.orbitInclination.toFixed(1)}°

`, }; } return { title: "Moon", bodyHtml: "" }; } const system = world.systems.get(selected.id); if (!system) { return { title: "Unknown selection", bodyHtml: "", }; } return { title: system.label, bodyHtml: `

Parent galaxy

${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: `

${renderSystemOwnership(world, activeSystem.id)}

`, }; } export function describeSelectionParent( world: WorldState | undefined, selection: Selectable, stationVisuals: Map, nodeVisuals: Map, ) { 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"; }