<h1>Pointer Movement Timing Analyzer</h1>
<h2>Checking Area / Timing Graph</h2>
<div id="layers">
<canvas id="axesCanvas" width="1036" height="540"></canvas>
<canvas id="dataCanvas" width="1036" height="540"></canvas>
</div>
<div>
Legend:
<ul class="legend">
<li class="red-dot">movementX</li>
<li class="green-dot">movementY</li>
<li class="white-bar">time reveiced</li>
</ul>
</div>
<h2 hidden="hidden">Options</h2>
<form autocomplete="off">
<input type="checkbox" id="showUncoalescedPoints" checked="checked" /> <label for="showUncoalescedPoints">Show rawupdate/uncoalesced points</label><br/>
<input type="checkbox" id="showMergedPoints" checked="checked" /> <label for="showMergedPoints">Show vsync'ed / keyframe points</label><br/>
<input type="checkbox" id="showPointGuideline" checked="checked" /> <label for="showPointGuideline">Show point guideline</label><br/>
</form>
<h2>Usage</h2>
<p>Draw circle on the checking area with your mouse/touch/pen for a few seconds.</p>
<p>To ensure mouse movement unadjusted, you may either press-hold right mouse button in Chromium browser, or disable mouse acceleration for your OS.</p>
<h2>Notes</h2>
<p>[Firefox] Timestamps of uncoalesced pointermove events seem to be vsync'ed thus are not accurate.</p>
<p>[Chromium] To show also vsync'ed pointermove events, please keep browser DevTools closed.</p>
<p>[HTTP server] Enable cross-origin isolation for hi-res timing.</p>
html {min-width: 1080px;}
@media (prefers-color-scheme: dark) {
html {background-color: #121212;}
}
#layers {
position: relative;
}
#axesCanvas {
background-color: #000000;
}
#dataCanvas {
position: absolute;
left: 0px;
top: 0px;
z-index: 1;
touch-action: none;
user-select: none;
user-select: none;
}
input[type="range"] {
vertical-align: middle;
}
input[type="checkbox"] {
position: relative;
top: 1px;
}
.legend {
list-style-type: none;
padding-left: 0px;
display: inline-block;
}
.legend>li {
margin-left: 1em;
display: inline-block;
}
.legend>li::before {
content: "\2022";
display: inline-block;
width: 1em;
font-weight: bold;
}
.red-dot::before {
color: #ff0000;
}
.green-dot::before {
color: #00ff00;
}
.white-dot::before {
color: #ffffff;
}
(function () {
'use strict';
class EventStat {
#callback;
#delay;
#handle;
#queue;
/**
* @param {Function} callback like (tps) => {}
* @param {Object} [options] like {delay: 1000}
*/
constructor(callback, options = {delay: 1000}) {
this.callback = callback;
this.delay = options?.delay;
this.#queue = [];
}
get callback() {
return this.#callback;
}
set callback(v) {
if (typeof v !== 'function') {
throw new TypeError('Invalid parameter callback');
}
this.#callback = v;
}
get delay() {
return this.#delay;
}
set delay(v) {
let delay;
if (v === void 0) {
delay = 1000;
} else if (!isFinite(v)) {
throw new TypeError('Invalid option delay');
} else if (v < 2) {
throw new RangeError('option delay should be at least 2ms');
} else {
delay = v;
}
this.#delay = delay;
if (this.#handle) {
this.#handle = void clearInterval(this.#handle);
this.start();
}
}
get running() {
return !!this.#handle;
}
start() {
if (this.#handle)
return;
this.#handle = setInterval(() => {
let queue = this.#queue;
let length = queue.length;
let tps;
if (length < 2) {
tps = length;
} else {
let duration = queue[length - 1] - queue[0];
let count = length - 1;
tps = duration <= 0 ? NaN : +(1000 / (duration / count)).toFixed(1);
}
this.#queue = [];
this.#callback(tps);
}, this.#delay);
}
stop() {
if (!this.#handle)
return;
this.#handle = void clearInterval(this.#handle);
}
report(e) {
this.#queue.push(e.timeStamp);
}
reset() {
this.#queue.length = 0;
}
}
/**
* This class is used to monitor animation frame rate
* @emits {CustomEvent} stabilitychange
*/
class FrameStat extends EventTarget {
#callback;
#delay;
#handle;
#history = [];
#stable = false;
/**
* @param {Function} callback like (fps) => {}
* @param {Object} [options] like {delay: 1000}
*/
constructor(callback, options = {delay: 1000}) {
super();
this.callback = callback;
this.delay = options?.delay;
}
get callback() {
return this.#callback;
}
set callback(v) {
if (typeof v !== 'function') {
throw new TypeError('Invalid parameter callback');
}
this.#callback = v;
}
get delay() {
return this.#delay;
}
set delay(v) {
let delay;
if (v === void 0) {
delay = 1000;
} else if (!isFinite(v)) {
throw new TypeError('Invalid option delay');
} else if (v < 2) {
throw new RangeError('option delay should be at least 2ms, got ' + v);
} else {
delay = v;
}
this.#delay = delay;
if (this.#handle) {
this.#handle = void cancelAnimationFrame(this.#handle);
this.start();
}
}
get running() {
return !!this.#handle;
}
get stable() {
return this.#stable;
}
start() {
if (this.#handle)
return;
let lastTime;
let currentCount = 0;
let lastCount = 0;
let tick = (currentTime) => {
currentCount++;
if (currentTime - lastTime > this.#delay - 2) {
let fps = (currentCount - lastCount) / ((currentTime - lastTime) / 1000);
lastCount = currentCount;
lastTime = currentTime;
this.#handle = requestAnimationFrame(tick);
this.#callback(fps);
// check FPS stablility
let history = this.#history;
let roundedFps = Math.round(fps);
let len = history.push(roundedFps);
if (len > 3) {
// check last 4 FPS records
let latestValues = history.slice(-3);
let set = new Set(latestValues);
if (this.#stable) {
if (set.size > 1) { // considerred unstable
this.#stable = false;
this.dispatchEvent(new CustomEvent('stabilitychange', {detail: fps}));
}
} else {
if (set.size === 1) { // considerred stable
this.#stable = true;
let avgFps = latestValues.reduce((n, v) => n + v, 0) / latestValues.length;
this.dispatchEvent(new CustomEvent('stabilitychange', {detail: avgFps}));
}
}
if (len > 60) {
history.length = 3;
}
}
} else {
this.#handle = requestAnimationFrame(tick);
}
};
this.#handle = requestAnimationFrame((currentTime) => {
lastTime = currentTime;
this.#handle = requestAnimationFrame(tick);
});
}
stop() {
if (!this.#handle)
return;
this.#handle = void cancelAnimationFrame(this.#handle);
}
}
class AnimationFrameControl extends EventTarget {
#delay;
#queue = [];
#handle;
#handle2;
constructor(delay) {
super();
this.delay = delay;
}
get delay() {
return this.#delay;
}
set delay(v) {
if (!isFinite(v)) {
throw new TypeError('Invalid option delay ' + v);
} else if (v < 2) {
throw new RangeError('option delay should be a number at least 2');
}
this.#delay = v;
}
start() {
if (this.#handle)
return;
let lastTime;
let tick = (currentTime) => {
if (currentTime - lastTime > this.#delay - 2) { // supports up to 480Hz frame rate
let queue = this.#queue;
let len = queue.length;
if (len > 0) {
this.#queue = []; // use a new queue before invoking callbacks
for (let i = 0; i < len; i++) {
try {
queue[i](currentTime);
} catch (e) {
queueMicrotask(() => { throw e; });
}
}
}
lastTime = currentTime;
this.tick(currentTime);
this.#handle = requestAnimationFrame(tick);
} else {
this.#handle = requestAnimationFrame(tick);
}
};
this.#handle = requestAnimationFrame((currentTime) => {
lastTime = currentTime;
this.#handle = requestAnimationFrame(tick);
this.dispatchEvent(new Event('start'));
});
}
stop() {
if (!this.#handle)
return;
this.#handle = void cancelAnimationFrame(this.#handle);
this.#handle2 = void clearInterval(this.#handle2);
this.dispatchEvent(new Event('stop'));
}
tick() {}
requestAnimationFrame(callback) {
this.#queue.push(callback);
}
}
const $ = (s, c = document) => c.querySelector(s);
const FIREFOX = navigator.userAgent.indexOf('Firefox/') > 0;
const support = {
pointerrawupdate: 'onpointerrawupdate' in HTMLElement.prototype,
coalescedEvents: 'getCoalescedEvents' in PointerEvent.prototype,
unadjustedMovement: !!(document.exitPointerLock && window.chrome), // FIXME
};
const MouseButton = {
LEFT: 0,
MIDDLE: 1,
RIGHT: 2,
BACK: 3,
FORWARD: 4,
};
function main() {
const settings = {
frameRate: 60, // initial value 60 is used to draw x-axis scales
showUncoalescedPoints: true,
showMergedPoints: true,
showPointGuideline: true,
};
let refs = {
showUncoalescedPoints: $('#showUncoalescedPoints'),
showMergedPoints: $('#showMergedPoints'),
showPointGuideline: $('#showPointGuideline'),
};
settings.showUncoalescedPoints = refs.showUncoalescedPoints.checked;
refs.showUncoalescedPoints.addEventListener('click', (e) => {
settings.showUncoalescedPoints = e.target.checked;
});
settings.showMergedPoints = refs.showMergedPoints.checked;
refs.showMergedPoints.addEventListener('click', (e) => {
settings.showMergedPoints = e.target.checked;
});
settings.showPointGuideline = refs.showPointGuideline.checked;
refs.showPointGuideline.addEventListener('click', (e) => {
settings.showPointGuideline = e.target.checked;
});
let axesCanvas = $('#axesCanvas');
let dataCanvas = $('#dataCanvas');
if (window.innerWidth > 1280) {
dataCanvas.width = axesCanvas.width = document.body.clientWidth;
}
let axesContext = axesCanvas.getContext('2d');
let axesRect = new DOMRect(0, 0, axesCanvas.clientWidth, axesCanvas.clientHeight);
// report rate
let reportRateRect = new DOMRect(axesRect.x + axesRect.width - 120, axesRect.y, 120, 30);
let eventStat = new EventStat((tps) => {
drawRate(axesContext, reportRateRect, 'Report rate: ' + tps + ' Hz');
// auto reduce graphics drawing to avoid stuck
if (tps > 249) {
settings.showPointGuideline = refs.showPointGuideline.checked = false;
if (tps > 499 && (settings.showUncoalescedPoints && settings.showMergedPoints)) {
settings.showUncoalescedPoints = refs.showUncoalescedPoints.checked = false;
}
}
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
eventStat.start();
} else {
eventStat.stop();
}
});
// stat refresh rate
let frameRateRect = new DOMRect(axesRect.x + axesRect.width - 120, axesRect.y + axesRect.height - 30, 120, 30);
let lastFrameRate;
let frameStat = new FrameStat((fps) => {
if (!lastFrameRate) {
settings.frameRate = Math.round(fps);
drawAxes(axesContext, axesRect, settings);
}
lastFrameRate = fps;
drawRate(axesContext, frameRateRect, 'Refresh rate: ' + fps.toFixed(1) + ' Hz');
});
let isApproxmate = (a, b, epsilon = 1) => {
return a > b - epsilon && a < b + epsilon;
};
let isApproxmatelyWhole = (a, epsilon = 0.1) => {
return isApproxmate(a, Math.round(a), epsilon);
};
frameStat.addEventListener('stabilitychange', (e) => {
if (e.target.stable) {
settings.frameRate = Math.round(e.detail);
drawAxes(axesContext, axesRect, settings);
let v = e.detail;
if (v < 62) {
frc.delay = 1000 / v;
} else if (isApproxmatelyWhole(v / 60, 2)) {
frc.delay = 1000 / 60;
} else if (isApproxmate(v, 144, 2)) {
frc.delay = 1000 / 72;
} else if (isApproxmate(v, 165, 2)) {
frc.delay = 1000 / 72;
} else {
frc.delay = 1000 / (v / Math.ceil(v / 60));
}
}
});
frameStat.start();
let dataContext = dataCanvas.getContext('2d');
let moveRecords = [];
let requestedPointerLock;
let startingEvent;
let pointerdownHandler = (e) => {
if (startingEvent)
return;
if (e.pointerType === 'mouse') {
if (e.button !== MouseButton.LEFT)
e.preventDefault();
requestedPointerLock = e.ctrlKey && support.unadjustedMovement;
if (requestedPointerLock) {
dataCanvas.addEventListener('pointermove', pointermoveHandlerOnce, {once: true});
} else {
dataCanvas.setPointerCapture(e.pointerId);
}
} else {
requestedPointerLock = false;
}
startingEvent = e;
dataCanvas.addEventListener('pointermove', pointermoveHandler);
dataCanvas.addEventListener('pointerup', pointerupHandler);
dataCanvas.addEventListener('pointercancel', pointerupHandler);
};
let pointermoveHandlerOnce = (e) => {
if (support.unadjustedMovement) {
dataCanvas.requestPointerLock({unadjustedMovement: true});
}
};
let lastMove;
let pointermoveHandler = support.coalescedEvents ? ((e) => {
if (startingEvent && e.pointerId !== startingEvent.pointerId)
return;
let now = performance.now();
let {movementX, movementY} = e;
if (FIREFOX) {
if (lastMove) {
movementX = e.clientX - lastMove.clientX;
movementY = e.clientY - lastMove.clientY;
}
lastMove = e;
}
let coalescedRecords = [];
moveRecords.push({timeStamp: e.timeStamp, movementX, movementY, timeReceived: now, coalescedRecords});
e.getCoalescedEvents().forEach((e2) => {
eventStat.report(e2);
coalescedRecords.push(getMoveRecord(e2, now));
});
}) : ((e) => {
if (startingEvent && e.pointerId !== startingEvent.pointerId)
return;
let now = performance.now();
eventStat.report(e);
moveRecords.push(getMoveRecord(e, now));
});
let pointerupHandler = (e) => {
if (!startingEvent || e.pointerId !== startingEvent.pointerId)
return;
if (e.pointerType === 'mouse') {
if (requestedPointerLock) {
dataCanvas.removeEventListener('pointermove', pointermoveHandlerOnce, {once: true});
if (document.pointerLockElement) {
document.exitPointerLock();
}
} else {
dataCanvas.releasePointerCapture(e.pointerId);
}
}
startingEvent = null;
dataCanvas.removeEventListener('pointermove', pointermoveHandler);
dataCanvas.removeEventListener('pointerup', pointerupHandler);
dataCanvas.removeEventListener('pointercancel', pointerupHandler);
};
dataCanvas.addEventListener('pointerdown', pointerdownHandler);
let lastRawUpdate;
let getMoveRecord = (e, time) => {
let {movementX, movementY} = e;
if (FIREFOX) {
if (lastRawUpdate) {
movementX = e.clientX - lastRawUpdate.clientX;
movementY = e.clientY - lastRawUpdate.clientY;
}
lastRawUpdate = e;
}
return {timeStamp: e.timeStamp, movementX, movementY, timeReceived: time};
};
let tick = () => {
if (moveRecords.length > 0) {
let now = performance.now();
truncateRecords(moveRecords, now, axesRect.width - 36);
dataContext.clearRect(0, 0, axesRect.width, axesRect.height);
plotMoveRecords(dataContext, axesRect, now, moveRecords, settings);
}
frc.requestAnimationFrame(tick);
};
let frc = new AnimationFrameControl(1000 / 60);
frc.addEventListener('start', () => {
frc.requestAnimationFrame(tick);
eventStat.start();
});
frc.start();
}
document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', main) : queueMicrotask(main);
function truncateRecords(events, now, displayInterval) {
let len = events.length;
let end = -1;
for (let i = 0, e; i < len; i++) {
e = events[i];
let elapsedTime = now - e.timeStamp;
if (elapsedTime >= displayInterval) {
end = i;
} else {
break;
}
}
if (end > -1) {
events.splice(0, end + 1);
}
}
function plotMoveRecords(context, rect, now, records, settings) {
let {showPointGuideline} = settings;
let middleY = rect.y + rect.height / 2;
let rightX = rect.x + rect.width;
let drawRecord = (e) => {
let vsyncX = rightX - (now - e.timeReceived);
let {movementX, movementY} = e;
let y1 = middleY + movementY;
let y2 = middleY + movementX;
if (settings.showMergedPoints && showPointGuideline) {
context.fillStyle = '#333333';
if (movementY < -1) {
context.fillRect(vsyncX, y1 + 1, 1, -movementY - 1);
} else if (movementY > 1) {
context.fillRect(vsyncX, middleY, 1, movementY - 1);
}
if (movementX < -1) {
context.fillRect(vsyncX, y2 + 1, 1, -movementX - 1);
} else if (movementX > 1) {
context.fillRect(vsyncX, middleY, 1, movementX - 1);
}
}
if (settings.showUncoalescedPoints && e.coalescedRecords) {
e.coalescedRecords.forEach((e2) => {
drawUncoalescedMove(e2, vsyncX);
});
}
if (settings.showMergedPoints) {
context.fillStyle = '#00ff00';
context.fillRect(vsyncX - 0.5, y1 - 0.5, 2, 2);
context.fillStyle = '#ff0000';
context.fillRect(vsyncX - 0.5, y2 - 0.5, 2, 2);
}
};
let drawUncoalescedMove = (e, vsyncX) => {
let x = rightX - (now - e.timeStamp);
let {movementX, movementY} = e;
let y1 = middleY + movementY;
let y2 = middleY + movementX;
if (showPointGuideline) {
context.strokeStyle = '#333333';
context.setLineDash([2, 1]);
if (movementY !== 0) {
context.beginPath();
context.moveTo(x, y1);
context.lineTo(vsyncX, middleY);
context.stroke();
}
if (movementX !== 0) {
context.beginPath();
context.moveTo(x, y2);
context.lineTo(vsyncX, middleY);
context.stroke();
}
}
context.fillStyle = '#00ff00';
context.fillRect(x, y1, 1, 1);
context.fillStyle = '#ff0000';
context.fillRect(x, y2, 1, 1);
};
for (let i = 0, e; i < records.length; i++) {
e = records[i];
let vsyncX = rightX - (now - e.timeReceived);
drawRecord(e);
context.fillStyle = '#ffffff';
context.fillRect(vsyncX, middleY, 1, 1);
}
}
function drawRate(context, rect, text) {
context.clearRect(rect.x, rect.y, rect.width, rect.height);
context.font = '10px sans-serif';
context.textBaseline = 'top';
context.fillStyle = '#c0c0c0';
context.fillText(text, rect.x, rect.y + 5);
}
function drawAxes(context, rect, settings) {
context.clearRect(rect.x, rect.y, rect.width, rect.height);
let {frameRate} = settings;
const marginLeft = 36;
let yLeft = rect.x + marginLeft;
let xTop = rect.y + rect.height / 2;
context.fillStyle = '#333333';
// Y-axis
context.fillRect(yLeft, rect.y, 1, rect.height);
// X-axis
context.fillRect(yLeft, xTop, rect.width, 1);
// Y-ruler
context.font = '10px monospace';
context.textBaseline = 'middle';
for (let y = -300, step = 100; y <= 300; y += step) {
let top = xTop + y;
context.fillStyle = '#333333';
context.fillRect(yLeft - 5, top, 5, 1);
context.fillStyle = '#808080';
context.fillText(y.toString().padStart(4, ' '), rect.x + 5, top + 1, marginLeft);
}
// X-ruler
for (let x = rect.right - 1, step = 1000 / frameRate, i = 0; x > yLeft; x -= step, i++) {
context.fillStyle = '#333333';
if (i % frameRate < 1) {
context.fillRect(x, xTop - 20, 1, 20);
} else {
context.fillRect(x, xTop - 5, 1, 5);
}
}
}
})();
This Pen doesn't use any external CSS resources.
This Pen doesn't use any external JavaScript resources.