<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;
  -webkit-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);
      }
    }
  }

})();

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.