Files
OpenFrontIO/src/client/graphics/layers/PerformanceOverlay.ts
T
2025-12-11 17:17:58 +01:00

766 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { UserSettings } from "../../../core/game/UserSettings";
import {
TickMetricsEvent,
TogglePerformanceOverlayEvent,
} from "../../InputHandler";
import { translateText } from "../../Utils";
import { FrameProfiler } from "../FrameProfiler";
import { Layer } from "./Layer";
@customElement("performance-overlay")
export class PerformanceOverlay extends LitElement implements Layer {
@property({ type: Object })
public eventBus!: EventBus;
@property({ type: Object })
public userSettings!: UserSettings;
@state()
private currentFPS: number = 0;
@state()
private averageFPS: number = 0;
@state()
private frameTime: number = 0;
@state()
private tickExecutionAvg: number = 0;
@state()
private tickExecutionMax: number = 0;
@state()
private tickDelayAvg: number = 0;
@state()
private tickDelayMax: number = 0;
@state()
private isVisible: boolean = false;
@state()
private isDragging: boolean = false;
@state()
private position: { x: number; y: number } = { x: 50, y: 20 }; // Percentage values
@state()
private copyStatus: "idle" | "success" | "error" = "idle";
private frameCount: number = 0;
private lastTime: number = 0;
private frameTimes: number[] = [];
private fpsHistory: number[] = [];
private lastSecondTime: number = 0;
private framesThisSecond: number = 0;
private dragStart: { x: number; y: number } = { x: 0, y: 0 };
private tickExecutionTimes: number[] = [];
private tickDelayTimes: number[] = [];
private copyStatusTimeoutId: ReturnType<typeof setTimeout> | null = null;
// Smoothed per-layer render timings (EMA over recent frames)
private layerStats: Map<
string,
{ avg: number; max: number; last: number; total: number }
> = new Map();
@state()
private layerBreakdown: {
name: string;
avg: number;
max: number;
total: number;
}[] = [];
static styles = css`
.performance-overlay {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 8px 16px;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
z-index: 9999;
user-select: none;
cursor: move;
transition: none;
min-width: 420px;
}
.performance-overlay.dragging {
cursor: grabbing;
transition: none;
opacity: 0.5;
}
.performance-line {
margin: 2px 0;
}
.performance-good {
color: #4ade80; /* green-400 */
}
.performance-warning {
color: #fbbf24; /* amber-400 */
}
.performance-bad {
color: #f87171; /* red-400 */
}
.close-button {
position: absolute;
top: 8px;
right: 8px;
width: 20px;
height: 20px;
background-color: rgba(0, 0, 0, 0.8);
border-radius: 4px;
color: white;
font-size: 14px;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
user-select: none;
pointer-events: auto;
}
.reset-button {
position: absolute;
top: 8px;
left: 8px;
height: 20px;
padding: 0 6px;
background-color: rgba(0, 0, 0, 0.8);
border-radius: 4px;
color: white;
font-size: 10px;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
user-select: none;
pointer-events: auto;
}
.copy-json-button {
position: absolute;
top: 8px;
left: 70px;
height: 20px;
padding: 0 6px;
background-color: rgba(0, 0, 0, 0.8);
border-radius: 4px;
color: white;
font-size: 10px;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
user-select: none;
pointer-events: auto;
}
.layers-section {
margin-top: 4px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 4px;
}
.layer-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
margin-top: 2px;
}
.layer-name {
flex: 0 0 280px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.layer-bar {
flex: 1;
height: 6px;
background: rgba(148, 163, 184, 0.25);
border-radius: 3px;
overflow: hidden;
}
.layer-bar-fill {
height: 100%;
background: #38bdf8;
border-radius: 3px;
}
.layer-metrics {
flex: 0 0 auto;
white-space: nowrap;
}
`;
constructor() {
super();
}
init() {
this.eventBus.on(TogglePerformanceOverlayEvent, () => {
this.userSettings.togglePerformanceOverlay();
this.setVisible(this.userSettings.performanceOverlay());
});
this.eventBus.on(TickMetricsEvent, (event: TickMetricsEvent) => {
this.updateTickMetrics(
event.tickExecutionDuration,
event.tickDelay,
event.backlogTurns,
event.ticksPerRender,
event.workerTicksPerSecond,
event.renderTicksPerSecond,
event.tileUpdatesCount,
event.ringBufferUtilization,
event.ringBufferOverflows,
event.ringDrainTime,
);
});
}
setVisible(visible: boolean) {
this.isVisible = visible;
FrameProfiler.setEnabled(visible);
}
private handleClose() {
this.userSettings.togglePerformanceOverlay();
}
private handleMouseDown = (e: MouseEvent) => {
// Don't start dragging if clicking on close button
const target = e.target as HTMLElement;
if (
target.classList.contains("close-button") ||
target.classList.contains("reset-button") ||
target.classList.contains("copy-json-button")
) {
return;
}
this.isDragging = true;
this.dragStart = {
x: e.clientX - this.position.x,
y: e.clientY - this.position.y,
};
document.addEventListener("mousemove", this.handleMouseMove);
document.addEventListener("mouseup", this.handleMouseUp);
e.preventDefault();
};
private handleMouseMove = (e: MouseEvent) => {
if (!this.isDragging) return;
const newX = e.clientX - this.dragStart.x;
const newY = e.clientY - this.dragStart.y;
// Convert to percentage of viewport
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
this.position = {
x: Math.max(0, Math.min(viewportWidth - 100, newX)), // Keep within viewport bounds
y: Math.max(0, Math.min(viewportHeight - 100, newY)),
};
this.requestUpdate();
};
private handleMouseUp = () => {
this.isDragging = false;
document.removeEventListener("mousemove", this.handleMouseMove);
document.removeEventListener("mouseup", this.handleMouseUp);
};
private handleReset = () => {
// reset FPS / frame stats
this.frameCount = 0;
this.lastTime = 0;
this.frameTimes = [];
this.fpsHistory = [];
this.lastSecondTime = 0;
this.framesThisSecond = 0;
this.currentFPS = 0;
this.averageFPS = 0;
this.frameTime = 0;
// reset tick metrics
this.tickExecutionTimes = [];
this.tickDelayTimes = [];
this.tickExecutionAvg = 0;
this.tickExecutionMax = 0;
this.tickDelayAvg = 0;
this.tickDelayMax = 0;
// reset layer breakdown
this.layerStats.clear();
this.layerBreakdown = [];
// reset tile metrics
this.tileUpdatesPerRender = 0;
this.tileUpdatesPeak = 0;
this.ringBufferUtilization = 0;
this.ringBufferOverflows = 0;
this.ringDrainTime = 0;
this.totalTilesUpdated = 0;
this.requestUpdate();
};
updateFrameMetrics(
frameDuration: number,
layerDurations?: Record<string, number>,
) {
const wasVisible = this.isVisible;
this.isVisible = this.userSettings.performanceOverlay();
// Update FrameProfiler enabled state when visibility changes
if (wasVisible !== this.isVisible) {
FrameProfiler.setEnabled(this.isVisible);
}
if (!this.isVisible) return;
const now = performance.now();
// Initialize timing on first call
if (this.lastTime === 0) {
this.lastTime = now;
this.lastSecondTime = now;
return;
}
const deltaTime = now - this.lastTime;
// Track frame times for current FPS calculation (last 60 frames)
this.frameTimes.push(deltaTime);
if (this.frameTimes.length > 60) {
this.frameTimes.shift();
}
// Calculate current FPS based on average frame time
if (this.frameTimes.length > 0) {
const avgFrameTime =
this.frameTimes.reduce((a, b) => a + b, 0) / this.frameTimes.length;
this.currentFPS = Math.round(1000 / avgFrameTime);
this.frameTime = Math.round(avgFrameTime);
}
// Track FPS for 60-second average
this.framesThisSecond++;
// Update every second
if (now - this.lastSecondTime >= 1000) {
this.fpsHistory.push(this.framesThisSecond);
if (this.fpsHistory.length > 60) {
this.fpsHistory.shift();
}
// Calculate 60-second average
if (this.fpsHistory.length > 0) {
this.averageFPS = Math.round(
this.fpsHistory.reduce((a, b) => a + b, 0) / this.fpsHistory.length,
);
}
this.framesThisSecond = 0;
this.lastSecondTime = now;
}
this.lastTime = now;
this.frameCount++;
if (layerDurations) {
this.updateLayerStats(layerDurations);
}
this.requestUpdate();
}
private updateLayerStats(layerDurations: Record<string, number>) {
const alpha = 0.2; // smoothing factor for EMA
Object.entries(layerDurations).forEach(([name, duration]) => {
const existing = this.layerStats.get(name);
if (!existing) {
this.layerStats.set(name, {
avg: duration,
max: duration,
last: duration,
total: duration,
});
} else {
const avg = existing.avg + alpha * (duration - existing.avg);
const max = Math.max(existing.max, duration);
const total = existing.total + duration;
this.layerStats.set(name, { avg, max, last: duration, total });
}
});
// Derive contributors sorted by total accumulated time spent
const breakdown = Array.from(this.layerStats.entries())
.map(([name, stats]) => ({
name,
avg: stats.avg,
max: stats.max,
total: stats.total,
}))
.sort((a, b) => b.total - a.total);
this.layerBreakdown = breakdown;
}
@state()
private backlogTurns: number = 0;
@state()
private ticksPerRender: number = 0;
@state()
private workerTicksPerSecond: number = 0;
@state()
private renderTicksPerSecond: number = 0;
@state()
private tileUpdatesPerRender: number = 0;
@state()
private tileUpdatesPeak: number = 0;
@state()
private ringBufferUtilization: number = 0;
@state()
private ringBufferOverflows: number = 0;
@state()
private ringDrainTime: number = 0;
@state()
private totalTilesUpdated: number = 0;
updateTickMetrics(
tickExecutionDuration?: number,
tickDelay?: number,
backlogTurns?: number,
ticksPerRender?: number,
workerTicksPerSecond?: number,
renderTicksPerSecond?: number,
tileUpdatesCount?: number,
ringBufferUtilization?: number,
ringBufferOverflows?: number,
ringDrainTime?: number,
) {
if (!this.isVisible || !this.userSettings.performanceOverlay()) return;
// Update tick execution duration stats
if (tickExecutionDuration !== undefined) {
this.tickExecutionTimes.push(tickExecutionDuration);
if (this.tickExecutionTimes.length > 60) {
this.tickExecutionTimes.shift();
}
if (this.tickExecutionTimes.length > 0) {
const avg =
this.tickExecutionTimes.reduce((a, b) => a + b, 0) /
this.tickExecutionTimes.length;
this.tickExecutionAvg = Math.round(avg * 100) / 100;
this.tickExecutionMax = Math.round(
Math.max(...this.tickExecutionTimes),
);
}
}
// Update tick delay stats
if (tickDelay !== undefined) {
this.tickDelayTimes.push(tickDelay);
if (this.tickDelayTimes.length > 60) {
this.tickDelayTimes.shift();
}
if (this.tickDelayTimes.length > 0) {
const avg =
this.tickDelayTimes.reduce((a, b) => a + b, 0) /
this.tickDelayTimes.length;
this.tickDelayAvg = Math.round(avg * 100) / 100;
this.tickDelayMax = Math.round(Math.max(...this.tickDelayTimes));
}
}
if (backlogTurns !== undefined) {
this.backlogTurns = backlogTurns;
}
if (ticksPerRender !== undefined) {
this.ticksPerRender = ticksPerRender;
}
if (workerTicksPerSecond !== undefined) {
this.workerTicksPerSecond = workerTicksPerSecond;
}
if (renderTicksPerSecond !== undefined) {
this.renderTicksPerSecond = renderTicksPerSecond;
}
if (tileUpdatesCount !== undefined) {
this.tileUpdatesPerRender = tileUpdatesCount;
this.tileUpdatesPeak = Math.max(this.tileUpdatesPeak, tileUpdatesCount);
this.totalTilesUpdated += tileUpdatesCount;
}
if (ringBufferUtilization !== undefined) {
this.ringBufferUtilization =
Math.round(ringBufferUtilization * 100) / 100;
}
if (ringBufferOverflows !== undefined) {
// Accumulate overflows (overflows is a flag, so add 1 if set)
this.ringBufferOverflows += ringBufferOverflows;
}
if (ringDrainTime !== undefined) {
this.ringDrainTime = Math.round(ringDrainTime * 100) / 100;
}
this.requestUpdate();
}
shouldTransform(): boolean {
return false;
}
private getPerformanceColor(fps: number): string {
if (fps >= 55) return "performance-good";
if (fps >= 30) return "performance-warning";
return "performance-bad";
}
private buildPerformanceSnapshot() {
return {
timestamp: new Date().toISOString(),
fps: {
current: this.currentFPS,
average60s: this.averageFPS,
frameTimeMs: this.frameTime,
history: [...this.fpsHistory],
},
ticks: {
executionAvgMs: this.tickExecutionAvg,
executionMaxMs: this.tickExecutionMax,
delayAvgMs: this.tickDelayAvg,
delayMaxMs: this.tickDelayMax,
executionSamples: [...this.tickExecutionTimes],
delaySamples: [...this.tickDelayTimes],
},
tiles: {
updatesPerRender: this.tileUpdatesPerRender,
peakUpdates: this.tileUpdatesPeak,
ringBufferUtilization: this.ringBufferUtilization,
ringBufferOverflows: this.ringBufferOverflows,
ringDrainTimeMs: this.ringDrainTime,
totalTilesUpdated: this.totalTilesUpdated,
},
layers: this.layerBreakdown.map((layer) => ({ ...layer })),
};
}
private clearCopyStatusTimeout() {
if (this.copyStatusTimeoutId !== null) {
clearTimeout(this.copyStatusTimeoutId);
this.copyStatusTimeoutId = null;
}
}
private scheduleCopyStatusReset() {
this.clearCopyStatusTimeout();
this.copyStatusTimeoutId = setTimeout(() => {
this.copyStatus = "idle";
this.copyStatusTimeoutId = null;
this.requestUpdate();
}, 2000);
}
private async handleCopyJson() {
const snapshot = this.buildPerformanceSnapshot();
const json = JSON.stringify(snapshot, null, 2);
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(json);
} else {
const textarea = document.createElement("textarea");
textarea.value = json;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
this.copyStatus = "success";
} catch (err) {
console.warn("Failed to copy performance snapshot", err);
this.copyStatus = "error";
}
this.scheduleCopyStatusReset();
}
render() {
if (!this.isVisible) {
return html``;
}
const style = `
left: ${this.position.x}px;
top: ${this.position.y}px;
transform: none;
`;
const copyLabel =
this.copyStatus === "success"
? translateText("performance_overlay.copied")
: this.copyStatus === "error"
? translateText("performance_overlay.failed_copy")
: translateText("performance_overlay.copy_clipboard");
const maxLayerAvg =
this.layerBreakdown.length > 0
? Math.max(...this.layerBreakdown.map((l) => l.avg))
: 1;
return html`
<div
class="performance-overlay ${this.isDragging ? "dragging" : ""}"
style="${style}"
@mousedown="${this.handleMouseDown}"
>
<button class="reset-button" @click="${this.handleReset}">
${translateText("performance_overlay.reset")}
</button>
<button
class="copy-json-button"
@click="${this.handleCopyJson}"
title="${translateText("performance_overlay.copy_json_title")}"
>
${copyLabel}
</button>
<button class="close-button" @click="${this.handleClose}">×</button>
<div class="performance-line">
${translateText("performance_overlay.fps")}
<span class="${this.getPerformanceColor(this.currentFPS)}"
>${this.currentFPS}</span
>
</div>
<div class="performance-line">
${translateText("performance_overlay.avg_60s")}
<span class="${this.getPerformanceColor(this.averageFPS)}"
>${this.averageFPS}</span
>
</div>
<div class="performance-line">
${translateText("performance_overlay.frame")}
<span class="${this.getPerformanceColor(1000 / this.frameTime)}"
>${this.frameTime}ms</span
>
</div>
<div class="performance-line">
${translateText("performance_overlay.tick_exec")}
<span>${this.tickExecutionAvg.toFixed(2)}ms</span>
(max: <span>${this.tickExecutionMax}ms</span>)
</div>
<div class="performance-line">
${translateText("performance_overlay.tick_delay")}
<span>${this.tickDelayAvg.toFixed(2)}ms</span>
(max: <span>${this.tickDelayMax}ms</span>)
</div>
<div class="performance-line">
Worker ticks/s:
<span>${this.workerTicksPerSecond.toFixed(1)}</span>
</div>
<div class="performance-line">
Render ticks/s:
<span>${this.renderTicksPerSecond.toFixed(1)}</span>
</div>
<div class="performance-line">
Ticks per render:
<span>${this.ticksPerRender}</span>
</div>
<div class="performance-line">
Backlog turns:
<span>${this.backlogTurns}</span>
</div>
<div class="performance-line">
Tile updates/render:
<span>${this.tileUpdatesPerRender}</span>
(peak: <span>${this.tileUpdatesPeak}</span>)
</div>
<div class="performance-line">
Ring buffer:
<span>${this.ringBufferUtilization}%</span>
(${this.totalTilesUpdated} total, ${this.ringBufferOverflows}
overflows)
</div>
<div class="performance-line">
Ring drain time:
<span>${this.ringDrainTime.toFixed(2)}ms</span>
</div>
${this.layerBreakdown.length
? html`<div class="layers-section">
<div class="performance-line">
${translateText("performance_overlay.layers_header")}
</div>
${this.layerBreakdown.map((layer) => {
const width = Math.min(
100,
(layer.avg / maxLayerAvg) * 100 || 0,
);
return html`<div class="layer-row">
<span class="layer-name" title=${layer.name}
>${layer.name}</span
>
<div class="layer-bar">
<div class="layer-bar-fill" style="width: ${width}%;"></div>
</div>
<span class="layer-metrics">
${layer.avg.toFixed(2)} / ${layer.max.toFixed(2)}ms
</span>
</div>`;
})}
</div>`
: html``}
</div>
`;
}
}