Created
February 9, 2026 20:41
-
-
Save ultrox/53116c47795ca0d1d8d2acc838cbabb0 to your computer and use it in GitHub Desktop.
Tr
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { useState, useEffect, useRef, useCallback } from "react"; | |
| const BUFFER_SIZE = 120; | |
| const TARGET_FPS = 60; | |
| const TARGET_FRAME_MS = 1000 / TARGET_FPS; | |
| function classifyFps(fps) { | |
| if (fps >= 55) return "good"; | |
| if (fps >= 30) return "warn"; | |
| return "bad"; | |
| } | |
| function classifyMs(ms) { | |
| if (ms <= 18) return "good"; | |
| if (ms <= 33) return "warn"; | |
| return "bad"; | |
| } | |
| const COLORS = { | |
| good: "#22c55e", | |
| warn: "#eab308", | |
| bad: "#ef4444", | |
| }; | |
| const GRAPH_WIDTH = 280; | |
| const GRAPH_HEIGHT = 80; | |
| const BAR_GAP = 1; | |
| export default function FPSMonitor({ position = "top-right", collapsed: initialCollapsed = false }) { | |
| const [fps, setFps] = useState(60); | |
| const [frameMs, setFrameMs] = useState(16.67); | |
| const [minFps, setMinFps] = useState(60); | |
| const [maxFps, setMaxFps] = useState(60); | |
| const [collapsed, setCollapsed] = useState(initialCollapsed); | |
| const bufferRef = useRef(new Float64Array(BUFFER_SIZE).fill(TARGET_FRAME_MS)); | |
| const indexRef = useRef(0); | |
| const lastTimeRef = useRef(performance.now()); | |
| const rafRef = useRef(null); | |
| const canvasRef = useRef(null); | |
| const sampleCountRef = useRef(0); | |
| const minFpsRef = useRef(60); | |
| const maxFpsRef = useRef(60); | |
| const drawGraph = useCallback(() => { | |
| const canvas = canvasRef.current; | |
| if (!canvas) return; | |
| const ctx = canvas.getContext("2d"); | |
| const dpr = window.devicePixelRatio || 1; | |
| const w = GRAPH_WIDTH; | |
| const h = GRAPH_HEIGHT; | |
| canvas.width = w * dpr; | |
| canvas.height = h * dpr; | |
| canvas.style.width = `${w}px`; | |
| canvas.style.height = `${h}px`; | |
| ctx.scale(dpr, dpr); | |
| ctx.clearRect(0, 0, w, h); | |
| // Background | |
| ctx.fillStyle = "rgba(0,0,0,0.3)"; | |
| ctx.fillRect(0, 0, w, h); | |
| // Threshold lines | |
| const maxMs = 50; // clip at 50ms (20fps) | |
| // 60fps line (16.67ms) | |
| const y60 = (TARGET_FRAME_MS / maxMs) * h; | |
| ctx.strokeStyle = "rgba(34,197,94,0.3)"; | |
| ctx.lineWidth = 1; | |
| ctx.setLineDash([4, 4]); | |
| ctx.beginPath(); | |
| ctx.moveTo(0, h - y60); | |
| ctx.lineTo(w, h - y60); | |
| ctx.stroke(); | |
| // 30fps line (33.33ms) | |
| const y30 = (33.33 / maxMs) * h; | |
| ctx.strokeStyle = "rgba(239,68,68,0.3)"; | |
| ctx.beginPath(); | |
| ctx.moveTo(0, h - y30); | |
| ctx.lineTo(w, h - y30); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| // Draw bars | |
| const barWidth = (w - BAR_GAP * (BUFFER_SIZE - 1)) / BUFFER_SIZE; | |
| const buffer = bufferRef.current; | |
| const currentIndex = indexRef.current; | |
| for (let i = 0; i < BUFFER_SIZE; i++) { | |
| // Read from oldest to newest | |
| const idx = (currentIndex + i) % BUFFER_SIZE; | |
| const ms = buffer[idx]; | |
| const clampedMs = Math.min(ms, maxMs); | |
| const barH = (clampedMs / maxMs) * h; | |
| const x = i * (barWidth + BAR_GAP); | |
| const classification = classifyMs(ms); | |
| ctx.fillStyle = COLORS[classification]; | |
| // Slight transparency for older frames | |
| ctx.globalAlpha = 0.4 + 0.6 * (i / BUFFER_SIZE); | |
| ctx.fillRect(x, h - barH, barWidth, barH); | |
| } | |
| ctx.globalAlpha = 1; | |
| }, []); | |
| useEffect(() => { | |
| let running = true; | |
| const tick = (now) => { | |
| if (!running) return; | |
| const delta = now - lastTimeRef.current; | |
| lastTimeRef.current = now; | |
| // Skip unreasonable deltas (tab was hidden, etc.) | |
| if (delta > 0 && delta < 500) { | |
| const buffer = bufferRef.current; | |
| buffer[indexRef.current] = delta; | |
| indexRef.current = (indexRef.current + 1) % BUFFER_SIZE; | |
| sampleCountRef.current++; | |
| const currentFps = 1000 / delta; | |
| // Update min/max after initial settling (first 60 frames) | |
| if (sampleCountRef.current > 60) { | |
| if (currentFps < minFpsRef.current) minFpsRef.current = currentFps; | |
| if (currentFps > maxFpsRef.current) maxFpsRef.current = currentFps; | |
| } | |
| // Calculate average FPS from last 20 frames | |
| let sum = 0; | |
| const sampleSize = Math.min(20, BUFFER_SIZE); | |
| for (let i = 0; i < sampleSize; i++) { | |
| const idx = (indexRef.current - 1 - i + BUFFER_SIZE) % BUFFER_SIZE; | |
| sum += buffer[idx]; | |
| } | |
| const avgMs = sum / sampleSize; | |
| const avgFps = 1000 / avgMs; | |
| setFps(Math.round(avgFps)); | |
| setFrameMs(avgMs); | |
| setMinFps(Math.round(minFpsRef.current)); | |
| setMaxFps(Math.round(maxFpsRef.current)); | |
| if (!collapsed) { | |
| drawGraph(); | |
| } | |
| } | |
| rafRef.current = requestAnimationFrame(tick); | |
| }; | |
| rafRef.current = requestAnimationFrame(tick); | |
| return () => { | |
| running = false; | |
| if (rafRef.current) cancelAnimationFrame(rafRef.current); | |
| }; | |
| }, [collapsed, drawGraph]); | |
| const positionStyles = { | |
| "top-right": { top: 12, right: 12 }, | |
| "top-left": { top: 12, left: 12 }, | |
| "bottom-right": { bottom: 12, right: 12 }, | |
| "bottom-left": { bottom: 12, left: 12 }, | |
| }; | |
| const classification = classifyFps(fps); | |
| const fpsColor = COLORS[classification]; | |
| return ( | |
| <div | |
| style={{ | |
| position: "fixed", | |
| zIndex: 999999, | |
| ...positionStyles[position], | |
| fontFamily: "'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', monospace", | |
| fontSize: 11, | |
| color: "#e2e8f0", | |
| userSelect: "none", | |
| pointerEvents: "auto", | |
| }} | |
| > | |
| <div | |
| style={{ | |
| background: "rgba(15, 23, 42, 0.92)", | |
| backdropFilter: "blur(12px)", | |
| border: "1px solid rgba(255,255,255,0.08)", | |
| borderRadius: 10, | |
| overflow: "hidden", | |
| boxShadow: "0 8px 32px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.05)", | |
| minWidth: collapsed ? "auto" : GRAPH_WIDTH + 20, | |
| }} | |
| > | |
| {/* Header */} | |
| <div | |
| onClick={() => setCollapsed((c) => !c)} | |
| style={{ | |
| display: "flex", | |
| alignItems: "center", | |
| justifyContent: "space-between", | |
| padding: "8px 12px", | |
| cursor: "pointer", | |
| borderBottom: collapsed ? "none" : "1px solid rgba(255,255,255,0.06)", | |
| transition: "background 0.15s", | |
| }} | |
| onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(255,255,255,0.04)")} | |
| onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")} | |
| > | |
| <div style={{ display: "flex", alignItems: "center", gap: 8 }}> | |
| {/* Pulsing dot */} | |
| <div | |
| style={{ | |
| width: 7, | |
| height: 7, | |
| borderRadius: "50%", | |
| background: fpsColor, | |
| boxShadow: `0 0 6px ${fpsColor}`, | |
| animation: classification === "bad" ? "fpsPulse 0.6s ease-in-out infinite alternate" : "none", | |
| }} | |
| /> | |
| <span style={{ fontWeight: 700, fontSize: 13, color: fpsColor, letterSpacing: "-0.02em" }}> | |
| {fps} | |
| </span> | |
| <span style={{ color: "#94a3b8", fontSize: 10, fontWeight: 500 }}>FPS</span> | |
| </div> | |
| <div style={{ display: "flex", alignItems: "center", gap: 10 }}> | |
| <span style={{ color: "#64748b", fontSize: 10 }}>{frameMs.toFixed(1)}ms</span> | |
| <span | |
| style={{ | |
| color: "#64748b", | |
| fontSize: 9, | |
| transform: collapsed ? "rotate(0deg)" : "rotate(180deg)", | |
| transition: "transform 0.2s", | |
| display: "inline-block", | |
| }} | |
| > | |
| ▼ | |
| </span> | |
| </div> | |
| </div> | |
| {/* Expanded panel */} | |
| {!collapsed && ( | |
| <div style={{ padding: "8px 10px 10px" }}> | |
| {/* Canvas graph */} | |
| <canvas | |
| ref={canvasRef} | |
| style={{ | |
| borderRadius: 6, | |
| display: "block", | |
| width: GRAPH_WIDTH, | |
| height: GRAPH_HEIGHT, | |
| }} | |
| /> | |
| {/* Stats row */} | |
| <div | |
| style={{ | |
| display: "flex", | |
| justifyContent: "space-between", | |
| marginTop: 8, | |
| padding: "0 2px", | |
| }} | |
| > | |
| <StatLabel label="MIN" value={minFps} color={COLORS[classifyFps(minFps)]} /> | |
| <StatLabel label="AVG" value={fps} color={fpsColor} /> | |
| <StatLabel label="MAX" value={maxFps} color={COLORS[classifyFps(maxFps)]} /> | |
| <StatLabel label="FRAME" value={`${frameMs.toFixed(1)}ms`} color="#94a3b8" /> | |
| </div> | |
| {/* Legend */} | |
| <div | |
| style={{ | |
| display: "flex", | |
| gap: 12, | |
| marginTop: 8, | |
| justifyContent: "center", | |
| }} | |
| > | |
| <Legend color={COLORS.good} label="≥55" /> | |
| <Legend color={COLORS.warn} label="30–55" /> | |
| <Legend color={COLORS.bad} label="<30" /> | |
| <span style={{ color: "#475569", fontSize: 9 }}>fps</span> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* Keyframe animation for bad FPS pulsing */} | |
| <style>{` | |
| @keyframes fpsPulse { | |
| from { opacity: 0.5; } | |
| to { opacity: 1; } | |
| } | |
| `}</style> | |
| </div> | |
| ); | |
| } | |
| function StatLabel({ label, value, color }) { | |
| return ( | |
| <div style={{ textAlign: "center" }}> | |
| <div style={{ fontSize: 9, color: "#475569", fontWeight: 600, letterSpacing: "0.05em" }}>{label}</div> | |
| <div style={{ fontSize: 12, fontWeight: 700, color, marginTop: 1 }}>{value}</div> | |
| </div> | |
| ); | |
| } | |
| function Legend({ color, label }) { | |
| return ( | |
| <div style={{ display: "flex", alignItems: "center", gap: 4 }}> | |
| <div style={{ width: 6, height: 6, borderRadius: 2, background: color }} /> | |
| <span style={{ color: "#64748b", fontSize: 9 }}>{label}</span> | |
| </div> | |
| ); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment