Add viewer network statistics panel

This commit is contained in:
2026-03-12 20:25:23 -04:00
parent 9849dbae61
commit 62d1c158e0
3 changed files with 160 additions and 5 deletions

View File

@@ -46,6 +46,24 @@ interface WorldState {
recentEvents: SimulationEventRecord[];
}
interface NetworkSample {
atMs: number;
bytes: number;
}
interface NetworkStats {
snapshotBytes: number;
deltasReceived: number;
deltaBytes: number;
lastDeltaBytes: number;
lastEntityChanges: number;
eventsReceived: number;
streamConnected: boolean;
streamOpenedAtMs?: number;
lastDeltaAtMs?: number;
throughputSamples: NetworkSample[];
}
export class GameViewer {
private readonly container: HTMLElement;
private readonly renderer = new THREE.WebGLRenderer({ antialias: true });
@@ -67,11 +85,22 @@ export class GameViewer {
private readonly detailTitleEl: HTMLHeadingElement;
private readonly detailBodyEl: HTMLDivElement;
private readonly factionStripEl: HTMLDivElement;
private readonly networkPanelEl: HTMLDivElement;
private readonly resetButton: HTMLButtonElement;
private readonly errorEl: HTMLDivElement;
private readonly streamEl: HTMLDivElement;
private world?: WorldState;
private stream?: EventSource;
private readonly networkStats: NetworkStats = {
snapshotBytes: 0,
deltasReceived: 0,
deltaBytes: 0,
lastDeltaBytes: 0,
lastEntityChanges: 0,
eventsReceived: 0,
streamConnected: false,
throughputSamples: [],
};
private selected?: Selectable;
private dragging = false;
private lastPointer = new THREE.Vector2();
@@ -113,6 +142,10 @@ export class GameViewer {
<div class="detail-body">Waiting for the authoritative snapshot.</div>
<div class="error-strip" hidden></div>
</aside>
<aside class="network-panel">
<h2>Network</h2>
<div class="network-body">Waiting for snapshot.</div>
</aside>
<section class="faction-strip"></section>
`;
@@ -121,6 +154,7 @@ export class GameViewer {
this.detailTitleEl = hud.querySelector(".detail-title") as HTMLHeadingElement;
this.detailBodyEl = hud.querySelector(".detail-body") as HTMLDivElement;
this.factionStripEl = hud.querySelector(".faction-strip") as HTMLDivElement;
this.networkPanelEl = hud.querySelector(".network-body") as HTMLDivElement;
this.resetButton = hud.querySelector(".reset-button") as HTMLButtonElement;
this.errorEl = hud.querySelector(".error-strip") as HTMLDivElement;
@@ -146,6 +180,7 @@ export class GameViewer {
try {
const snapshot = await fetchWorldSnapshot();
this.world = this.createWorldState(snapshot);
this.networkStats.snapshotBytes = new Blob([JSON.stringify(snapshot)]).size;
this.statusEl.textContent = `Snapshot ${snapshot.sequence}`;
this.errorEl.hidden = true;
this.applySnapshot(snapshot);
@@ -163,18 +198,23 @@ export class GameViewer {
this.stream?.close();
this.stream = openWorldStream(afterSequence, {
onOpen: () => {
this.networkStats.streamConnected = true;
this.networkStats.streamOpenedAtMs = performance.now();
this.streamEl.textContent = "Stream Live";
this.updateNetworkPanel();
},
onError: () => {
this.networkStats.streamConnected = false;
this.streamEl.textContent = "Stream Reconnecting";
this.updateNetworkPanel();
},
onDelta: (delta) => {
void this.handleDelta(delta);
onDelta: (delta, rawBytes) => {
void this.handleDelta(delta, rawBytes);
},
});
}
private async handleDelta(delta: WorldDelta) {
private async handleDelta(delta: WorldDelta, rawBytes: number) {
if (!this.world) {
return;
}
@@ -185,8 +225,10 @@ export class GameViewer {
}
this.applyDelta(delta);
this.recordDeltaStats(delta, rawBytes);
this.statusEl.textContent = `Seq ${delta.sequence} · ${new Date(delta.generatedAtUtc).toLocaleTimeString()}`;
this.updatePanels();
this.updateNetworkPanel();
}
private async handleReset() {
@@ -194,9 +236,11 @@ export class GameViewer {
try {
const snapshot = await resetWorld();
this.world = this.createWorldState(snapshot);
this.networkStats.snapshotBytes = new Blob([JSON.stringify(snapshot)]).size;
this.applySnapshot(snapshot);
this.openDeltaStream(snapshot.sequence);
this.updatePanels();
this.updateNetworkPanel();
} finally {
this.resetButton.disabled = false;
}
@@ -229,6 +273,7 @@ export class GameViewer {
this.syncStations(snapshot.stations);
this.syncShips(snapshot.ships, snapshot.tickIntervalMs);
this.rebuildFactions(snapshot.factions);
this.updateNetworkPanel();
}
private applyDelta(delta: WorldDelta) {
@@ -515,9 +560,51 @@ export class GameViewer {
this.camera.position.lerp(new THREE.Vector3(this.focus.x + 2200, 1600, this.focus.z + 2200), Math.min(1, delta * 2));
this.camera.lookAt(this.focus);
this.updateShipPresentation();
this.updateNetworkPanel();
this.renderer.render(this.scene, this.camera);
}
private recordDeltaStats(delta: WorldDelta, rawBytes: number) {
const changedEntities = delta.ships.length + delta.stations.length + delta.nodes.length + delta.factions.length;
this.networkStats.deltasReceived += 1;
this.networkStats.deltaBytes += rawBytes;
this.networkStats.lastDeltaBytes = rawBytes;
this.networkStats.lastEntityChanges = changedEntities;
this.networkStats.eventsReceived += delta.events.length;
this.networkStats.lastDeltaAtMs = performance.now();
this.networkStats.throughputSamples.push({
atMs: performance.now(),
bytes: rawBytes,
});
const cutoff = performance.now() - 4000;
this.networkStats.throughputSamples = this.networkStats.throughputSamples.filter((sample) => sample.atMs >= cutoff);
}
private updateNetworkPanel() {
const now = performance.now();
const uptimeSeconds = this.networkStats.streamOpenedAtMs
? (now - this.networkStats.streamOpenedAtMs) / 1000
: 0;
const recentBytes = this.networkStats.throughputSamples.reduce((sum, sample) => sum + sample.bytes, 0);
const recentWindowSeconds = this.networkStats.throughputSamples.length > 1
? Math.max((now - this.networkStats.throughputSamples[0].atMs) / 1000, 1)
: 1;
const kbPerSecond = recentBytes / 1024 / recentWindowSeconds;
const averageDeltaBytes = this.networkStats.deltasReceived > 0
? this.networkStats.deltaBytes / this.networkStats.deltasReceived
: 0;
const secondsSinceLastDelta = this.networkStats.lastDeltaAtMs
? ((now - this.networkStats.lastDeltaAtMs) / 1000).toFixed(1)
: "n/a";
this.networkPanelEl.textContent = this.buildNetworkPanelText({
uptimeSeconds,
kbPerSecond,
averageDeltaBytes,
secondsSinceLastDelta,
});
}
private updateShipPresentation() {
const now = performance.now();
for (const visual of this.shipVisuals.values()) {
@@ -584,6 +671,36 @@ export class GameViewer {
return `${vector.x.toFixed(1)}, ${vector.y.toFixed(1)}, ${vector.z.toFixed(1)}`;
}
private formatBytes(bytes: number) {
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
if (bytes >= 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${Math.round(bytes)} B`;
}
private buildNetworkPanelText(values: {
uptimeSeconds: number;
kbPerSecond: number;
averageDeltaBytes: number;
secondsSinceLastDelta: string;
}) {
return [
`snapshot: ${this.formatBytes(this.networkStats.snapshotBytes)}`,
`stream: ${this.networkStats.streamConnected ? "live" : "offline"}`,
`deltas: ${this.networkStats.deltasReceived}`,
`events: ${this.networkStats.eventsReceived}`,
`avg delta: ${this.formatBytes(values.averageDeltaBytes)}`,
`last delta: ${this.formatBytes(this.networkStats.lastDeltaBytes)}`,
`recent rate: ${values.kbPerSecond.toFixed(1)} KB/s`,
`changed: ${this.networkStats.lastEntityChanges}`,
`uptime: ${values.uptimeSeconds.toFixed(1)}s`,
`last packet: ${values.secondsSinceLastDelta}s`,
].join("\n");
}
private toThreeVector(vector: Vector3Dto) {
return new THREE.Vector3(vector.x, vector.y, vector.z);
}

View File

@@ -11,7 +11,7 @@ export async function fetchWorldSnapshot(signal?: AbortSignal) {
export function openWorldStream(
afterSequence: number,
handlers: {
onDelta: (delta: WorldDelta) => void;
onDelta: (delta: WorldDelta, rawBytes: number) => void;
onOpen?: () => void;
onError?: () => void;
},
@@ -21,7 +21,10 @@ export function openWorldStream(
stream.addEventListener("error", () => handlers.onError?.());
stream.addEventListener("world-delta", (event) => {
const message = event as MessageEvent<string>;
handlers.onDelta(JSON.parse(message.data) as WorldDelta);
handlers.onDelta(
JSON.parse(message.data) as WorldDelta,
new Blob([message.data]).size,
);
});
return stream;
}

View File

@@ -39,6 +39,7 @@ canvas {
.topbar,
.details-panel,
.network-panel,
.faction-strip {
position: absolute;
backdrop-filter: blur(18px);
@@ -118,6 +119,16 @@ canvas {
overflow: auto;
}
.network-panel {
top: 110px;
left: 20px;
width: min(360px, calc(100vw - 40px));
border-radius: 24px;
padding: 18px;
color: var(--text);
pointer-events: auto;
}
.details-panel h2 {
color: var(--accent);
letter-spacing: 0.16em;
@@ -125,6 +136,23 @@ canvas {
text-transform: uppercase;
}
.network-panel h2 {
margin: 0;
color: var(--accent);
letter-spacing: 0.16em;
font-size: 0.72rem;
text-transform: uppercase;
}
.network-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;
}
.detail-title {
margin-top: 12px;
font-size: 1.05rem;
@@ -214,6 +242,13 @@ canvas {
max-height: 38vh;
}
.network-panel {
top: 110px;
right: 20px;
left: 20px;
width: auto;
}
.faction-strip {
left: 20px;
right: 20px;