Files
psysonic/src/components/FpsOverlay.tsx
T
Psychotoxical 7c724a642f chore(eslint): add ESLint toolchain and clean src to strict 0/0 (#1165)
* chore(eslint): add eslint toolchain and configs

* fix(eslint): resolve gradual-config errors (rules-of-hooks, no-empty, prefer-const, …)

* chore(eslint): clear unused vars in config, contexts, app and test

* chore(eslint): clear unused vars in api and music-network

* chore(eslint): clear unused vars in utils

* chore(eslint): clear unused vars in store

* chore(eslint): clear unused vars in cover and hooks

* chore(eslint): clear unused vars in components

* chore(eslint): clear unused vars in pages

* chore(eslint): remove explicit any in src

* chore(eslint): align react-hooks exhaustive-deps

* chore(eslint): zero gradual config on src

* chore(eslint): strict hook rules in store and utils

* chore(eslint): strict hook rules in hooks and cover

* chore(eslint): strict hook rules in components

* chore(eslint): strict hook rules in pages and contexts

* chore(eslint): document scripts ignore in eslint config

* chore(eslint): add lint script and zero strict findings

* chore(eslint): address review round 1 (gradual 0/0, per-site disable reasons)

* chore(eslint): tighten two set-state disable comments (review round 2 LOW)

* chore(nix): sync npmDepsHash with package-lock.json

* docs(changelog): add Under the Hood entry for ESLint setup (PR #1165)

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-24 01:33:34 +02:00

310 lines
10 KiB
TypeScript

import { useEffect, useMemo, useState, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import { analysisGetPipelineQueueStats, type AnalysisPipelineQueueStatsDto } from '../api/analysis';
import { coverGetPipelineQueueStats, type CoverPipelineQueueStatsDto } from '../api/coverCache';
import { coverEnsureQueueStats } from '../cover/ensureQueue';
import { coverPeekQueueStats } from '../cover/peekQueue';
import PerfOverlaySparkline from './perf/PerfOverlaySparkline';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import {
formatPerfMs,
getAnalysisTracksPerMinute,
useAnalysisPerfLast,
} from '../utils/perf/analysisPerfStore';
import { formatAnalysisPipelineQueueOverlay } from '../utils/perf/formatAnalysisQueueStats';
import { formatCoverPipelineQueueOverlay } from '../utils/perf/formatCoverPipelineQueueOverlay';
import {
buildLiveOverlayItems,
type LiveOverlayItem,
} from '../utils/perf/formatLiveOverlayItems';
import {
getPerfLiveHistorySamples,
} from '../utils/perf/perfLiveHistory';
import { usePerfLiveSnapshot } from '../utils/perf/perfLiveStore';
import { usePerfLiveOverlayPins } from '../utils/perf/perfOverlayPins';
import {
perfOverlayCornerClass,
usePerfOverlayAppearance,
} from '../utils/perf/perfOverlayAppearance';
import {
resolveOverlayVisibility,
usePerfOverlayMode,
} from '../utils/perf/perfOverlayMode';
import { useAnalysisPerfListener } from '../hooks/useAnalysisPerfListener';
import { useCoverPerfListener } from '../hooks/useCoverPerfListener';
import { getCoverCachedPerMinute, getCoverUiPerMinute } from '../utils/perf/coverPerfStore';
const SAMPLE_MS = 500;
const TPM_REFRESH_MS = 500;
const QUEUE_STATS_MS = 750;
function LiveOverlayPinnedMetric({
item,
now,
history,
}: {
item: LiveOverlayItem;
now: number;
history: ReturnType<typeof getPerfLiveHistorySamples>;
}) {
const sparklineKind = item.kind === 'memory' ? 'memory' : 'cpu';
return (
<div className="fps-overlay__live-metric">
<div className="fps-overlay__row fps-overlay__row--live">{item.line}</div>
{item.sparkline && (
<PerfOverlaySparkline samples={history} kind={sparklineKind} now={now} />
)}
</div>
);
}
/** FPS + pipeline + pinned live metrics overlay (PsyLab). */
export default function FpsOverlay() {
const overlayMode = usePerfOverlayMode();
const perfFlags = usePerfProbeFlags();
const livePins = usePerfLiveOverlayPins();
const live = usePerfLiveSnapshot();
const overlayAppearance = usePerfOverlayAppearance();
const [fps, setFps] = useState(0);
const [tpm, setTpm] = useState(0);
const [cpm, setCpm] = useState(0);
const [cpmUi, setCpmUi] = useState(0);
const [queueStats, setQueueStats] = useState<AnalysisPipelineQueueStatsDto | null>(null);
const [coverQueueLines, setCoverQueueLines] = useState<string[]>([]);
const last = useAnalysisPerfLast();
const liveOverlayItems = useMemo(
() => buildLiveOverlayItems(livePins, live),
[livePins, live],
);
const visibility = useMemo(
() => resolveOverlayVisibility(overlayMode, perfFlags, liveOverlayItems.length),
[overlayMode, perfFlags, liveOverlayItems.length],
);
const {
showFps: showFpsOverlay,
showAnalysis: showAnalysisPerfOverlay,
showCover: showCoverPerfOverlay,
showLive,
} = visibility;
const sparklineNow = useMemo(
// React Compiler purity rule: intentional live-timestamp read at render (Date.now()); the value is allowed to differ between renders.
// eslint-disable-next-line react-hooks/purity
() => (live.sampleAt > 0 ? live.sampleAt : Date.now()),
[live.sampleAt],
);
useAnalysisPerfListener(showAnalysisPerfOverlay || livePins.has('analysis:tpm') || livePins.has('analysis:last'));
useCoverPerfListener(showCoverPerfOverlay || livePins.has('cover:cpm'));
useEffect(() => {
if (!showAnalysisPerfOverlay) {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setTpm(0);
return;
}
const refresh = () => setTpm(getAnalysisTracksPerMinute());
refresh();
const id = window.setInterval(refresh, TPM_REFRESH_MS);
return () => window.clearInterval(id);
}, [showAnalysisPerfOverlay, last?.at]);
useEffect(() => {
if (!showAnalysisPerfOverlay) {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setQueueStats(null);
return;
}
let cancelled = false;
const refresh = () => {
void analysisGetPipelineQueueStats()
.then(stats => {
if (!cancelled) setQueueStats(stats);
})
.catch(() => {
if (!cancelled) setQueueStats(null);
});
};
refresh();
const id = window.setInterval(refresh, QUEUE_STATS_MS);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, [showAnalysisPerfOverlay]);
useEffect(() => {
if (!showCoverPerfOverlay) {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setCpm(0);
setCpmUi(0);
return;
}
const refresh = () => {
setCpm(getCoverCachedPerMinute());
setCpmUi(getCoverUiPerMinute());
};
refresh();
const id = window.setInterval(refresh, TPM_REFRESH_MS);
return () => window.clearInterval(id);
}, [showCoverPerfOverlay]);
useEffect(() => {
if (!showCoverPerfOverlay) {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setCoverQueueLines([]);
return;
}
let cancelled = false;
const refresh = () => {
void coverGetPipelineQueueStats()
.then((rust: CoverPipelineQueueStatsDto) => {
if (cancelled) return;
setCoverQueueLines(
formatCoverPipelineQueueOverlay({
rust,
ensure: coverEnsureQueueStats(),
peek: coverPeekQueueStats(),
}),
);
})
.catch(() => {
if (!cancelled) setCoverQueueLines([]);
});
};
refresh();
const id = window.setInterval(refresh, QUEUE_STATS_MS);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, [showCoverPerfOverlay]);
useEffect(() => {
if (!showFpsOverlay) {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setFps(0);
return;
}
let frames = 0;
let lastReport = performance.now();
let rafId = 0;
const loop = () => {
frames++;
const now = performance.now();
if (now - lastReport >= SAMPLE_MS) {
const elapsedSec = (now - lastReport) / 1000;
setFps(Math.round(frames / elapsedSec));
frames = 0;
lastReport = now;
}
rafId = requestAnimationFrame(loop);
};
rafId = requestAnimationFrame(loop);
return () => cancelAnimationFrame(rafId);
}, [showFpsOverlay]);
if (overlayMode === 'off') return null;
if (!showFpsOverlay && !showAnalysisPerfOverlay && !showCoverPerfOverlay && !showLive) return null;
const analysisQueueLines = queueStats ? formatAnalysisPipelineQueueOverlay(queueStats) : [];
return createPortal(
<div
className={`fps-overlay ${perfOverlayCornerClass(overlayAppearance.corner)}`}
style={{ '--fps-overlay-opacity': overlayAppearance.opacity } as CSSProperties}
aria-hidden="true"
>
{showFpsOverlay && (
<div className="fps-overlay__row fps-overlay__row--fps">
{fps}
{' '}
<span className="fps-overlay__unit">FPS</span>
</div>
)}
{showLive && (
<div className="fps-overlay__block">
<div className="fps-overlay__block-title">Live</div>
{liveOverlayItems.map(item => (
<LiveOverlayPinnedMetric
key={item.id}
item={item}
now={sparklineNow}
history={item.sparkline ? getPerfLiveHistorySamples(item.id) : []}
/>
))}
</div>
)}
{showAnalysisPerfOverlay && (
<div className="fps-overlay__block">
<div className="fps-overlay__block-title">Analysis pipeline</div>
<div className="fps-overlay__row">
{tpm.toFixed(1)}
{' '}
<span className="fps-overlay__unit">tpm</span>
</div>
{last && (
<>
<div className="fps-overlay__row fps-overlay__row--detail">
last
{' '}
{formatPerfMs(last.totalMs)}
</div>
<div className="fps-overlay__row fps-overlay__row--steps">
f
{formatPerfMs(last.fetchMs)}
{' · '}
s
{formatPerfMs(last.seedMs)}
{' · '}
b
{formatPerfMs(last.bpmMs)}
</div>
</>
)}
{analysisQueueLines.map(line => (
<div key={line} className="fps-overlay__row fps-overlay__row--steps">
{line}
</div>
))}
</div>
)}
{showCoverPerfOverlay && (
<div className="fps-overlay__block">
<div className="fps-overlay__block-title">Cover pipeline</div>
<div className="fps-overlay__row">
{cpm.toFixed(1)}
{' '}
<span className="fps-overlay__unit">lib cpm</span>
</div>
<div className="fps-overlay__row">
{cpmUi.toFixed(1)}
{' '}
<span className="fps-overlay__unit">ui cpm</span>
</div>
{coverQueueLines.length > 0 ? coverQueueLines.map(line => (
<div key={line} className="fps-overlay__row fps-overlay__row--steps">
{line}
</div>
)) : (
<div className="fps-overlay__row fps-overlay__row--steps">collecting</div>
)}
</div>
)}
</div>,
document.body,
);
}