Add viewer network statistics panel
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user