diff --git a/apps/viewer/src/GameViewer.ts b/apps/viewer/src/GameViewer.ts index 4f3327b..6d6b3da 100644 --- a/apps/viewer/src/GameViewer.ts +++ b/apps/viewer/src/GameViewer.ts @@ -27,6 +27,7 @@ type Selectable = | { kind: "planet"; systemId: string; planetIndex: number }; interface ShipVisual { + systemId: string; mesh: THREE.Mesh; icon: THREE.Sprite; startPosition: THREE.Vector3; @@ -38,7 +39,9 @@ interface ShipVisual { } interface PlanetVisual { + systemId: string; planet: PlanetSnapshot; + orbit: THREE.LineLoop; mesh: THREE.Mesh; icon: THREE.Sprite; ring?: THREE.Mesh; @@ -50,6 +53,19 @@ interface MoonVisual { orbit: THREE.LineLoop; } +interface StructureVisual { + systemId: string; + mesh: THREE.Mesh; + icon: THREE.Sprite; + worldPosition: THREE.Vector3; +} + +interface SystemVisual { + root: THREE.Group; + detailGroup: THREE.Group; + summary: SystemSummaryVisual; +} + interface WorldState { label: string; seed: number; @@ -96,6 +112,7 @@ interface PerformanceStats { interface PresentationEntry { detail: THREE.Object3D; icon: THREE.Sprite; + systemId?: string; hideDetailInUniverse?: boolean; hideIconInUniverse?: boolean; } @@ -111,6 +128,8 @@ const ZOOM_DISTANCE: Record = { system: 3200, universe: 26000, }; +const ACTIVE_SYSTEM_DETAIL_SCALE = 2.2; +const ACTIVE_SYSTEM_CAPTURE_RADIUS = 9000; const MIN_CAMERA_DISTANCE = 450; const MAX_CAMERA_DISTANCE = 42000; @@ -138,13 +157,17 @@ export class GameViewer { private readonly ambienceGroup = new THREE.Group(); private readonly selectableTargets = new Map(); private readonly presentationEntries: PresentationEntry[] = []; - private readonly nodeMeshes = new Map(); - private readonly stationMeshes = new Map(); + private readonly nodeVisuals = new Map(); + private readonly stationVisuals = new Map(); private readonly shipVisuals = new Map(); + private readonly systemVisuals = new Map(); private readonly systemSummaryVisuals = new Map(); private readonly planetVisuals: PlanetVisual[] = []; private readonly orbitLines: THREE.Object3D[] = []; private readonly statusEl: HTMLDivElement; + private readonly systemPanelEl: HTMLDivElement; + private readonly systemTitleEl: HTMLHeadingElement; + private readonly systemBodyEl: HTMLDivElement; private readonly detailTitleEl: HTMLHeadingElement; private readonly detailBodyEl: HTMLDivElement; private readonly factionStripEl: HTMLDivElement; @@ -185,6 +208,8 @@ export class GameViewer { private dragLast = new THREE.Vector2(); private marqueeActive = false; private suppressClickSelection = false; + private activeSystemId?: string; + private followedShipId?: string; constructor(container: HTMLElement) { this.container = container; @@ -217,17 +242,27 @@ export class GameViewer {
Waiting for frame samples.
- +
`; this.statusEl = hud.querySelector(".topbar-body") as HTMLDivElement; + this.systemPanelEl = hud.querySelector(".system-panel-section") as HTMLDivElement; + this.systemTitleEl = hud.querySelector(".system-title") as HTMLHeadingElement; + this.systemBodyEl = hud.querySelector(".system-body") as HTMLDivElement; this.detailTitleEl = hud.querySelector(".detail-title") as HTMLHeadingElement; this.detailBodyEl = hud.querySelector(".detail-body") as HTMLDivElement; this.factionStripEl = hud.querySelector(".faction-strip") as HTMLDivElement; @@ -383,18 +418,21 @@ export class GameViewer { this.presentationEntries.length = 0; this.planetVisuals.length = 0; this.orbitLines.length = 0; + this.systemVisuals.clear(); this.systemSummaryVisuals.clear(); for (const system of systems) { const root = new THREE.Group(); root.position.set(system.position.x, system.position.y, system.position.z); + const detailGroup = new THREE.Group(); const starCluster = this.createStarCluster(system); const systemIcon = this.createTacticalIcon(system.starColor, 96); const summaryVisual = this.createSystemSummaryVisual(new THREE.Vector3(system.position.x, system.position.y + system.starSize + 140, system.position.z)); summaryVisual.sprite.position.set(0, system.starSize + 110, 0); - root.add(starCluster, systemIcon, summaryVisual.sprite); + root.add(starCluster, systemIcon, summaryVisual.sprite, detailGroup); this.registerPresentation(starCluster, systemIcon, true); + this.systemVisuals.set(system.id, { root, detailGroup, summary: summaryVisual }); this.systemSummaryVisuals.set(system.id, summaryVisual); starCluster.traverse((child) => { if (child instanceof THREE.Mesh) { @@ -422,23 +460,23 @@ export class GameViewer { ring.position.copy(planetMesh.position); } const moons = this.createMoonVisuals(planet); - root.add(orbit, planetMesh, planetIcon); + detailGroup.add(orbit, planetMesh, planetIcon); if (ring) { - root.add(ring); + detailGroup.add(ring); } for (const moon of moons) { moon.orbit.position.copy(planetMesh.position); moon.mesh.position.copy(planetMesh.position); - root.add(moon.orbit, moon.mesh); + detailGroup.add(moon.orbit, moon.mesh); this.orbitLines.push(moon.orbit); - this.registerPresentation(moon.mesh, planetIcon, true, true); + this.registerPresentation(moon.mesh, planetIcon, true, true, system.id); } this.orbitLines.push(orbit); - this.registerPresentation(planetMesh, planetIcon, true, true); + this.registerPresentation(planetMesh, planetIcon, true, true, system.id); if (ring) { - this.registerPresentation(ring, planetIcon, true, true); + this.registerPresentation(ring, planetIcon, true, true, system.id); } - this.planetVisuals.push({ planet, mesh: planetMesh, icon: planetIcon, ring, moons }); + this.planetVisuals.push({ systemId: system.id, planet, orbit, mesh: planetMesh, icon: planetIcon, ring, moons }); this.selectableTargets.set(planetMesh, { kind: "planet", systemId: system.id, planetIndex }); this.selectableTargets.set(planetIcon, { kind: "planet", systemId: system.id, planetIndex }); } @@ -449,15 +487,20 @@ export class GameViewer { private syncNodes(nodes: ResourceNodeSnapshot[]) { this.nodeGroup.clear(); - this.nodeMeshes.clear(); + this.nodeVisuals.clear(); for (const node of nodes) { const mesh = this.createNodeMesh(node); const icon = this.createTacticalIcon(node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 20); icon.position.copy(mesh.position); - this.nodeMeshes.set(node.id, mesh); + this.nodeVisuals.set(node.id, { + systemId: node.systemId, + mesh, + icon, + worldPosition: this.toThreeVector(node.position), + }); this.nodeGroup.add(mesh, icon); - this.registerPresentation(mesh, icon, true, true); + this.registerPresentation(mesh, icon, true, true, node.systemId); this.selectableTargets.set(mesh, { kind: "node", id: node.id }); this.selectableTargets.set(icon, { kind: "node", id: node.id }); } @@ -465,15 +508,20 @@ export class GameViewer { private syncStations(stations: StationSnapshot[]) { this.stationGroup.clear(); - this.stationMeshes.clear(); + this.stationVisuals.clear(); for (const station of stations) { const mesh = this.createStationMesh(station); const icon = this.createTacticalIcon(station.color, 26); icon.position.copy(mesh.position); - this.stationMeshes.set(station.id, mesh); + this.stationVisuals.set(station.id, { + systemId: station.systemId, + mesh, + icon, + worldPosition: this.toThreeVector(station.position), + }); this.stationGroup.add(mesh, icon); - this.registerPresentation(mesh, icon, true, true); + this.registerPresentation(mesh, icon, true, true, station.systemId); this.selectableTargets.set(mesh, { kind: "station", id: station.id }); this.selectableTargets.set(icon, { kind: "station", id: station.id }); } @@ -491,8 +539,9 @@ export class GameViewer { this.shipGroup.add(mesh, icon); this.selectableTargets.set(mesh, { kind: "ship", id: ship.id }); this.selectableTargets.set(icon, { kind: "ship", id: ship.id }); - this.registerPresentation(mesh, icon, true, true); + this.registerPresentation(mesh, icon, true, true, ship.systemId); this.shipVisuals.set(ship.id, { + systemId: ship.systemId, mesh, icon, startPosition: position.clone(), @@ -507,25 +556,27 @@ export class GameViewer { private applyNodeDeltas(nodes: ResourceNodeDelta[]) { for (const node of nodes) { - const mesh = this.nodeMeshes.get(node.id); - if (!mesh) { + const visual = this.nodeVisuals.get(node.id); + if (!visual) { continue; } - mesh.position.copy(this.toThreeVector(node.position)); - mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6); + visual.systemId = node.systemId; + visual.worldPosition.copy(this.toThreeVector(node.position)); + visual.mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6); } } private applyStationDeltas(stations: StationDelta[]) { for (const station of stations) { - const mesh = this.stationMeshes.get(station.id); - if (!mesh) { + const visual = this.stationVisuals.get(station.id); + if (!visual) { continue; } - mesh.position.copy(this.toThreeVector(station.position)); - const material = mesh.material as THREE.MeshStandardMaterial; + visual.systemId = station.systemId; + visual.worldPosition.copy(this.toThreeVector(station.position)); + const material = visual.mesh.material as THREE.MeshStandardMaterial; material.color.set(station.color); material.emissive = new THREE.Color(station.color).multiplyScalar(0.1); } @@ -538,7 +589,8 @@ export class GameViewer { continue; } - visual.startPosition.copy(visual.mesh.position); + visual.systemId = ship.systemId; + visual.startPosition.copy(visual.authoritativePosition); visual.authoritativePosition.copy(this.toThreeVector(ship.position)); visual.targetPosition.copy(this.toThreeVector(ship.targetPosition)); visual.velocity.copy(this.toThreeVector(ship.velocity)); @@ -567,6 +619,8 @@ export class GameViewer { return; } + this.updateSystemPanel(); + if (this.selectedItems.length === 0) { this.detailTitleEl.textContent = this.world.label; this.detailBodyEl.innerHTML = ` @@ -601,6 +655,7 @@ export class GameViewer {

State ${ship.state}
Behavior ${ship.defaultBehaviorKind}
Task ${ship.controllerTaskKind}

Cargo ${ship.cargo.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)} ${ship.cargoItemId ?? ""}

Velocity ${this.formatVector(ship.velocity)}

+

${this.followedShipId === ship.id ? "Camera follow engaged" : "Camera follow idle"}

${ship.history.join("
")}

`; return; @@ -655,11 +710,7 @@ export class GameViewer { return; } this.detailTitleEl.textContent = system.label; - this.detailBodyEl.innerHTML = ` -

${system.id}

-

${system.starKind} · ${system.starCount} star${system.starCount > 1 ? "s" : ""}

-

Planets ${system.planets.length}
Height ${system.position.y.toFixed(0)}

- `; + this.detailBodyEl.innerHTML = this.renderSystemDetails(system, false); } private render() { @@ -685,6 +736,8 @@ export class GameViewer { private updateCamera(delta: number) { this.currentDistance = THREE.MathUtils.damp(this.currentDistance, this.desiredDistance, 7.5, delta); this.zoomLevel = this.classifyZoomLevel(this.currentDistance); + this.updateActiveSystem(); + this.updateFollowCamera(delta); this.updatePanFromKeyboard(delta); this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.18, 1.3); @@ -699,6 +752,10 @@ export class GameViewer { } private updatePanFromKeyboard(delta: number) { + if (this.followedShipId) { + return; + } + const move = new THREE.Vector3(); if (this.keyState.has("w")) { move.z -= 1; @@ -728,11 +785,13 @@ export class GameViewer { const blend = this.computeZoomBlend(this.currentDistance); for (const entry of this.presentationEntries) { + const systemId = entry.systemId; + const isActiveDetail = !systemId || systemId === this.activeSystemId; const detailAlpha = entry.hideDetailInUniverse - ? Math.max(blend.localWeight, blend.systemWeight) + ? Math.max(blend.localWeight, blend.systemWeight) * (isActiveDetail ? 1 : 0) : 1; const iconAlpha = entry.hideIconInUniverse - ? blend.systemWeight + ? blend.systemWeight * (isActiveDetail ? 1 : 0) : Math.max(blend.systemWeight, blend.universeWeight); this.setObjectOpacity(entry.detail, detailAlpha); @@ -740,12 +799,15 @@ export class GameViewer { } for (const orbitLine of this.orbitLines) { - const alpha = Math.max(blend.localWeight * 0.55, blend.systemWeight); + const alpha = Math.max(blend.localWeight * 0.55, blend.systemWeight) * (this.activeSystemId ? 1 : 0); this.setObjectOpacity(orbitLine, alpha); } - for (const summaryVisual of this.systemSummaryVisuals.values()) { - this.setObjectOpacity(summaryVisual.sprite, blend.universeWeight); + for (const [systemId, summaryVisual] of this.systemSummaryVisuals.entries()) { + const summaryOpacity = systemId === this.activeSystemId + ? 0 + : (this.activeSystemId ? 0.72 : 0.96); + this.setObjectOpacity(summaryVisual.sprite, summaryOpacity); } this.scene.fog = new THREE.FogExp2(0x040912, 0.000035); @@ -850,36 +912,45 @@ export class GameViewer { for (const visual of this.shipVisuals.values()) { const elapsedMs = now - visual.receivedAtMs; const blendT = THREE.MathUtils.clamp(elapsedMs / visual.blendDurationMs, 0, 1); - visual.mesh.position.lerpVectors(visual.startPosition, visual.authoritativePosition, blendT); + const worldPosition = new THREE.Vector3().lerpVectors(visual.startPosition, visual.authoritativePosition, blendT); if (blendT >= 1) { const extrapolationSeconds = Math.min((elapsedMs - visual.blendDurationMs) / 1000, 0.35); - visual.mesh.position.copy(visual.authoritativePosition).addScaledVector(visual.velocity, extrapolationSeconds); + worldPosition.copy(visual.authoritativePosition).addScaledVector(visual.velocity, extrapolationSeconds); } + visual.mesh.position.copy(this.toDisplayPosition(worldPosition, visual.systemId)); visual.icon.position.copy(visual.mesh.position); + const shipVisible = visual.systemId === this.activeSystemId; + visual.mesh.visible = shipVisible; + visual.icon.visible = shipVisible && visual.icon.visible; const desiredHeading = visual.targetPosition.clone().sub(visual.mesh.position); if (desiredHeading.lengthSq() > 0.01) { visual.mesh.lookAt(visual.mesh.position.clone().add(desiredHeading)); } } - for (const mesh of this.nodeMeshes.values()) { - const entry = this.presentationEntries.find((candidate) => candidate.detail === mesh); - entry?.icon.position.copy(mesh.position); + for (const visual of this.nodeVisuals.values()) { + visual.mesh.position.copy(this.toDisplayPosition(visual.worldPosition, visual.systemId)); + visual.icon.position.copy(visual.mesh.position); + visual.mesh.visible = visual.systemId === this.activeSystemId; } - for (const mesh of this.stationMeshes.values()) { - const entry = this.presentationEntries.find((candidate) => candidate.detail === mesh); - entry?.icon.position.copy(mesh.position); + for (const visual of this.stationVisuals.values()) { + visual.mesh.position.copy(this.toDisplayPosition(visual.worldPosition, visual.systemId)); + visual.icon.position.copy(visual.mesh.position); + visual.mesh.visible = visual.systemId === this.activeSystemId; } + this.updateSystemDetailVisibility(); this.updateSystemSummaryPresentation(); } private updatePlanetPresentation() { const nowSeconds = this.currentWorldTimeSeconds(); for (const visual of this.planetVisuals) { - const position = this.computePlanetLocalPosition(visual.planet, nowSeconds); + const scale = visual.systemId === this.activeSystemId ? ACTIVE_SYSTEM_DETAIL_SCALE : 1; + const position = this.computePlanetLocalPosition(visual.planet, nowSeconds).multiplyScalar(scale); + visual.orbit.scale.setScalar(scale); visual.mesh.position.copy(position); visual.icon.position.copy(position); if (visual.ring) { @@ -887,7 +958,8 @@ export class GameViewer { } for (const [moonIndex, moon] of visual.moons.entries()) { moon.orbit.position.copy(position); - moon.mesh.position.copy(position).add(this.computeMoonLocalPosition(visual.planet, moonIndex, nowSeconds)); + moon.orbit.scale.setScalar(scale); + moon.mesh.position.copy(position).add(this.computeMoonLocalPosition(visual.planet, moonIndex, nowSeconds).multiplyScalar(scale)); } } } @@ -1290,16 +1362,23 @@ export class GameViewer { } private updateSystemSummaryPresentation() { - const distanceScale = this.zoomLevel === "universe" ? 0.11 : 0.05; - for (const visual of this.systemSummaryVisuals.values()) { + const distanceScale = this.activeSystemId ? 0.05 : 0.085; + for (const [systemId, visual] of this.systemSummaryVisuals.entries()) { const distance = this.camera.position.distanceTo(visual.anchor); - const scale = Math.max(1400, distance * distanceScale); + const minimumScale = this.activeSystemId && systemId !== this.activeSystemId ? 1200 : 1400; + const scale = Math.max(minimumScale, distance * distanceScale); visual.sprite.scale.set(scale, scale * 0.3125, 1); } } - private registerPresentation(detail: THREE.Object3D, icon: THREE.Sprite, hideDetailInUniverse: boolean, hideIconInUniverse = false) { - this.presentationEntries.push({ detail, icon, hideDetailInUniverse, hideIconInUniverse }); + private registerPresentation( + detail: THREE.Object3D, + icon: THREE.Sprite, + hideDetailInUniverse: boolean, + hideIconInUniverse = false, + systemId?: string, + ) { + this.presentationEntries.push({ detail, icon, systemId, hideDetailInUniverse, hideIconInUniverse }); } private renderRecentEvents(entityKind: string, entityId: string) { @@ -1362,9 +1441,11 @@ export class GameViewer { const generatedAt = this.world?.generatedAtUtc ? new Date(this.world.generatedAtUtc).toLocaleTimeString() : "n/a"; + const activeSystem = this.activeSystemId ?? "deep-space"; this.statusEl.textContent = [ `mode: ${mode}`, `zoom: ${this.zoomLevel}`, + `system: ${activeSystem}`, `sequence: ${sequence}`, `snapshot: ${generatedAt}`, ].join("\n"); @@ -1594,6 +1675,7 @@ export class GameViewer { this.raycaster.setFromCamera(this.mouse, this.camera); const hit = this.raycaster.intersectObjects([...this.selectableTargets.keys()], false)[0]; this.selectedItems = hit ? [this.selectableTargets.get(hit.object)!] : []; + this.syncFollowStateFromSelection(); this.updatePanels(); }; @@ -1605,6 +1687,7 @@ export class GameViewer { if (nextFocus) { this.focus.copy(nextFocus); } + this.syncFollowStateFromSelection(); }; private onWheel = (event: WheelEvent) => { @@ -1621,6 +1704,9 @@ export class GameViewer { } const key = event.key.toLowerCase(); this.keyState.add(key); + if (["w", "a", "s", "d"].includes(key)) { + this.followedShipId = undefined; + } if (key === "1") { this.desiredDistance = ZOOM_DISTANCE.local; } else if (key === "2") { @@ -1642,22 +1728,25 @@ export class GameViewer { if (selection.kind === "ship") { const ship = this.world.ships.get(selection.id); - return ship ? this.toThreeVector(ship.position) : undefined; + return ship ? this.toDisplayPosition(this.toThreeVector(ship.position), ship.systemId) : undefined; } if (selection.kind === "station") { const station = this.world.stations.get(selection.id); - return station ? this.toThreeVector(station.position) : undefined; + return station ? this.toDisplayPosition(this.toThreeVector(station.position), station.systemId) : undefined; } if (selection.kind === "node") { const node = this.world.nodes.get(selection.id); - return node ? this.toThreeVector(node.position) : undefined; + return node ? this.toDisplayPosition(this.toThreeVector(node.position), node.systemId) : undefined; } if (selection.kind === "planet") { const system = this.world.systems.get(selection.systemId); const planet = system?.planets[selection.planetIndex]; - return system && planet - ? new THREE.Vector3(system.position.x + planet.orbitRadius, system.position.y, system.position.z) - : undefined; + if (!system || !planet) { + return undefined; + } + const visual = this.planetVisuals.find((candidate) => + candidate.systemId === selection.systemId && candidate.planet === planet); + return visual?.mesh.getWorldPosition(new THREE.Vector3()); } const system = this.world.systems.get(selection.id); return system ? this.toThreeVector(system.position) : undefined; @@ -1714,6 +1803,7 @@ export class GameViewer { const selection = [...grouped.entries()] .sort((left, right) => right[1].length - left[1].length)[0]?.[1] ?? []; this.selectedItems = selection; + this.syncFollowStateFromSelection(); this.updatePanels(); } @@ -1753,4 +1843,182 @@ export class GameViewer { this.camera.updateProjectionMatrix(); this.renderer.setSize(width, height); }; + + private updateActiveSystem() { + const nextActiveSystemId = this.determineActiveSystemId(); + if (nextActiveSystemId === this.activeSystemId) { + return; + } + + this.activeSystemId = nextActiveSystemId; + this.updateSystemDetailVisibility(); + this.updatePanels(); + this.updateGamePanel("Live"); + } + + private determineActiveSystemId() { + if (!this.world || this.currentDistance >= 12000) { + return undefined; + } + + const selected = this.selectedItems[0]; + if (selected && this.selectedItems.length === 1) { + if (selected.kind === "system") { + return selected.id; + } + if (selected.kind === "planet") { + return selected.systemId; + } + const selectedSystemId = this.resolveSelectableSystemId(selected); + if (selectedSystemId) { + return selectedSystemId; + } + } + + if (this.followedShipId) { + return this.world.ships.get(this.followedShipId)?.systemId; + } + + let nearestSystemId: string | undefined; + let nearestDistance = Number.POSITIVE_INFINITY; + for (const system of this.world.systems.values()) { + const center = this.toThreeVector(system.position); + const distance = center.distanceTo(this.focus); + if (distance < nearestDistance) { + nearestDistance = distance; + nearestSystemId = system.id; + } + } + + return nearestDistance <= Math.max(ACTIVE_SYSTEM_CAPTURE_RADIUS, this.currentDistance * 2.2) + ? nearestSystemId + : undefined; + } + + private updateFollowCamera(delta: number) { + if (!this.followedShipId || !this.world) { + return; + } + + const ship = this.world.ships.get(this.followedShipId); + if (!ship) { + this.followedShipId = undefined; + return; + } + + const target = this.toDisplayPosition(this.toThreeVector(ship.position), ship.systemId); + this.focus.lerp(target, 1 - Math.exp(-delta * 8)); + } + + private syncFollowStateFromSelection() { + if (this.selectedItems.length === 1 && this.selectedItems[0].kind === "ship") { + this.followedShipId = this.selectedItems[0].id; + this.desiredDistance = Math.min(this.desiredDistance, 1600); + return; + } + + this.followedShipId = undefined; + } + + private updateSystemDetailVisibility() { + for (const [systemId, visual] of this.systemVisuals.entries()) { + visual.detailGroup.visible = systemId === this.activeSystemId; + } + } + + private resolveSelectableSystemId(selection: Selectable) { + if (!this.world) { + return undefined; + } + + if (selection.kind === "ship") { + return this.world.ships.get(selection.id)?.systemId; + } + if (selection.kind === "station") { + return this.world.stations.get(selection.id)?.systemId; + } + if (selection.kind === "node") { + return this.world.nodes.get(selection.id)?.systemId; + } + if (selection.kind === "planet") { + return selection.systemId; + } + return selection.id; + } + + private toDisplayPosition(worldPosition: THREE.Vector3, systemId?: string) { + if (!this.world || !systemId || systemId !== this.activeSystemId) { + return worldPosition.clone(); + } + + const system = this.world.systems.get(systemId); + if (!system) { + return worldPosition.clone(); + } + + const center = this.toThreeVector(system.position); + return worldPosition.clone().sub(center).multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE).add(center); + } + + private renderSystemDetails(system: SystemSnapshot, activeContext: boolean) { + if (!this.world) { + return ""; + } + + let shipCount = 0; + let stationCount = 0; + let nodeCount = 0; + let moonCount = 0; + + for (const ship of this.world.ships.values()) { + if (ship.systemId === system.id) { + shipCount += 1; + } + } + for (const station of this.world.stations.values()) { + if (station.systemId === system.id) { + stationCount += 1; + } + } + for (const node of this.world.nodes.values()) { + if (node.systemId === system.id) { + nodeCount += 1; + } + } + for (const planet of system.planets) { + moonCount += planet.moonCount; + } + + const followText = activeContext && this.followedShipId + ? `

Camera locked to ${this.world.ships.get(this.followedShipId)?.label ?? this.followedShipId}

` + : ""; + + return ` +

${system.id}${activeContext ? " · active system" : ""}

+

${system.starKind} · ${system.starCount} star${system.starCount > 1 ? "s" : ""}

+

Planets ${system.planets.length}
Moons ${moonCount}
Ships ${shipCount}
Stations ${stationCount}
Nodes ${nodeCount}

+

Height ${system.position.y.toFixed(0)}

+

${system.planets.slice(0, 8).map((planet) => `${planet.label} (${planet.planetType})`).join("
")}

+ ${followText} + `; + } + + private updateSystemPanel() { + if (!this.world) { + return; + } + + const activeSystem = this.activeSystemId ? this.world.systems.get(this.activeSystemId) : undefined; + const showSystemPanel = !!activeSystem; + this.systemPanelEl.hidden = !showSystemPanel; + + if (!activeSystem) { + this.systemTitleEl.textContent = "Deep Space"; + this.systemBodyEl.innerHTML = ""; + return; + } + + this.systemTitleEl.textContent = activeSystem.label; + this.systemBodyEl.innerHTML = this.renderSystemDetails(activeSystem, true); + } } diff --git a/apps/viewer/src/style.css b/apps/viewer/src/style.css index a188fd9..aa001d7 100644 --- a/apps/viewer/src/style.css +++ b/apps/viewer/src/style.css @@ -47,6 +47,16 @@ canvas { gap: 16px; } +.right-panel-stack { + position: absolute; + top: 20px; + right: 20px; + width: min(380px, calc(100vw - 40px)); + display: flex; + flex-direction: column; + gap: 16px; +} + .marquee-box { position: absolute; display: none; @@ -56,7 +66,7 @@ canvas { } .topbar, -.details-panel, +.info-panel, .network-panel, .performance-panel, .faction-strip { @@ -82,8 +92,8 @@ canvas { .topbar h1, .topbar h2, -.details-panel h2, -.details-panel h3, +.info-panel h2, +.info-panel h3, .faction-card h3 { margin: 0; } @@ -109,12 +119,7 @@ canvas { white-space: pre-wrap; } -.details-panel { - position: absolute; - top: 110px; - right: 20px; - width: min(380px, calc(100vw - 40px)); - bottom: 20px; +.info-panel { border-radius: 24px; padding: 18px; color: var(--text); @@ -137,7 +142,7 @@ canvas { pointer-events: auto; } -.details-panel h2 { +.info-panel h2 { color: var(--accent); letter-spacing: 0.16em; font-size: 0.72rem; @@ -168,12 +173,19 @@ canvas { font-size: 1.05rem; } +.system-title { + margin-top: 12px; + font-size: 1.05rem; +} + +.system-body, .detail-body { margin-top: 12px; color: var(--muted); line-height: 1.55; } +.system-body p, .detail-body p { margin: 0 0 12px; } @@ -185,11 +197,27 @@ canvas { } .error-strip { - margin-top: 14px; - padding: 12px 14px; border-radius: 14px; + padding: 12px 14px; background: rgba(255, 116, 88, 0.14); color: #ffd8cf; + pointer-events: auto; +} + +.right-panel-stack .error-strip { + margin-top: -4px; +} + +.system-panel-section[hidden] { + display: none; +} + +.detail-panel-section[hidden] { + display: none; +} + +.error-strip[hidden] { + display: none; } .faction-strip { @@ -243,14 +271,25 @@ canvas { width: auto; } - .details-panel { - position: absolute; - top: auto; + .right-panel-stack { left: 20px; right: 20px; - bottom: 148px; + top: auto; width: auto; + bottom: 148px; max-height: 38vh; + overflow: auto; + } + + .info-panel { + max-height: none; + overflow: visible; + } + + .system-panel-section, + .detail-panel-section, + .error-strip { + width: auto; } .network-panel {