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",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -p tsconfig.json && vite build",
|
||||
"build": "vue-tsc -p tsconfig.json --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"three": "^0.179.1"
|
||||
"pinia": "^3.0.3",
|
||||
"three": "^0.179.1",
|
||||
"vue": "^3.5.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/three": "^0.183.1",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"tailwindcss": "^4.2.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";
|
||||
|
||||
export class GameViewer {
|
||||
private readonly controller: ViewerAppController;
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.controller = new ViewerAppController(container);
|
||||
constructor(container: HTMLElement, hud: ViewerHudBindings) {
|
||||
this.controller = new ViewerAppController(container, hud);
|
||||
}
|
||||
|
||||
async 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,
|
||||
NAV_DISTANCE,
|
||||
} from "./viewerConstants";
|
||||
import { createViewerHud } from "./viewerHud";
|
||||
import { updatePanFromKeyboard } from "./viewerCamera";
|
||||
import { setShellReticleOpacity } from "./viewerControls";
|
||||
import { renderFrame, resizeViewer, stepCamera } from "./viewerRenderLoop";
|
||||
@@ -21,16 +20,21 @@ import { ViewerNavigationController } from "./viewerNavigationController";
|
||||
import { ViewerSceneDataController } from "./viewerSceneDataController";
|
||||
import { ViewerPresentationController } from "./viewerPresentationController";
|
||||
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 { UniverseLayer } from "./viewerUniverseLayer";
|
||||
import { GalaxyLayer } from "./viewerGalaxyLayer";
|
||||
import { SystemLayer } from "./viewerSystemLayer";
|
||||
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 {
|
||||
CameraMode,
|
||||
DragMode,
|
||||
HistoryWindowState,
|
||||
NetworkStats,
|
||||
PerformanceStats,
|
||||
Selectable,
|
||||
@@ -41,7 +45,8 @@ import type {
|
||||
|
||||
export class ViewerAppController {
|
||||
private readonly container: HTMLElement;
|
||||
private readonly renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
private readonly renderer = createViewerRenderer();
|
||||
private readonly renderSurface: ViewerRenderSurface;
|
||||
|
||||
// ── Three independent rendering layers ───────────────────────────────────
|
||||
readonly universeLayer = new UniverseLayer();
|
||||
@@ -61,23 +66,9 @@ export class ViewerAppController {
|
||||
private readonly cameraOffset = new THREE.Vector3();
|
||||
private readonly keyState = new Set<string>();
|
||||
|
||||
private readonly gamePanelEl: HTMLDivElement;
|
||||
|
||||
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;
|
||||
readonly hudState: ViewerHudState;
|
||||
readonly selectionStore: ViewerSelectionStore;
|
||||
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 marqueeEl: HTMLDivElement;
|
||||
private readonly hoverLabelEl: HTMLDivElement;
|
||||
@@ -124,30 +115,14 @@ export class ViewerAppController {
|
||||
private readonly navigationController: ViewerNavigationController;
|
||||
private readonly sceneDataController: ViewerSceneDataController;
|
||||
private readonly presentationController: ViewerPresentationController;
|
||||
private readonly disposeEventBindings: () => void;
|
||||
private readonly unsubscribeSelectionStore: () => void;
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
constructor(container: HTMLElement, hud: ViewerHudBindings) {
|
||||
this.container = container;
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
|
||||
|
||||
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.hudState = hud.state;
|
||||
this.selectionStore = hud.selectionStore;
|
||||
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.marqueeEl = hud.marqueeEl;
|
||||
this.hoverLabelEl = hud.hoverLabelEl;
|
||||
@@ -160,33 +135,51 @@ export class ViewerAppController {
|
||||
interactionController: this.interactionController,
|
||||
} = createViewerControllers(this));
|
||||
this.presentationController.initializeAmbience();
|
||||
|
||||
this.container.append(this.renderer.domElement, hud.root);
|
||||
this.initializePanelToggles();
|
||||
wireViewerEvents(this);
|
||||
this.onResize();
|
||||
this.unsubscribeSelectionStore = this.selectionStore.$subscribe((_mutation, state) => {
|
||||
this.syncSelectionFromStore(state.selectedEntityKind, state.selectedEntityId);
|
||||
});
|
||||
this.renderSurface = new ViewerRenderSurface({
|
||||
container: this.container,
|
||||
renderer: this.renderer,
|
||||
onFrame: () => this.render(),
|
||||
onResize: (width, height) => this.onResize(width, height),
|
||||
});
|
||||
this.disposeEventBindings = wireViewerEvents(this);
|
||||
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() {
|
||||
this.selectionStore.clearSelection();
|
||||
await this.worldLifecycle.bootstrapWorld();
|
||||
this.renderSurface.start();
|
||||
}
|
||||
|
||||
async start() {
|
||||
await this.worldLifecycle.bootstrapWorld();
|
||||
this.renderer.setAnimationLoop(() => this.render());
|
||||
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() {
|
||||
@@ -214,6 +207,32 @@ export class ViewerAppController {
|
||||
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() {
|
||||
renderFrame({
|
||||
clock: this.clock,
|
||||
@@ -324,14 +343,15 @@ export class ViewerAppController {
|
||||
return resolveFocusedCelestialId(this.world, this.selectedItems);
|
||||
}
|
||||
|
||||
private onResize = () => {
|
||||
private onResize(width: number, height: number) {
|
||||
resizeViewer({
|
||||
renderer: this.renderer,
|
||||
galaxyLayer: this.galaxyLayer,
|
||||
systemLayer: this.systemLayer,
|
||||
localLayer: this.localLayer,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
private setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opacity: number) {
|
||||
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 { GameViewer } from "./GameViewer";
|
||||
import "./styles/index.css";
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import { viewerPinia } from "./ui/stores/pinia";
|
||||
|
||||
const root = document.querySelector<HTMLDivElement>("#app");
|
||||
|
||||
@@ -7,5 +9,6 @@ if (!root) {
|
||||
throw new Error("Missing #app root element");
|
||||
}
|
||||
|
||||
const viewer = new GameViewer(root);
|
||||
void viewer.start();
|
||||
createApp(App)
|
||||
.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 {
|
||||
color-scheme: dark;
|
||||
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||
--bg: #050812;
|
||||
--panel: rgba(9, 18, 34, 0.78);
|
||||
--panel-border: rgba(132, 196, 255, 0.18);
|
||||
--text: #eaf4ff;
|
||||
--muted: #98adc4;
|
||||
--accent: #7fd6ff;
|
||||
--warning: #ffbf69;
|
||||
--viewer-panel: rgba(9, 18, 34, 0.78);
|
||||
--viewer-panel-border: rgba(132, 196, 255, 0.18);
|
||||
--viewer-text: #eaf4ff;
|
||||
--viewer-muted: #98adc4;
|
||||
--viewer-accent: #7fd6ff;
|
||||
--viewer-warning: #ffbf69;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -27,39 +26,95 @@ body,
|
||||
linear-gradient(180deg, #03060d 0%, #060c18 100%);
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--viewer-text);
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.viewer-shell {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
.viewer-app,
|
||||
.viewer-canvas-host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.left-panel-stack {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
width: min(360px, calc(100vw - 40px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
.panel-summary,
|
||||
.hud-mono,
|
||||
.system-body,
|
||||
.detail-body,
|
||||
.ship-card p,
|
||||
.history,
|
||||
.history-window-body,
|
||||
.hover-label {
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||
}
|
||||
|
||||
.right-panel-stack {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: min(380px, calc(100vw - 40px));
|
||||
.collapsible-panel.is-collapsed .game-body,
|
||||
.collapsible-panel.is-collapsed .network-body,
|
||||
.collapsible-panel.is-collapsed .performance-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
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 {
|
||||
position: absolute;
|
||||
display: none;
|
||||
border: 1px solid rgba(127, 214, 255, 0.72);
|
||||
background: rgba(127, 214, 255, 0.14);
|
||||
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);
|
||||
border: 1px solid rgba(255, 88, 72, 0.5);
|
||||
color: #fff2ef;
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.35;
|
||||
white-space: pre-line;
|
||||
@@ -102,235 +156,6 @@ canvas {
|
||||
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 {
|
||||
position: absolute;
|
||||
right: auto;
|
||||
@@ -353,10 +178,6 @@ canvas {
|
||||
resize: both;
|
||||
}
|
||||
|
||||
.history-window[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.history-window-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -369,41 +190,16 @@ canvas {
|
||||
|
||||
.history-window-title {
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
color: var(--viewer-accent);
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.16em;
|
||||
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 {
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
color: var(--text);
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||
color: var(--viewer-text);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
@@ -411,34 +207,10 @@ canvas {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.error-strip {
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255, 116, 88, 0.14);
|
||||
color: #ffd8cf;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.right-panel-stack .error-strip {
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.system-panel-section[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.detail-panel-section[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.error-strip[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.history-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
.history-window-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ops-strip {
|
||||
@@ -448,8 +220,6 @@ canvas {
|
||||
bottom: 0;
|
||||
width: 50vw;
|
||||
min-height: 128px;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
@@ -461,7 +231,6 @@ canvas {
|
||||
}
|
||||
|
||||
.ship-card {
|
||||
border-radius: 0;
|
||||
border-top: 1px solid rgba(127, 214, 255, 0.14);
|
||||
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));
|
||||
@@ -471,7 +240,7 @@ canvas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
color: var(--text);
|
||||
color: var(--viewer-text);
|
||||
cursor: pointer;
|
||||
transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
|
||||
}
|
||||
@@ -499,11 +268,26 @@ canvas {
|
||||
}
|
||||
|
||||
.ship-card h3 {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.15;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -515,30 +299,12 @@ canvas {
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(127, 214, 255, 0.12);
|
||||
color: var(--accent);
|
||||
color: var(--viewer-accent);
|
||||
font-size: 0.64rem;
|
||||
letter-spacing: 0.12em;
|
||||
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 {
|
||||
margin-top: 2px;
|
||||
padding-top: 6px;
|
||||
@@ -547,11 +313,22 @@ canvas {
|
||||
|
||||
.ship-card-section-title {
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
color: var(--viewer-accent);
|
||||
letter-spacing: 0.14em;
|
||||
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 {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
@@ -564,6 +341,11 @@ canvas {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.history-window-copy,
|
||||
.history-window-close {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.faction-card {
|
||||
border-top-color: rgba(180, 130, 255, 0.3);
|
||||
cursor: default;
|
||||
@@ -582,11 +364,14 @@ canvas {
|
||||
border-color: rgba(127, 255, 180, 0.5);
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 14px;
|
||||
height: 48px;
|
||||
border-radius: 999px;
|
||||
flex: none;
|
||||
.ship-card-split-line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.selection-action-button {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
@@ -596,53 +381,8 @@ canvas {
|
||||
}
|
||||
|
||||
@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 {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 50vw;
|
||||
width: 100vw;
|
||||
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({
|
||||
renderer: host.renderer,
|
||||
hudState: host.hudState,
|
||||
galaxyScene: host.galaxyLayer.scene,
|
||||
galaxyCamera: host.galaxyLayer.camera,
|
||||
systemCamera: host.systemLayer.camera,
|
||||
galaxyAnchor: host.galaxyAnchor,
|
||||
systemAnchor: host.systemAnchor,
|
||||
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,
|
||||
performanceStats: host.performanceStats,
|
||||
getWorld: () => host.world,
|
||||
@@ -137,10 +129,7 @@ export function createViewerControllers(host: any) {
|
||||
getCameraTargetShipId: () => host.cameraTargetShipId,
|
||||
getNetworkStats: () => host.networkStats,
|
||||
getSystemSummaryVisuals: () => new Map(),
|
||||
errorEl: host.errorEl,
|
||||
opsStripEl: host.opsStripEl,
|
||||
detailTitleEl: host.detailTitleEl,
|
||||
detailBodyEl: host.detailBodyEl,
|
||||
hudState: host.hudState,
|
||||
worldLabel: () => host.world?.label ?? "",
|
||||
rebuildSystems: (systems) => sceneDataController.rebuildSystems(systems),
|
||||
syncCelestials: (celestials) => sceneDataController.syncCelestials(celestials),
|
||||
@@ -166,7 +155,6 @@ export function createViewerControllers(host: any) {
|
||||
});
|
||||
|
||||
const historyController = new ViewerHistoryWindowController({
|
||||
historyLayerEl: host.historyLayerEl,
|
||||
historyWindows: host.historyWindows,
|
||||
getWorld: () => host.world,
|
||||
getHistoryWindowCounter: () => host.historyWindowCounter,
|
||||
@@ -200,13 +188,14 @@ export function createViewerControllers(host: any) {
|
||||
hoverLabelEl: host.hoverLabelEl,
|
||||
hoverConnectorLineEl: host.hoverConnectorLineEl,
|
||||
marqueeEl: host.marqueeEl,
|
||||
hudState: host.hudState,
|
||||
keyState: host.keyState,
|
||||
getWorld: () => host.world,
|
||||
getActiveSystemId: () => host.activeSystemId,
|
||||
getPovLevel: () => host.povLevel,
|
||||
getSelectedItems: () => host.selectedItems,
|
||||
setSelectedItems: (items) => {
|
||||
host.selectedItems = items;
|
||||
host.applySelectedItems(items, "viewer");
|
||||
},
|
||||
getDragMode: () => host.dragMode,
|
||||
setDragMode: (mode) => {
|
||||
@@ -268,20 +257,33 @@ export function createViewerControllers(host: any) {
|
||||
}
|
||||
|
||||
export function wireViewerEvents(host: any) {
|
||||
host.renderer.domElement.addEventListener("pointerdown", host.interactionController.onPointerDown);
|
||||
host.renderer.domElement.addEventListener("pointermove", host.interactionController.onPointerMove);
|
||||
host.renderer.domElement.addEventListener("pointerup", host.interactionController.onPointerUp);
|
||||
host.renderer.domElement.addEventListener("pointerleave", host.interactionController.onPointerUp);
|
||||
host.renderer.domElement.addEventListener("click", host.interactionController.onClick);
|
||||
host.renderer.domElement.addEventListener("dblclick", host.interactionController.onDoubleClick);
|
||||
host.renderer.domElement.addEventListener("wheel", host.interactionController.onWheel, { passive: false });
|
||||
host.opsStripEl.addEventListener("click", host.interactionController.onOpsStripClick);
|
||||
host.opsStripEl.addEventListener("dblclick", host.interactionController.onOpsStripDoubleClick);
|
||||
const canvas = host.renderer.domElement;
|
||||
canvas.addEventListener("pointerdown", host.interactionController.onPointerDown);
|
||||
canvas.addEventListener("pointermove", host.interactionController.onPointerMove);
|
||||
canvas.addEventListener("pointerup", host.interactionController.onPointerUp);
|
||||
canvas.addEventListener("pointerleave", host.interactionController.onPointerUp);
|
||||
canvas.addEventListener("click", host.interactionController.onClick);
|
||||
canvas.addEventListener("dblclick", host.interactionController.onDoubleClick);
|
||||
canvas.addEventListener("wheel", host.interactionController.onWheel, { passive: false });
|
||||
host.historyLayerEl.addEventListener("click", host.interactionController.onHistoryLayerClick);
|
||||
host.historyLayerEl.addEventListener("pointerdown", host.interactionController.onHistoryLayerPointerDown);
|
||||
window.addEventListener("pointermove", host.interactionController.onHistoryWindowPointerMove);
|
||||
window.addEventListener("pointerup", host.interactionController.onHistoryWindowPointerUp);
|
||||
window.addEventListener("keydown", host.interactionController.onKeyDown);
|
||||
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(
|
||||
documentRef: Document,
|
||||
target: Selectable,
|
||||
historyWindowsCount: number,
|
||||
historyWindowCounter: number,
|
||||
): 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 {
|
||||
id,
|
||||
id: `history-${historyWindowCounter}`,
|
||||
target,
|
||||
root,
|
||||
titleEl: root.querySelector(".history-window-title") as HTMLHeadingElement,
|
||||
bodyEl: root.querySelector(".history-window-body") as HTMLDivElement,
|
||||
copyButtonEl: root.querySelector(".history-window-copy") as HTMLButtonElement,
|
||||
title: "History",
|
||||
bodyHtml: "No history selected.",
|
||||
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;
|
||||
}
|
||||
|
||||
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.bodyEl.innerHTML = windowState.text.replaceAll("\n", "<br>");
|
||||
windowState.bodyHtml = windowState.text.replaceAll("\n", "<br>");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -60,9 +44,9 @@ export function refreshHistoryWindow(
|
||||
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.bodyEl.innerHTML = windowState.text.replaceAll("\n", "<br>");
|
||||
windowState.bodyHtml = windowState.text.replaceAll("\n", "<br>");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as THREE from "three";
|
||||
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(
|
||||
historyWindows: HistoryWindowState[],
|
||||
historyLayerEl: HTMLDivElement,
|
||||
target: Selectable,
|
||||
nextCounter: number,
|
||||
bringToFront: (windowState: HistoryWindowState) => void,
|
||||
@@ -17,9 +17,8 @@ export function openHistoryWindow(
|
||||
return nextCounter;
|
||||
}
|
||||
|
||||
const windowState = createHistoryWindowState(document, target, historyWindows.length, nextCounter);
|
||||
const windowState = createHistoryWindowState(target, historyWindows.length, nextCounter);
|
||||
historyWindows.push(windowState);
|
||||
historyLayerEl.append(windowState.root);
|
||||
bringToFront(windowState);
|
||||
refreshWindows();
|
||||
return nextCounter;
|
||||
@@ -56,8 +55,7 @@ export function destroyHistoryWindow(
|
||||
};
|
||||
}
|
||||
|
||||
const [removed] = historyWindows.splice(index, 1);
|
||||
removed.root.remove();
|
||||
historyWindows.splice(index, 1);
|
||||
if (historyWindowDragId === id) {
|
||||
return {
|
||||
historyWindowDragId: undefined,
|
||||
@@ -72,7 +70,7 @@ export function destroyHistoryWindow(
|
||||
}
|
||||
|
||||
export function bringHistoryWindowToFront(windowState: HistoryWindowState, nextZIndex: number) {
|
||||
windowState.root.style.zIndex = `${nextZIndex}`;
|
||||
windowState.zIndex = nextZIndex;
|
||||
}
|
||||
|
||||
export function beginHistoryWindowDrag(
|
||||
@@ -91,9 +89,7 @@ export function beginHistoryWindowDrag(
|
||||
};
|
||||
}
|
||||
|
||||
const bounds = windowState.root.getBoundingClientRect();
|
||||
historyWindowDragOffset.set(clientX - bounds.left, clientY - bounds.top);
|
||||
windowState.root.setPointerCapture?.(pointerId);
|
||||
historyWindowDragOffset.set(clientX - windowState.x, clientY - windowState.y);
|
||||
return {
|
||||
historyWindowDragId: windowId,
|
||||
historyWindowDragPointerId: pointerId,
|
||||
@@ -118,16 +114,12 @@ export function updateHistoryWindowDrag(
|
||||
return;
|
||||
}
|
||||
|
||||
const width = windowState.root.offsetWidth;
|
||||
const height = windowState.root.offsetHeight;
|
||||
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`;
|
||||
windowState.x = THREE.MathUtils.clamp(clientX - historyWindowDragOffset.x, 20, window.innerWidth - windowState.width - 20);
|
||||
windowState.y = THREE.MathUtils.clamp(clientY - historyWindowDragOffset.y, 20, window.innerHeight - windowState.height - 20);
|
||||
}
|
||||
|
||||
export function endHistoryWindowDrag(
|
||||
historyWindows: HistoryWindowState[],
|
||||
_historyWindows: HistoryWindowState[],
|
||||
historyWindowDragId: string | undefined,
|
||||
historyWindowDragPointerId: number | undefined,
|
||||
pointerId: number,
|
||||
@@ -139,8 +131,6 @@ export function endHistoryWindowDrag(
|
||||
};
|
||||
}
|
||||
|
||||
const windowState = historyWindows.find((candidate) => candidate.id === historyWindowDragId);
|
||||
windowState?.root.releasePointerCapture?.(pointerId);
|
||||
return {
|
||||
historyWindowDragId: undefined,
|
||||
historyWindowDragPointerId: undefined,
|
||||
|
||||
@@ -9,10 +9,10 @@ import {
|
||||
refreshHistoryWindows,
|
||||
updateHistoryWindowDrag,
|
||||
} from "./viewerHistoryManager";
|
||||
import type { HistoryWindowState, Selectable, WorldState } from "./viewerTypes";
|
||||
import type { HistoryWindowState } from "./viewerHudState";
|
||||
import type { Selectable, WorldState } from "./viewerTypes";
|
||||
|
||||
export interface ViewerHistoryWindowContext {
|
||||
historyLayerEl: HTMLDivElement;
|
||||
historyWindows: HistoryWindowState[];
|
||||
getWorld: () => WorldState | undefined;
|
||||
getHistoryWindowCounter: () => number;
|
||||
@@ -33,7 +33,6 @@ export class ViewerHistoryWindowController {
|
||||
openHistoryWindow(target: Selectable) {
|
||||
const nextCounter = openHistoryWindow(
|
||||
this.context.historyWindows,
|
||||
this.context.historyLayerEl,
|
||||
target,
|
||||
this.context.getHistoryWindowCounter() + 1,
|
||||
(windowState) => this.bringHistoryWindowToFront(windowState),
|
||||
@@ -155,14 +154,14 @@ export class ViewerHistoryWindowController {
|
||||
|
||||
try {
|
||||
await copyTextToClipboard(windowState.text);
|
||||
windowState.copyButtonEl.textContent = "Copied";
|
||||
windowState.copyLabel = "Copied";
|
||||
window.setTimeout(() => {
|
||||
windowState.copyButtonEl.textContent = "Copy";
|
||||
windowState.copyLabel = "Copy";
|
||||
}, 1200);
|
||||
} catch {
|
||||
windowState.copyButtonEl.textContent = "Failed";
|
||||
windowState.copyLabel = "Failed";
|
||||
window.setTimeout(() => {
|
||||
windowState.copyButtonEl.textContent = "Copy";
|
||||
windowState.copyLabel = "Copy";
|
||||
}, 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 { ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants";
|
||||
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";
|
||||
|
||||
export interface HoverPickResult {
|
||||
@@ -67,6 +68,7 @@ export function pickSelectableHitAtClientPosition(
|
||||
|
||||
export function updateHoverLabel(params: {
|
||||
dragMode?: string;
|
||||
hoverState: HoverLabelState;
|
||||
hoverLabelEl: HTMLDivElement;
|
||||
hoverConnectorLineEl: SVGLineElement;
|
||||
hoverPick: HoverPickResult | undefined;
|
||||
@@ -77,6 +79,7 @@ export function updateHoverLabel(params: {
|
||||
}) {
|
||||
const {
|
||||
dragMode,
|
||||
hoverState,
|
||||
hoverLabelEl,
|
||||
hoverConnectorLineEl,
|
||||
hoverPick,
|
||||
@@ -87,6 +90,8 @@ export function updateHoverLabel(params: {
|
||||
} = params;
|
||||
|
||||
if (dragMode || !hoverPick) {
|
||||
hoverState.hidden = true;
|
||||
hoverState.connectorHidden = true;
|
||||
hoverLabelEl.hidden = true;
|
||||
hoverConnectorLineEl.setAttribute("hidden", "");
|
||||
return;
|
||||
@@ -95,6 +100,8 @@ export function updateHoverLabel(params: {
|
||||
const { selection, object, camera } = hoverPick;
|
||||
const label = describeHoverLabel(world, selection);
|
||||
if (!label) {
|
||||
hoverState.hidden = true;
|
||||
hoverState.connectorHidden = true;
|
||||
hoverLabelEl.hidden = true;
|
||||
hoverConnectorLineEl.setAttribute("hidden", "");
|
||||
return;
|
||||
@@ -102,18 +109,27 @@ export function updateHoverLabel(params: {
|
||||
|
||||
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.textContent = `${label}\n${distance}`;
|
||||
hoverLabelEl.style.left = `${point.x + 44}px`;
|
||||
hoverLabelEl.style.top = `${point.y - 90}px`;
|
||||
hoverLabelEl.textContent = hoverState.text;
|
||||
hoverLabelEl.style.left = `${hoverState.x}px`;
|
||||
hoverLabelEl.style.top = `${hoverState.y}px`;
|
||||
|
||||
const rect = hoverLabelEl.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.setAttribute("x1", String(point.x));
|
||||
hoverConnectorLineEl.setAttribute("y1", String(point.y));
|
||||
hoverConnectorLineEl.setAttribute("x2", String(rect.left - svgRect.left));
|
||||
hoverConnectorLineEl.setAttribute("y2", String(rect.top - svgRect.top + rect.height / 2));
|
||||
hoverConnectorLineEl.setAttribute("x1", String(hoverState.x1));
|
||||
hoverConnectorLineEl.setAttribute("y1", String(hoverState.y1));
|
||||
hoverConnectorLineEl.setAttribute("x2", String(hoverState.x2));
|
||||
hoverConnectorLineEl.setAttribute("y2", String(hoverState.y2));
|
||||
}
|
||||
|
||||
function formatHoverDistance(
|
||||
@@ -150,6 +166,7 @@ function formatHoverDistance(
|
||||
}
|
||||
|
||||
export function updateMarqueeBox(
|
||||
marqueeState: MarqueeState,
|
||||
marqueeEl: HTMLDivElement,
|
||||
dragStart: THREE.Vector2,
|
||||
dragLast: THREE.Vector2,
|
||||
@@ -158,13 +175,21 @@ export function updateMarqueeBox(
|
||||
const minY = Math.min(dragStart.y, dragLast.y);
|
||||
const maxX = Math.max(dragStart.x, dragLast.x);
|
||||
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.top = `${minY}px`;
|
||||
marqueeEl.style.width = `${maxX - minX}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.width = "0";
|
||||
marqueeEl.style.height = "0";
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "./viewerControls";
|
||||
import { NAV_DISTANCE, NAV_DISTANCE_PLANET_ORBIT, NAV_DISTANCE_SHIP_HULL } from "./viewerConstants";
|
||||
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
|
||||
import type { ViewerHudState } from "./viewerHudState";
|
||||
import type {
|
||||
CameraMode,
|
||||
DragMode,
|
||||
@@ -33,6 +34,7 @@ export interface ViewerInteractionContext {
|
||||
hoverLabelEl: HTMLDivElement;
|
||||
hoverConnectorLineEl: SVGLineElement;
|
||||
marqueeEl: HTMLDivElement;
|
||||
hudState: ViewerHudState;
|
||||
keyState: Set<string>;
|
||||
getWorld: () => WorldState | undefined;
|
||||
getActiveSystemId: () => string | undefined;
|
||||
@@ -109,6 +111,7 @@ export class ViewerInteractionController {
|
||||
if (!this.context.getMarqueeActive() && dragDistance > 8) {
|
||||
this.context.setMarqueeActive(true);
|
||||
this.context.setSuppressClickSelection(true);
|
||||
this.context.hudState.marquee.visible = true;
|
||||
this.context.marqueeEl.style.display = "block";
|
||||
}
|
||||
|
||||
@@ -117,7 +120,7 @@ export class ViewerInteractionController {
|
||||
}
|
||||
|
||||
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) => {
|
||||
@@ -131,7 +134,7 @@ export class ViewerInteractionController {
|
||||
|
||||
if (this.context.getDragMode() === "marquee" && this.context.getMarqueeActive()) {
|
||||
this.completeMarqueeSelection();
|
||||
hideMarqueeBox(this.context.marqueeEl);
|
||||
hideMarqueeBox(this.context.hudState.marquee, this.context.marqueeEl);
|
||||
}
|
||||
|
||||
this.context.setDragMode(undefined);
|
||||
@@ -285,6 +288,7 @@ export class ViewerInteractionController {
|
||||
updateHoverLabel(event: PointerEvent) {
|
||||
updateHoverLabel({
|
||||
dragMode: this.context.getDragMode(),
|
||||
hoverState: this.context.hudState.hoverLabel,
|
||||
hoverLabelEl: this.context.hoverLabelEl,
|
||||
hoverConnectorLineEl: this.context.hoverConnectorLineEl,
|
||||
hoverPick: this.pickSelectableHitAtClientPosition(event.clientX, event.clientY),
|
||||
@@ -299,6 +303,10 @@ export class ViewerInteractionController {
|
||||
this.context.historyController.refreshHistoryWindows();
|
||||
}
|
||||
|
||||
openHistoryWindow(selection: Selectable) {
|
||||
this.context.historyController.openHistoryWindow(selection);
|
||||
}
|
||||
|
||||
toggleCameraMode(forceMode?: CameraMode) {
|
||||
const nextState = toggleCameraMode({
|
||||
cameraMode: this.context.getCameraMode(),
|
||||
|
||||
@@ -1,154 +1,131 @@
|
||||
import type { StationSnapshot } from "./contractsInfrastructure";
|
||||
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 type { CameraMode, Selectable, WorldState, PovLevel } from "./viewerTypes";
|
||||
|
||||
function renderFactionCard(faction: FactionSnapshot): string {
|
||||
function buildFactionCard(faction: FactionSnapshot): OpsFactionCardState {
|
||||
const state = faction.goapState;
|
||||
const priorities = faction.goapPriorities;
|
||||
|
||||
return `
|
||||
<article 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>
|
||||
${state ? `
|
||||
<div class="ship-card-ai">
|
||||
<p class="ship-card-section-title">GOAP State</p>
|
||||
<p>Military ${state.militaryShipCount} · Miners ${state.minerShipCount}</p>
|
||||
<p>Transport ${state.transportShipCount} · Constructors ${state.constructorShipCount}</p>
|
||||
<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>
|
||||
`;
|
||||
return {
|
||||
kind: "faction",
|
||||
id: faction.id,
|
||||
label: faction.label,
|
||||
stateLines: state ? [
|
||||
`Military ${state.militaryShipCount} · Miners ${state.minerShipCount}`,
|
||||
`Transport ${state.transportShipCount} · Constructors ${state.constructorShipCount}`,
|
||||
`Systems ${state.controlledSystemCount} / ${state.targetSystemCount}`,
|
||||
`Factory ${state.hasShipFactory ? "yes" : "no"} · Ore ${state.oreStockpile.toFixed(0)}`,
|
||||
] : [],
|
||||
priorities: (faction.goapPriorities ?? []).map((entry) => ({
|
||||
label: entry.goalName,
|
||||
value: entry.priority.toFixed(0),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function renderStationCard(station: StationSnapshot, isSelected: boolean): string {
|
||||
const cargo = station.inventory.reduce((sum, e) => sum + e.amount, 0);
|
||||
const processes = station.currentProcesses;
|
||||
|
||||
return `
|
||||
<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>
|
||||
`;
|
||||
function buildProgressBar(label: string, progress: number): HudProgressBar {
|
||||
return {
|
||||
label,
|
||||
valueLabel: `${Math.round(progress * 100)}%`,
|
||||
progress: Number((progress * 100).toFixed(1)),
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
selectedItems: Selectable[],
|
||||
cameraMode: CameraMode,
|
||||
cameraTargetShipId?: string,
|
||||
povLevel?: PovLevel,
|
||||
activeSystemId?: string,
|
||||
) {
|
||||
): OpsStripState {
|
||||
if (!world) {
|
||||
return "";
|
||||
return {
|
||||
factions: [],
|
||||
stations: [],
|
||||
ships: [],
|
||||
};
|
||||
}
|
||||
|
||||
const isSystemFiltered = povLevel !== "galaxy" && activeSystemId != null;
|
||||
|
||||
const factionCards = [...world.factions.values()]
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
.map(renderFactionCard)
|
||||
.join("");
|
||||
const factions = [...world.factions.values()]
|
||||
.sort((left, right) => left.label.localeCompare(right.label))
|
||||
.map(buildFactionCard);
|
||||
|
||||
const stationCards = [...world.stations.values()]
|
||||
const stations = [...world.stations.values()]
|
||||
.filter((station) => !isSystemFiltered || station.systemId === activeSystemId)
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
.map((station) => {
|
||||
const isSelected = selectedItems.length === 1
|
||||
&& selectedItems[0].kind === "station"
|
||||
&& selectedItems[0].id === station.id;
|
||||
return renderStationCard(station, isSelected);
|
||||
})
|
||||
.join("");
|
||||
.sort((left, right) => left.label.localeCompare(right.label))
|
||||
.map((station) => buildStationCard(
|
||||
station,
|
||||
selectedItems.length === 1 && selectedItems[0].kind === "station" && selectedItems[0].id === station.id,
|
||||
));
|
||||
|
||||
const ships = [...world.ships.values()]
|
||||
.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
|
||||
.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;
|
||||
return { factions, stations, ships };
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ const itemTransportById = new Map<string, string>(
|
||||
import { describeCelestialPathWithinSystem, describeOrbitalParent, describeSelectable, describeShipBehavior, describeShipCurrentAction, describeShipOrder, describeShipState, getSelectionGroup, renderSystemDetails } from "./viewerSelection";
|
||||
import type {
|
||||
CameraMode,
|
||||
HistoryWindowState,
|
||||
NodeVisual,
|
||||
OrbitalAnchor,
|
||||
Selectable,
|
||||
@@ -39,9 +38,6 @@ interface DetailPanelParams {
|
||||
interface SystemPanelParams {
|
||||
world: WorldState;
|
||||
activeSystemId?: string;
|
||||
systemTitleEl: HTMLHeadingElement;
|
||||
systemBodyEl: HTMLDivElement;
|
||||
systemPanelEl: HTMLDivElement;
|
||||
cameraMode: CameraMode;
|
||||
cameraTargetShipId?: string;
|
||||
}
|
||||
@@ -70,7 +66,15 @@ function formatModuleListWithConstruction(
|
||||
world: WorldState,
|
||||
stationId: string,
|
||||
installedModules: string[],
|
||||
currentProcesses: { lane: string; label: string; progress: number; timeRemainingSeconds: number }[],
|
||||
currentProcesses: {
|
||||
lane: string;
|
||||
label: string;
|
||||
progress: number;
|
||||
timeRemainingSeconds: number;
|
||||
cycleSeconds: number;
|
||||
inputs: { itemId: string; amount: number }[];
|
||||
outputs: { itemId: string; amount: number }[];
|
||||
}[],
|
||||
): string {
|
||||
const processByModule = new Map<string, { label: string; progress: number; timeRemainingSeconds: number; cycleSeconds: number; inputs: { itemId: string; amount: number }[]; outputs: { itemId: string; amount: number }[] }[]>();
|
||||
for (const process of currentProcesses) {
|
||||
@@ -175,11 +179,7 @@ function renderSystemOwnership(world: WorldState, systemId: string): string {
|
||||
.join("<br>");
|
||||
}
|
||||
|
||||
export function updateDetailPanel(
|
||||
detailTitleEl: HTMLHeadingElement,
|
||||
detailBodyEl: HTMLDivElement,
|
||||
params: DetailPanelParams,
|
||||
) {
|
||||
export function buildDetailPanelState(params: DetailPanelParams) {
|
||||
const {
|
||||
world,
|
||||
selectedItems,
|
||||
@@ -191,35 +191,37 @@ export function updateDetailPanel(
|
||||
} = params;
|
||||
|
||||
if (selectedItems.length === 0) {
|
||||
detailTitleEl.textContent = worldLabel;
|
||||
detailBodyEl.innerHTML = `
|
||||
Zoom ${povLevel}<br>
|
||||
Systems ${world.systems.size}<br>
|
||||
Celestials ${world.celestials.size}<br>
|
||||
Stations ${world.stations.size}<br>
|
||||
Claims ${world.claims.size}<br>
|
||||
Construction ${world.constructionSites.size}<br>
|
||||
Ships ${world.ships.size}<br>
|
||||
Recent events ${world.recentEvents.length}
|
||||
`;
|
||||
return;
|
||||
return {
|
||||
title: worldLabel,
|
||||
bodyHtml: `
|
||||
Zoom ${povLevel}<br>
|
||||
Systems ${world.systems.size}<br>
|
||||
Celestials ${world.celestials.size}<br>
|
||||
Stations ${world.stations.size}<br>
|
||||
Claims ${world.claims.size}<br>
|
||||
Construction ${world.constructionSites.size}<br>
|
||||
Ships ${world.ships.size}<br>
|
||||
Recent events ${world.recentEvents.length}
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
if (selectedItems.length > 1) {
|
||||
const group = getSelectionGroup(selectedItems[0]);
|
||||
detailTitleEl.textContent = `${selectedItems.length} selected`;
|
||||
detailBodyEl.innerHTML = `
|
||||
Type ${group}<br>
|
||||
${selectedItems.slice(0, 8).map((item) => describeSelectable(world, item)).join("<br>")}
|
||||
`;
|
||||
return;
|
||||
return {
|
||||
title: `${selectedItems.length} selected`,
|
||||
bodyHtml: `
|
||||
Type ${group}<br>
|
||||
${selectedItems.slice(0, 8).map((item) => describeSelectable(world, item)).join("<br>")}
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
const selected = selectedItems[0];
|
||||
if (selected.kind === "ship") {
|
||||
const ship = world.ships.get(selected.id);
|
||||
if (!ship) {
|
||||
return;
|
||||
return { title: "Missing ship", bodyHtml: "" };
|
||||
}
|
||||
const parent = describeSelectionParent(selected);
|
||||
const cargoUsed = ship.inventory.reduce((sum, e) => sum + e.amount, 0);
|
||||
@@ -227,36 +229,37 @@ export function updateDetailPanel(
|
||||
const shipBehavior = describeShipBehavior(ship);
|
||||
const shipOrder = describeShipOrder(ship);
|
||||
const shipAction = describeShipCurrentAction(ship);
|
||||
detailTitleEl.textContent = ship.label;
|
||||
detailBodyEl.innerHTML = `
|
||||
<p>Parent ${parent}</p>
|
||||
<p>Behavior ${shipBehavior}</p>
|
||||
<p>State ${shipState}</p>
|
||||
<p>Order ${shipOrder}</p>
|
||||
<p>Task ${ship.controllerTaskKind}</p>
|
||||
${shipAction ? `
|
||||
<div class="detail-progress">
|
||||
<div class="detail-progress-label">
|
||||
<span>${shipAction.label}</span>
|
||||
<span>${Math.round(shipAction.progress * 100)}%</span>
|
||||
return {
|
||||
title: ship.label,
|
||||
bodyHtml: `
|
||||
<p>Parent ${parent}</p>
|
||||
<p>Behavior ${shipBehavior}</p>
|
||||
<p>State ${shipState}</p>
|
||||
<p>Order ${shipOrder}</p>
|
||||
<p>Task ${ship.controllerTaskKind}</p>
|
||||
${shipAction ? `
|
||||
<div class="detail-progress">
|
||||
<div class="detail-progress-label">
|
||||
<span>${shipAction.label}</span>
|
||||
<span>${Math.round(shipAction.progress * 100)}%</span>
|
||||
</div>
|
||||
<div class="detail-progress-track">
|
||||
<div class="detail-progress-fill" style="width: ${(shipAction.progress * 100).toFixed(1)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-progress-track">
|
||||
<div class="detail-progress-fill" style="width: ${(shipAction.progress * 100).toFixed(1)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
` : ""}
|
||||
<p>Cargo ${cargoUsed.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)}</p>
|
||||
<p>Inventory ${formatInventory(ship.inventory)}</p>
|
||||
<p>Speed ${formatShipSpeed(ship)}</p>
|
||||
<p>Camera ${cameraMode === "follow" && cameraTargetShipId === ship.id ? "camera-follow" : "tactical"}<br>Press C to toggle follow</p>
|
||||
`;
|
||||
return;
|
||||
` : ""}
|
||||
<p>Cargo ${cargoUsed.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)}</p>
|
||||
<p>Inventory ${formatInventory(ship.inventory)}</p>
|
||||
<p>Speed ${formatShipSpeed(ship)}</p>
|
||||
<p>Camera ${cameraMode === "follow" && cameraTargetShipId === ship.id ? "camera-follow" : "tactical"}<br>Press C to toggle follow</p>
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
if (selected.kind === "station") {
|
||||
const station = world.stations.get(selected.id);
|
||||
if (!station) {
|
||||
return;
|
||||
return { title: "Missing station", bodyHtml: "" };
|
||||
}
|
||||
const parent = describeSelectionParent(selected);
|
||||
const moduleList = formatModuleListWithConstruction(world, station.id, station.installedModules, station.currentProcesses);
|
||||
@@ -264,110 +267,116 @@ export function updateDetailPanel(
|
||||
? station.dockedShipIds.map((shipId) => world.ships.get(shipId)?.label ?? shipId).join("<br>")
|
||||
: "none";
|
||||
const stationStorage = formatStorageWithInventory(station.storageUsage, station.inventory);
|
||||
detailTitleEl.textContent = station.label;
|
||||
detailBodyEl.innerHTML = `
|
||||
<p>${station.category} · ${station.systemId}</p>
|
||||
<p>Parent ${parent}</p>
|
||||
<p>Docked ${station.dockedShips} / ${station.dockingPads}
|
||||
<br>
|
||||
${dockedShipLabels}</p>
|
||||
<p>Modules ${moduleList}</p>
|
||||
<p>Storage ${stationStorage}</p>
|
||||
`;
|
||||
return;
|
||||
return {
|
||||
title: station.label,
|
||||
bodyHtml: `
|
||||
<p>${station.category} · ${station.systemId}</p>
|
||||
<p>Parent ${parent}</p>
|
||||
<p>Docked ${station.dockedShips} / ${station.dockingPads}
|
||||
<br>
|
||||
${dockedShipLabels}</p>
|
||||
<p>Modules ${moduleList}</p>
|
||||
<p>Storage ${stationStorage}</p>
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
if (selected.kind === "node") {
|
||||
const node = world.nodes.get(selected.id);
|
||||
if (!node) {
|
||||
return;
|
||||
return { title: "Missing node", bodyHtml: "" };
|
||||
}
|
||||
const parent = describeSelectionParent(selected);
|
||||
const nodeLevel = node.maxOre > 0
|
||||
? Math.max(0, Math.min(node.oreRemaining / node.maxOre, 1))
|
||||
: 0;
|
||||
detailTitleEl.textContent = `Node ${node.id}`;
|
||||
detailBodyEl.innerHTML = `
|
||||
<p>${node.systemId}</p>
|
||||
<p>Parent ${parent}</p>
|
||||
<p>Source ${node.sourceKind}<br>Resource ${node.itemId}</p>
|
||||
<div class="detail-progress">
|
||||
<div class="detail-progress-label">
|
||||
<span>Level</span>
|
||||
<span>${Math.round(nodeLevel * 100)}%</span>
|
||||
return {
|
||||
title: `Node ${node.id}`,
|
||||
bodyHtml: `
|
||||
<p>${node.systemId}</p>
|
||||
<p>Parent ${parent}</p>
|
||||
<p>Source ${node.sourceKind}<br>Resource ${node.itemId}</p>
|
||||
<div class="detail-progress">
|
||||
<div class="detail-progress-label">
|
||||
<span>Level</span>
|
||||
<span>${Math.round(nodeLevel * 100)}%</span>
|
||||
</div>
|
||||
<div class="detail-progress-track">
|
||||
<div class="detail-progress-fill" style="width: ${(nodeLevel * 100).toFixed(1)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-progress-track">
|
||||
<div class="detail-progress-fill" style="width: ${(nodeLevel * 100).toFixed(1)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p>Stock ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}</p>
|
||||
`;
|
||||
return;
|
||||
<p>Stock ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}</p>
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
if (selected.kind === "celestial") {
|
||||
const celestial = world.celestials.get(selected.id);
|
||||
if (!celestial) {
|
||||
return;
|
||||
return { title: "Missing celestial", bodyHtml: "" };
|
||||
}
|
||||
detailTitleEl.textContent = `${celestial.kind} celestial`;
|
||||
detailBodyEl.innerHTML = `
|
||||
<p>${celestial.systemId}</p>
|
||||
<p>Parent ${celestial.parentNodeId ?? "none"}<br>Orbit ref ${celestial.orbitReferenceId ?? "none"}</p>
|
||||
<p>Occupying structure ${celestial.occupyingStructureId ?? "none"}</p>
|
||||
<p>Local space radius ${celestial.localSpaceRadius.toFixed(0)} km</p>
|
||||
`;
|
||||
return;
|
||||
return {
|
||||
title: `${celestial.kind} celestial`,
|
||||
bodyHtml: `
|
||||
<p>${celestial.systemId}</p>
|
||||
<p>Parent ${celestial.parentNodeId ?? "none"}<br>Orbit ref ${celestial.orbitReferenceId ?? "none"}</p>
|
||||
<p>Occupying structure ${celestial.occupyingStructureId ?? "none"}</p>
|
||||
<p>Local space radius ${celestial.localSpaceRadius.toFixed(0)} km</p>
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
if (selected.kind === "claim") {
|
||||
const claim = world.claims.get(selected.id);
|
||||
if (!claim) {
|
||||
return;
|
||||
return { title: "Missing claim", bodyHtml: "" };
|
||||
}
|
||||
detailTitleEl.textContent = `Claim ${claim.id}`;
|
||||
detailBodyEl.innerHTML = `
|
||||
<p>${claim.systemId}</p>
|
||||
<p>Celestial ${claim.celestialId}</p>
|
||||
<p>State ${claim.state}<br>Health ${claim.health.toFixed(0)}</p>
|
||||
<p>Activates ${new Date(claim.activatesAtUtc).toLocaleTimeString()}</p>
|
||||
`;
|
||||
return;
|
||||
return {
|
||||
title: `Claim ${claim.id}`,
|
||||
bodyHtml: `
|
||||
<p>${claim.systemId}</p>
|
||||
<p>Celestial ${claim.celestialId}</p>
|
||||
<p>State ${claim.state}<br>Health ${claim.health.toFixed(0)}</p>
|
||||
<p>Activates ${new Date(claim.activatesAtUtc).toLocaleTimeString()}</p>
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
if (selected.kind === "construction-site") {
|
||||
const site = world.constructionSites.get(selected.id);
|
||||
if (!site) {
|
||||
return;
|
||||
return { title: "Missing construction", bodyHtml: "" };
|
||||
}
|
||||
const orderCount = [...world.marketOrders.values()].filter((order) => order.constructionSiteId === site.id).length;
|
||||
detailTitleEl.textContent = `Construction ${site.id}`;
|
||||
detailBodyEl.innerHTML = `
|
||||
<p>${site.systemId}</p>
|
||||
<p>Celestial ${site.celestialId}</p>
|
||||
<p>${site.targetKind} ${site.targetDefinitionId}</p>
|
||||
<p>State ${site.state}<br>Progress ${(site.progress * 100).toFixed(0)}%</p>
|
||||
<p>Orders ${orderCount}<br>Assigned constructors ${site.assignedConstructorShipIds.length}</p>
|
||||
`;
|
||||
return;
|
||||
return {
|
||||
title: `Construction ${site.id}`,
|
||||
bodyHtml: `
|
||||
<p>${site.systemId}</p>
|
||||
<p>Celestial ${site.celestialId}</p>
|
||||
<p>${site.targetKind} ${site.targetDefinitionId}</p>
|
||||
<p>State ${site.state}<br>Progress ${(site.progress * 100).toFixed(0)}%</p>
|
||||
<p>Orders ${orderCount}<br>Assigned constructors ${site.assignedConstructorShipIds.length}</p>
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
if (selected.kind === "planet") {
|
||||
const system = world.systems.get(selected.systemId);
|
||||
const planet = system?.planets[selected.planetIndex];
|
||||
if (!system || !planet) {
|
||||
return;
|
||||
return { title: "Missing planet", bodyHtml: "" };
|
||||
}
|
||||
const parent = describeSelectionParent(selected);
|
||||
detailTitleEl.textContent = planet.label;
|
||||
detailBodyEl.innerHTML = `
|
||||
<p>${system.label}</p>
|
||||
<p>Parent ${parent}</p>
|
||||
<p>${planet.planetType} · ${planet.shape} · Moons ${planet.moons.length}</p>
|
||||
<p>Orbit ${formatSystemDistance(planet.orbitRadius)}<br>Speed ${planet.orbitSpeed.toFixed(3)}<br>Ecc ${planet.orbitEccentricity.toFixed(3)}<br>Inc ${planet.orbitInclination.toFixed(1)}°</p>
|
||||
<p>Phase ${planet.orbitPhaseAtEpoch.toFixed(1)}°</p>
|
||||
`;
|
||||
return;
|
||||
return {
|
||||
title: planet.label,
|
||||
bodyHtml: `
|
||||
<p>${system.label}</p>
|
||||
<p>Parent ${parent}</p>
|
||||
<p>${planet.planetType} · ${planet.shape} · Moons ${planet.moons.length}</p>
|
||||
<p>Orbit ${formatSystemDistance(planet.orbitRadius)}<br>Speed ${planet.orbitSpeed.toFixed(3)}<br>Ecc ${planet.orbitEccentricity.toFixed(3)}<br>Inc ${planet.orbitInclination.toFixed(1)}°</p>
|
||||
<p>Phase ${planet.orbitPhaseAtEpoch.toFixed(1)}°</p>
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
if (selected.kind === "moon") {
|
||||
@@ -375,51 +384,57 @@ export function updateDetailPanel(
|
||||
const planet = system?.planets[selected.planetIndex];
|
||||
const moon = planet?.moons[selected.moonIndex];
|
||||
if (moon) {
|
||||
detailTitleEl.textContent = moon.label;
|
||||
detailBodyEl.innerHTML = `
|
||||
<p>${system?.label ?? selected.systemId} / ${planet?.label ?? `planet ${selected.planetIndex + 1}`}</p>
|
||||
<p>Orbit ${formatSystemDistance(moon.orbitRadius)}<br>Inc ${moon.orbitInclination.toFixed(1)}°</p>
|
||||
`;
|
||||
return {
|
||||
title: moon.label,
|
||||
bodyHtml: `
|
||||
<p>${system?.label ?? selected.systemId} / ${planet?.label ?? `planet ${selected.planetIndex + 1}`}</p>
|
||||
<p>Orbit ${formatSystemDistance(moon.orbitRadius)}<br>Inc ${moon.orbitInclination.toFixed(1)}°</p>
|
||||
`,
|
||||
};
|
||||
}
|
||||
return;
|
||||
return { title: "Moon", bodyHtml: "" };
|
||||
}
|
||||
|
||||
const system = world.systems.get(selected.id);
|
||||
if (!system) {
|
||||
return;
|
||||
return {
|
||||
title: "Unknown selection",
|
||||
bodyHtml: "",
|
||||
};
|
||||
}
|
||||
|
||||
detailTitleEl.textContent = system.label;
|
||||
detailBodyEl.innerHTML = `
|
||||
<p>Parent galaxy</p>
|
||||
${renderSystemDetails(world, system, false, cameraMode, cameraTargetShipId)}
|
||||
`;
|
||||
return {
|
||||
title: system.label,
|
||||
bodyHtml: `
|
||||
<p>Parent galaxy</p>
|
||||
${renderSystemDetails(world, system, false, cameraMode, cameraTargetShipId)}
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateSystemPanel(params: SystemPanelParams) {
|
||||
export function buildSystemPanelState(params: SystemPanelParams) {
|
||||
const {
|
||||
world,
|
||||
activeSystemId,
|
||||
systemTitleEl,
|
||||
systemBodyEl,
|
||||
systemPanelEl,
|
||||
cameraMode,
|
||||
cameraTargetShipId,
|
||||
} = params;
|
||||
|
||||
const activeSystem = activeSystemId ? world.systems.get(activeSystemId) : undefined;
|
||||
systemPanelEl.hidden = !activeSystem;
|
||||
|
||||
if (!activeSystem) {
|
||||
systemTitleEl.textContent = "Deep Space";
|
||||
systemBodyEl.innerHTML = "";
|
||||
return;
|
||||
return {
|
||||
hidden: true,
|
||||
title: "Deep Space",
|
||||
bodyHtml: "",
|
||||
};
|
||||
}
|
||||
|
||||
systemTitleEl.textContent = activeSystem.label;
|
||||
systemBodyEl.innerHTML = `
|
||||
<p>${renderSystemOwnership(world, activeSystem.id)}</p>
|
||||
`;
|
||||
return {
|
||||
hidden: false,
|
||||
title: activeSystem.label,
|
||||
bodyHtml: `
|
||||
<p>${renderSystemOwnership(world, activeSystem.id)}</p>
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
export function describeSelectionParent(
|
||||
|
||||
@@ -1,34 +1,27 @@
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
updateNetworkPanel as renderNetworkPanel,
|
||||
describeNetworkPanel,
|
||||
describePerformancePanel,
|
||||
recordPerformanceStats,
|
||||
summarizeNetworkStats,
|
||||
summarizePerformanceStats,
|
||||
updatePerformancePanel as renderPerformancePanel,
|
||||
} from "./viewerTelemetry";
|
||||
import { updatePlanetPresentation } from "./viewerPresentation";
|
||||
import { renderRecentEvents, updateGameStatus, updateSystemSummaries, updateWorldPresentation } from "./viewerWorldPresentation";
|
||||
import { updateSystemPanel } from "./viewerPanels";
|
||||
import { buildSystemPanelState } from "./viewerPanels";
|
||||
import { describeGameStatus, renderRecentEvents, updateSystemSummaries, updateWorldPresentation } from "./viewerWorldPresentation";
|
||||
import { createBackdropStars, createNebulaClouds, createNebulaTexture } from "./viewerSceneFactory";
|
||||
import type { ViewerHudState } from "./viewerHudState";
|
||||
import type { Selectable } from "./viewerTypes";
|
||||
|
||||
export interface ViewerPresentationContext {
|
||||
renderer: THREE.WebGLRenderer;
|
||||
hudState: ViewerHudState;
|
||||
galaxyScene: THREE.Scene;
|
||||
galaxyCamera: THREE.PerspectiveCamera;
|
||||
systemCamera: THREE.PerspectiveCamera;
|
||||
galaxyAnchor: THREE.Vector3;
|
||||
systemAnchor: THREE.Vector3;
|
||||
ambienceGroup: THREE.Group;
|
||||
gameSummaryEl: HTMLSpanElement;
|
||||
networkSummaryEl: HTMLSpanElement;
|
||||
performanceSummaryEl: HTMLSpanElement;
|
||||
statusEl: HTMLDivElement;
|
||||
networkPanelEl: HTMLDivElement;
|
||||
performancePanelEl: HTMLDivElement;
|
||||
systemPanelEl: HTMLDivElement;
|
||||
systemTitleEl: HTMLHeadingElement;
|
||||
systemBodyEl: HTMLDivElement;
|
||||
networkStats: any;
|
||||
performanceStats: any;
|
||||
getWorld: () => any;
|
||||
@@ -61,7 +54,6 @@ export class ViewerPresentationController {
|
||||
}
|
||||
|
||||
applyZoomPresentation() {
|
||||
const activeSystemId = this.context.getActiveSystemId();
|
||||
const povLevel = this.context.getPovLevel();
|
||||
|
||||
this.context.galaxyScene.fog = new THREE.FogExp2(0x040912, 0.000035);
|
||||
@@ -73,8 +65,8 @@ export class ViewerPresentationController {
|
||||
}
|
||||
|
||||
updateNetworkPanel() {
|
||||
renderNetworkPanel(this.context.networkPanelEl, this.context.networkStats);
|
||||
this.context.networkSummaryEl.textContent = summarizeNetworkStats(this.context.networkStats);
|
||||
this.context.hudState.networkPanel.bodyText = describeNetworkPanel(this.context.networkStats);
|
||||
this.context.hudState.networkPanel.summary = summarizeNetworkStats(this.context.networkStats);
|
||||
}
|
||||
|
||||
recordPerformanceStats(frameMs: number) {
|
||||
@@ -82,8 +74,11 @@ export class ViewerPresentationController {
|
||||
}
|
||||
|
||||
updatePerformancePanel() {
|
||||
renderPerformancePanel(this.context.performancePanelEl, this.context.performanceStats, this.context.renderer);
|
||||
this.context.performanceSummaryEl.textContent = summarizePerformanceStats(this.context.performanceStats);
|
||||
const bodyText = describePerformancePanel(this.context.performanceStats, this.context.renderer);
|
||||
if (bodyText) {
|
||||
this.context.hudState.performancePanel.bodyText = bodyText;
|
||||
}
|
||||
this.context.hudState.performancePanel.summary = summarizePerformanceStats(this.context.performanceStats);
|
||||
}
|
||||
|
||||
updateShipPresentation() {
|
||||
@@ -109,9 +104,7 @@ export class ViewerPresentationController {
|
||||
}
|
||||
|
||||
updateGamePanel(mode: string) {
|
||||
updateGameStatus({
|
||||
statusEl: this.context.statusEl,
|
||||
summaryEl: this.context.gameSummaryEl,
|
||||
const state = describeGameStatus({
|
||||
world: this.context.getWorld(),
|
||||
activeSystemId: this.context.getActiveSystemId(),
|
||||
cameraMode: this.context.getCameraMode(),
|
||||
@@ -121,6 +114,8 @@ export class ViewerPresentationController {
|
||||
galaxyAnchor: this.context.galaxyAnchor,
|
||||
systemAnchor: this.context.systemAnchor,
|
||||
});
|
||||
this.context.hudState.gamePanel.bodyText = state.bodyText;
|
||||
this.context.hudState.gamePanel.summary = state.summaryText;
|
||||
}
|
||||
|
||||
updateSystemPanel() {
|
||||
@@ -129,15 +124,15 @@ export class ViewerPresentationController {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSystemPanel({
|
||||
const state = buildSystemPanelState({
|
||||
world,
|
||||
activeSystemId: this.context.getActiveSystemId(),
|
||||
systemTitleEl: this.context.systemTitleEl,
|
||||
systemBodyEl: this.context.systemBodyEl,
|
||||
systemPanelEl: this.context.systemPanelEl,
|
||||
cameraMode: this.context.getCameraMode(),
|
||||
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) {
|
||||
|
||||
@@ -25,10 +25,11 @@ export interface RenderFrameParams {
|
||||
}
|
||||
|
||||
export interface ResizeParams {
|
||||
renderer: THREE.WebGLRenderer;
|
||||
galaxyLayer: GalaxyLayer;
|
||||
systemLayer: SystemLayer;
|
||||
localLayer: LocalLayer;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface CameraStepParams {
|
||||
@@ -72,12 +73,10 @@ export function renderFrame(params: RenderFrameParams) {
|
||||
}
|
||||
|
||||
export function resizeViewer(params: ResizeParams) {
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
params.galaxyLayer.onResize(width / height);
|
||||
params.systemLayer.onResize(width / height);
|
||||
params.localLayer.onResize(width / height);
|
||||
params.renderer.setSize(width, height);
|
||||
const aspect = params.width / params.height;
|
||||
params.galaxyLayer.onResize(aspect);
|
||||
params.systemLayer.onResize(aspect);
|
||||
params.localLayer.onResize(aspect);
|
||||
}
|
||||
|
||||
export function stepCamera(params: CameraStepParams) {
|
||||
|
||||
@@ -6,6 +6,10 @@ import type {
|
||||
} from "./viewerTypes";
|
||||
|
||||
export function updateNetworkPanel(networkPanelEl: HTMLDivElement, networkStats: NetworkStats) {
|
||||
networkPanelEl.textContent = describeNetworkPanel(networkStats);
|
||||
}
|
||||
|
||||
export function describeNetworkPanel(networkStats: NetworkStats) {
|
||||
const now = performance.now();
|
||||
const uptimeSeconds = networkStats.streamOpenedAtMs
|
||||
? (now - networkStats.streamOpenedAtMs) / 1000
|
||||
@@ -22,7 +26,7 @@ export function updateNetworkPanel(networkPanelEl: HTMLDivElement, networkStats:
|
||||
? ((now - networkStats.lastDeltaAtMs) / 1000).toFixed(1)
|
||||
: "n/a";
|
||||
|
||||
networkPanelEl.textContent = [
|
||||
return [
|
||||
`snapshot: ${formatBytes(networkStats.snapshotBytes)}`,
|
||||
`stream: ${networkStats.streamConnected ? "live" : "offline"}`,
|
||||
`deltas: ${networkStats.deltasReceived}`,
|
||||
@@ -59,13 +63,23 @@ export function updatePerformancePanel(
|
||||
performancePanelEl: HTMLDivElement,
|
||||
performanceStats: PerformanceStats,
|
||||
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();
|
||||
if (
|
||||
performanceStats.lastPanelUpdateAtMs > 0 &&
|
||||
now - performanceStats.lastPanelUpdateAtMs < 250
|
||||
) {
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const samples = performanceStats.frameSamples;
|
||||
@@ -84,7 +98,8 @@ export function updatePerformancePanel(
|
||||
const recentLowFps = averageFrameMs > 0 ? 1000 / Math.max(worstFrameMs, averageFrameMs) : 0;
|
||||
const renderInfo = renderer.info;
|
||||
|
||||
performancePanelEl.textContent = [
|
||||
performanceStats.lastPanelUpdateAtMs = now;
|
||||
return [
|
||||
`fps: ${fps.toFixed(1)}`,
|
||||
`frame avg: ${averageFrameMs.toFixed(2)} ms`,
|
||||
`frame last: ${performanceStats.lastFrameMs.toFixed(2)} ms`,
|
||||
@@ -98,7 +113,6 @@ export function updatePerformancePanel(
|
||||
`textures: ${renderInfo.memory.textures}`,
|
||||
`pixel ratio: ${renderer.getPixelRatio().toFixed(2)}`,
|
||||
].join("\n");
|
||||
performanceStats.lastPanelUpdateAtMs = now;
|
||||
}
|
||||
|
||||
export function summarizePerformanceStats(performanceStats: PerformanceStats): string {
|
||||
|
||||
@@ -183,13 +183,3 @@ export interface PerformanceStats {
|
||||
lastFrameMs: 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 { renderOpsStrip } from "./viewerOpsStrip";
|
||||
import { updateDetailPanel } from "./viewerPanels";
|
||||
import type { ViewerHudState } from "./viewerHudState";
|
||||
import { buildOpsStripState } from "./viewerOpsStrip";
|
||||
import { buildDetailPanelState } from "./viewerPanels";
|
||||
import { applyDeltaToWorld, cloneFactions, createWorldState, recordDeltaStats } from "./viewerState";
|
||||
import type {
|
||||
CelestialDelta,
|
||||
@@ -46,10 +47,7 @@ export interface ViewerWorldLifecycleContext {
|
||||
getCameraTargetShipId: () => string | undefined;
|
||||
getNetworkStats: () => NetworkStats;
|
||||
getSystemSummaryVisuals: () => Map<string, unknown>;
|
||||
errorEl: HTMLDivElement;
|
||||
opsStripEl: HTMLDivElement;
|
||||
detailTitleEl: HTMLHeadingElement;
|
||||
detailBodyEl: HTMLDivElement;
|
||||
hudState: ViewerHudState;
|
||||
worldLabel: () => string;
|
||||
rebuildSystems: (systems: SystemSnapshot[]) => void;
|
||||
syncCelestials: (celestials: CelestialSnapshot[]) => void;
|
||||
@@ -83,14 +81,15 @@ export class ViewerWorldLifecycle {
|
||||
this.context.setWorld(createWorldState(snapshot));
|
||||
this.context.getNetworkStats().snapshotBytes = new Blob([JSON.stringify(snapshot)]).size;
|
||||
this.context.updateGamePanel("Bootstrapped");
|
||||
this.context.errorEl.hidden = true;
|
||||
this.context.hudState.error.hidden = true;
|
||||
this.context.hudState.error.message = "";
|
||||
this.applySnapshot(snapshot);
|
||||
this.openDeltaStream(snapshot.sequence);
|
||||
this.updatePanels();
|
||||
} catch (error) {
|
||||
this.context.updateGamePanel("Backend offline");
|
||||
this.context.errorEl.hidden = false;
|
||||
this.context.errorEl.textContent = error instanceof Error ? error.message : "Unable to bootstrap the backend snapshot.";
|
||||
this.context.hudState.error.hidden = false;
|
||||
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[]) {
|
||||
this.context.opsStripEl.innerHTML = renderOpsStrip(
|
||||
this.context.hudState.opsStrip = buildOpsStripState(
|
||||
this.context.getWorld(),
|
||||
this.context.getSelectedItems(),
|
||||
this.context.getCameraMode(),
|
||||
@@ -207,7 +206,7 @@ export class ViewerWorldLifecycle {
|
||||
this.context.refreshHistoryWindows();
|
||||
this.context.updateSystemPanel();
|
||||
this.refreshStreamScopeIfNeeded();
|
||||
updateDetailPanel(this.context.detailTitleEl, this.context.detailBodyEl, {
|
||||
const detailState = buildDetailPanelState({
|
||||
world,
|
||||
selectedItems: this.context.getSelectedItems(),
|
||||
povLevel: this.context.getPovLevel(),
|
||||
@@ -216,6 +215,9 @@ export class ViewerWorldLifecycle {
|
||||
worldLabel: this.context.worldLabel(),
|
||||
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() {
|
||||
|
||||
@@ -68,8 +68,6 @@ export interface WorldPresentationContext extends WorldOrbitalContext {
|
||||
}
|
||||
|
||||
export interface GameStatusParams {
|
||||
statusEl: HTMLDivElement;
|
||||
summaryEl?: HTMLSpanElement;
|
||||
world?: WorldState;
|
||||
activeSystemId?: string;
|
||||
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)}`;
|
||||
}
|
||||
|
||||
export function updateGameStatus(params: GameStatusParams) {
|
||||
const { statusEl, summaryEl, world, activeSystemId, cameraMode, povLevel, selectedItems, mode, galaxyAnchor, systemAnchor } = params;
|
||||
export function describeGameStatus(params: GameStatusParams) {
|
||||
const { world, activeSystemId, cameraMode, povLevel, selectedItems, mode, galaxyAnchor, systemAnchor } = params;
|
||||
const sequence = world?.sequence ?? 0;
|
||||
const generatedAt = world?.generatedAtUtc
|
||||
? new Date(world.generatedAtUtc).toLocaleTimeString()
|
||||
@@ -296,19 +294,27 @@ export function updateGameStatus(params: GameStatusParams) {
|
||||
? `loc pos: ${fmtVec(systemAnchor.clone().sub(toThreeVector(celestialAnchor)), 0)} km`
|
||||
: "";
|
||||
|
||||
statusEl.textContent = [
|
||||
`mode: ${mode}`,
|
||||
`camera: ${cameraModeLabel}`,
|
||||
`zoom: ${displayPovLevel}`,
|
||||
`space: ${activeSpace}`,
|
||||
galPos,
|
||||
sysPos,
|
||||
locPos,
|
||||
`sequence: ${sequence}`,
|
||||
`snapshot: ${generatedAt}`,
|
||||
].filter(Boolean).join("\n");
|
||||
if (summaryEl) {
|
||||
summaryEl.textContent = `${mode} | ${displayPovLevel} | ${activeSpace}`;
|
||||
return {
|
||||
bodyText: [
|
||||
`mode: ${mode}`,
|
||||
`camera: ${cameraModeLabel}`,
|
||||
`zoom: ${displayPovLevel}`,
|
||||
`space: ${activeSpace}`,
|
||||
galPos,
|
||||
sysPos,
|
||||
locPos,
|
||||
`sequence: ${sequence}`,
|
||||
`snapshot: ${generatedAt}`,
|
||||
].filter(Boolean).join("\n"),
|
||||
summaryText: `${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,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
"skipLibCheck": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
const root = new URL(".", import.meta.url).pathname;
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), vue()],
|
||||
root,
|
||||
server: {
|
||||
host: true,
|
||||
|
||||
Reference in New Issue
Block a user