Last active
December 5, 2025 17:23
-
-
Save SunjunKim/947b18b38b72c22ad02f0dba8f05aa91 to your computer and use it in GitHub Desktop.
A web page to measure the mouse polling rate
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
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Raw Mouse Event Capture Test</title> | |
| <style> | |
| body { | |
| margin: 0; | |
| padding: 20px; | |
| font-family: Arial, sans-serif; | |
| background: #1a1a1a; | |
| color: #fff; | |
| min-height: 100vh; | |
| } | |
| .container { | |
| max-width: 800px; | |
| margin: 0 auto; | |
| } | |
| h1 { | |
| text-align: center; | |
| margin-bottom: 10px; | |
| } | |
| .subtitle { | |
| text-align: center; | |
| color: #aaa; | |
| margin-bottom: 30px; | |
| } | |
| .status { | |
| text-align: center; | |
| padding: 15px; | |
| background: #2a2a2a; | |
| border-radius: 8px; | |
| margin-bottom: 20px; | |
| font-size: 18px; | |
| } | |
| .stats { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 20px; | |
| margin-bottom: 20px; | |
| } | |
| .stat-box { | |
| background: #2a2a2a; | |
| padding: 20px; | |
| border-radius: 8px; | |
| text-align: center; | |
| } | |
| .stat-label { | |
| font-size: 14px; | |
| color: #aaa; | |
| margin-bottom: 10px; | |
| } | |
| .stat-value { | |
| font-size: 36px; | |
| font-weight: bold; | |
| color: #4CAF50; | |
| } | |
| .warning { | |
| background: linear-gradient(to right, #f44336, #e91e63); | |
| padding: 15px; | |
| border-radius: 8px; | |
| text-align: center; | |
| margin-bottom: 20px; | |
| display: none; | |
| } | |
| .warning.show { | |
| display: block; | |
| } | |
| .instructions { | |
| background: #2a2a2a; | |
| padding: 20px; | |
| border-radius: 8px; | |
| line-height: 1.6; | |
| } | |
| .instructions h2 { | |
| margin-top: 0; | |
| } | |
| .instructions ol { | |
| margin-left: 20px; | |
| } | |
| .click-area { | |
| background: #2a2a2a; | |
| padding: 40px; | |
| border-radius: 8px; | |
| text-align: center; | |
| cursor: pointer; | |
| border: 2px dashed #555; | |
| margin-bottom: 20px; | |
| transition: all 0.3s; | |
| } | |
| .click-area:hover { | |
| border-color: #4CAF50; | |
| background: #333; | |
| } | |
| .click-area.locked { | |
| border-color: #4CAF50; | |
| background: #1a3a1a; | |
| } | |
| .event-log { | |
| background: #1a1a1a; | |
| border: 1px solid #333; | |
| border-radius: 8px; | |
| padding: 15px; | |
| max-height: 300px; | |
| overflow-y: auto; | |
| font-family: monospace; | |
| font-size: 12px; | |
| margin-top: 20px; | |
| } | |
| .event-log-item { | |
| padding: 2px 0; | |
| border-bottom: 1px solid #2a2a2a; | |
| } | |
| .event-log-item:last-child { | |
| border-bottom: none; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>Raw Mouse Event Capture Test</h1> | |
| <p class="subtitle">Capture mouse events as raw as possible</p> | |
| <div class="warning" id="powerWarning"> | |
| ⚠️ Connect power to your device for best results | |
| </div> | |
| <div class="status" id="status">Click anywhere to start capturing mouse events</div> | |
| <div class="stats"> | |
| <div class="stat-box"> | |
| <div class="stat-label">Realtime Rate</div> | |
| <div class="stat-value" id="realtimeRate">0 Hz</div> | |
| </div> | |
| <div class="stat-box"> | |
| <div class="stat-label">Maximum Rate</div> | |
| <div class="stat-value" id="maxRate">0 Hz</div> | |
| </div> | |
| </div> | |
| <div class="click-area" id="clickArea"> | |
| Click here to lock pointer and start capturing | |
| </div> | |
| <div class="instructions"> | |
| <h2>Instructions</h2> | |
| <ol> | |
| <li>Click in the area above to lock your pointer</li> | |
| <li>Move your mouse with varying speeds</li> | |
| <li>Watch the realtime polling rate</li> | |
| <li>Click again (or press ESC) to unlock</li> | |
| </ol> | |
| <p><strong>Note:</strong> This uses raw input with <code>unadjustedMovement: true</code> to bypass OS acceleration and capture events as raw as possible.</p> | |
| </div> | |
| <div class="event-log" id="eventLog"> | |
| <div style="color: #666;">Event log will appear here...</div> | |
| </div> | |
| </div> | |
| <script> | |
| let isMeasuring = false; | |
| let pollCount = 0; | |
| let realtimeRate = 0; | |
| let maxRate = 0; | |
| let firstFrame = true; | |
| let eventLogCount = 0; | |
| const maxLogItems = 50; | |
| // Accumulate movements between 250ms reports | |
| let accumulatedMovementX = 0; | |
| let accumulatedMovementY = 0; | |
| let accumulatedEventCount = 0; | |
| let eventTimestamps = []; // Track timestamps for interval calculation | |
| // Check power connection | |
| let isPowerConnected = true; | |
| // On laptops or mobile devices, mouse or trackpad polling rates may be lower when running on battery to save power, | |
| // resulting in less accurate measurements. This code checks if the device is plugged in and warns the user if not. | |
| if (navigator.getBattery) { | |
| navigator.getBattery().then((battery) => { | |
| isPowerConnected = battery.charging; | |
| updatePowerWarning(); | |
| battery.addEventListener("chargingchange", () => { | |
| isPowerConnected = battery.charging; | |
| updatePowerWarning(); | |
| }); | |
| }); | |
| } | |
| function updatePowerWarning() { | |
| const warning = document.getElementById("powerWarning"); | |
| if (!isPowerConnected) { | |
| warning.classList.add("show"); | |
| } else { | |
| warning.classList.remove("show"); | |
| } | |
| } | |
| // Update polling rate every 250ms and report accumulated movements | |
| setInterval(() => { | |
| if (isMeasuring) { | |
| // Report accumulated movements every 250ms | |
| if (accumulatedEventCount > 0) { | |
| // Calculate mean interval and temporal jitter | |
| let meanInterval = 0; | |
| let temporalJitter = 0; | |
| if (eventTimestamps.length > 1) { | |
| // Calculate intervals between consecutive events | |
| const intervals = []; | |
| for (let i = 1; i < eventTimestamps.length; i++) { | |
| intervals.push(eventTimestamps[i] - eventTimestamps[i - 1]); | |
| } | |
| // Calculate mean interval | |
| const sum = intervals.reduce((a, b) => a + b, 0); | |
| meanInterval = sum / intervals.length; | |
| // Calculate temporal jitter (standard deviation of intervals) | |
| if (intervals.length > 1) { | |
| const variance = intervals.reduce((acc, interval) => { | |
| return acc + Math.pow(interval - meanInterval, 2); | |
| }, 0) / intervals.length; | |
| temporalJitter = Math.sqrt(variance); | |
| } | |
| } | |
| // Calculate realtimeRate as the inverse of meanInterval (convert ms to Hz) | |
| realtimeRate = (meanInterval > 0) ? (1000 / meanInterval) : 0; | |
| document.getElementById("realtimeRate").textContent = realtimeRate.toFixed(1) + " Hz"; | |
| if (!firstFrame && realtimeRate > maxRate) { | |
| maxRate = realtimeRate; | |
| document.getElementById("maxRate").textContent = maxRate.toFixed(1) + " Hz"; | |
| } | |
| logEvent(`${accumulatedEventCount} events`, { | |
| movementX: accumulatedMovementX, | |
| movementY: accumulatedMovementY, | |
| meanInterval: meanInterval, | |
| temporalJitter: temporalJitter | |
| }); | |
| // Reset accumulators | |
| accumulatedMovementX = 0; | |
| accumulatedMovementY = 0; | |
| accumulatedEventCount = 0; | |
| eventTimestamps = []; | |
| } | |
| firstFrame = false; | |
| pollCount = 0; | |
| } | |
| }, 250); | |
| // Raw mouse event handler - captures all events including coalesced ones | |
| function handlePointerMove(e) { | |
| if (!isMeasuring) return; | |
| // Use getCoalescedEvents() to capture ALL events, including those that were coalesced | |
| // This is crucial for accurate polling rate measurement | |
| let eventCount = 1; | |
| if (e.getCoalescedEvents && e.getCoalescedEvents().length > 1) { | |
| eventCount = e.getCoalescedEvents().length; | |
| pollCount += eventCount; | |
| // Accumulate each coalesced event's movement | |
| const coalescedEvents = e.getCoalescedEvents(); | |
| coalescedEvents.forEach(event => { | |
| accumulatedMovementX += event.movementX; | |
| accumulatedMovementY += event.movementY; | |
| accumulatedEventCount++; | |
| // Use the event's timestamp (coalesced events have their own timeStamp) | |
| // Use nullish coalescing (??) instead of || to avoid treating 0 as falsy | |
| eventTimestamps.push(event.timeStamp ?? performance.now()); | |
| }); | |
| } else { | |
| pollCount++; | |
| // Accumulate single event movement | |
| accumulatedMovementX += e.movementX; | |
| accumulatedMovementY += e.movementY; | |
| accumulatedEventCount++; | |
| // Record timestamp for interval calculation | |
| // Use nullish coalescing (??) instead of || to avoid treating 0 as falsy | |
| eventTimestamps.push(e.timeStamp ?? performance.now()); | |
| } | |
| } | |
| // Log events (reported every 250ms) | |
| function logEvent(type, data) { | |
| const log = document.getElementById("eventLog"); | |
| const item = document.createElement("div"); | |
| item.className = "event-log-item"; | |
| let logText = `${type} | X:${data.movementX} | Y:${data.movementY}`; | |
| if (data.meanInterval !== undefined && data.temporalJitter !== undefined) { | |
| logText += ` | Mean Interval: ${data.meanInterval.toFixed(3)}ms | Jitter (SD): ${data.temporalJitter.toFixed(3)}ms`; | |
| } | |
| item.textContent = logText; | |
| log.insertBefore(item, log.firstChild); | |
| // Keep only last N items | |
| while (log.children.length > maxLogItems) { | |
| log.removeChild(log.lastChild); | |
| } | |
| } | |
| let pointerMoveListenerActive = false; | |
| function togglePointerMoveListener(active) { | |
| if (active && !pointerMoveListenerActive) { | |
| document.addEventListener("pointermove", handlePointerMove, { | |
| passive: true, | |
| capture: true | |
| }); | |
| pointerMoveListenerActive = true; | |
| } else if (!active && pointerMoveListenerActive) { | |
| document.removeEventListener("pointermove", handlePointerMove, { | |
| passive: true, | |
| capture: true | |
| }); | |
| pointerMoveListenerActive = false; | |
| } | |
| } | |
| // Handle pointer lock changes | |
| document.addEventListener("pointerlockchange", () => { | |
| const clickArea = document.getElementById("clickArea"); | |
| const status = document.getElementById("status"); | |
| if (document.pointerLockElement === document.body) { | |
| console.log("Pointer locked - starting measurement"); | |
| status.textContent = "Keep moving your mouse"; | |
| clickArea.classList.add("locked"); | |
| clickArea.textContent = "Pointer locked - Move your mouse"; | |
| isMeasuring = true; | |
| maxRate = 0; | |
| firstFrame = true; | |
| pollCount = 0; | |
| accumulatedMovementX = 0; | |
| accumulatedMovementY = 0; | |
| accumulatedEventCount = 0; | |
| eventTimestamps = []; | |
| document.getElementById("maxRate").textContent = "0 Hz"; | |
| togglePointerMoveListener(true); // Only start capturing events now | |
| } else { | |
| console.log("Pointer unlocked - stopping measurement"); | |
| status.textContent = "Click in the highlighted area to start capturing mouse events"; | |
| clickArea.classList.remove("locked"); | |
| clickArea.textContent = "Click here to lock pointer and start capturing"; | |
| isMeasuring = false; | |
| togglePointerMoveListener(false); // Stop listening | |
| } | |
| }, false); | |
| // Exit pointer lock on mouse up | |
| document.addEventListener("mouseup", () => { | |
| if (document.pointerLockElement) { | |
| document.exitPointerLock(); | |
| } | |
| }); | |
| // Request pointer lock only when click happens in clickArea | |
| const clickArea = document.getElementById("clickArea"); | |
| clickArea.addEventListener("click", (e) => { | |
| // Don't lock if clicking on links or inputs | |
| if (e.target.tagName?.toLowerCase() === "a" || | |
| e.target.tagName?.toLowerCase() === "input") { | |
| return; | |
| } | |
| if (!document.pointerLockElement) { | |
| // Request pointer lock with unadjustedMovement: true for raw input | |
| // This bypasses OS-level mouse acceleration and pointer precision | |
| document.body.requestPointerLock({ | |
| unadjustedMovement: true | |
| }).catch(err => { | |
| console.error("Error requesting pointer lock:", err); | |
| alert("Could not lock pointer. Make sure you're using a modern browser and allow pointer lock."); | |
| }); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment