feat(viewer): add Vue-based HUD, ops strip, and history window
This commit is contained in:
1031
apps/viewer/package-lock.json
generated
1031
apps/viewer/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,15 +5,21 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -p tsconfig.json && vite build",
|
"build": "vue-tsc -p tsconfig.json --noEmit && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"three": "^0.179.1"
|
"pinia": "^3.0.3",
|
||||||
|
"three": "^0.179.1",
|
||||||
|
"vue": "^3.5.21"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@types/three": "^0.183.1",
|
"@types/three": "^0.183.1",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"vite": "^7.1.3"
|
"vite": "^7.1.3",
|
||||||
|
"vue-tsc": "^3.0.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
197
apps/viewer/src/App.vue
Normal file
197
apps/viewer/src/App.vue
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
import { nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
|
import { GameViewer } from "./GameViewer";
|
||||||
|
import CollapsibleHudPanel from "./components/CollapsibleHudPanel.vue";
|
||||||
|
import HtmlInfoPanel from "./components/HtmlInfoPanel.vue";
|
||||||
|
import ViewerHistoryLayer from "./components/ViewerHistoryLayer.vue";
|
||||||
|
import ViewerOpsStrip from "./components/ViewerOpsStrip.vue";
|
||||||
|
import { createViewerHudState } from "./viewerHudState";
|
||||||
|
import { useViewerSelectionStore } from "./ui/stores/viewerSelection";
|
||||||
|
import type { Selectable } from "./viewerTypes";
|
||||||
|
|
||||||
|
const canvasHostEl = ref<HTMLDivElement | null>(null);
|
||||||
|
const opsStripHostEl = ref<HTMLDivElement | null>(null);
|
||||||
|
const historyLayerHostEl = ref<HTMLDivElement | null>(null);
|
||||||
|
const marqueeEl = ref<HTMLDivElement | null>(null);
|
||||||
|
const hoverLabelEl = ref<HTMLDivElement | null>(null);
|
||||||
|
const hoverConnectorLineEl = ref<SVGLineElement | null>(null);
|
||||||
|
|
||||||
|
const hudState = createViewerHudState();
|
||||||
|
const selectionStore = useViewerSelectionStore();
|
||||||
|
const { selectedEntityId, selectedEntityLabel } = storeToRefs(selectionStore);
|
||||||
|
let viewer: GameViewer | undefined;
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick();
|
||||||
|
if (
|
||||||
|
!canvasHostEl.value
|
||||||
|
|| !opsStripHostEl.value
|
||||||
|
|| !historyLayerHostEl.value
|
||||||
|
|| !marqueeEl.value
|
||||||
|
|| !hoverLabelEl.value
|
||||||
|
|| !hoverConnectorLineEl.value
|
||||||
|
) {
|
||||||
|
throw new Error("Viewer HUD mount failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
viewer = new GameViewer(canvasHostEl.value, {
|
||||||
|
state: hudState,
|
||||||
|
selectionStore,
|
||||||
|
opsStripEl: opsStripHostEl.value,
|
||||||
|
historyLayerEl: historyLayerHostEl.value,
|
||||||
|
marqueeEl: marqueeEl.value,
|
||||||
|
hoverLabelEl: hoverLabelEl.value,
|
||||||
|
hoverConnectorLineEl: hoverConnectorLineEl.value,
|
||||||
|
});
|
||||||
|
void viewer.start();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
viewer?.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
function onHistoryWindowResize(id: string, width: number, height: number) {
|
||||||
|
const windowState = hudState.historyWindows.find((entry) => entry.id === id);
|
||||||
|
if (!windowState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
windowState.width = width;
|
||||||
|
windowState.height = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOpenHistory(selection: Selectable) {
|
||||||
|
viewer?.openHistoryWindow(selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactical") {
|
||||||
|
viewer?.focusSelection(selection, cameraMode);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="viewer-app">
|
||||||
|
<div
|
||||||
|
ref="canvasHostEl"
|
||||||
|
class="viewer-canvas-host"
|
||||||
|
/>
|
||||||
|
<div class="pointer-events-none fixed inset-0">
|
||||||
|
<div class="absolute left-5 top-5 flex w-[min(360px,calc(100vw-40px))] flex-col gap-4 max-[760px]:right-5 max-[760px]:w-auto">
|
||||||
|
<CollapsibleHudPanel
|
||||||
|
v-model:collapsed="hudState.gamePanel.collapsed"
|
||||||
|
class-name="topbar"
|
||||||
|
panel-name="game"
|
||||||
|
title="Game"
|
||||||
|
:summary="hudState.gamePanel.summary"
|
||||||
|
:body-text="hudState.gamePanel.bodyText"
|
||||||
|
/>
|
||||||
|
<CollapsibleHudPanel
|
||||||
|
v-model:collapsed="hudState.networkPanel.collapsed"
|
||||||
|
class-name="network-panel"
|
||||||
|
panel-name="network"
|
||||||
|
title="Network"
|
||||||
|
:summary="hudState.networkPanel.summary"
|
||||||
|
:body-text="hudState.networkPanel.bodyText"
|
||||||
|
/>
|
||||||
|
<CollapsibleHudPanel
|
||||||
|
v-model:collapsed="hudState.performancePanel.collapsed"
|
||||||
|
class-name="performance-panel"
|
||||||
|
panel-name="performance"
|
||||||
|
title="Performance"
|
||||||
|
:summary="hudState.performancePanel.summary"
|
||||||
|
:body-text="hudState.performancePanel.bodyText"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="absolute right-5 top-5 flex w-[min(380px,calc(100vw-40px))] flex-col gap-4 max-[760px]:bottom-[148px] max-[760px]:left-5 max-[760px]:right-5 max-[760px]:top-auto max-[760px]:max-h-[38vh] max-[760px]:w-auto max-[760px]:overflow-auto">
|
||||||
|
<HtmlInfoPanel
|
||||||
|
class-name="system-panel-section"
|
||||||
|
title="System"
|
||||||
|
:subtitle="hudState.systemPanel.title"
|
||||||
|
:body-html="hudState.systemPanel.bodyHtml"
|
||||||
|
:hidden="hudState.systemPanel.hidden"
|
||||||
|
subtitle-class="system-title"
|
||||||
|
body-class="system-body"
|
||||||
|
/>
|
||||||
|
<HtmlInfoPanel
|
||||||
|
class-name="detail-panel-section"
|
||||||
|
title="Focus"
|
||||||
|
:subtitle="hudState.detailPanel.title"
|
||||||
|
:body-html="hudState.detailPanel.bodyHtml"
|
||||||
|
:hidden="hudState.detailPanel.hidden"
|
||||||
|
subtitle-class="detail-title"
|
||||||
|
body-class="detail-body"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="pointer-events-auto rounded-xl bg-[rgba(255,116,88,0.14)] px-3.5 py-3 text-[#ffd8cf]"
|
||||||
|
:hidden="hudState.error.hidden"
|
||||||
|
>
|
||||||
|
{{ hudState.error.message }}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="selectedEntityId"
|
||||||
|
type="button"
|
||||||
|
class="selection-action-button pointer-events-auto self-end rounded-full border border-white/10 bg-white/5 px-3.5 py-2.5 text-sm text-[color:var(--viewer-text)] transition hover:bg-white/10"
|
||||||
|
@click="selectionStore.clearSelection('ui')"
|
||||||
|
>
|
||||||
|
Clear {{ selectedEntityLabel ?? "Selection" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="historyLayerHostEl">
|
||||||
|
<ViewerHistoryLayer
|
||||||
|
:windows="hudState.historyWindows"
|
||||||
|
@resize="onHistoryWindowResize"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="opsStripHostEl">
|
||||||
|
<ViewerOpsStrip
|
||||||
|
:state="hudState.opsStrip"
|
||||||
|
@history="onOpenHistory"
|
||||||
|
@focus="onFocusSelection"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="marqueeEl"
|
||||||
|
class="marquee-box"
|
||||||
|
:style="{
|
||||||
|
display: hudState.marquee.visible ? 'block' : 'none',
|
||||||
|
left: `${hudState.marquee.x}px`,
|
||||||
|
top: `${hudState.marquee.y}px`,
|
||||||
|
width: `${hudState.marquee.width}px`,
|
||||||
|
height: `${hudState.marquee.height}px`,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
class="hover-connector-svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<line
|
||||||
|
ref="hoverConnectorLineEl"
|
||||||
|
class="hover-connector-line"
|
||||||
|
:x1="hudState.hoverLabel.x1"
|
||||||
|
:y1="hudState.hoverLabel.y1"
|
||||||
|
:x2="hudState.hoverLabel.x2"
|
||||||
|
:y2="hudState.hoverLabel.y2"
|
||||||
|
:hidden="hudState.hoverLabel.connectorHidden"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="hoverLabelEl"
|
||||||
|
class="hover-label"
|
||||||
|
:hidden="hudState.hoverLabel.hidden"
|
||||||
|
:style="{
|
||||||
|
left: `${hudState.hoverLabel.x}px`,
|
||||||
|
top: `${hudState.hoverLabel.y}px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ hudState.hoverLabel.text }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,13 +1,27 @@
|
|||||||
|
import type { ViewerHudBindings } from "./viewerHudState";
|
||||||
|
import type { Selectable, CameraMode } from "./viewerTypes";
|
||||||
import { ViewerAppController } from "./ViewerAppController";
|
import { ViewerAppController } from "./ViewerAppController";
|
||||||
|
|
||||||
export class GameViewer {
|
export class GameViewer {
|
||||||
private readonly controller: ViewerAppController;
|
private readonly controller: ViewerAppController;
|
||||||
|
|
||||||
constructor(container: HTMLElement) {
|
constructor(container: HTMLElement, hud: ViewerHudBindings) {
|
||||||
this.controller = new ViewerAppController(container);
|
this.controller = new ViewerAppController(container, hud);
|
||||||
}
|
}
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
await this.controller.start();
|
await this.controller.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
focusSelection(selection: Selectable, cameraMode?: CameraMode) {
|
||||||
|
this.controller.focusSelection(selection, cameraMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
openHistoryWindow(selection: Selectable) {
|
||||||
|
this.controller.openHistoryWindow(selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.controller.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
MIN_CAMERA_DISTANCE,
|
MIN_CAMERA_DISTANCE,
|
||||||
NAV_DISTANCE,
|
NAV_DISTANCE,
|
||||||
} from "./viewerConstants";
|
} from "./viewerConstants";
|
||||||
import { createViewerHud } from "./viewerHud";
|
|
||||||
import { updatePanFromKeyboard } from "./viewerCamera";
|
import { updatePanFromKeyboard } from "./viewerCamera";
|
||||||
import { setShellReticleOpacity } from "./viewerControls";
|
import { setShellReticleOpacity } from "./viewerControls";
|
||||||
import { renderFrame, resizeViewer, stepCamera } from "./viewerRenderLoop";
|
import { renderFrame, resizeViewer, stepCamera } from "./viewerRenderLoop";
|
||||||
@@ -21,16 +20,21 @@ import { ViewerNavigationController } from "./viewerNavigationController";
|
|||||||
import { ViewerSceneDataController } from "./viewerSceneDataController";
|
import { ViewerSceneDataController } from "./viewerSceneDataController";
|
||||||
import { ViewerPresentationController } from "./viewerPresentationController";
|
import { ViewerPresentationController } from "./viewerPresentationController";
|
||||||
import { createViewerControllers, wireViewerEvents } from "./viewerControllerFactory";
|
import { createViewerControllers, wireViewerEvents } from "./viewerControllerFactory";
|
||||||
|
import { createViewerRenderer } from "./runtime/rendering/createViewerRenderer";
|
||||||
|
import { disposeSceneResources } from "./runtime/rendering/disposeThreeResources";
|
||||||
|
import { ViewerRenderSurface } from "./runtime/rendering/ViewerRenderSurface";
|
||||||
import { toDisplayLocalPosition, getSystemCameraFocus } from "./viewerCamera";
|
import { toDisplayLocalPosition, getSystemCameraFocus } from "./viewerCamera";
|
||||||
import { UniverseLayer } from "./viewerUniverseLayer";
|
import { UniverseLayer } from "./viewerUniverseLayer";
|
||||||
import { GalaxyLayer } from "./viewerGalaxyLayer";
|
import { GalaxyLayer } from "./viewerGalaxyLayer";
|
||||||
import { SystemLayer } from "./viewerSystemLayer";
|
import { SystemLayer } from "./viewerSystemLayer";
|
||||||
import { LocalLayer } from "./viewerLocalLayer";
|
import { LocalLayer } from "./viewerLocalLayer";
|
||||||
|
import type { HistoryWindowState, ViewerHudBindings, ViewerHudState } from "./viewerHudState";
|
||||||
|
import { describeSelectable } from "./viewerSelection";
|
||||||
|
import { entityIdToSelectable, selectionToEntityId, type ViewerSelectionStore } from "./ui/stores/viewerSelection";
|
||||||
import type { FactionSnapshot } from "./contracts";
|
import type { FactionSnapshot } from "./contracts";
|
||||||
import type {
|
import type {
|
||||||
CameraMode,
|
CameraMode,
|
||||||
DragMode,
|
DragMode,
|
||||||
HistoryWindowState,
|
|
||||||
NetworkStats,
|
NetworkStats,
|
||||||
PerformanceStats,
|
PerformanceStats,
|
||||||
Selectable,
|
Selectable,
|
||||||
@@ -41,7 +45,8 @@ import type {
|
|||||||
|
|
||||||
export class ViewerAppController {
|
export class ViewerAppController {
|
||||||
private readonly container: HTMLElement;
|
private readonly container: HTMLElement;
|
||||||
private readonly renderer = new THREE.WebGLRenderer({ antialias: true });
|
private readonly renderer = createViewerRenderer();
|
||||||
|
private readonly renderSurface: ViewerRenderSurface;
|
||||||
|
|
||||||
// ── Three independent rendering layers ───────────────────────────────────
|
// ── Three independent rendering layers ───────────────────────────────────
|
||||||
readonly universeLayer = new UniverseLayer();
|
readonly universeLayer = new UniverseLayer();
|
||||||
@@ -61,23 +66,9 @@ export class ViewerAppController {
|
|||||||
private readonly cameraOffset = new THREE.Vector3();
|
private readonly cameraOffset = new THREE.Vector3();
|
||||||
private readonly keyState = new Set<string>();
|
private readonly keyState = new Set<string>();
|
||||||
|
|
||||||
private readonly gamePanelEl: HTMLDivElement;
|
readonly hudState: ViewerHudState;
|
||||||
|
readonly selectionStore: ViewerSelectionStore;
|
||||||
private readonly statusEl: HTMLDivElement;
|
|
||||||
private readonly gameSummaryEl: HTMLSpanElement;
|
|
||||||
private readonly systemPanelEl: HTMLDivElement;
|
|
||||||
private readonly systemTitleEl: HTMLHeadingElement;
|
|
||||||
private readonly systemBodyEl: HTMLDivElement;
|
|
||||||
private readonly detailTitleEl: HTMLHeadingElement;
|
|
||||||
private readonly detailBodyEl: HTMLDivElement;
|
|
||||||
private readonly opsStripEl: HTMLDivElement;
|
private readonly opsStripEl: HTMLDivElement;
|
||||||
private readonly networkSectionEl: HTMLDivElement;
|
|
||||||
private readonly networkSummaryEl: HTMLSpanElement;
|
|
||||||
private readonly networkPanelEl: HTMLDivElement;
|
|
||||||
private readonly performanceSectionEl: HTMLDivElement;
|
|
||||||
private readonly performanceSummaryEl: HTMLSpanElement;
|
|
||||||
private readonly performancePanelEl: HTMLDivElement;
|
|
||||||
private readonly errorEl: HTMLDivElement;
|
|
||||||
private readonly historyLayerEl: HTMLDivElement;
|
private readonly historyLayerEl: HTMLDivElement;
|
||||||
private readonly marqueeEl: HTMLDivElement;
|
private readonly marqueeEl: HTMLDivElement;
|
||||||
private readonly hoverLabelEl: HTMLDivElement;
|
private readonly hoverLabelEl: HTMLDivElement;
|
||||||
@@ -124,30 +115,14 @@ export class ViewerAppController {
|
|||||||
private readonly navigationController: ViewerNavigationController;
|
private readonly navigationController: ViewerNavigationController;
|
||||||
private readonly sceneDataController: ViewerSceneDataController;
|
private readonly sceneDataController: ViewerSceneDataController;
|
||||||
private readonly presentationController: ViewerPresentationController;
|
private readonly presentationController: ViewerPresentationController;
|
||||||
|
private readonly disposeEventBindings: () => void;
|
||||||
|
private readonly unsubscribeSelectionStore: () => void;
|
||||||
|
|
||||||
constructor(container: HTMLElement) {
|
constructor(container: HTMLElement, hud: ViewerHudBindings) {
|
||||||
this.container = container;
|
this.container = container;
|
||||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
this.hudState = hud.state;
|
||||||
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
|
this.selectionStore = hud.selectionStore;
|
||||||
|
|
||||||
|
|
||||||
const hud = createViewerHud(document);
|
|
||||||
this.gamePanelEl = hud.gamePanelEl;
|
|
||||||
this.statusEl = hud.statusEl;
|
|
||||||
this.gameSummaryEl = hud.gameSummaryEl;
|
|
||||||
this.networkSectionEl = hud.networkSectionEl;
|
|
||||||
this.systemPanelEl = hud.systemPanelEl;
|
|
||||||
this.systemTitleEl = hud.systemTitleEl;
|
|
||||||
this.systemBodyEl = hud.systemBodyEl;
|
|
||||||
this.detailTitleEl = hud.detailTitleEl;
|
|
||||||
this.detailBodyEl = hud.detailBodyEl;
|
|
||||||
this.opsStripEl = hud.opsStripEl;
|
this.opsStripEl = hud.opsStripEl;
|
||||||
this.networkSummaryEl = hud.networkSummaryEl;
|
|
||||||
this.networkPanelEl = hud.networkPanelEl;
|
|
||||||
this.performanceSectionEl = hud.performanceSectionEl;
|
|
||||||
this.performanceSummaryEl = hud.performanceSummaryEl;
|
|
||||||
this.performancePanelEl = hud.performancePanelEl;
|
|
||||||
this.errorEl = hud.errorEl;
|
|
||||||
this.historyLayerEl = hud.historyLayerEl;
|
this.historyLayerEl = hud.historyLayerEl;
|
||||||
this.marqueeEl = hud.marqueeEl;
|
this.marqueeEl = hud.marqueeEl;
|
||||||
this.hoverLabelEl = hud.hoverLabelEl;
|
this.hoverLabelEl = hud.hoverLabelEl;
|
||||||
@@ -160,33 +135,51 @@ export class ViewerAppController {
|
|||||||
interactionController: this.interactionController,
|
interactionController: this.interactionController,
|
||||||
} = createViewerControllers(this));
|
} = createViewerControllers(this));
|
||||||
this.presentationController.initializeAmbience();
|
this.presentationController.initializeAmbience();
|
||||||
|
this.unsubscribeSelectionStore = this.selectionStore.$subscribe((_mutation, state) => {
|
||||||
this.container.append(this.renderer.domElement, hud.root);
|
this.syncSelectionFromStore(state.selectedEntityKind, state.selectedEntityId);
|
||||||
this.initializePanelToggles();
|
});
|
||||||
wireViewerEvents(this);
|
this.renderSurface = new ViewerRenderSurface({
|
||||||
this.onResize();
|
container: this.container,
|
||||||
|
renderer: this.renderer,
|
||||||
|
onFrame: () => this.render(),
|
||||||
|
onResize: (width, height) => this.onResize(width, height),
|
||||||
|
});
|
||||||
|
this.disposeEventBindings = wireViewerEvents(this);
|
||||||
this.updateCamera(0);
|
this.updateCamera(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private initializePanelToggles() {
|
|
||||||
for (const panel of [this.gamePanelEl, this.networkSectionEl, this.performanceSectionEl]) {
|
|
||||||
const toggle = panel.querySelector(".panel-toggle");
|
|
||||||
if (!(toggle instanceof HTMLButtonElement)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle.addEventListener("click", () => {
|
|
||||||
const collapsed = panel.classList.toggle("is-collapsed");
|
|
||||||
toggle.textContent = collapsed ? "+" : "-";
|
|
||||||
toggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
|
||||||
toggle.setAttribute("aria-label", `${collapsed ? "Expand" : "Collapse"} ${panel.dataset.panelName ?? "panel"}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
|
this.selectionStore.clearSelection();
|
||||||
await this.worldLifecycle.bootstrapWorld();
|
await this.worldLifecycle.bootstrapWorld();
|
||||||
this.renderer.setAnimationLoop(() => this.render());
|
this.renderSurface.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.disposeEventBindings();
|
||||||
|
this.unsubscribeSelectionStore();
|
||||||
|
this.stream?.close();
|
||||||
|
this.renderSurface.dispose();
|
||||||
|
disposeSceneResources(this.universeLayer.scene);
|
||||||
|
disposeSceneResources(this.galaxyLayer.scene);
|
||||||
|
disposeSceneResources(this.systemLayer.scene);
|
||||||
|
disposeSceneResources(this.localLayer.scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
focusSelection(selection: Selectable, cameraMode?: CameraMode) {
|
||||||
|
this.applySelectedItems([selection], "ui");
|
||||||
|
this.navigationController.focusOnSelection(selection);
|
||||||
|
if (cameraMode) {
|
||||||
|
this.interactionController.toggleCameraMode(cameraMode);
|
||||||
|
if (selection.kind === "ship" && cameraMode === "follow") {
|
||||||
|
this.desiredDistance = 0.00018;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.updatePanels();
|
||||||
|
this.updateGamePanel("Live");
|
||||||
|
}
|
||||||
|
|
||||||
|
openHistoryWindow(selection: Selectable) {
|
||||||
|
this.interactionController.openHistoryWindow(selection);
|
||||||
}
|
}
|
||||||
|
|
||||||
private refreshStreamScopeIfNeeded() {
|
private refreshStreamScopeIfNeeded() {
|
||||||
@@ -214,6 +207,32 @@ export class ViewerAppController {
|
|||||||
this.worldLifecycle.updatePanels();
|
this.worldLifecycle.updatePanels();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private applySelectedItems(items: Selectable[], source: "viewer" | "ui") {
|
||||||
|
this.selectedItems = items;
|
||||||
|
if (items.length === 1) {
|
||||||
|
const selection = items[0];
|
||||||
|
this.selectionStore.selectSelection({
|
||||||
|
id: selectionToEntityId(selection),
|
||||||
|
kind: selection.kind,
|
||||||
|
label: describeSelectable(this.world, selection),
|
||||||
|
}, source);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectionStore.clearSelection(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncSelectionFromStore(
|
||||||
|
kind: Selectable["kind"] | null,
|
||||||
|
entityId: string | null,
|
||||||
|
) {
|
||||||
|
const selection = entityIdToSelectable(kind, entityId);
|
||||||
|
this.selectedItems = selection ? [selection] : [];
|
||||||
|
this.navigationController.syncFollowStateFromSelection();
|
||||||
|
this.updatePanels();
|
||||||
|
this.updateGamePanel("Live");
|
||||||
|
}
|
||||||
|
|
||||||
private render() {
|
private render() {
|
||||||
renderFrame({
|
renderFrame({
|
||||||
clock: this.clock,
|
clock: this.clock,
|
||||||
@@ -324,14 +343,15 @@ export class ViewerAppController {
|
|||||||
return resolveFocusedCelestialId(this.world, this.selectedItems);
|
return resolveFocusedCelestialId(this.world, this.selectedItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
private onResize = () => {
|
private onResize(width: number, height: number) {
|
||||||
resizeViewer({
|
resizeViewer({
|
||||||
renderer: this.renderer,
|
|
||||||
galaxyLayer: this.galaxyLayer,
|
galaxyLayer: this.galaxyLayer,
|
||||||
systemLayer: this.systemLayer,
|
systemLayer: this.systemLayer,
|
||||||
localLayer: this.localLayer,
|
localLayer: this.localLayer,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
private setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opacity: number) {
|
private setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opacity: number) {
|
||||||
setShellReticleOpacity(sprite, opacity);
|
setShellReticleOpacity(sprite, opacity);
|
||||||
|
|||||||
40
apps/viewer/src/components/CollapsibleHudPanel.vue
Normal file
40
apps/viewer/src/components/CollapsibleHudPanel.vue
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const collapsed = defineModel<boolean>("collapsed", { required: true });
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
bodyText: string;
|
||||||
|
className?: string;
|
||||||
|
panelName: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section :class="[
|
||||||
|
'pointer-events-auto rounded-3xl border border-(--viewer-panel-border) bg-(--viewer-panel) p-4 text-(--viewer-text) shadow-[0_18px_54px_rgba(0,0,0,0.35)] backdrop-blur-xl',
|
||||||
|
'collapsible-panel',
|
||||||
|
className,
|
||||||
|
collapsed && 'is-collapsed',
|
||||||
|
]" :data-panel-name="panelName">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<h2 class="m-0 text-[0.64rem] leading-none tracking-[0.16em] text-(--viewer-accent) uppercase">
|
||||||
|
{{ title }}
|
||||||
|
</h2>
|
||||||
|
<div class="flex min-w-0 items-center gap-2.5">
|
||||||
|
<span class="panel-summary hud-mono hidden text-right text-[0.72rem] leading-none text-(--viewer-muted)">
|
||||||
|
{{ summary }}
|
||||||
|
</span>
|
||||||
|
<button type="button"
|
||||||
|
class="h-7 w-7 cursor-pointer rounded-full border border-white/10 bg-white/5 text-sm text-(--viewer-text) transition hover:bg-white/10"
|
||||||
|
:aria-expanded="!collapsed" :aria-label="`${collapsed ? 'Expand' : 'Collapse'} ${title} panel`"
|
||||||
|
@click="collapsed = !collapsed">
|
||||||
|
{{ collapsed ? "+" : "-" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div :class="`${panelName}-body hud-mono mt-3.5 text-[0.8rem] leading-6 text-(--viewer-muted) whitespace-pre-wrap`">
|
||||||
|
{{ bodyText }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
25
apps/viewer/src/components/HtmlInfoPanel.vue
Normal file
25
apps/viewer/src/components/HtmlInfoPanel.vue
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
bodyHtml: string;
|
||||||
|
hidden?: boolean;
|
||||||
|
className?: string;
|
||||||
|
subtitleClass?: string;
|
||||||
|
bodyClass?: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside v-if="!hidden" :class="[
|
||||||
|
'pointer-events-auto rounded-3xl border border-(--viewer-panel-border) bg-(--viewer-panel) p-4 text-(--viewer-text) shadow-[0_18px_54px_rgba(0,0,0,0.35)] backdrop-blur-xl',
|
||||||
|
'info-panel',
|
||||||
|
className,
|
||||||
|
]">
|
||||||
|
<h2 class="m-0 text-[0.72rem] tracking-[0.16em] text-(--viewer-accent) uppercase">
|
||||||
|
{{ title }}
|
||||||
|
</h2>
|
||||||
|
<h3 :class="subtitleClass">{{ subtitle }}</h3>
|
||||||
|
<div :class="bodyClass" v-html="bodyHtml" />
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
88
apps/viewer/src/components/ViewerHistoryLayer.vue
Normal file
88
apps/viewer/src/components/ViewerHistoryLayer.vue
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, onBeforeUnmount, onMounted, useTemplateRef, watch } from "vue";
|
||||||
|
import type { HistoryWindowState } from "../viewerHudState";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
windows: HistoryWindowState[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
resize: [id: string, width: number, height: number];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const windowRefs = useTemplateRef<HTMLElement[]>("historyWindows");
|
||||||
|
let resizeObserver: ResizeObserver | undefined;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const id = (entry.target as HTMLElement).dataset.historyWindowId;
|
||||||
|
if (!id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("resize", id, entry.contentRect.width, entry.contentRect.height);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const element of windowRefs.value ?? []) {
|
||||||
|
resizeObserver.observe(element);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.windows.map((windowState) => `${windowState.id}:${windowState.width}:${windowState.height}`).join("|"),
|
||||||
|
async () => {
|
||||||
|
await nextTick();
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
for (const element of windowRefs.value ?? []) {
|
||||||
|
resizeObserver?.observe(element);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="history-layer">
|
||||||
|
<aside
|
||||||
|
v-for="windowState in props.windows"
|
||||||
|
:key="windowState.id"
|
||||||
|
ref="historyWindows"
|
||||||
|
class="history-window"
|
||||||
|
:data-history-window-id="windowState.id"
|
||||||
|
:style="{
|
||||||
|
left: `${windowState.x}px`,
|
||||||
|
top: `${windowState.y}px`,
|
||||||
|
width: `${windowState.width}px`,
|
||||||
|
height: `${windowState.height}px`,
|
||||||
|
zIndex: windowState.zIndex.toString(),
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="history-window-header">
|
||||||
|
<h2 class="history-window-title">{{ windowState.title }}</h2>
|
||||||
|
<div class="history-window-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="history-window-copy"
|
||||||
|
>
|
||||||
|
{{ windowState.copyLabel }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="history-window-close"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="history-window-body"
|
||||||
|
v-html="windowState.bodyHtml"
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
184
apps/viewer/src/components/ViewerOpsStrip.vue
Normal file
184
apps/viewer/src/components/ViewerOpsStrip.vue
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
import type { OpsStripState } from "../viewerHudState";
|
||||||
|
import { useViewerSelectionStore } from "../ui/stores/viewerSelection";
|
||||||
|
import type { Selectable } from "../viewerTypes";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
state: OpsStripState;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
history: [selection: Selectable];
|
||||||
|
focus: [selection: Selectable, cameraMode?: "follow" | "tactical"];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const selectionStore = useViewerSelectionStore();
|
||||||
|
const { selectedEntityId, selectedEntityKind } = storeToRefs(selectionStore);
|
||||||
|
|
||||||
|
function isSelected(kind: Selectable["kind"], id: string) {
|
||||||
|
return selectedEntityKind.value === kind && selectedEntityId.value === id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStationClick(id: string, label: string) {
|
||||||
|
selectionStore.selectSelection({ id, kind: "station", label }, "ui");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStationDoubleClick(id: string, label: string) {
|
||||||
|
selectionStore.selectSelection({ id, kind: "station", label }, "ui");
|
||||||
|
emit("focus", { kind: "station", id }, "tactical");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onShipClick(id: string, label: string) {
|
||||||
|
selectionStore.selectSelection({ id, kind: "ship", label }, "ui");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onShipDoubleClick(id: string, label: string) {
|
||||||
|
selectionStore.selectSelection({ id, kind: "ship", label }, "ui");
|
||||||
|
emit("focus", { kind: "ship", id }, "follow");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="ops-strip">
|
||||||
|
<article
|
||||||
|
v-for="faction in state.factions"
|
||||||
|
:key="faction.id"
|
||||||
|
class="ship-card faction-card"
|
||||||
|
:data-faction-id="faction.id"
|
||||||
|
>
|
||||||
|
<div class="ship-card-header">
|
||||||
|
<h3>{{ faction.label }}</h3>
|
||||||
|
<span class="ship-card-badge">faction</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="faction.stateLines.length > 0"
|
||||||
|
class="ship-card-ai"
|
||||||
|
>
|
||||||
|
<p class="ship-card-section-title">GOAP State</p>
|
||||||
|
<p
|
||||||
|
v-for="line in faction.stateLines"
|
||||||
|
:key="line"
|
||||||
|
>
|
||||||
|
{{ line }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="faction.priorities.length > 0"
|
||||||
|
class="ship-card-ai"
|
||||||
|
>
|
||||||
|
<p class="ship-card-section-title">Priorities</p>
|
||||||
|
<p
|
||||||
|
v-for="priority in faction.priorities"
|
||||||
|
:key="`${faction.id}-${priority.label}`"
|
||||||
|
class="ship-card-split-line"
|
||||||
|
>
|
||||||
|
<span>{{ priority.label }}</span>
|
||||||
|
<span>{{ priority.value }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article
|
||||||
|
v-for="station in state.stations"
|
||||||
|
:key="station.id"
|
||||||
|
:class="['ship-card', 'station-card', isSelected('station', station.id) && 'is-selected']"
|
||||||
|
:data-station-id="station.id"
|
||||||
|
@click="onStationClick(station.id, station.label)"
|
||||||
|
@dblclick="onStationDoubleClick(station.id, station.label)"
|
||||||
|
>
|
||||||
|
<div class="ship-card-header">
|
||||||
|
<h3>{{ station.label }}</h3>
|
||||||
|
<span class="ship-card-badge">{{ station.badge }}</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-for="line in station.lines"
|
||||||
|
:key="`${station.id}-${line}`"
|
||||||
|
>
|
||||||
|
{{ line }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-if="station.processes.length > 0"
|
||||||
|
class="ship-card-ai"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="process in station.processes"
|
||||||
|
:key="`${station.id}-${process.label}`"
|
||||||
|
class="ship-action-progress"
|
||||||
|
>
|
||||||
|
<div class="ship-action-progress-label">
|
||||||
|
<span>{{ process.label }}</span>
|
||||||
|
<span>{{ process.valueLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ship-action-progress-track">
|
||||||
|
<div
|
||||||
|
class="ship-action-progress-fill"
|
||||||
|
:style="{ width: `${process.progress}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article
|
||||||
|
v-for="ship in state.ships"
|
||||||
|
:key="ship.id"
|
||||||
|
:class="['ship-card', isSelected('ship', ship.id) && 'is-selected', ship.followed && 'is-followed']"
|
||||||
|
:data-ship-id="ship.id"
|
||||||
|
@click="onShipClick(ship.id, ship.label)"
|
||||||
|
@dblclick="onShipDoubleClick(ship.id, ship.label)"
|
||||||
|
>
|
||||||
|
<div class="ship-card-header">
|
||||||
|
<h3>{{ ship.label }}</h3>
|
||||||
|
<div class="ship-card-meta">
|
||||||
|
<span class="ship-card-badge">{{ ship.badge }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ship-card-history-button"
|
||||||
|
:data-history-ship-id="ship.id"
|
||||||
|
:aria-label="`Open history for ${ship.label}`"
|
||||||
|
title="Open history"
|
||||||
|
@click.stop="emit('history', { kind: 'ship', id: ship.id })"
|
||||||
|
>
|
||||||
|
🕔
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-for="line in ship.locationLines"
|
||||||
|
:key="`${ship.id}-${line}`"
|
||||||
|
>
|
||||||
|
{{ line }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-for="line in ship.lines"
|
||||||
|
:key="`${ship.id}-${line}`"
|
||||||
|
>
|
||||||
|
{{ line }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-if="ship.action"
|
||||||
|
class="ship-action-progress"
|
||||||
|
>
|
||||||
|
<div class="ship-action-progress-label">
|
||||||
|
<span>{{ ship.action.label }}</span>
|
||||||
|
<span>{{ ship.action.valueLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ship-action-progress-track">
|
||||||
|
<div
|
||||||
|
class="ship-action-progress-fill"
|
||||||
|
:style="{ width: `${ship.action.progress}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ship-card-ai">
|
||||||
|
<p
|
||||||
|
v-for="line in ship.aiLines"
|
||||||
|
:key="`${ship.id}-${line}`"
|
||||||
|
>
|
||||||
|
{{ line }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
8
apps/viewer/src/env.d.ts
vendored
Normal file
8
apps/viewer/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module "*.vue" {
|
||||||
|
import type { DefineComponent } from "vue";
|
||||||
|
|
||||||
|
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import "./style.css";
|
import "./styles/index.css";
|
||||||
import { GameViewer } from "./GameViewer";
|
import { createApp } from "vue";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import { viewerPinia } from "./ui/stores/pinia";
|
||||||
|
|
||||||
const root = document.querySelector<HTMLDivElement>("#app");
|
const root = document.querySelector<HTMLDivElement>("#app");
|
||||||
|
|
||||||
@@ -7,5 +9,6 @@ if (!root) {
|
|||||||
throw new Error("Missing #app root element");
|
throw new Error("Missing #app root element");
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewer = new GameViewer(root);
|
createApp(App)
|
||||||
void viewer.start();
|
.use(viewerPinia)
|
||||||
|
.mount(root);
|
||||||
|
|||||||
52
apps/viewer/src/runtime/rendering/ViewerRenderSurface.ts
Normal file
52
apps/viewer/src/runtime/rendering/ViewerRenderSurface.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
interface ViewerRenderSurfaceOptions {
|
||||||
|
container: HTMLElement;
|
||||||
|
renderer: THREE.WebGLRenderer;
|
||||||
|
onFrame: () => void;
|
||||||
|
onResize: (width: number, height: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ViewerRenderSurface {
|
||||||
|
private readonly container: HTMLElement;
|
||||||
|
readonly renderer: THREE.WebGLRenderer;
|
||||||
|
private readonly onFrame: () => void;
|
||||||
|
private readonly onResizeCallback: (width: number, height: number) => void;
|
||||||
|
private readonly resizeListener = () => this.resize();
|
||||||
|
|
||||||
|
constructor(options: ViewerRenderSurfaceOptions) {
|
||||||
|
this.container = options.container;
|
||||||
|
this.renderer = options.renderer;
|
||||||
|
this.onFrame = options.onFrame;
|
||||||
|
this.onResizeCallback = options.onResize;
|
||||||
|
this.container.append(this.renderer.domElement);
|
||||||
|
window.addEventListener("resize", this.resizeListener);
|
||||||
|
this.resize();
|
||||||
|
}
|
||||||
|
|
||||||
|
get domElement() {
|
||||||
|
return this.renderer.domElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.renderer.setAnimationLoop(this.onFrame);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.renderer.setAnimationLoop(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
resize() {
|
||||||
|
const width = this.container.clientWidth || window.innerWidth;
|
||||||
|
const height = this.container.clientHeight || window.innerHeight;
|
||||||
|
this.renderer.setSize(width, height, false);
|
||||||
|
this.onResizeCallback(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.stop();
|
||||||
|
window.removeEventListener("resize", this.resizeListener);
|
||||||
|
this.renderer.dispose();
|
||||||
|
this.renderer.domElement.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
export function createViewerRenderer() {
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
|
renderer.setClearColor(0x040912, 1);
|
||||||
|
return renderer;
|
||||||
|
}
|
||||||
35
apps/viewer/src/runtime/rendering/disposeThreeResources.ts
Normal file
35
apps/viewer/src/runtime/rendering/disposeThreeResources.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
function disposeMaterialTextures(material: THREE.Material, disposedTextures: Set<THREE.Texture>) {
|
||||||
|
for (const value of Object.values(material)) {
|
||||||
|
if (value instanceof THREE.Texture && !disposedTextures.has(value)) {
|
||||||
|
disposedTextures.add(value);
|
||||||
|
value.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disposeSceneResources(root: THREE.Object3D) {
|
||||||
|
const disposedGeometries = new Set<THREE.BufferGeometry>();
|
||||||
|
const disposedMaterials = new Set<THREE.Material>();
|
||||||
|
const disposedTextures = new Set<THREE.Texture>();
|
||||||
|
|
||||||
|
root.traverse((object) => {
|
||||||
|
const mesh = object as THREE.Mesh;
|
||||||
|
const geometry = mesh.geometry;
|
||||||
|
if (geometry instanceof THREE.BufferGeometry && !disposedGeometries.has(geometry)) {
|
||||||
|
disposedGeometries.add(geometry);
|
||||||
|
geometry.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
const material = mesh.material;
|
||||||
|
const materials = Array.isArray(material) ? material : material ? [material] : [];
|
||||||
|
for (const candidate of materials) {
|
||||||
|
if (candidate instanceof THREE.Material && !disposedMaterials.has(candidate)) {
|
||||||
|
disposedMaterials.add(candidate);
|
||||||
|
disposeMaterialTextures(candidate, disposedTextures);
|
||||||
|
candidate.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
2
apps/viewer/src/styles/index.css
Normal file
2
apps/viewer/src/styles/index.css
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "./viewer.css";
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||||
--bg: #050812;
|
--viewer-panel: rgba(9, 18, 34, 0.78);
|
||||||
--panel: rgba(9, 18, 34, 0.78);
|
--viewer-panel-border: rgba(132, 196, 255, 0.18);
|
||||||
--panel-border: rgba(132, 196, 255, 0.18);
|
--viewer-text: #eaf4ff;
|
||||||
--text: #eaf4ff;
|
--viewer-muted: #98adc4;
|
||||||
--muted: #98adc4;
|
--viewer-accent: #7fd6ff;
|
||||||
--accent: #7fd6ff;
|
--viewer-warning: #ffbf69;
|
||||||
--warning: #ffbf69;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -27,39 +26,95 @@ body,
|
|||||||
linear-gradient(180deg, #03060d 0%, #060c18 100%);
|
linear-gradient(180deg, #03060d 0%, #060c18 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: var(--viewer-text);
|
||||||
|
}
|
||||||
|
|
||||||
canvas {
|
canvas {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer-shell {
|
.viewer-app,
|
||||||
position: fixed;
|
.viewer-canvas-host {
|
||||||
inset: 0;
|
width: 100%;
|
||||||
pointer-events: none;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-panel-stack {
|
.panel-summary,
|
||||||
position: absolute;
|
.hud-mono,
|
||||||
top: 20px;
|
.system-body,
|
||||||
left: 20px;
|
.detail-body,
|
||||||
width: min(360px, calc(100vw - 40px));
|
.ship-card p,
|
||||||
display: flex;
|
.history,
|
||||||
flex-direction: column;
|
.history-window-body,
|
||||||
gap: 16px;
|
.hover-label {
|
||||||
|
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.right-panel-stack {
|
.collapsible-panel.is-collapsed .game-body,
|
||||||
position: absolute;
|
.collapsible-panel.is-collapsed .network-body,
|
||||||
top: 20px;
|
.collapsible-panel.is-collapsed .performance-body {
|
||||||
right: 20px;
|
display: none;
|
||||||
width: min(380px, calc(100vw - 40px));
|
}
|
||||||
|
|
||||||
|
.collapsible-panel.is-collapsed .panel-summary {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-title,
|
||||||
|
.detail-title {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-body,
|
||||||
|
.detail-body {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: var(--viewer-muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-body p,
|
||||||
|
.detail-body p {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-progress,
|
||||||
|
.ship-action-progress {
|
||||||
|
margin: 0 0 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-progress-label,
|
||||||
|
.ship-action-progress-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 16px;
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--viewer-muted);
|
||||||
|
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-progress-track,
|
||||||
|
.ship-action-progress-track {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(127, 214, 255, 0.12);
|
||||||
|
border: 1px solid rgba(127, 214, 255, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-progress-fill,
|
||||||
|
.ship-action-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, rgba(127, 214, 255, 0.72), rgba(255, 191, 105, 0.9));
|
||||||
}
|
}
|
||||||
|
|
||||||
.marquee-box {
|
.marquee-box {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: none;
|
|
||||||
border: 1px solid rgba(127, 214, 255, 0.72);
|
border: 1px solid rgba(127, 214, 255, 0.72);
|
||||||
background: rgba(127, 214, 255, 0.14);
|
background: rgba(127, 214, 255, 0.14);
|
||||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04);
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||||
@@ -91,7 +146,6 @@ canvas {
|
|||||||
background: rgba(7, 15, 28, 0.88);
|
background: rgba(7, 15, 28, 0.88);
|
||||||
border: 1px solid rgba(255, 88, 72, 0.5);
|
border: 1px solid rgba(255, 88, 72, 0.5);
|
||||||
color: #fff2ef;
|
color: #fff2ef;
|
||||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
@@ -102,235 +156,6 @@ canvas {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar,
|
|
||||||
.info-panel,
|
|
||||||
.network-panel,
|
|
||||||
.performance-panel,
|
|
||||||
.ops-strip {
|
|
||||||
backdrop-filter: blur(18px);
|
|
||||||
background: var(--panel);
|
|
||||||
border: 1px solid var(--panel-border);
|
|
||||||
box-shadow: 0 18px 54px rgba(0, 0, 0, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar {
|
|
||||||
border-radius: 22px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eyebrow {
|
|
||||||
margin: 0 0 6px;
|
|
||||||
color: var(--accent);
|
|
||||||
letter-spacing: 0.18em;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar h1,
|
|
||||||
.topbar h2,
|
|
||||||
.info-panel h2,
|
|
||||||
.info-panel h3,
|
|
||||||
.ship-card h3 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar {
|
|
||||||
display: block;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar h2 {
|
|
||||||
color: var(--accent);
|
|
||||||
letter-spacing: 0.16em;
|
|
||||||
font-size: 0.64rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-heading-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-summary {
|
|
||||||
display: none;
|
|
||||||
color: var(--muted);
|
|
||||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
line-height: 1;
|
|
||||||
text-align: right;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-toggle {
|
|
||||||
border: 1px solid rgba(127, 214, 255, 0.2);
|
|
||||||
background: rgba(127, 214, 255, 0.08);
|
|
||||||
color: var(--text);
|
|
||||||
border-radius: 999px;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
cursor: pointer;
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-toggle:hover {
|
|
||||||
background: rgba(127, 214, 255, 0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar-body {
|
|
||||||
margin-top: 14px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-panel {
|
|
||||||
border-radius: 24px;
|
|
||||||
padding: 16px;
|
|
||||||
color: var(--text);
|
|
||||||
pointer-events: auto;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.network-panel {
|
|
||||||
border-radius: 24px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
color: var(--text);
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.performance-panel {
|
|
||||||
width: min(360px, calc(100vw - 40px));
|
|
||||||
border-radius: 24px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
color: var(--text);
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-panel h2 {
|
|
||||||
color: var(--accent);
|
|
||||||
letter-spacing: 0.16em;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.network-panel h2,
|
|
||||||
.performance-panel h2 {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--accent);
|
|
||||||
letter-spacing: 0.16em;
|
|
||||||
font-size: 0.64rem;
|
|
||||||
line-height: 1;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.network-body,
|
|
||||||
.performance-body {
|
|
||||||
margin-top: 14px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapsible-panel.is-collapsed .topbar-body,
|
|
||||||
.collapsible-panel.is-collapsed .network-body,
|
|
||||||
.collapsible-panel.is-collapsed .performance-body {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapsible-panel.is-collapsed .panel-summary {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapsible-panel.is-collapsed {
|
|
||||||
padding-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-title {
|
|
||||||
margin-top: 12px;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-progress,
|
|
||||||
.ship-action-progress {
|
|
||||||
margin: 0 0 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-progress-label,
|
|
||||||
.ship-action-progress-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-item {
|
|
||||||
display: block;
|
|
||||||
padding-left: 1em;
|
|
||||||
color: var(--muted);
|
|
||||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-progress-track,
|
|
||||||
.ship-action-progress-track {
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 999px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: rgba(127, 214, 255, 0.12);
|
|
||||||
border: 1px solid rgba(127, 214, 255, 0.14);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-progress-fill,
|
|
||||||
.ship-action-progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: linear-gradient(90deg, rgba(127, 214, 255, 0.72), rgba(255, 191, 105, 0.9));
|
|
||||||
}
|
|
||||||
|
|
||||||
.history {
|
|
||||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-window {
|
.history-window {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: auto;
|
right: auto;
|
||||||
@@ -353,10 +178,6 @@ canvas {
|
|||||||
resize: both;
|
resize: both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-window[hidden] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-window-header {
|
.history-window-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -369,41 +190,16 @@ canvas {
|
|||||||
|
|
||||||
.history-window-title {
|
.history-window-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--accent);
|
color: var(--viewer-accent);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
letter-spacing: 0.16em;
|
letter-spacing: 0.16em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-window-close,
|
|
||||||
.ship-card-history-button {
|
|
||||||
border: 1px solid rgba(127, 214, 255, 0.22);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(127, 214, 255, 0.08);
|
|
||||||
color: var(--text);
|
|
||||||
font: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-window-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-window-close {
|
|
||||||
padding: 8px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-window-copy {
|
|
||||||
padding: 8px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-window-body {
|
.history-window-body {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
color: var(--text);
|
color: var(--viewer-text);
|
||||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
@@ -411,34 +207,10 @@ canvas {
|
|||||||
cursor: text;
|
cursor: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-strip {
|
.history-window-actions {
|
||||||
border-radius: 14px;
|
display: flex;
|
||||||
padding: 12px 14px;
|
align-items: center;
|
||||||
background: rgba(255, 116, 88, 0.14);
|
gap: 8px;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-layer {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-strip {
|
.ops-strip {
|
||||||
@@ -448,8 +220,6 @@ canvas {
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 50vw;
|
width: 50vw;
|
||||||
min-height: 128px;
|
min-height: 128px;
|
||||||
border-radius: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
@@ -461,7 +231,6 @@ canvas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ship-card {
|
.ship-card {
|
||||||
border-radius: 0;
|
|
||||||
border-top: 1px solid rgba(127, 214, 255, 0.14);
|
border-top: 1px solid rgba(127, 214, 255, 0.14);
|
||||||
border-right: 1px solid rgba(127, 214, 255, 0.1);
|
border-right: 1px solid rgba(127, 214, 255, 0.1);
|
||||||
background: linear-gradient(180deg, rgba(10, 20, 36, 0.96), rgba(6, 12, 22, 0.98));
|
background: linear-gradient(180deg, rgba(10, 20, 36, 0.96), rgba(6, 12, 22, 0.98));
|
||||||
@@ -471,7 +240,7 @@ canvas {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
color: var(--text);
|
color: var(--viewer-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
|
transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
|
||||||
}
|
}
|
||||||
@@ -499,11 +268,26 @@ canvas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ship-card h3 {
|
.ship-card h3 {
|
||||||
|
margin: 0;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ship-card p {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
color: var(--viewer-muted);
|
||||||
|
line-height: 1.35;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ship-card-header + p {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.ship-card-meta {
|
.ship-card-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -515,30 +299,12 @@ canvas {
|
|||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(127, 214, 255, 0.12);
|
background: rgba(127, 214, 255, 0.12);
|
||||||
color: var(--accent);
|
color: var(--viewer-accent);
|
||||||
font-size: 0.64rem;
|
font-size: 0.64rem;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.12em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ship-card p {
|
|
||||||
margin: 2px 0 0;
|
|
||||||
color: var(--muted);
|
|
||||||
line-height: 1.35;
|
|
||||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ship-card-header+p {
|
|
||||||
font-size: 0.62rem;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ship-action-progress {
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ship-card-ai {
|
.ship-card-ai {
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
padding-top: 6px;
|
padding-top: 6px;
|
||||||
@@ -547,11 +313,22 @@ canvas {
|
|||||||
|
|
||||||
.ship-card-section-title {
|
.ship-card-section-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--accent);
|
color: var(--viewer-accent);
|
||||||
letter-spacing: 0.14em;
|
letter-spacing: 0.14em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ship-card-history-button,
|
||||||
|
.history-window-copy,
|
||||||
|
.history-window-close {
|
||||||
|
border: 1px solid rgba(127, 214, 255, 0.22);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(127, 214, 255, 0.08);
|
||||||
|
color: var(--viewer-text);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.ship-card-history-button {
|
.ship-card-history-button {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
@@ -564,6 +341,11 @@ canvas {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-window-copy,
|
||||||
|
.history-window-close {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.faction-card {
|
.faction-card {
|
||||||
border-top-color: rgba(180, 130, 255, 0.3);
|
border-top-color: rgba(180, 130, 255, 0.3);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
@@ -582,11 +364,14 @@ canvas {
|
|||||||
border-color: rgba(127, 255, 180, 0.5);
|
border-color: rgba(127, 255, 180, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.swatch {
|
.ship-card-split-line {
|
||||||
width: 14px;
|
display: flex;
|
||||||
height: 48px;
|
justify-content: space-between;
|
||||||
border-radius: 999px;
|
gap: 12px;
|
||||||
flex: none;
|
}
|
||||||
|
|
||||||
|
.selection-action-button {
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1080px) {
|
@media (max-width: 1080px) {
|
||||||
@@ -596,53 +381,8 @@ canvas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.left-panel-stack {
|
|
||||||
right: 20px;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-panel-stack {
|
|
||||||
left: 20px;
|
|
||||||
right: 20px;
|
|
||||||
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 {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.performance-panel {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ops-strip {
|
.ops-strip {
|
||||||
left: 0;
|
width: 100vw;
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 50vw;
|
|
||||||
min-height: 120px;
|
min-height: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-window {
|
|
||||||
left: 20px;
|
|
||||||
right: 20px;
|
|
||||||
width: auto;
|
|
||||||
max-width: calc(100vw - 40px);
|
|
||||||
max-height: calc(100vh - 40px);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
3
apps/viewer/src/ui/stores/pinia.ts
Normal file
3
apps/viewer/src/ui/stores/pinia.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { createPinia } from "pinia";
|
||||||
|
|
||||||
|
export const viewerPinia = createPinia();
|
||||||
108
apps/viewer/src/ui/stores/viewerSelection.ts
Normal file
108
apps/viewer/src/ui/stores/viewerSelection.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import type { Selectable } from "../../viewerTypes";
|
||||||
|
|
||||||
|
export type ViewerSelectionSource = "viewer" | "ui" | null;
|
||||||
|
|
||||||
|
export interface ViewerSelectionSummary {
|
||||||
|
id: string;
|
||||||
|
kind: Selectable["kind"];
|
||||||
|
label?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectionToEntityId(selection: Selectable): string {
|
||||||
|
switch (selection.kind) {
|
||||||
|
case "planet":
|
||||||
|
return `${selection.systemId}:${selection.planetIndex}`;
|
||||||
|
case "moon":
|
||||||
|
return `${selection.systemId}:${selection.planetIndex}:${selection.moonIndex}`;
|
||||||
|
default:
|
||||||
|
return selection.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function entityIdToSelectable(
|
||||||
|
kind: Selectable["kind"] | null,
|
||||||
|
entityId: string | null,
|
||||||
|
): Selectable | null {
|
||||||
|
if (!kind || !entityId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "planet") {
|
||||||
|
const [systemId, planetIndex] = entityId.split(":");
|
||||||
|
if (!systemId || planetIndex == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
systemId,
|
||||||
|
planetIndex: Number(planetIndex),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "moon") {
|
||||||
|
const [systemId, planetIndex, moonIndex] = entityId.split(":");
|
||||||
|
if (!systemId || planetIndex == null || moonIndex == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
systemId,
|
||||||
|
planetIndex: Number(planetIndex),
|
||||||
|
moonIndex: Number(moonIndex),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
id: entityId,
|
||||||
|
} as Selectable;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useViewerSelectionStore = defineStore("viewerSelection", {
|
||||||
|
state: () => ({
|
||||||
|
selectedEntityId: null as string | null,
|
||||||
|
selectedEntityKind: null as Selectable["kind"] | null,
|
||||||
|
selectedEntityLabel: null as string | null,
|
||||||
|
hoveredEntityId: null as string | null,
|
||||||
|
inspectedEntityId: null as string | null,
|
||||||
|
selectionSource: null as ViewerSelectionSource,
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
selectEntity(id: string | null, source: ViewerSelectionSource = null) {
|
||||||
|
this.selectedEntityId = id;
|
||||||
|
this.selectionSource = source;
|
||||||
|
if (id == null) {
|
||||||
|
this.selectedEntityKind = null;
|
||||||
|
this.selectedEntityLabel = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectSelection(selection: ViewerSelectionSummary | null, source: ViewerSelectionSource = null) {
|
||||||
|
if (!selection) {
|
||||||
|
this.clearSelection(source);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedEntityId = selection.id;
|
||||||
|
this.selectedEntityKind = selection.kind;
|
||||||
|
this.selectedEntityLabel = selection.label ?? null;
|
||||||
|
this.selectionSource = source;
|
||||||
|
},
|
||||||
|
clearSelection(source: ViewerSelectionSource = null) {
|
||||||
|
this.selectedEntityId = null;
|
||||||
|
this.selectedEntityKind = null;
|
||||||
|
this.selectedEntityLabel = null;
|
||||||
|
this.selectionSource = source;
|
||||||
|
},
|
||||||
|
hoverEntity(id: string | null) {
|
||||||
|
this.hoveredEntityId = id;
|
||||||
|
},
|
||||||
|
inspectEntity(id: string | null) {
|
||||||
|
this.inspectedEntityId = id;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ViewerSelectionStore = ReturnType<typeof useViewerSelectionStore>;
|
||||||
@@ -79,21 +79,13 @@ export function createViewerControllers(host: any) {
|
|||||||
|
|
||||||
const presentationController = new ViewerPresentationController({
|
const presentationController = new ViewerPresentationController({
|
||||||
renderer: host.renderer,
|
renderer: host.renderer,
|
||||||
|
hudState: host.hudState,
|
||||||
galaxyScene: host.galaxyLayer.scene,
|
galaxyScene: host.galaxyLayer.scene,
|
||||||
galaxyCamera: host.galaxyLayer.camera,
|
galaxyCamera: host.galaxyLayer.camera,
|
||||||
systemCamera: host.systemLayer.camera,
|
systemCamera: host.systemLayer.camera,
|
||||||
galaxyAnchor: host.galaxyAnchor,
|
galaxyAnchor: host.galaxyAnchor,
|
||||||
systemAnchor: host.systemAnchor,
|
systemAnchor: host.systemAnchor,
|
||||||
ambienceGroup: host.universeLayer.ambienceGroup,
|
ambienceGroup: host.universeLayer.ambienceGroup,
|
||||||
gameSummaryEl: host.gameSummaryEl,
|
|
||||||
networkSummaryEl: host.networkSummaryEl,
|
|
||||||
performanceSummaryEl: host.performanceSummaryEl,
|
|
||||||
statusEl: host.statusEl,
|
|
||||||
networkPanelEl: host.networkPanelEl,
|
|
||||||
performancePanelEl: host.performancePanelEl,
|
|
||||||
systemPanelEl: host.systemPanelEl,
|
|
||||||
systemTitleEl: host.systemTitleEl,
|
|
||||||
systemBodyEl: host.systemBodyEl,
|
|
||||||
networkStats: host.networkStats,
|
networkStats: host.networkStats,
|
||||||
performanceStats: host.performanceStats,
|
performanceStats: host.performanceStats,
|
||||||
getWorld: () => host.world,
|
getWorld: () => host.world,
|
||||||
@@ -137,10 +129,7 @@ export function createViewerControllers(host: any) {
|
|||||||
getCameraTargetShipId: () => host.cameraTargetShipId,
|
getCameraTargetShipId: () => host.cameraTargetShipId,
|
||||||
getNetworkStats: () => host.networkStats,
|
getNetworkStats: () => host.networkStats,
|
||||||
getSystemSummaryVisuals: () => new Map(),
|
getSystemSummaryVisuals: () => new Map(),
|
||||||
errorEl: host.errorEl,
|
hudState: host.hudState,
|
||||||
opsStripEl: host.opsStripEl,
|
|
||||||
detailTitleEl: host.detailTitleEl,
|
|
||||||
detailBodyEl: host.detailBodyEl,
|
|
||||||
worldLabel: () => host.world?.label ?? "",
|
worldLabel: () => host.world?.label ?? "",
|
||||||
rebuildSystems: (systems) => sceneDataController.rebuildSystems(systems),
|
rebuildSystems: (systems) => sceneDataController.rebuildSystems(systems),
|
||||||
syncCelestials: (celestials) => sceneDataController.syncCelestials(celestials),
|
syncCelestials: (celestials) => sceneDataController.syncCelestials(celestials),
|
||||||
@@ -166,7 +155,6 @@ export function createViewerControllers(host: any) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const historyController = new ViewerHistoryWindowController({
|
const historyController = new ViewerHistoryWindowController({
|
||||||
historyLayerEl: host.historyLayerEl,
|
|
||||||
historyWindows: host.historyWindows,
|
historyWindows: host.historyWindows,
|
||||||
getWorld: () => host.world,
|
getWorld: () => host.world,
|
||||||
getHistoryWindowCounter: () => host.historyWindowCounter,
|
getHistoryWindowCounter: () => host.historyWindowCounter,
|
||||||
@@ -200,13 +188,14 @@ export function createViewerControllers(host: any) {
|
|||||||
hoverLabelEl: host.hoverLabelEl,
|
hoverLabelEl: host.hoverLabelEl,
|
||||||
hoverConnectorLineEl: host.hoverConnectorLineEl,
|
hoverConnectorLineEl: host.hoverConnectorLineEl,
|
||||||
marqueeEl: host.marqueeEl,
|
marqueeEl: host.marqueeEl,
|
||||||
|
hudState: host.hudState,
|
||||||
keyState: host.keyState,
|
keyState: host.keyState,
|
||||||
getWorld: () => host.world,
|
getWorld: () => host.world,
|
||||||
getActiveSystemId: () => host.activeSystemId,
|
getActiveSystemId: () => host.activeSystemId,
|
||||||
getPovLevel: () => host.povLevel,
|
getPovLevel: () => host.povLevel,
|
||||||
getSelectedItems: () => host.selectedItems,
|
getSelectedItems: () => host.selectedItems,
|
||||||
setSelectedItems: (items) => {
|
setSelectedItems: (items) => {
|
||||||
host.selectedItems = items;
|
host.applySelectedItems(items, "viewer");
|
||||||
},
|
},
|
||||||
getDragMode: () => host.dragMode,
|
getDragMode: () => host.dragMode,
|
||||||
setDragMode: (mode) => {
|
setDragMode: (mode) => {
|
||||||
@@ -268,20 +257,33 @@ export function createViewerControllers(host: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function wireViewerEvents(host: any) {
|
export function wireViewerEvents(host: any) {
|
||||||
host.renderer.domElement.addEventListener("pointerdown", host.interactionController.onPointerDown);
|
const canvas = host.renderer.domElement;
|
||||||
host.renderer.domElement.addEventListener("pointermove", host.interactionController.onPointerMove);
|
canvas.addEventListener("pointerdown", host.interactionController.onPointerDown);
|
||||||
host.renderer.domElement.addEventListener("pointerup", host.interactionController.onPointerUp);
|
canvas.addEventListener("pointermove", host.interactionController.onPointerMove);
|
||||||
host.renderer.domElement.addEventListener("pointerleave", host.interactionController.onPointerUp);
|
canvas.addEventListener("pointerup", host.interactionController.onPointerUp);
|
||||||
host.renderer.domElement.addEventListener("click", host.interactionController.onClick);
|
canvas.addEventListener("pointerleave", host.interactionController.onPointerUp);
|
||||||
host.renderer.domElement.addEventListener("dblclick", host.interactionController.onDoubleClick);
|
canvas.addEventListener("click", host.interactionController.onClick);
|
||||||
host.renderer.domElement.addEventListener("wheel", host.interactionController.onWheel, { passive: false });
|
canvas.addEventListener("dblclick", host.interactionController.onDoubleClick);
|
||||||
host.opsStripEl.addEventListener("click", host.interactionController.onOpsStripClick);
|
canvas.addEventListener("wheel", host.interactionController.onWheel, { passive: false });
|
||||||
host.opsStripEl.addEventListener("dblclick", host.interactionController.onOpsStripDoubleClick);
|
|
||||||
host.historyLayerEl.addEventListener("click", host.interactionController.onHistoryLayerClick);
|
host.historyLayerEl.addEventListener("click", host.interactionController.onHistoryLayerClick);
|
||||||
host.historyLayerEl.addEventListener("pointerdown", host.interactionController.onHistoryLayerPointerDown);
|
host.historyLayerEl.addEventListener("pointerdown", host.interactionController.onHistoryLayerPointerDown);
|
||||||
window.addEventListener("pointermove", host.interactionController.onHistoryWindowPointerMove);
|
window.addEventListener("pointermove", host.interactionController.onHistoryWindowPointerMove);
|
||||||
window.addEventListener("pointerup", host.interactionController.onHistoryWindowPointerUp);
|
window.addEventListener("pointerup", host.interactionController.onHistoryWindowPointerUp);
|
||||||
window.addEventListener("keydown", host.interactionController.onKeyDown);
|
window.addEventListener("keydown", host.interactionController.onKeyDown);
|
||||||
window.addEventListener("keyup", host.interactionController.onKeyUp);
|
window.addEventListener("keyup", host.interactionController.onKeyUp);
|
||||||
window.addEventListener("resize", host.onResize);
|
return () => {
|
||||||
|
canvas.removeEventListener("pointerdown", host.interactionController.onPointerDown);
|
||||||
|
canvas.removeEventListener("pointermove", host.interactionController.onPointerMove);
|
||||||
|
canvas.removeEventListener("pointerup", host.interactionController.onPointerUp);
|
||||||
|
canvas.removeEventListener("pointerleave", host.interactionController.onPointerUp);
|
||||||
|
canvas.removeEventListener("click", host.interactionController.onClick);
|
||||||
|
canvas.removeEventListener("dblclick", host.interactionController.onDoubleClick);
|
||||||
|
canvas.removeEventListener("wheel", host.interactionController.onWheel);
|
||||||
|
host.historyLayerEl.removeEventListener("click", host.interactionController.onHistoryLayerClick);
|
||||||
|
host.historyLayerEl.removeEventListener("pointerdown", host.interactionController.onHistoryLayerPointerDown);
|
||||||
|
window.removeEventListener("pointermove", host.interactionController.onHistoryWindowPointerMove);
|
||||||
|
window.removeEventListener("pointerup", host.interactionController.onHistoryWindowPointerUp);
|
||||||
|
window.removeEventListener("keydown", host.interactionController.onKeyDown);
|
||||||
|
window.removeEventListener("keyup", host.interactionController.onKeyUp);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,23 @@
|
|||||||
import type { HistoryWindowState, Selectable, WorldState } from "./viewerTypes";
|
import type { HistoryWindowState } from "./viewerHudState";
|
||||||
|
import type { Selectable, WorldState } from "./viewerTypes";
|
||||||
|
|
||||||
export function createHistoryWindowState(
|
export function createHistoryWindowState(
|
||||||
documentRef: Document,
|
|
||||||
target: Selectable,
|
target: Selectable,
|
||||||
historyWindowsCount: number,
|
historyWindowsCount: number,
|
||||||
historyWindowCounter: number,
|
historyWindowCounter: number,
|
||||||
): HistoryWindowState {
|
): HistoryWindowState {
|
||||||
const id = `history-${historyWindowCounter}`;
|
|
||||||
const root = documentRef.createElement("aside");
|
|
||||||
root.className = "history-window";
|
|
||||||
root.dataset.historyWindowId = id;
|
|
||||||
root.innerHTML = `
|
|
||||||
<div class="history-window-header">
|
|
||||||
<h2 class="history-window-title">History</h2>
|
|
||||||
<div class="history-window-actions">
|
|
||||||
<button type="button" class="history-window-copy">Copy</button>
|
|
||||||
<button type="button" class="history-window-close">Close</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="history-window-body">No history selected.</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
root.style.width = `${Math.min(520, window.innerWidth - 40)}px`;
|
|
||||||
root.style.height = `${Math.min(360, Math.max(240, window.innerHeight * 0.42))}px`;
|
|
||||||
root.style.left = `${Math.max(20, 20 + ((historyWindowsCount * 28) % Math.max(40, window.innerWidth - 580)))}px`;
|
|
||||||
root.style.top = `${Math.max(20, 20 + ((historyWindowsCount * 28) % Math.max(40, window.innerHeight - 420)))}px`;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id: `history-${historyWindowCounter}`,
|
||||||
target,
|
target,
|
||||||
root,
|
title: "History",
|
||||||
titleEl: root.querySelector(".history-window-title") as HTMLHeadingElement,
|
bodyHtml: "No history selected.",
|
||||||
bodyEl: root.querySelector(".history-window-body") as HTMLDivElement,
|
|
||||||
copyButtonEl: root.querySelector(".history-window-copy") as HTMLButtonElement,
|
|
||||||
text: "",
|
text: "",
|
||||||
|
copyLabel: "Copy",
|
||||||
|
width: Math.min(520, window.innerWidth - 40),
|
||||||
|
height: Math.min(360, Math.max(240, window.innerHeight * 0.42)),
|
||||||
|
x: Math.max(20, 20 + ((historyWindowsCount * 28) % Math.max(40, window.innerWidth - 580))),
|
||||||
|
y: Math.max(20, 20 + ((historyWindowsCount * 28) % Math.max(40, window.innerHeight - 420))),
|
||||||
|
zIndex: 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,9 +32,9 @@ export function refreshHistoryWindow(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
windowState.titleEl.textContent = `${ship.label} History`;
|
windowState.title = `${ship.label} History`;
|
||||||
windowState.text = ship.history.length > 0 ? ship.history.join("\n") : "No history yet.";
|
windowState.text = ship.history.length > 0 ? ship.history.join("\n") : "No history yet.";
|
||||||
windowState.bodyEl.innerHTML = windowState.text.replaceAll("\n", "<br>");
|
windowState.bodyHtml = windowState.text.replaceAll("\n", "<br>");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +44,9 @@ export function refreshHistoryWindow(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
windowState.titleEl.textContent = `${station.label} History`;
|
windowState.title = `${station.label} History`;
|
||||||
windowState.text = renderRecentEvents("station", station.id).replaceAll("<br>", "\n") || "No history yet.";
|
windowState.text = renderRecentEvents("station", station.id).replaceAll("<br>", "\n") || "No history yet.";
|
||||||
windowState.bodyEl.innerHTML = windowState.text.replaceAll("\n", "<br>");
|
windowState.bodyHtml = windowState.text.replaceAll("\n", "<br>");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { createHistoryWindowState, refreshHistoryWindow } from "./viewerHistory";
|
import { createHistoryWindowState, refreshHistoryWindow } from "./viewerHistory";
|
||||||
import type { HistoryWindowState, Selectable, WorldState } from "./viewerTypes";
|
import type { HistoryWindowState } from "./viewerHudState";
|
||||||
|
import type { Selectable, WorldState } from "./viewerTypes";
|
||||||
|
|
||||||
export function openHistoryWindow(
|
export function openHistoryWindow(
|
||||||
historyWindows: HistoryWindowState[],
|
historyWindows: HistoryWindowState[],
|
||||||
historyLayerEl: HTMLDivElement,
|
|
||||||
target: Selectable,
|
target: Selectable,
|
||||||
nextCounter: number,
|
nextCounter: number,
|
||||||
bringToFront: (windowState: HistoryWindowState) => void,
|
bringToFront: (windowState: HistoryWindowState) => void,
|
||||||
@@ -17,9 +17,8 @@ export function openHistoryWindow(
|
|||||||
return nextCounter;
|
return nextCounter;
|
||||||
}
|
}
|
||||||
|
|
||||||
const windowState = createHistoryWindowState(document, target, historyWindows.length, nextCounter);
|
const windowState = createHistoryWindowState(target, historyWindows.length, nextCounter);
|
||||||
historyWindows.push(windowState);
|
historyWindows.push(windowState);
|
||||||
historyLayerEl.append(windowState.root);
|
|
||||||
bringToFront(windowState);
|
bringToFront(windowState);
|
||||||
refreshWindows();
|
refreshWindows();
|
||||||
return nextCounter;
|
return nextCounter;
|
||||||
@@ -56,8 +55,7 @@ export function destroyHistoryWindow(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const [removed] = historyWindows.splice(index, 1);
|
historyWindows.splice(index, 1);
|
||||||
removed.root.remove();
|
|
||||||
if (historyWindowDragId === id) {
|
if (historyWindowDragId === id) {
|
||||||
return {
|
return {
|
||||||
historyWindowDragId: undefined,
|
historyWindowDragId: undefined,
|
||||||
@@ -72,7 +70,7 @@ export function destroyHistoryWindow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function bringHistoryWindowToFront(windowState: HistoryWindowState, nextZIndex: number) {
|
export function bringHistoryWindowToFront(windowState: HistoryWindowState, nextZIndex: number) {
|
||||||
windowState.root.style.zIndex = `${nextZIndex}`;
|
windowState.zIndex = nextZIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function beginHistoryWindowDrag(
|
export function beginHistoryWindowDrag(
|
||||||
@@ -91,9 +89,7 @@ export function beginHistoryWindowDrag(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const bounds = windowState.root.getBoundingClientRect();
|
historyWindowDragOffset.set(clientX - windowState.x, clientY - windowState.y);
|
||||||
historyWindowDragOffset.set(clientX - bounds.left, clientY - bounds.top);
|
|
||||||
windowState.root.setPointerCapture?.(pointerId);
|
|
||||||
return {
|
return {
|
||||||
historyWindowDragId: windowId,
|
historyWindowDragId: windowId,
|
||||||
historyWindowDragPointerId: pointerId,
|
historyWindowDragPointerId: pointerId,
|
||||||
@@ -118,16 +114,12 @@ export function updateHistoryWindowDrag(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const width = windowState.root.offsetWidth;
|
windowState.x = THREE.MathUtils.clamp(clientX - historyWindowDragOffset.x, 20, window.innerWidth - windowState.width - 20);
|
||||||
const height = windowState.root.offsetHeight;
|
windowState.y = THREE.MathUtils.clamp(clientY - historyWindowDragOffset.y, 20, window.innerHeight - windowState.height - 20);
|
||||||
const left = THREE.MathUtils.clamp(clientX - historyWindowDragOffset.x, 20, window.innerWidth - width - 20);
|
|
||||||
const top = THREE.MathUtils.clamp(clientY - historyWindowDragOffset.y, 20, window.innerHeight - height - 20);
|
|
||||||
windowState.root.style.left = `${left}px`;
|
|
||||||
windowState.root.style.top = `${top}px`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function endHistoryWindowDrag(
|
export function endHistoryWindowDrag(
|
||||||
historyWindows: HistoryWindowState[],
|
_historyWindows: HistoryWindowState[],
|
||||||
historyWindowDragId: string | undefined,
|
historyWindowDragId: string | undefined,
|
||||||
historyWindowDragPointerId: number | undefined,
|
historyWindowDragPointerId: number | undefined,
|
||||||
pointerId: number,
|
pointerId: number,
|
||||||
@@ -139,8 +131,6 @@ export function endHistoryWindowDrag(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const windowState = historyWindows.find((candidate) => candidate.id === historyWindowDragId);
|
|
||||||
windowState?.root.releasePointerCapture?.(pointerId);
|
|
||||||
return {
|
return {
|
||||||
historyWindowDragId: undefined,
|
historyWindowDragId: undefined,
|
||||||
historyWindowDragPointerId: undefined,
|
historyWindowDragPointerId: undefined,
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import {
|
|||||||
refreshHistoryWindows,
|
refreshHistoryWindows,
|
||||||
updateHistoryWindowDrag,
|
updateHistoryWindowDrag,
|
||||||
} from "./viewerHistoryManager";
|
} from "./viewerHistoryManager";
|
||||||
import type { HistoryWindowState, Selectable, WorldState } from "./viewerTypes";
|
import type { HistoryWindowState } from "./viewerHudState";
|
||||||
|
import type { Selectable, WorldState } from "./viewerTypes";
|
||||||
|
|
||||||
export interface ViewerHistoryWindowContext {
|
export interface ViewerHistoryWindowContext {
|
||||||
historyLayerEl: HTMLDivElement;
|
|
||||||
historyWindows: HistoryWindowState[];
|
historyWindows: HistoryWindowState[];
|
||||||
getWorld: () => WorldState | undefined;
|
getWorld: () => WorldState | undefined;
|
||||||
getHistoryWindowCounter: () => number;
|
getHistoryWindowCounter: () => number;
|
||||||
@@ -33,7 +33,6 @@ export class ViewerHistoryWindowController {
|
|||||||
openHistoryWindow(target: Selectable) {
|
openHistoryWindow(target: Selectable) {
|
||||||
const nextCounter = openHistoryWindow(
|
const nextCounter = openHistoryWindow(
|
||||||
this.context.historyWindows,
|
this.context.historyWindows,
|
||||||
this.context.historyLayerEl,
|
|
||||||
target,
|
target,
|
||||||
this.context.getHistoryWindowCounter() + 1,
|
this.context.getHistoryWindowCounter() + 1,
|
||||||
(windowState) => this.bringHistoryWindowToFront(windowState),
|
(windowState) => this.bringHistoryWindowToFront(windowState),
|
||||||
@@ -155,14 +154,14 @@ export class ViewerHistoryWindowController {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await copyTextToClipboard(windowState.text);
|
await copyTextToClipboard(windowState.text);
|
||||||
windowState.copyButtonEl.textContent = "Copied";
|
windowState.copyLabel = "Copied";
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
windowState.copyButtonEl.textContent = "Copy";
|
windowState.copyLabel = "Copy";
|
||||||
}, 1200);
|
}, 1200);
|
||||||
} catch {
|
} catch {
|
||||||
windowState.copyButtonEl.textContent = "Failed";
|
windowState.copyLabel = "Failed";
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
windowState.copyButtonEl.textContent = "Copy";
|
windowState.copyLabel = "Copy";
|
||||||
}, 1200);
|
}, 1200);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
export interface ViewerHudElements {
|
|
||||||
root: HTMLDivElement;
|
|
||||||
gamePanelEl: HTMLDivElement;
|
|
||||||
statusEl: HTMLDivElement;
|
|
||||||
gameSummaryEl: HTMLSpanElement;
|
|
||||||
networkSectionEl: HTMLDivElement;
|
|
||||||
systemPanelEl: HTMLDivElement;
|
|
||||||
systemTitleEl: HTMLHeadingElement;
|
|
||||||
systemBodyEl: HTMLDivElement;
|
|
||||||
detailTitleEl: HTMLHeadingElement;
|
|
||||||
detailBodyEl: HTMLDivElement;
|
|
||||||
opsStripEl: HTMLDivElement;
|
|
||||||
networkSummaryEl: HTMLSpanElement;
|
|
||||||
networkPanelEl: HTMLDivElement;
|
|
||||||
performanceSectionEl: HTMLDivElement;
|
|
||||||
performanceSummaryEl: HTMLSpanElement;
|
|
||||||
performancePanelEl: HTMLDivElement;
|
|
||||||
errorEl: HTMLDivElement;
|
|
||||||
historyLayerEl: HTMLDivElement;
|
|
||||||
marqueeEl: HTMLDivElement;
|
|
||||||
hoverLabelEl: HTMLDivElement;
|
|
||||||
hoverConnectorLineEl: SVGLineElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createViewerHud(documentRef: Document): ViewerHudElements {
|
|
||||||
const root = documentRef.createElement("div");
|
|
||||||
root.className = "viewer-shell";
|
|
||||||
root.innerHTML = `
|
|
||||||
<div class="left-panel-stack">
|
|
||||||
<header class="topbar collapsible-panel is-collapsed" data-panel-name="game">
|
|
||||||
<div class="panel-heading">
|
|
||||||
<h2>Game</h2>
|
|
||||||
<div class="panel-heading-meta">
|
|
||||||
<span class="panel-summary game-summary">Bootstrapping</span>
|
|
||||||
<button type="button" class="panel-toggle" aria-expanded="false" aria-label="Expand Game panel">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="topbar-body">Bootstrapping</div>
|
|
||||||
</header>
|
|
||||||
<aside class="network-panel collapsible-panel is-collapsed" data-panel-name="network">
|
|
||||||
<div class="panel-heading">
|
|
||||||
<h2>Network</h2>
|
|
||||||
<div class="panel-heading-meta">
|
|
||||||
<span class="panel-summary network-summary">Waiting</span>
|
|
||||||
<button type="button" class="panel-toggle" aria-expanded="false" aria-label="Expand Network panel">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="network-body">Waiting for snapshot.</div>
|
|
||||||
</aside>
|
|
||||||
<aside class="performance-panel collapsible-panel is-collapsed" data-panel-name="performance">
|
|
||||||
<div class="panel-heading">
|
|
||||||
<h2>Performance</h2>
|
|
||||||
<div class="panel-heading-meta">
|
|
||||||
<span class="panel-summary performance-summary">Waiting</span>
|
|
||||||
<button type="button" class="panel-toggle" aria-expanded="false" aria-label="Expand Performance panel">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="performance-body">Waiting for frame samples.</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
<div class="right-panel-stack">
|
|
||||||
<aside class="info-panel system-panel-section">
|
|
||||||
<h2>System</h2>
|
|
||||||
<h3 class="system-title">Deep Space</h3>
|
|
||||||
<div class="system-body">Waiting for the authoritative snapshot.</div>
|
|
||||||
</aside>
|
|
||||||
<aside class="info-panel detail-panel-section">
|
|
||||||
<h2>Focus</h2>
|
|
||||||
<h3 class="detail-title">Nothing selected</h3>
|
|
||||||
<div class="detail-body">Waiting for the authoritative snapshot.</div>
|
|
||||||
</aside>
|
|
||||||
<div class="error-strip" hidden></div>
|
|
||||||
</div>
|
|
||||||
<div class="history-layer"></div>
|
|
||||||
<section class="ops-strip"></section>
|
|
||||||
<div class="marquee-box"></div>
|
|
||||||
<svg class="hover-connector-svg" aria-hidden="true">
|
|
||||||
<line class="hover-connector-line" x1="0" y1="0" x2="0" y2="0" hidden></line>
|
|
||||||
</svg>
|
|
||||||
<div class="hover-label" hidden></div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
root,
|
|
||||||
gamePanelEl: root.querySelector(".topbar") as HTMLDivElement,
|
|
||||||
statusEl: root.querySelector(".topbar-body") as HTMLDivElement,
|
|
||||||
gameSummaryEl: root.querySelector(".game-summary") as HTMLSpanElement,
|
|
||||||
networkSectionEl: root.querySelector(".network-panel") as HTMLDivElement,
|
|
||||||
systemPanelEl: root.querySelector(".system-panel-section") as HTMLDivElement,
|
|
||||||
systemTitleEl: root.querySelector(".system-title") as HTMLHeadingElement,
|
|
||||||
systemBodyEl: root.querySelector(".system-body") as HTMLDivElement,
|
|
||||||
detailTitleEl: root.querySelector(".detail-title") as HTMLHeadingElement,
|
|
||||||
detailBodyEl: root.querySelector(".detail-body") as HTMLDivElement,
|
|
||||||
opsStripEl: root.querySelector(".ops-strip") as HTMLDivElement,
|
|
||||||
networkSummaryEl: root.querySelector(".network-summary") as HTMLSpanElement,
|
|
||||||
networkPanelEl: root.querySelector(".network-body") as HTMLDivElement,
|
|
||||||
performanceSectionEl: root.querySelector(".performance-panel") as HTMLDivElement,
|
|
||||||
performanceSummaryEl: root.querySelector(".performance-summary") as HTMLSpanElement,
|
|
||||||
performancePanelEl: root.querySelector(".performance-body") as HTMLDivElement,
|
|
||||||
errorEl: root.querySelector(".error-strip") as HTMLDivElement,
|
|
||||||
historyLayerEl: root.querySelector(".history-layer") as HTMLDivElement,
|
|
||||||
marqueeEl: root.querySelector(".marquee-box") as HTMLDivElement,
|
|
||||||
hoverLabelEl: root.querySelector(".hover-label") as HTMLDivElement,
|
|
||||||
hoverConnectorLineEl: root.querySelector(".hover-connector-line") as unknown as SVGLineElement,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
178
apps/viewer/src/viewerHudState.ts
Normal file
178
apps/viewer/src/viewerHudState.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { reactive } from "vue";
|
||||||
|
import type { ViewerSelectionStore } from "./ui/stores/viewerSelection";
|
||||||
|
import type { Selectable } from "./viewerTypes";
|
||||||
|
|
||||||
|
export interface HudPanelState {
|
||||||
|
collapsed: boolean;
|
||||||
|
summary: string;
|
||||||
|
bodyText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HudHtmlPanelState {
|
||||||
|
hidden: boolean;
|
||||||
|
title: string;
|
||||||
|
bodyHtml: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HudErrorState {
|
||||||
|
hidden: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HudProgressBar {
|
||||||
|
label: string;
|
||||||
|
valueLabel: string;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpsFactionCardState {
|
||||||
|
kind: "faction";
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
stateLines: string[];
|
||||||
|
priorities: { label: string; value: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpsStationCardState {
|
||||||
|
kind: "station";
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
badge: string;
|
||||||
|
selected: boolean;
|
||||||
|
lines: string[];
|
||||||
|
processes: HudProgressBar[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpsShipCardState {
|
||||||
|
kind: "ship";
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
badge: string;
|
||||||
|
selected: boolean;
|
||||||
|
followed: boolean;
|
||||||
|
locationLines: string[];
|
||||||
|
lines: string[];
|
||||||
|
action?: HudProgressBar;
|
||||||
|
aiLines: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpsStripState {
|
||||||
|
factions: OpsFactionCardState[];
|
||||||
|
stations: OpsStationCardState[];
|
||||||
|
ships: OpsShipCardState[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryWindowState {
|
||||||
|
id: string;
|
||||||
|
target: Selectable;
|
||||||
|
title: string;
|
||||||
|
bodyHtml: string;
|
||||||
|
text: string;
|
||||||
|
copyLabel: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
zIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HoverLabelState {
|
||||||
|
hidden: boolean;
|
||||||
|
text: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
connectorHidden: boolean;
|
||||||
|
x1: number;
|
||||||
|
y1: number;
|
||||||
|
x2: number;
|
||||||
|
y2: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarqueeState {
|
||||||
|
visible: boolean;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewerHudState {
|
||||||
|
gamePanel: HudPanelState;
|
||||||
|
networkPanel: HudPanelState;
|
||||||
|
performancePanel: HudPanelState;
|
||||||
|
systemPanel: HudHtmlPanelState;
|
||||||
|
detailPanel: HudHtmlPanelState;
|
||||||
|
error: HudErrorState;
|
||||||
|
opsStrip: OpsStripState;
|
||||||
|
historyWindows: HistoryWindowState[];
|
||||||
|
hoverLabel: HoverLabelState;
|
||||||
|
marquee: MarqueeState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewerHudBindings {
|
||||||
|
state: ViewerHudState;
|
||||||
|
selectionStore: ViewerSelectionStore;
|
||||||
|
opsStripEl: HTMLDivElement;
|
||||||
|
historyLayerEl: HTMLDivElement;
|
||||||
|
marqueeEl: HTMLDivElement;
|
||||||
|
hoverLabelEl: HTMLDivElement;
|
||||||
|
hoverConnectorLineEl: SVGLineElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createViewerHudState(): ViewerHudState {
|
||||||
|
return reactive({
|
||||||
|
gamePanel: {
|
||||||
|
collapsed: true,
|
||||||
|
summary: "Bootstrapping",
|
||||||
|
bodyText: "Bootstrapping",
|
||||||
|
},
|
||||||
|
networkPanel: {
|
||||||
|
collapsed: true,
|
||||||
|
summary: "Waiting",
|
||||||
|
bodyText: "Waiting for snapshot.",
|
||||||
|
},
|
||||||
|
performancePanel: {
|
||||||
|
collapsed: true,
|
||||||
|
summary: "Waiting",
|
||||||
|
bodyText: "Waiting for frame samples.",
|
||||||
|
},
|
||||||
|
systemPanel: {
|
||||||
|
hidden: false,
|
||||||
|
title: "Deep Space",
|
||||||
|
bodyHtml: "Waiting for the authoritative snapshot.",
|
||||||
|
},
|
||||||
|
detailPanel: {
|
||||||
|
hidden: false,
|
||||||
|
title: "Nothing selected",
|
||||||
|
bodyHtml: "Waiting for the authoritative snapshot.",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
hidden: true,
|
||||||
|
message: "",
|
||||||
|
},
|
||||||
|
opsStrip: {
|
||||||
|
factions: [],
|
||||||
|
stations: [],
|
||||||
|
ships: [],
|
||||||
|
},
|
||||||
|
historyWindows: [],
|
||||||
|
hoverLabel: {
|
||||||
|
hidden: true,
|
||||||
|
text: "",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
connectorHidden: true,
|
||||||
|
x1: 0,
|
||||||
|
y1: 0,
|
||||||
|
x2: 0,
|
||||||
|
y2: 0,
|
||||||
|
},
|
||||||
|
marquee: {
|
||||||
|
visible: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import * as THREE from "three";
|
|||||||
import { describeHoverLabel, getSelectionGroup } from "./viewerSelection";
|
import { describeHoverLabel, getSelectionGroup } from "./viewerSelection";
|
||||||
import { ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants";
|
import { ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants";
|
||||||
import { DISPLAY_UNITS_PER_KILOMETER, DISPLAY_UNITS_PER_LIGHT_YEAR, KILOMETERS_PER_AU, formatAdaptiveDistanceFromKilometers, formatSystemDistance } from "./viewerMath";
|
import { DISPLAY_UNITS_PER_KILOMETER, DISPLAY_UNITS_PER_LIGHT_YEAR, KILOMETERS_PER_AU, formatAdaptiveDistanceFromKilometers, formatSystemDistance } from "./viewerMath";
|
||||||
|
import type { HoverLabelState, MarqueeState } from "./viewerHudState";
|
||||||
import type { Selectable, SelectionGroup, WorldState, PovLevel } from "./viewerTypes";
|
import type { Selectable, SelectionGroup, WorldState, PovLevel } from "./viewerTypes";
|
||||||
|
|
||||||
export interface HoverPickResult {
|
export interface HoverPickResult {
|
||||||
@@ -67,6 +68,7 @@ export function pickSelectableHitAtClientPosition(
|
|||||||
|
|
||||||
export function updateHoverLabel(params: {
|
export function updateHoverLabel(params: {
|
||||||
dragMode?: string;
|
dragMode?: string;
|
||||||
|
hoverState: HoverLabelState;
|
||||||
hoverLabelEl: HTMLDivElement;
|
hoverLabelEl: HTMLDivElement;
|
||||||
hoverConnectorLineEl: SVGLineElement;
|
hoverConnectorLineEl: SVGLineElement;
|
||||||
hoverPick: HoverPickResult | undefined;
|
hoverPick: HoverPickResult | undefined;
|
||||||
@@ -77,6 +79,7 @@ export function updateHoverLabel(params: {
|
|||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
dragMode,
|
dragMode,
|
||||||
|
hoverState,
|
||||||
hoverLabelEl,
|
hoverLabelEl,
|
||||||
hoverConnectorLineEl,
|
hoverConnectorLineEl,
|
||||||
hoverPick,
|
hoverPick,
|
||||||
@@ -87,6 +90,8 @@ export function updateHoverLabel(params: {
|
|||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
if (dragMode || !hoverPick) {
|
if (dragMode || !hoverPick) {
|
||||||
|
hoverState.hidden = true;
|
||||||
|
hoverState.connectorHidden = true;
|
||||||
hoverLabelEl.hidden = true;
|
hoverLabelEl.hidden = true;
|
||||||
hoverConnectorLineEl.setAttribute("hidden", "");
|
hoverConnectorLineEl.setAttribute("hidden", "");
|
||||||
return;
|
return;
|
||||||
@@ -95,6 +100,8 @@ export function updateHoverLabel(params: {
|
|||||||
const { selection, object, camera } = hoverPick;
|
const { selection, object, camera } = hoverPick;
|
||||||
const label = describeHoverLabel(world, selection);
|
const label = describeHoverLabel(world, selection);
|
||||||
if (!label) {
|
if (!label) {
|
||||||
|
hoverState.hidden = true;
|
||||||
|
hoverState.connectorHidden = true;
|
||||||
hoverLabelEl.hidden = true;
|
hoverLabelEl.hidden = true;
|
||||||
hoverConnectorLineEl.setAttribute("hidden", "");
|
hoverConnectorLineEl.setAttribute("hidden", "");
|
||||||
return;
|
return;
|
||||||
@@ -102,18 +109,27 @@ export function updateHoverLabel(params: {
|
|||||||
|
|
||||||
const distance = formatHoverDistance(camera, object, selection, povLevel, activeSystemId);
|
const distance = formatHoverDistance(camera, object, selection, povLevel, activeSystemId);
|
||||||
|
|
||||||
|
hoverState.hidden = false;
|
||||||
|
hoverState.text = `${label}\n${distance}`;
|
||||||
|
hoverState.x = point.x + 44;
|
||||||
|
hoverState.y = point.y - 90;
|
||||||
hoverLabelEl.hidden = false;
|
hoverLabelEl.hidden = false;
|
||||||
hoverLabelEl.textContent = `${label}\n${distance}`;
|
hoverLabelEl.textContent = hoverState.text;
|
||||||
hoverLabelEl.style.left = `${point.x + 44}px`;
|
hoverLabelEl.style.left = `${hoverState.x}px`;
|
||||||
hoverLabelEl.style.top = `${point.y - 90}px`;
|
hoverLabelEl.style.top = `${hoverState.y}px`;
|
||||||
|
|
||||||
const rect = hoverLabelEl.getBoundingClientRect();
|
const rect = hoverLabelEl.getBoundingClientRect();
|
||||||
const svgRect = (hoverConnectorLineEl.ownerSVGElement as SVGSVGElement).getBoundingClientRect();
|
const svgRect = (hoverConnectorLineEl.ownerSVGElement as SVGSVGElement).getBoundingClientRect();
|
||||||
|
hoverState.connectorHidden = false;
|
||||||
|
hoverState.x1 = point.x;
|
||||||
|
hoverState.y1 = point.y;
|
||||||
|
hoverState.x2 = rect.left - svgRect.left;
|
||||||
|
hoverState.y2 = rect.top - svgRect.top + rect.height / 2;
|
||||||
hoverConnectorLineEl.removeAttribute("hidden");
|
hoverConnectorLineEl.removeAttribute("hidden");
|
||||||
hoverConnectorLineEl.setAttribute("x1", String(point.x));
|
hoverConnectorLineEl.setAttribute("x1", String(hoverState.x1));
|
||||||
hoverConnectorLineEl.setAttribute("y1", String(point.y));
|
hoverConnectorLineEl.setAttribute("y1", String(hoverState.y1));
|
||||||
hoverConnectorLineEl.setAttribute("x2", String(rect.left - svgRect.left));
|
hoverConnectorLineEl.setAttribute("x2", String(hoverState.x2));
|
||||||
hoverConnectorLineEl.setAttribute("y2", String(rect.top - svgRect.top + rect.height / 2));
|
hoverConnectorLineEl.setAttribute("y2", String(hoverState.y2));
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatHoverDistance(
|
function formatHoverDistance(
|
||||||
@@ -150,6 +166,7 @@ function formatHoverDistance(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function updateMarqueeBox(
|
export function updateMarqueeBox(
|
||||||
|
marqueeState: MarqueeState,
|
||||||
marqueeEl: HTMLDivElement,
|
marqueeEl: HTMLDivElement,
|
||||||
dragStart: THREE.Vector2,
|
dragStart: THREE.Vector2,
|
||||||
dragLast: THREE.Vector2,
|
dragLast: THREE.Vector2,
|
||||||
@@ -158,13 +175,21 @@ export function updateMarqueeBox(
|
|||||||
const minY = Math.min(dragStart.y, dragLast.y);
|
const minY = Math.min(dragStart.y, dragLast.y);
|
||||||
const maxX = Math.max(dragStart.x, dragLast.x);
|
const maxX = Math.max(dragStart.x, dragLast.x);
|
||||||
const maxY = Math.max(dragStart.y, dragLast.y);
|
const maxY = Math.max(dragStart.y, dragLast.y);
|
||||||
|
marqueeState.visible = true;
|
||||||
|
marqueeState.x = minX;
|
||||||
|
marqueeState.y = minY;
|
||||||
|
marqueeState.width = maxX - minX;
|
||||||
|
marqueeState.height = maxY - minY;
|
||||||
marqueeEl.style.left = `${minX}px`;
|
marqueeEl.style.left = `${minX}px`;
|
||||||
marqueeEl.style.top = `${minY}px`;
|
marqueeEl.style.top = `${minY}px`;
|
||||||
marqueeEl.style.width = `${maxX - minX}px`;
|
marqueeEl.style.width = `${maxX - minX}px`;
|
||||||
marqueeEl.style.height = `${maxY - minY}px`;
|
marqueeEl.style.height = `${maxY - minY}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hideMarqueeBox(marqueeEl: HTMLDivElement) {
|
export function hideMarqueeBox(marqueeState: MarqueeState, marqueeEl: HTMLDivElement) {
|
||||||
|
marqueeState.visible = false;
|
||||||
|
marqueeState.width = 0;
|
||||||
|
marqueeState.height = 0;
|
||||||
marqueeEl.style.display = "none";
|
marqueeEl.style.display = "none";
|
||||||
marqueeEl.style.width = "0";
|
marqueeEl.style.width = "0";
|
||||||
marqueeEl.style.height = "0";
|
marqueeEl.style.height = "0";
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from "./viewerControls";
|
} from "./viewerControls";
|
||||||
import { NAV_DISTANCE, NAV_DISTANCE_PLANET_ORBIT, NAV_DISTANCE_SHIP_HULL } from "./viewerConstants";
|
import { NAV_DISTANCE, NAV_DISTANCE_PLANET_ORBIT, NAV_DISTANCE_SHIP_HULL } from "./viewerConstants";
|
||||||
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
|
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
|
||||||
|
import type { ViewerHudState } from "./viewerHudState";
|
||||||
import type {
|
import type {
|
||||||
CameraMode,
|
CameraMode,
|
||||||
DragMode,
|
DragMode,
|
||||||
@@ -33,6 +34,7 @@ export interface ViewerInteractionContext {
|
|||||||
hoverLabelEl: HTMLDivElement;
|
hoverLabelEl: HTMLDivElement;
|
||||||
hoverConnectorLineEl: SVGLineElement;
|
hoverConnectorLineEl: SVGLineElement;
|
||||||
marqueeEl: HTMLDivElement;
|
marqueeEl: HTMLDivElement;
|
||||||
|
hudState: ViewerHudState;
|
||||||
keyState: Set<string>;
|
keyState: Set<string>;
|
||||||
getWorld: () => WorldState | undefined;
|
getWorld: () => WorldState | undefined;
|
||||||
getActiveSystemId: () => string | undefined;
|
getActiveSystemId: () => string | undefined;
|
||||||
@@ -109,6 +111,7 @@ export class ViewerInteractionController {
|
|||||||
if (!this.context.getMarqueeActive() && dragDistance > 8) {
|
if (!this.context.getMarqueeActive() && dragDistance > 8) {
|
||||||
this.context.setMarqueeActive(true);
|
this.context.setMarqueeActive(true);
|
||||||
this.context.setSuppressClickSelection(true);
|
this.context.setSuppressClickSelection(true);
|
||||||
|
this.context.hudState.marquee.visible = true;
|
||||||
this.context.marqueeEl.style.display = "block";
|
this.context.marqueeEl.style.display = "block";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +120,7 @@ export class ViewerInteractionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.context.dragLast.copy(point);
|
this.context.dragLast.copy(point);
|
||||||
updateMarqueeBox(this.context.marqueeEl, this.context.dragStart, this.context.dragLast);
|
updateMarqueeBox(this.context.hudState.marquee, this.context.marqueeEl, this.context.dragStart, this.context.dragLast);
|
||||||
};
|
};
|
||||||
|
|
||||||
readonly onPointerUp = (event: PointerEvent) => {
|
readonly onPointerUp = (event: PointerEvent) => {
|
||||||
@@ -131,7 +134,7 @@ export class ViewerInteractionController {
|
|||||||
|
|
||||||
if (this.context.getDragMode() === "marquee" && this.context.getMarqueeActive()) {
|
if (this.context.getDragMode() === "marquee" && this.context.getMarqueeActive()) {
|
||||||
this.completeMarqueeSelection();
|
this.completeMarqueeSelection();
|
||||||
hideMarqueeBox(this.context.marqueeEl);
|
hideMarqueeBox(this.context.hudState.marquee, this.context.marqueeEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.context.setDragMode(undefined);
|
this.context.setDragMode(undefined);
|
||||||
@@ -285,6 +288,7 @@ export class ViewerInteractionController {
|
|||||||
updateHoverLabel(event: PointerEvent) {
|
updateHoverLabel(event: PointerEvent) {
|
||||||
updateHoverLabel({
|
updateHoverLabel({
|
||||||
dragMode: this.context.getDragMode(),
|
dragMode: this.context.getDragMode(),
|
||||||
|
hoverState: this.context.hudState.hoverLabel,
|
||||||
hoverLabelEl: this.context.hoverLabelEl,
|
hoverLabelEl: this.context.hoverLabelEl,
|
||||||
hoverConnectorLineEl: this.context.hoverConnectorLineEl,
|
hoverConnectorLineEl: this.context.hoverConnectorLineEl,
|
||||||
hoverPick: this.pickSelectableHitAtClientPosition(event.clientX, event.clientY),
|
hoverPick: this.pickSelectableHitAtClientPosition(event.clientX, event.clientY),
|
||||||
@@ -299,6 +303,10 @@ export class ViewerInteractionController {
|
|||||||
this.context.historyController.refreshHistoryWindows();
|
this.context.historyController.refreshHistoryWindows();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openHistoryWindow(selection: Selectable) {
|
||||||
|
this.context.historyController.openHistoryWindow(selection);
|
||||||
|
}
|
||||||
|
|
||||||
toggleCameraMode(forceMode?: CameraMode) {
|
toggleCameraMode(forceMode?: CameraMode) {
|
||||||
const nextState = toggleCameraMode({
|
const nextState = toggleCameraMode({
|
||||||
cameraMode: this.context.getCameraMode(),
|
cameraMode: this.context.getCameraMode(),
|
||||||
|
|||||||
@@ -1,154 +1,131 @@
|
|||||||
import type { StationSnapshot } from "./contractsInfrastructure";
|
import type { StationSnapshot } from "./contractsInfrastructure";
|
||||||
import type { FactionSnapshot } from "./contractsFactions";
|
import type { FactionSnapshot } from "./contractsFactions";
|
||||||
import { inventoryAmount } from "./viewerMath";
|
import type {
|
||||||
|
HudProgressBar,
|
||||||
|
OpsFactionCardState,
|
||||||
|
OpsShipCardState,
|
||||||
|
OpsStationCardState,
|
||||||
|
OpsStripState,
|
||||||
|
} from "./viewerHudState";
|
||||||
import { describeShipCurrentAction, describeShipLocation, describeShipObjective, describeShipState } from "./viewerSelection";
|
import { describeShipCurrentAction, describeShipLocation, describeShipObjective, describeShipState } from "./viewerSelection";
|
||||||
import type { CameraMode, Selectable, WorldState, PovLevel } from "./viewerTypes";
|
import type { CameraMode, Selectable, WorldState, PovLevel } from "./viewerTypes";
|
||||||
|
|
||||||
function renderFactionCard(faction: FactionSnapshot): string {
|
function buildFactionCard(faction: FactionSnapshot): OpsFactionCardState {
|
||||||
const state = faction.goapState;
|
const state = faction.goapState;
|
||||||
const priorities = faction.goapPriorities;
|
return {
|
||||||
|
kind: "faction",
|
||||||
return `
|
id: faction.id,
|
||||||
<article class="ship-card faction-card" data-faction-id="${faction.id}">
|
label: faction.label,
|
||||||
<div class="ship-card-header">
|
stateLines: state ? [
|
||||||
<h3>${faction.label}</h3>
|
`Military ${state.militaryShipCount} · Miners ${state.minerShipCount}`,
|
||||||
<span class="ship-card-badge">faction</span>
|
`Transport ${state.transportShipCount} · Constructors ${state.constructorShipCount}`,
|
||||||
</div>
|
`Systems ${state.controlledSystemCount} / ${state.targetSystemCount}`,
|
||||||
${state ? `
|
`Factory ${state.hasShipFactory ? "yes" : "no"} · Ore ${state.oreStockpile.toFixed(0)}`,
|
||||||
<div class="ship-card-ai">
|
] : [],
|
||||||
<p class="ship-card-section-title">GOAP State</p>
|
priorities: (faction.goapPriorities ?? []).map((entry) => ({
|
||||||
<p>Military ${state.militaryShipCount} · Miners ${state.minerShipCount}</p>
|
label: entry.goalName,
|
||||||
<p>Transport ${state.transportShipCount} · Constructors ${state.constructorShipCount}</p>
|
value: entry.priority.toFixed(0),
|
||||||
<p>Systems ${state.controlledSystemCount} / ${state.targetSystemCount}</p>
|
})),
|
||||||
<p>Factory ${state.hasShipFactory ? "yes" : "no"} · Ore ${state.oreStockpile.toFixed(0)}</p>
|
};
|
||||||
</div>
|
|
||||||
` : ""}
|
|
||||||
${priorities && priorities.length > 0 ? `
|
|
||||||
<div class="ship-card-ai">
|
|
||||||
<p class="ship-card-section-title">Priorities</p>
|
|
||||||
${priorities.map(p => `<p>${p.goalName} <span style="float:right">${p.priority.toFixed(0)}</span></p>`).join("")}
|
|
||||||
</div>
|
|
||||||
` : ""}
|
|
||||||
</article>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStationCard(station: StationSnapshot, isSelected: boolean): string {
|
function buildProgressBar(label: string, progress: number): HudProgressBar {
|
||||||
const cargo = station.inventory.reduce((sum, e) => sum + e.amount, 0);
|
return {
|
||||||
const processes = station.currentProcesses;
|
label,
|
||||||
|
valueLabel: `${Math.round(progress * 100)}%`,
|
||||||
return `
|
progress: Number((progress * 100).toFixed(1)),
|
||||||
<article class="ship-card station-card${isSelected ? " is-selected" : ""}" data-station-id="${station.id}">
|
};
|
||||||
<div class="ship-card-header">
|
|
||||||
<h3>${station.label}</h3>
|
|
||||||
<span class="ship-card-badge">${station.category}</span>
|
|
||||||
</div>
|
|
||||||
<p>${station.systemId}</p>
|
|
||||||
<p>Docked ${station.dockedShips} / ${station.dockingPads}</p>
|
|
||||||
<p>Cargo ${cargo.toFixed(0)} · Pop ${station.population.toFixed(0)}</p>
|
|
||||||
<p>Modules ${station.installedModules.length}</p>
|
|
||||||
${processes.length > 0 ? `
|
|
||||||
<div class="ship-card-ai">
|
|
||||||
${processes.map(p => `
|
|
||||||
<div class="ship-action-progress">
|
|
||||||
<div class="ship-action-progress-label">
|
|
||||||
<span>${p.label}</span>
|
|
||||||
<span>${Math.round(p.progress * 100)}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="ship-action-progress-track">
|
|
||||||
<div class="ship-action-progress-fill" style="width: ${(p.progress * 100).toFixed(1)}%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join("")}
|
|
||||||
</div>
|
|
||||||
` : ""}
|
|
||||||
</article>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderOpsStrip(
|
function buildStationCard(station: StationSnapshot, isSelected: boolean): OpsStationCardState {
|
||||||
|
const cargo = station.inventory.reduce((sum, entry) => sum + entry.amount, 0);
|
||||||
|
return {
|
||||||
|
kind: "station",
|
||||||
|
id: station.id,
|
||||||
|
label: station.label,
|
||||||
|
badge: station.category,
|
||||||
|
selected: isSelected,
|
||||||
|
lines: [
|
||||||
|
station.systemId,
|
||||||
|
`Docked ${station.dockedShips} / ${station.dockingPads}`,
|
||||||
|
`Cargo ${cargo.toFixed(0)} · Pop ${station.population.toFixed(0)}`,
|
||||||
|
`Modules ${station.installedModules.length}`,
|
||||||
|
],
|
||||||
|
processes: station.currentProcesses.map((process) => buildProgressBar(process.label, process.progress)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildShipCard(
|
||||||
|
world: WorldState,
|
||||||
|
ship: WorldState["ships"] extends Map<string, infer Ship> ? Ship : never,
|
||||||
|
isSelected: boolean,
|
||||||
|
isFollowed: boolean,
|
||||||
|
): OpsShipCardState {
|
||||||
|
const cargo = ship.inventory.reduce((sum, entry) => sum + entry.amount, 0);
|
||||||
|
const shipLocation = describeShipLocation(world, ship);
|
||||||
|
const shipState = describeShipState(world, ship);
|
||||||
|
const shipAction = describeShipCurrentAction(ship);
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "ship",
|
||||||
|
id: ship.id,
|
||||||
|
label: ship.label,
|
||||||
|
badge: ship.class,
|
||||||
|
selected: isSelected,
|
||||||
|
followed: isFollowed,
|
||||||
|
locationLines: [shipLocation.system, ...(shipLocation.local ? [shipLocation.local] : [])],
|
||||||
|
lines: [
|
||||||
|
`Cargo ${cargo.toFixed(0)}`,
|
||||||
|
`State ${shipState}`,
|
||||||
|
],
|
||||||
|
action: shipAction ? buildProgressBar(shipAction.label, shipAction.progress) : undefined,
|
||||||
|
aiLines: [
|
||||||
|
...(ship.commanderObjective ? [`Objective ${describeShipObjective(ship.commanderObjective)}`] : []),
|
||||||
|
`Behavior ${ship.defaultBehaviorKind}${ship.behaviorPhase ? ` · ${ship.behaviorPhase}` : ""}`,
|
||||||
|
`Task ${ship.controllerTaskKind}`,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOpsStripState(
|
||||||
world: WorldState | undefined,
|
world: WorldState | undefined,
|
||||||
selectedItems: Selectable[],
|
selectedItems: Selectable[],
|
||||||
cameraMode: CameraMode,
|
cameraMode: CameraMode,
|
||||||
cameraTargetShipId?: string,
|
cameraTargetShipId?: string,
|
||||||
povLevel?: PovLevel,
|
povLevel?: PovLevel,
|
||||||
activeSystemId?: string,
|
activeSystemId?: string,
|
||||||
) {
|
): OpsStripState {
|
||||||
if (!world) {
|
if (!world) {
|
||||||
return "";
|
return {
|
||||||
|
factions: [],
|
||||||
|
stations: [],
|
||||||
|
ships: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSystemFiltered = povLevel !== "galaxy" && activeSystemId != null;
|
const isSystemFiltered = povLevel !== "galaxy" && activeSystemId != null;
|
||||||
|
|
||||||
const factionCards = [...world.factions.values()]
|
const factions = [...world.factions.values()]
|
||||||
.sort((a, b) => a.label.localeCompare(b.label))
|
.sort((left, right) => left.label.localeCompare(right.label))
|
||||||
.map(renderFactionCard)
|
.map(buildFactionCard);
|
||||||
.join("");
|
|
||||||
|
|
||||||
const stationCards = [...world.stations.values()]
|
const stations = [...world.stations.values()]
|
||||||
.filter((station) => !isSystemFiltered || station.systemId === activeSystemId)
|
.filter((station) => !isSystemFiltered || station.systemId === activeSystemId)
|
||||||
.sort((a, b) => a.label.localeCompare(b.label))
|
.sort((left, right) => left.label.localeCompare(right.label))
|
||||||
.map((station) => {
|
.map((station) => buildStationCard(
|
||||||
const isSelected = selectedItems.length === 1
|
station,
|
||||||
&& selectedItems[0].kind === "station"
|
selectedItems.length === 1 && selectedItems[0].kind === "station" && selectedItems[0].id === station.id,
|
||||||
&& selectedItems[0].id === station.id;
|
));
|
||||||
return renderStationCard(station, isSelected);
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
const ships = [...world.ships.values()]
|
const ships = [...world.ships.values()]
|
||||||
.filter((ship) => !isSystemFiltered || ship.systemId === activeSystemId)
|
.filter((ship) => !isSystemFiltered || ship.systemId === activeSystemId)
|
||||||
.sort((a, b) => a.label.localeCompare(b.label));
|
.sort((left, right) => left.label.localeCompare(right.label))
|
||||||
|
.map((ship) => buildShipCard(
|
||||||
|
world,
|
||||||
|
ship,
|
||||||
|
selectedItems.length === 1 && selectedItems[0].kind === "ship" && selectedItems[0].id === ship.id,
|
||||||
|
cameraMode === "follow" && cameraTargetShipId === ship.id,
|
||||||
|
));
|
||||||
|
|
||||||
const shipCards = ships
|
return { factions, stations, ships };
|
||||||
.map((ship) => {
|
|
||||||
const cargo = ship.inventory.reduce((sum, e) => sum + e.amount, 0);
|
|
||||||
const shipLocation = describeShipLocation(world, ship);
|
|
||||||
const shipState = describeShipState(world, ship);
|
|
||||||
const shipAction = describeShipCurrentAction(ship);
|
|
||||||
const isSelected = selectedItems.length === 1
|
|
||||||
&& selectedItems[0].kind === "ship"
|
|
||||||
&& selectedItems[0].id === ship.id;
|
|
||||||
const isFollowed = cameraMode === "follow" && cameraTargetShipId === ship.id;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<article class="ship-card${isSelected ? " is-selected" : ""}${isFollowed ? " is-followed" : ""}" data-ship-id="${ship.id}">
|
|
||||||
<div class="ship-card-header">
|
|
||||||
<h3>${ship.label}</h3>
|
|
||||||
<div class="ship-card-meta">
|
|
||||||
<span class="ship-card-badge">${ship.class}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="ship-card-history-button"
|
|
||||||
data-history-ship-id="${ship.id}"
|
|
||||||
aria-label="Open history for ${ship.label}"
|
|
||||||
title="Open history"
|
|
||||||
>🕔</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p>${shipLocation.system}${shipLocation.local ? `<br>${shipLocation.local}` : ""}</p>
|
|
||||||
<p>Cargo ${cargo.toFixed(0)}</p>
|
|
||||||
<p>State ${shipState}</p>
|
|
||||||
${shipAction ? `
|
|
||||||
<div class="ship-action-progress">
|
|
||||||
<div class="ship-action-progress-label">
|
|
||||||
<span>${shipAction.label}</span>
|
|
||||||
<span>${Math.round(shipAction.progress * 100)}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="ship-action-progress-track">
|
|
||||||
<div class="ship-action-progress-fill" style="width: ${(shipAction.progress * 100).toFixed(1)}%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
` : ""}
|
|
||||||
<div class="ship-card-ai">
|
|
||||||
${ship.commanderObjective ? `<p>Objective ${describeShipObjective(ship.commanderObjective)}</p>` : ""}
|
|
||||||
<p>Behavior ${ship.defaultBehaviorKind}${ship.behaviorPhase ? ` · ${ship.behaviorPhase}` : ""}</p>
|
|
||||||
<p>Task ${ship.controllerTaskKind}</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
`;
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
return factionCards + stationCards + shipCards;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ const itemTransportById = new Map<string, string>(
|
|||||||
import { describeCelestialPathWithinSystem, describeOrbitalParent, describeSelectable, describeShipBehavior, describeShipCurrentAction, describeShipOrder, describeShipState, getSelectionGroup, renderSystemDetails } from "./viewerSelection";
|
import { describeCelestialPathWithinSystem, describeOrbitalParent, describeSelectable, describeShipBehavior, describeShipCurrentAction, describeShipOrder, describeShipState, getSelectionGroup, renderSystemDetails } from "./viewerSelection";
|
||||||
import type {
|
import type {
|
||||||
CameraMode,
|
CameraMode,
|
||||||
HistoryWindowState,
|
|
||||||
NodeVisual,
|
NodeVisual,
|
||||||
OrbitalAnchor,
|
OrbitalAnchor,
|
||||||
Selectable,
|
Selectable,
|
||||||
@@ -39,9 +38,6 @@ interface DetailPanelParams {
|
|||||||
interface SystemPanelParams {
|
interface SystemPanelParams {
|
||||||
world: WorldState;
|
world: WorldState;
|
||||||
activeSystemId?: string;
|
activeSystemId?: string;
|
||||||
systemTitleEl: HTMLHeadingElement;
|
|
||||||
systemBodyEl: HTMLDivElement;
|
|
||||||
systemPanelEl: HTMLDivElement;
|
|
||||||
cameraMode: CameraMode;
|
cameraMode: CameraMode;
|
||||||
cameraTargetShipId?: string;
|
cameraTargetShipId?: string;
|
||||||
}
|
}
|
||||||
@@ -70,7 +66,15 @@ function formatModuleListWithConstruction(
|
|||||||
world: WorldState,
|
world: WorldState,
|
||||||
stationId: string,
|
stationId: string,
|
||||||
installedModules: 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 {
|
): string {
|
||||||
const processByModule = new Map<string, { label: string; progress: number; timeRemainingSeconds: number; cycleSeconds: number; inputs: { itemId: string; amount: number }[]; outputs: { itemId: string; amount: number }[] }[]>();
|
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) {
|
for (const process of currentProcesses) {
|
||||||
@@ -175,11 +179,7 @@ function renderSystemOwnership(world: WorldState, systemId: string): string {
|
|||||||
.join("<br>");
|
.join("<br>");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateDetailPanel(
|
export function buildDetailPanelState(params: DetailPanelParams) {
|
||||||
detailTitleEl: HTMLHeadingElement,
|
|
||||||
detailBodyEl: HTMLDivElement,
|
|
||||||
params: DetailPanelParams,
|
|
||||||
) {
|
|
||||||
const {
|
const {
|
||||||
world,
|
world,
|
||||||
selectedItems,
|
selectedItems,
|
||||||
@@ -191,8 +191,9 @@ export function updateDetailPanel(
|
|||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
if (selectedItems.length === 0) {
|
if (selectedItems.length === 0) {
|
||||||
detailTitleEl.textContent = worldLabel;
|
return {
|
||||||
detailBodyEl.innerHTML = `
|
title: worldLabel,
|
||||||
|
bodyHtml: `
|
||||||
Zoom ${povLevel}<br>
|
Zoom ${povLevel}<br>
|
||||||
Systems ${world.systems.size}<br>
|
Systems ${world.systems.size}<br>
|
||||||
Celestials ${world.celestials.size}<br>
|
Celestials ${world.celestials.size}<br>
|
||||||
@@ -201,25 +202,26 @@ export function updateDetailPanel(
|
|||||||
Construction ${world.constructionSites.size}<br>
|
Construction ${world.constructionSites.size}<br>
|
||||||
Ships ${world.ships.size}<br>
|
Ships ${world.ships.size}<br>
|
||||||
Recent events ${world.recentEvents.length}
|
Recent events ${world.recentEvents.length}
|
||||||
`;
|
`,
|
||||||
return;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedItems.length > 1) {
|
if (selectedItems.length > 1) {
|
||||||
const group = getSelectionGroup(selectedItems[0]);
|
const group = getSelectionGroup(selectedItems[0]);
|
||||||
detailTitleEl.textContent = `${selectedItems.length} selected`;
|
return {
|
||||||
detailBodyEl.innerHTML = `
|
title: `${selectedItems.length} selected`,
|
||||||
|
bodyHtml: `
|
||||||
Type ${group}<br>
|
Type ${group}<br>
|
||||||
${selectedItems.slice(0, 8).map((item) => describeSelectable(world, item)).join("<br>")}
|
${selectedItems.slice(0, 8).map((item) => describeSelectable(world, item)).join("<br>")}
|
||||||
`;
|
`,
|
||||||
return;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const selected = selectedItems[0];
|
const selected = selectedItems[0];
|
||||||
if (selected.kind === "ship") {
|
if (selected.kind === "ship") {
|
||||||
const ship = world.ships.get(selected.id);
|
const ship = world.ships.get(selected.id);
|
||||||
if (!ship) {
|
if (!ship) {
|
||||||
return;
|
return { title: "Missing ship", bodyHtml: "" };
|
||||||
}
|
}
|
||||||
const parent = describeSelectionParent(selected);
|
const parent = describeSelectionParent(selected);
|
||||||
const cargoUsed = ship.inventory.reduce((sum, e) => sum + e.amount, 0);
|
const cargoUsed = ship.inventory.reduce((sum, e) => sum + e.amount, 0);
|
||||||
@@ -227,8 +229,9 @@ export function updateDetailPanel(
|
|||||||
const shipBehavior = describeShipBehavior(ship);
|
const shipBehavior = describeShipBehavior(ship);
|
||||||
const shipOrder = describeShipOrder(ship);
|
const shipOrder = describeShipOrder(ship);
|
||||||
const shipAction = describeShipCurrentAction(ship);
|
const shipAction = describeShipCurrentAction(ship);
|
||||||
detailTitleEl.textContent = ship.label;
|
return {
|
||||||
detailBodyEl.innerHTML = `
|
title: ship.label,
|
||||||
|
bodyHtml: `
|
||||||
<p>Parent ${parent}</p>
|
<p>Parent ${parent}</p>
|
||||||
<p>Behavior ${shipBehavior}</p>
|
<p>Behavior ${shipBehavior}</p>
|
||||||
<p>State ${shipState}</p>
|
<p>State ${shipState}</p>
|
||||||
@@ -249,14 +252,14 @@ export function updateDetailPanel(
|
|||||||
<p>Inventory ${formatInventory(ship.inventory)}</p>
|
<p>Inventory ${formatInventory(ship.inventory)}</p>
|
||||||
<p>Speed ${formatShipSpeed(ship)}</p>
|
<p>Speed ${formatShipSpeed(ship)}</p>
|
||||||
<p>Camera ${cameraMode === "follow" && cameraTargetShipId === ship.id ? "camera-follow" : "tactical"}<br>Press C to toggle follow</p>
|
<p>Camera ${cameraMode === "follow" && cameraTargetShipId === ship.id ? "camera-follow" : "tactical"}<br>Press C to toggle follow</p>
|
||||||
`;
|
`,
|
||||||
return;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selected.kind === "station") {
|
if (selected.kind === "station") {
|
||||||
const station = world.stations.get(selected.id);
|
const station = world.stations.get(selected.id);
|
||||||
if (!station) {
|
if (!station) {
|
||||||
return;
|
return { title: "Missing station", bodyHtml: "" };
|
||||||
}
|
}
|
||||||
const parent = describeSelectionParent(selected);
|
const parent = describeSelectionParent(selected);
|
||||||
const moduleList = formatModuleListWithConstruction(world, station.id, station.installedModules, station.currentProcesses);
|
const moduleList = formatModuleListWithConstruction(world, station.id, station.installedModules, station.currentProcesses);
|
||||||
@@ -264,8 +267,9 @@ export function updateDetailPanel(
|
|||||||
? station.dockedShipIds.map((shipId) => world.ships.get(shipId)?.label ?? shipId).join("<br>")
|
? station.dockedShipIds.map((shipId) => world.ships.get(shipId)?.label ?? shipId).join("<br>")
|
||||||
: "none";
|
: "none";
|
||||||
const stationStorage = formatStorageWithInventory(station.storageUsage, station.inventory);
|
const stationStorage = formatStorageWithInventory(station.storageUsage, station.inventory);
|
||||||
detailTitleEl.textContent = station.label;
|
return {
|
||||||
detailBodyEl.innerHTML = `
|
title: station.label,
|
||||||
|
bodyHtml: `
|
||||||
<p>${station.category} · ${station.systemId}</p>
|
<p>${station.category} · ${station.systemId}</p>
|
||||||
<p>Parent ${parent}</p>
|
<p>Parent ${parent}</p>
|
||||||
<p>Docked ${station.dockedShips} / ${station.dockingPads}
|
<p>Docked ${station.dockedShips} / ${station.dockingPads}
|
||||||
@@ -273,21 +277,22 @@ export function updateDetailPanel(
|
|||||||
${dockedShipLabels}</p>
|
${dockedShipLabels}</p>
|
||||||
<p>Modules ${moduleList}</p>
|
<p>Modules ${moduleList}</p>
|
||||||
<p>Storage ${stationStorage}</p>
|
<p>Storage ${stationStorage}</p>
|
||||||
`;
|
`,
|
||||||
return;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selected.kind === "node") {
|
if (selected.kind === "node") {
|
||||||
const node = world.nodes.get(selected.id);
|
const node = world.nodes.get(selected.id);
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return;
|
return { title: "Missing node", bodyHtml: "" };
|
||||||
}
|
}
|
||||||
const parent = describeSelectionParent(selected);
|
const parent = describeSelectionParent(selected);
|
||||||
const nodeLevel = node.maxOre > 0
|
const nodeLevel = node.maxOre > 0
|
||||||
? Math.max(0, Math.min(node.oreRemaining / node.maxOre, 1))
|
? Math.max(0, Math.min(node.oreRemaining / node.maxOre, 1))
|
||||||
: 0;
|
: 0;
|
||||||
detailTitleEl.textContent = `Node ${node.id}`;
|
return {
|
||||||
detailBodyEl.innerHTML = `
|
title: `Node ${node.id}`,
|
||||||
|
bodyHtml: `
|
||||||
<p>${node.systemId}</p>
|
<p>${node.systemId}</p>
|
||||||
<p>Parent ${parent}</p>
|
<p>Parent ${parent}</p>
|
||||||
<p>Source ${node.sourceKind}<br>Resource ${node.itemId}</p>
|
<p>Source ${node.sourceKind}<br>Resource ${node.itemId}</p>
|
||||||
@@ -301,73 +306,77 @@ export function updateDetailPanel(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p>Stock ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}</p>
|
<p>Stock ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}</p>
|
||||||
`;
|
`,
|
||||||
return;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selected.kind === "celestial") {
|
if (selected.kind === "celestial") {
|
||||||
const celestial = world.celestials.get(selected.id);
|
const celestial = world.celestials.get(selected.id);
|
||||||
if (!celestial) {
|
if (!celestial) {
|
||||||
return;
|
return { title: "Missing celestial", bodyHtml: "" };
|
||||||
}
|
}
|
||||||
detailTitleEl.textContent = `${celestial.kind} celestial`;
|
return {
|
||||||
detailBodyEl.innerHTML = `
|
title: `${celestial.kind} celestial`,
|
||||||
|
bodyHtml: `
|
||||||
<p>${celestial.systemId}</p>
|
<p>${celestial.systemId}</p>
|
||||||
<p>Parent ${celestial.parentNodeId ?? "none"}<br>Orbit ref ${celestial.orbitReferenceId ?? "none"}</p>
|
<p>Parent ${celestial.parentNodeId ?? "none"}<br>Orbit ref ${celestial.orbitReferenceId ?? "none"}</p>
|
||||||
<p>Occupying structure ${celestial.occupyingStructureId ?? "none"}</p>
|
<p>Occupying structure ${celestial.occupyingStructureId ?? "none"}</p>
|
||||||
<p>Local space radius ${celestial.localSpaceRadius.toFixed(0)} km</p>
|
<p>Local space radius ${celestial.localSpaceRadius.toFixed(0)} km</p>
|
||||||
`;
|
`,
|
||||||
return;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selected.kind === "claim") {
|
if (selected.kind === "claim") {
|
||||||
const claim = world.claims.get(selected.id);
|
const claim = world.claims.get(selected.id);
|
||||||
if (!claim) {
|
if (!claim) {
|
||||||
return;
|
return { title: "Missing claim", bodyHtml: "" };
|
||||||
}
|
}
|
||||||
detailTitleEl.textContent = `Claim ${claim.id}`;
|
return {
|
||||||
detailBodyEl.innerHTML = `
|
title: `Claim ${claim.id}`,
|
||||||
|
bodyHtml: `
|
||||||
<p>${claim.systemId}</p>
|
<p>${claim.systemId}</p>
|
||||||
<p>Celestial ${claim.celestialId}</p>
|
<p>Celestial ${claim.celestialId}</p>
|
||||||
<p>State ${claim.state}<br>Health ${claim.health.toFixed(0)}</p>
|
<p>State ${claim.state}<br>Health ${claim.health.toFixed(0)}</p>
|
||||||
<p>Activates ${new Date(claim.activatesAtUtc).toLocaleTimeString()}</p>
|
<p>Activates ${new Date(claim.activatesAtUtc).toLocaleTimeString()}</p>
|
||||||
`;
|
`,
|
||||||
return;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selected.kind === "construction-site") {
|
if (selected.kind === "construction-site") {
|
||||||
const site = world.constructionSites.get(selected.id);
|
const site = world.constructionSites.get(selected.id);
|
||||||
if (!site) {
|
if (!site) {
|
||||||
return;
|
return { title: "Missing construction", bodyHtml: "" };
|
||||||
}
|
}
|
||||||
const orderCount = [...world.marketOrders.values()].filter((order) => order.constructionSiteId === site.id).length;
|
const orderCount = [...world.marketOrders.values()].filter((order) => order.constructionSiteId === site.id).length;
|
||||||
detailTitleEl.textContent = `Construction ${site.id}`;
|
return {
|
||||||
detailBodyEl.innerHTML = `
|
title: `Construction ${site.id}`,
|
||||||
|
bodyHtml: `
|
||||||
<p>${site.systemId}</p>
|
<p>${site.systemId}</p>
|
||||||
<p>Celestial ${site.celestialId}</p>
|
<p>Celestial ${site.celestialId}</p>
|
||||||
<p>${site.targetKind} ${site.targetDefinitionId}</p>
|
<p>${site.targetKind} ${site.targetDefinitionId}</p>
|
||||||
<p>State ${site.state}<br>Progress ${(site.progress * 100).toFixed(0)}%</p>
|
<p>State ${site.state}<br>Progress ${(site.progress * 100).toFixed(0)}%</p>
|
||||||
<p>Orders ${orderCount}<br>Assigned constructors ${site.assignedConstructorShipIds.length}</p>
|
<p>Orders ${orderCount}<br>Assigned constructors ${site.assignedConstructorShipIds.length}</p>
|
||||||
`;
|
`,
|
||||||
return;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selected.kind === "planet") {
|
if (selected.kind === "planet") {
|
||||||
const system = world.systems.get(selected.systemId);
|
const system = world.systems.get(selected.systemId);
|
||||||
const planet = system?.planets[selected.planetIndex];
|
const planet = system?.planets[selected.planetIndex];
|
||||||
if (!system || !planet) {
|
if (!system || !planet) {
|
||||||
return;
|
return { title: "Missing planet", bodyHtml: "" };
|
||||||
}
|
}
|
||||||
const parent = describeSelectionParent(selected);
|
const parent = describeSelectionParent(selected);
|
||||||
detailTitleEl.textContent = planet.label;
|
return {
|
||||||
detailBodyEl.innerHTML = `
|
title: planet.label,
|
||||||
|
bodyHtml: `
|
||||||
<p>${system.label}</p>
|
<p>${system.label}</p>
|
||||||
<p>Parent ${parent}</p>
|
<p>Parent ${parent}</p>
|
||||||
<p>${planet.planetType} · ${planet.shape} · Moons ${planet.moons.length}</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>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>
|
<p>Phase ${planet.orbitPhaseAtEpoch.toFixed(1)}°</p>
|
||||||
`;
|
`,
|
||||||
return;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selected.kind === "moon") {
|
if (selected.kind === "moon") {
|
||||||
@@ -375,51 +384,57 @@ export function updateDetailPanel(
|
|||||||
const planet = system?.planets[selected.planetIndex];
|
const planet = system?.planets[selected.planetIndex];
|
||||||
const moon = planet?.moons[selected.moonIndex];
|
const moon = planet?.moons[selected.moonIndex];
|
||||||
if (moon) {
|
if (moon) {
|
||||||
detailTitleEl.textContent = moon.label;
|
return {
|
||||||
detailBodyEl.innerHTML = `
|
title: moon.label,
|
||||||
|
bodyHtml: `
|
||||||
<p>${system?.label ?? selected.systemId} / ${planet?.label ?? `planet ${selected.planetIndex + 1}`}</p>
|
<p>${system?.label ?? selected.systemId} / ${planet?.label ?? `planet ${selected.planetIndex + 1}`}</p>
|
||||||
<p>Orbit ${formatSystemDistance(moon.orbitRadius)}<br>Inc ${moon.orbitInclination.toFixed(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);
|
const system = world.systems.get(selected.id);
|
||||||
if (!system) {
|
if (!system) {
|
||||||
return;
|
return {
|
||||||
|
title: "Unknown selection",
|
||||||
|
bodyHtml: "",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
detailTitleEl.textContent = system.label;
|
return {
|
||||||
detailBodyEl.innerHTML = `
|
title: system.label,
|
||||||
|
bodyHtml: `
|
||||||
<p>Parent galaxy</p>
|
<p>Parent galaxy</p>
|
||||||
${renderSystemDetails(world, system, false, cameraMode, cameraTargetShipId)}
|
${renderSystemDetails(world, system, false, cameraMode, cameraTargetShipId)}
|
||||||
`;
|
`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateSystemPanel(params: SystemPanelParams) {
|
export function buildSystemPanelState(params: SystemPanelParams) {
|
||||||
const {
|
const {
|
||||||
world,
|
world,
|
||||||
activeSystemId,
|
activeSystemId,
|
||||||
systemTitleEl,
|
|
||||||
systemBodyEl,
|
|
||||||
systemPanelEl,
|
|
||||||
cameraMode,
|
|
||||||
cameraTargetShipId,
|
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const activeSystem = activeSystemId ? world.systems.get(activeSystemId) : undefined;
|
const activeSystem = activeSystemId ? world.systems.get(activeSystemId) : undefined;
|
||||||
systemPanelEl.hidden = !activeSystem;
|
|
||||||
|
|
||||||
if (!activeSystem) {
|
if (!activeSystem) {
|
||||||
systemTitleEl.textContent = "Deep Space";
|
return {
|
||||||
systemBodyEl.innerHTML = "";
|
hidden: true,
|
||||||
return;
|
title: "Deep Space",
|
||||||
|
bodyHtml: "",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
systemTitleEl.textContent = activeSystem.label;
|
return {
|
||||||
systemBodyEl.innerHTML = `
|
hidden: false,
|
||||||
|
title: activeSystem.label,
|
||||||
|
bodyHtml: `
|
||||||
<p>${renderSystemOwnership(world, activeSystem.id)}</p>
|
<p>${renderSystemOwnership(world, activeSystem.id)}</p>
|
||||||
`;
|
`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function describeSelectionParent(
|
export function describeSelectionParent(
|
||||||
|
|||||||
@@ -1,34 +1,27 @@
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import {
|
import {
|
||||||
updateNetworkPanel as renderNetworkPanel,
|
describeNetworkPanel,
|
||||||
|
describePerformancePanel,
|
||||||
recordPerformanceStats,
|
recordPerformanceStats,
|
||||||
summarizeNetworkStats,
|
summarizeNetworkStats,
|
||||||
summarizePerformanceStats,
|
summarizePerformanceStats,
|
||||||
updatePerformancePanel as renderPerformancePanel,
|
|
||||||
} from "./viewerTelemetry";
|
} from "./viewerTelemetry";
|
||||||
import { updatePlanetPresentation } from "./viewerPresentation";
|
import { updatePlanetPresentation } from "./viewerPresentation";
|
||||||
import { renderRecentEvents, updateGameStatus, updateSystemSummaries, updateWorldPresentation } from "./viewerWorldPresentation";
|
import { buildSystemPanelState } from "./viewerPanels";
|
||||||
import { updateSystemPanel } from "./viewerPanels";
|
import { describeGameStatus, renderRecentEvents, updateSystemSummaries, updateWorldPresentation } from "./viewerWorldPresentation";
|
||||||
import { createBackdropStars, createNebulaClouds, createNebulaTexture } from "./viewerSceneFactory";
|
import { createBackdropStars, createNebulaClouds, createNebulaTexture } from "./viewerSceneFactory";
|
||||||
|
import type { ViewerHudState } from "./viewerHudState";
|
||||||
import type { Selectable } from "./viewerTypes";
|
import type { Selectable } from "./viewerTypes";
|
||||||
|
|
||||||
export interface ViewerPresentationContext {
|
export interface ViewerPresentationContext {
|
||||||
renderer: THREE.WebGLRenderer;
|
renderer: THREE.WebGLRenderer;
|
||||||
|
hudState: ViewerHudState;
|
||||||
galaxyScene: THREE.Scene;
|
galaxyScene: THREE.Scene;
|
||||||
galaxyCamera: THREE.PerspectiveCamera;
|
galaxyCamera: THREE.PerspectiveCamera;
|
||||||
systemCamera: THREE.PerspectiveCamera;
|
systemCamera: THREE.PerspectiveCamera;
|
||||||
galaxyAnchor: THREE.Vector3;
|
galaxyAnchor: THREE.Vector3;
|
||||||
systemAnchor: THREE.Vector3;
|
systemAnchor: THREE.Vector3;
|
||||||
ambienceGroup: THREE.Group;
|
ambienceGroup: THREE.Group;
|
||||||
gameSummaryEl: HTMLSpanElement;
|
|
||||||
networkSummaryEl: HTMLSpanElement;
|
|
||||||
performanceSummaryEl: HTMLSpanElement;
|
|
||||||
statusEl: HTMLDivElement;
|
|
||||||
networkPanelEl: HTMLDivElement;
|
|
||||||
performancePanelEl: HTMLDivElement;
|
|
||||||
systemPanelEl: HTMLDivElement;
|
|
||||||
systemTitleEl: HTMLHeadingElement;
|
|
||||||
systemBodyEl: HTMLDivElement;
|
|
||||||
networkStats: any;
|
networkStats: any;
|
||||||
performanceStats: any;
|
performanceStats: any;
|
||||||
getWorld: () => any;
|
getWorld: () => any;
|
||||||
@@ -61,7 +54,6 @@ export class ViewerPresentationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applyZoomPresentation() {
|
applyZoomPresentation() {
|
||||||
const activeSystemId = this.context.getActiveSystemId();
|
|
||||||
const povLevel = this.context.getPovLevel();
|
const povLevel = this.context.getPovLevel();
|
||||||
|
|
||||||
this.context.galaxyScene.fog = new THREE.FogExp2(0x040912, 0.000035);
|
this.context.galaxyScene.fog = new THREE.FogExp2(0x040912, 0.000035);
|
||||||
@@ -73,8 +65,8 @@ export class ViewerPresentationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateNetworkPanel() {
|
updateNetworkPanel() {
|
||||||
renderNetworkPanel(this.context.networkPanelEl, this.context.networkStats);
|
this.context.hudState.networkPanel.bodyText = describeNetworkPanel(this.context.networkStats);
|
||||||
this.context.networkSummaryEl.textContent = summarizeNetworkStats(this.context.networkStats);
|
this.context.hudState.networkPanel.summary = summarizeNetworkStats(this.context.networkStats);
|
||||||
}
|
}
|
||||||
|
|
||||||
recordPerformanceStats(frameMs: number) {
|
recordPerformanceStats(frameMs: number) {
|
||||||
@@ -82,8 +74,11 @@ export class ViewerPresentationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updatePerformancePanel() {
|
updatePerformancePanel() {
|
||||||
renderPerformancePanel(this.context.performancePanelEl, this.context.performanceStats, this.context.renderer);
|
const bodyText = describePerformancePanel(this.context.performanceStats, this.context.renderer);
|
||||||
this.context.performanceSummaryEl.textContent = summarizePerformanceStats(this.context.performanceStats);
|
if (bodyText) {
|
||||||
|
this.context.hudState.performancePanel.bodyText = bodyText;
|
||||||
|
}
|
||||||
|
this.context.hudState.performancePanel.summary = summarizePerformanceStats(this.context.performanceStats);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateShipPresentation() {
|
updateShipPresentation() {
|
||||||
@@ -109,9 +104,7 @@ export class ViewerPresentationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateGamePanel(mode: string) {
|
updateGamePanel(mode: string) {
|
||||||
updateGameStatus({
|
const state = describeGameStatus({
|
||||||
statusEl: this.context.statusEl,
|
|
||||||
summaryEl: this.context.gameSummaryEl,
|
|
||||||
world: this.context.getWorld(),
|
world: this.context.getWorld(),
|
||||||
activeSystemId: this.context.getActiveSystemId(),
|
activeSystemId: this.context.getActiveSystemId(),
|
||||||
cameraMode: this.context.getCameraMode(),
|
cameraMode: this.context.getCameraMode(),
|
||||||
@@ -121,6 +114,8 @@ export class ViewerPresentationController {
|
|||||||
galaxyAnchor: this.context.galaxyAnchor,
|
galaxyAnchor: this.context.galaxyAnchor,
|
||||||
systemAnchor: this.context.systemAnchor,
|
systemAnchor: this.context.systemAnchor,
|
||||||
});
|
});
|
||||||
|
this.context.hudState.gamePanel.bodyText = state.bodyText;
|
||||||
|
this.context.hudState.gamePanel.summary = state.summaryText;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSystemPanel() {
|
updateSystemPanel() {
|
||||||
@@ -129,15 +124,15 @@ export class ViewerPresentationController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSystemPanel({
|
const state = buildSystemPanelState({
|
||||||
world,
|
world,
|
||||||
activeSystemId: this.context.getActiveSystemId(),
|
activeSystemId: this.context.getActiveSystemId(),
|
||||||
systemTitleEl: this.context.systemTitleEl,
|
|
||||||
systemBodyEl: this.context.systemBodyEl,
|
|
||||||
systemPanelEl: this.context.systemPanelEl,
|
|
||||||
cameraMode: this.context.getCameraMode(),
|
cameraMode: this.context.getCameraMode(),
|
||||||
cameraTargetShipId: this.context.getCameraTargetShipId(),
|
cameraTargetShipId: this.context.getCameraTargetShipId(),
|
||||||
});
|
});
|
||||||
|
this.context.hudState.systemPanel.hidden = state.hidden;
|
||||||
|
this.context.hudState.systemPanel.title = state.title;
|
||||||
|
this.context.hudState.systemPanel.bodyHtml = state.bodyHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
screenPointFromClient(clientX: number, clientY: number) {
|
screenPointFromClient(clientX: number, clientY: number) {
|
||||||
|
|||||||
@@ -25,10 +25,11 @@ export interface RenderFrameParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ResizeParams {
|
export interface ResizeParams {
|
||||||
renderer: THREE.WebGLRenderer;
|
|
||||||
galaxyLayer: GalaxyLayer;
|
galaxyLayer: GalaxyLayer;
|
||||||
systemLayer: SystemLayer;
|
systemLayer: SystemLayer;
|
||||||
localLayer: LocalLayer;
|
localLayer: LocalLayer;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CameraStepParams {
|
export interface CameraStepParams {
|
||||||
@@ -72,12 +73,10 @@ export function renderFrame(params: RenderFrameParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resizeViewer(params: ResizeParams) {
|
export function resizeViewer(params: ResizeParams) {
|
||||||
const width = window.innerWidth;
|
const aspect = params.width / params.height;
|
||||||
const height = window.innerHeight;
|
params.galaxyLayer.onResize(aspect);
|
||||||
params.galaxyLayer.onResize(width / height);
|
params.systemLayer.onResize(aspect);
|
||||||
params.systemLayer.onResize(width / height);
|
params.localLayer.onResize(aspect);
|
||||||
params.localLayer.onResize(width / height);
|
|
||||||
params.renderer.setSize(width, height);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stepCamera(params: CameraStepParams) {
|
export function stepCamera(params: CameraStepParams) {
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import type {
|
|||||||
} from "./viewerTypes";
|
} from "./viewerTypes";
|
||||||
|
|
||||||
export function updateNetworkPanel(networkPanelEl: HTMLDivElement, networkStats: NetworkStats) {
|
export function updateNetworkPanel(networkPanelEl: HTMLDivElement, networkStats: NetworkStats) {
|
||||||
|
networkPanelEl.textContent = describeNetworkPanel(networkStats);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeNetworkPanel(networkStats: NetworkStats) {
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
const uptimeSeconds = networkStats.streamOpenedAtMs
|
const uptimeSeconds = networkStats.streamOpenedAtMs
|
||||||
? (now - networkStats.streamOpenedAtMs) / 1000
|
? (now - networkStats.streamOpenedAtMs) / 1000
|
||||||
@@ -22,7 +26,7 @@ export function updateNetworkPanel(networkPanelEl: HTMLDivElement, networkStats:
|
|||||||
? ((now - networkStats.lastDeltaAtMs) / 1000).toFixed(1)
|
? ((now - networkStats.lastDeltaAtMs) / 1000).toFixed(1)
|
||||||
: "n/a";
|
: "n/a";
|
||||||
|
|
||||||
networkPanelEl.textContent = [
|
return [
|
||||||
`snapshot: ${formatBytes(networkStats.snapshotBytes)}`,
|
`snapshot: ${formatBytes(networkStats.snapshotBytes)}`,
|
||||||
`stream: ${networkStats.streamConnected ? "live" : "offline"}`,
|
`stream: ${networkStats.streamConnected ? "live" : "offline"}`,
|
||||||
`deltas: ${networkStats.deltasReceived}`,
|
`deltas: ${networkStats.deltasReceived}`,
|
||||||
@@ -59,13 +63,23 @@ export function updatePerformancePanel(
|
|||||||
performancePanelEl: HTMLDivElement,
|
performancePanelEl: HTMLDivElement,
|
||||||
performanceStats: PerformanceStats,
|
performanceStats: PerformanceStats,
|
||||||
renderer: THREE.WebGLRenderer,
|
renderer: THREE.WebGLRenderer,
|
||||||
|
) {
|
||||||
|
const text = describePerformancePanel(performanceStats, renderer);
|
||||||
|
if (text) {
|
||||||
|
performancePanelEl.textContent = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describePerformancePanel(
|
||||||
|
performanceStats: PerformanceStats,
|
||||||
|
renderer: THREE.WebGLRenderer,
|
||||||
) {
|
) {
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
if (
|
if (
|
||||||
performanceStats.lastPanelUpdateAtMs > 0 &&
|
performanceStats.lastPanelUpdateAtMs > 0 &&
|
||||||
now - performanceStats.lastPanelUpdateAtMs < 250
|
now - performanceStats.lastPanelUpdateAtMs < 250
|
||||||
) {
|
) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const samples = performanceStats.frameSamples;
|
const samples = performanceStats.frameSamples;
|
||||||
@@ -84,7 +98,8 @@ export function updatePerformancePanel(
|
|||||||
const recentLowFps = averageFrameMs > 0 ? 1000 / Math.max(worstFrameMs, averageFrameMs) : 0;
|
const recentLowFps = averageFrameMs > 0 ? 1000 / Math.max(worstFrameMs, averageFrameMs) : 0;
|
||||||
const renderInfo = renderer.info;
|
const renderInfo = renderer.info;
|
||||||
|
|
||||||
performancePanelEl.textContent = [
|
performanceStats.lastPanelUpdateAtMs = now;
|
||||||
|
return [
|
||||||
`fps: ${fps.toFixed(1)}`,
|
`fps: ${fps.toFixed(1)}`,
|
||||||
`frame avg: ${averageFrameMs.toFixed(2)} ms`,
|
`frame avg: ${averageFrameMs.toFixed(2)} ms`,
|
||||||
`frame last: ${performanceStats.lastFrameMs.toFixed(2)} ms`,
|
`frame last: ${performanceStats.lastFrameMs.toFixed(2)} ms`,
|
||||||
@@ -98,7 +113,6 @@ export function updatePerformancePanel(
|
|||||||
`textures: ${renderInfo.memory.textures}`,
|
`textures: ${renderInfo.memory.textures}`,
|
||||||
`pixel ratio: ${renderer.getPixelRatio().toFixed(2)}`,
|
`pixel ratio: ${renderer.getPixelRatio().toFixed(2)}`,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
performanceStats.lastPanelUpdateAtMs = now;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function summarizePerformanceStats(performanceStats: PerformanceStats): string {
|
export function summarizePerformanceStats(performanceStats: PerformanceStats): string {
|
||||||
|
|||||||
@@ -183,13 +183,3 @@ export interface PerformanceStats {
|
|||||||
lastFrameMs: number;
|
lastFrameMs: number;
|
||||||
lastPanelUpdateAtMs: number;
|
lastPanelUpdateAtMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoryWindowState {
|
|
||||||
id: string;
|
|
||||||
target: Selectable;
|
|
||||||
root: HTMLElement;
|
|
||||||
titleEl: HTMLHeadingElement;
|
|
||||||
bodyEl: HTMLDivElement;
|
|
||||||
copyButtonEl: HTMLButtonElement;
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { fetchWorldSnapshot, openWorldStream } from "./api";
|
import { fetchWorldSnapshot, openWorldStream } from "./api";
|
||||||
import { renderOpsStrip } from "./viewerOpsStrip";
|
import type { ViewerHudState } from "./viewerHudState";
|
||||||
import { updateDetailPanel } from "./viewerPanels";
|
import { buildOpsStripState } from "./viewerOpsStrip";
|
||||||
|
import { buildDetailPanelState } from "./viewerPanels";
|
||||||
import { applyDeltaToWorld, cloneFactions, createWorldState, recordDeltaStats } from "./viewerState";
|
import { applyDeltaToWorld, cloneFactions, createWorldState, recordDeltaStats } from "./viewerState";
|
||||||
import type {
|
import type {
|
||||||
CelestialDelta,
|
CelestialDelta,
|
||||||
@@ -46,10 +47,7 @@ export interface ViewerWorldLifecycleContext {
|
|||||||
getCameraTargetShipId: () => string | undefined;
|
getCameraTargetShipId: () => string | undefined;
|
||||||
getNetworkStats: () => NetworkStats;
|
getNetworkStats: () => NetworkStats;
|
||||||
getSystemSummaryVisuals: () => Map<string, unknown>;
|
getSystemSummaryVisuals: () => Map<string, unknown>;
|
||||||
errorEl: HTMLDivElement;
|
hudState: ViewerHudState;
|
||||||
opsStripEl: HTMLDivElement;
|
|
||||||
detailTitleEl: HTMLHeadingElement;
|
|
||||||
detailBodyEl: HTMLDivElement;
|
|
||||||
worldLabel: () => string;
|
worldLabel: () => string;
|
||||||
rebuildSystems: (systems: SystemSnapshot[]) => void;
|
rebuildSystems: (systems: SystemSnapshot[]) => void;
|
||||||
syncCelestials: (celestials: CelestialSnapshot[]) => void;
|
syncCelestials: (celestials: CelestialSnapshot[]) => void;
|
||||||
@@ -83,14 +81,15 @@ export class ViewerWorldLifecycle {
|
|||||||
this.context.setWorld(createWorldState(snapshot));
|
this.context.setWorld(createWorldState(snapshot));
|
||||||
this.context.getNetworkStats().snapshotBytes = new Blob([JSON.stringify(snapshot)]).size;
|
this.context.getNetworkStats().snapshotBytes = new Blob([JSON.stringify(snapshot)]).size;
|
||||||
this.context.updateGamePanel("Bootstrapped");
|
this.context.updateGamePanel("Bootstrapped");
|
||||||
this.context.errorEl.hidden = true;
|
this.context.hudState.error.hidden = true;
|
||||||
|
this.context.hudState.error.message = "";
|
||||||
this.applySnapshot(snapshot);
|
this.applySnapshot(snapshot);
|
||||||
this.openDeltaStream(snapshot.sequence);
|
this.openDeltaStream(snapshot.sequence);
|
||||||
this.updatePanels();
|
this.updatePanels();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.context.updateGamePanel("Backend offline");
|
this.context.updateGamePanel("Backend offline");
|
||||||
this.context.errorEl.hidden = false;
|
this.context.hudState.error.hidden = false;
|
||||||
this.context.errorEl.textContent = error instanceof Error ? error.message : "Unable to bootstrap the backend snapshot.";
|
this.context.hudState.error.message = error instanceof Error ? error.message : "Unable to bootstrap the backend snapshot.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +187,7 @@ export class ViewerWorldLifecycle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rebuildFactions(_factions: FactionSnapshot[]) {
|
rebuildFactions(_factions: FactionSnapshot[]) {
|
||||||
this.context.opsStripEl.innerHTML = renderOpsStrip(
|
this.context.hudState.opsStrip = buildOpsStripState(
|
||||||
this.context.getWorld(),
|
this.context.getWorld(),
|
||||||
this.context.getSelectedItems(),
|
this.context.getSelectedItems(),
|
||||||
this.context.getCameraMode(),
|
this.context.getCameraMode(),
|
||||||
@@ -207,7 +206,7 @@ export class ViewerWorldLifecycle {
|
|||||||
this.context.refreshHistoryWindows();
|
this.context.refreshHistoryWindows();
|
||||||
this.context.updateSystemPanel();
|
this.context.updateSystemPanel();
|
||||||
this.refreshStreamScopeIfNeeded();
|
this.refreshStreamScopeIfNeeded();
|
||||||
updateDetailPanel(this.context.detailTitleEl, this.context.detailBodyEl, {
|
const detailState = buildDetailPanelState({
|
||||||
world,
|
world,
|
||||||
selectedItems: this.context.getSelectedItems(),
|
selectedItems: this.context.getSelectedItems(),
|
||||||
povLevel: this.context.getPovLevel(),
|
povLevel: this.context.getPovLevel(),
|
||||||
@@ -216,6 +215,9 @@ export class ViewerWorldLifecycle {
|
|||||||
worldLabel: this.context.worldLabel(),
|
worldLabel: this.context.worldLabel(),
|
||||||
describeSelectionParent: this.context.describeSelectionParent,
|
describeSelectionParent: this.context.describeSelectionParent,
|
||||||
});
|
});
|
||||||
|
this.context.hudState.detailPanel.hidden = false;
|
||||||
|
this.context.hudState.detailPanel.title = detailState.title;
|
||||||
|
this.context.hudState.detailPanel.bodyHtml = detailState.bodyHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPreferredStreamScope() {
|
private getPreferredStreamScope() {
|
||||||
|
|||||||
@@ -68,8 +68,6 @@ export interface WorldPresentationContext extends WorldOrbitalContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GameStatusParams {
|
export interface GameStatusParams {
|
||||||
statusEl: HTMLDivElement;
|
|
||||||
summaryEl?: HTMLSpanElement;
|
|
||||||
world?: WorldState;
|
world?: WorldState;
|
||||||
activeSystemId?: string;
|
activeSystemId?: string;
|
||||||
cameraMode: CameraMode;
|
cameraMode: CameraMode;
|
||||||
@@ -269,8 +267,8 @@ function fmtVec(v: THREE.Vector3, digits: number) {
|
|||||||
return `${v.x.toFixed(digits)} ${v.y.toFixed(digits)} ${v.z.toFixed(digits)}`;
|
return `${v.x.toFixed(digits)} ${v.y.toFixed(digits)} ${v.z.toFixed(digits)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateGameStatus(params: GameStatusParams) {
|
export function describeGameStatus(params: GameStatusParams) {
|
||||||
const { statusEl, summaryEl, world, activeSystemId, cameraMode, povLevel, selectedItems, mode, galaxyAnchor, systemAnchor } = params;
|
const { world, activeSystemId, cameraMode, povLevel, selectedItems, mode, galaxyAnchor, systemAnchor } = params;
|
||||||
const sequence = world?.sequence ?? 0;
|
const sequence = world?.sequence ?? 0;
|
||||||
const generatedAt = world?.generatedAtUtc
|
const generatedAt = world?.generatedAtUtc
|
||||||
? new Date(world.generatedAtUtc).toLocaleTimeString()
|
? new Date(world.generatedAtUtc).toLocaleTimeString()
|
||||||
@@ -296,7 +294,8 @@ export function updateGameStatus(params: GameStatusParams) {
|
|||||||
? `loc pos: ${fmtVec(systemAnchor.clone().sub(toThreeVector(celestialAnchor)), 0)} km`
|
? `loc pos: ${fmtVec(systemAnchor.clone().sub(toThreeVector(celestialAnchor)), 0)} km`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
statusEl.textContent = [
|
return {
|
||||||
|
bodyText: [
|
||||||
`mode: ${mode}`,
|
`mode: ${mode}`,
|
||||||
`camera: ${cameraModeLabel}`,
|
`camera: ${cameraModeLabel}`,
|
||||||
`zoom: ${displayPovLevel}`,
|
`zoom: ${displayPovLevel}`,
|
||||||
@@ -306,9 +305,16 @@ export function updateGameStatus(params: GameStatusParams) {
|
|||||||
locPos,
|
locPos,
|
||||||
`sequence: ${sequence}`,
|
`sequence: ${sequence}`,
|
||||||
`snapshot: ${generatedAt}`,
|
`snapshot: ${generatedAt}`,
|
||||||
].filter(Boolean).join("\n");
|
].filter(Boolean).join("\n"),
|
||||||
if (summaryEl) {
|
summaryText: `${mode} | ${displayPovLevel} | ${activeSpace}`,
|
||||||
summaryEl.textContent = `${mode} | ${displayPovLevel} | ${activeSpace}`;
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateGameStatus(params: GameStatusParams & { statusEl: HTMLDivElement; summaryEl?: HTMLSpanElement }) {
|
||||||
|
const state = describeGameStatus(params);
|
||||||
|
params.statusEl.textContent = state.bodyText;
|
||||||
|
if (params.summaryEl) {
|
||||||
|
params.summaryEl.textContent = state.summaryText;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
},
|
},
|
||||||
"include": ["src", "vite.config.ts"]
|
"include": ["src", "vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
const root = new URL(".", import.meta.url).pathname;
|
const root = new URL(".", import.meta.url).pathname;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
plugins: [tailwindcss(), vue()],
|
||||||
root,
|
root,
|
||||||
server: {
|
server: {
|
||||||
host: true,
|
host: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user