Skip to content

Instantly share code, notes, and snippets.

@KillyMXI
Last active January 14, 2018 14:31
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save KillyMXI/555ada7f77489bfc440d1f73146ef443 to your computer and use it in GitHub Desktop.
Save KillyMXI/555ada7f77489bfc440d1f73146ef443 to your computer and use it in GitHub Desktop.
Mouse scroll emulation test

Mouse scroll emulation test

This is a playground and POC implementation of "movement to scroll" algorithm.

Important bits are in touchpadAccelerationCurve, emulateScroll and mouseDragged functions.

<!DOCTYPE html>
<html>
<head>
<title>Mouse scroll emulation test</title>
<link href="style.css" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.5.16/p5.js"></script>
<script src="pointerlock.js"></script><!-- https://github.com/IceCreamYou/PointerLock.js -->
<script src="sketch.js"></script>
</head>
<body>
<div id="controls">
<div>
<div class="controlsSubtitle">Mode select</div>
<label>
<span>scroll emulation mode </span>
<select id="scrollEmulationModeSelect">
<option value="combined">combined</option>
<option value="touchpad">touchpad</option>
<option value="ratchet_wheel">ratchet_wheel</option>
</select>
</label>
<div class="control p for_combined">Combined mode works almost like ratchet wheel when moved slowly (displacement amount is in fixed region) and
like touchpad when moved fast.</div>
<div class="control p for_ratchet_wheel">Accumulating travel distance and emitting scroll of fixed size for every unit distance.</div>
<div class="control p for_touchpad">Every movement is translated into scroll event.</div>
</div>
<div id="inputProcessingControls">
<div class="controlsSubtitle">Input processing</div>
<label>
<span>displacement mode </span>
<select id="displacementModeSelect">
<option value="vector_len">vector_len</option>
<option value="weighted">weighted</option>
<option value="only_y">only_y</option>
</select>
</label>
<label>
<span>input filter</span>
<input type="range" min="0" max="0.5" step="1e-18" id="filterFactor">
</label>
</div>
<div id="touchpadEmulation">
<div class="controlsSubtitle">Touchpad emulation</div>
<label class="control for_touchpad for_combined">
<span>minimum scroll</span>
<input type="range" min="0" max="10" step="1" id="minimumScroll">
</label>
<label class="control for_touchpad for_combined">
<span>fixed region</span>
<input type="range" min="0" max="10" step="1" id="fixedRegion">
</label>
<label class="control for_touchpad for_combined">
<span>acceleration power factor</span>
<input type="range" min="0.5" max="2" step="1e-18" id="accelerationPowerFactor">
</label>
<label class="control for_touchpad for_combined">
<span>acceleration multiplier</span>
<input type="range" min="0.25" max="4" step="1e-18" id="accelerationMultiplier">
</label>
</div>
<div id="ratchetWheelEmulation">
<div class="controlsSubtitle">Ratched wheel emulation</div>
<label class="control for_ratchet_wheel for_combined">
<span>accumulator boundary</span>
<input type="range" min="1" max="20" step="1" id="accumulatorBoundary">
</label>
<label class="control for_ratchet_wheel">
<span>ratchet scroll</span>
<input type="range" min="1" max="20" step="1" id="ratchetScroll">
</label>
</div>
<div>
<div class="controlsSubtitle">State</div>
<button id="resetButton">clear screen</button>
<button id="logButton">log state to browser console</button>
<label>pointer lock <input type="checkbox" id="pointerLockCheckbox"></label>
<label>logarithmic scale plot + zoom<input type="checkbox" id="logScalePlotCheckbox"></label>
</div>
</div>
<p>left mouse button drag - scroll emulation; mouse scroll - actual scroll</p>
<p id="lockedNotification">Locked mouse mode is active! Press Esc when done with scrolling emulation.</p>
</body>
</html>
const clamp = (min, max) => v => Math.min(max, Math.max(min, v));
const wrap = (min, max) => v => (v < min) ? wrap(min,max)(v + (max-min)) : (max < v) ? wrap(min,max)(v - (max-min)) : v;
const vectorLength = (x,y) => Math.sqrt(x*x + y*y);
const proportion = (p, a, b) => (a * p) + (b * (1.0-p));
const validNumber = x => !!x || x === 0;
class LimitedQueue {
constructor(length) {
const _elements = [];
const _fixLength = () => _elements.length = length;
this.push = x => { _elements.unshift(x); _fixLength(); };
this.pop = () => {let x = _elements.pop(); _fixLength(); return x; };
this.toArray = () => Array.from(_elements);
this.getFilteredArray = () => _elements.filter(validNumber);
this.peekLastAdded = () => _elements[0];
this.reset = () => { _elements.length = 0; _fixLength(); }
this.reset();
}
}
class MyValue {
constructor(min, max) {
this.value = 0;
this.wrap = wrap(min, max);
this.add = delta => this.value = this.wrap(this.value + delta);
this.reset = () => this.value = 0;
}
}
const state = {
xs : new LimitedQueue(20),
ys : new LimitedQueue(20),
ds : new LimitedQueue(200),
rs : new LimitedQueue(200),
es : new LimitedQueue(200),
valMovement1PerEvent : new MyValue(-100, 100),
valMovementByAmount : new MyValue(-100, 100),
valEmulatedScroll1PerEvent: new MyValue(-100, 100),
valEmulatedScrollByAmount : new MyValue(-100, 100),
valMouseScroll1PerEvent : new MyValue(-100, 100),
valMouseScrollByAmount : new MyValue(-100, 100),
usePointerLock: false,
logScalePlot: false,
displacementMode: 'weighted', // vector_len, weighted, only_y
minimumScroll: 2,
fixedRegion: 2,
accelerationPowerFactor: 1.3,
accelerationMultiplier: 1.3,
scrollEmulationMode: 'combined', // combined, touchpad, ratchet_wheel
accumulator: 0,
accumulatorBoundary: 12,
ratchetScroll: 15,
filterFactor: 0.4
};
const graph0BaseX = 50;
const graph0BaseY = 330;
const graph1BaseX = 200;
const graph1BaseY = 150;
const graph2BaseX = 400;
const graph2BaseY = 150;
const graph3BaseX = 400;
const graph3BaseY = 530;
const clamp100 = clamp(-100, 100);
const logZoom = a => x => (x == 0) ? 0 : Math.sign(x) * Math.log2(Math.abs(x)+1)*a;
const logZoom1 = logZoom(10);
const logZoom2 = logZoom(20);
let canvas;
function setup() {
canvas = createCanvas(650, 650);
document.body.insertBefore(canvas.elt, document.getElementById('controls'));
let resetButton = document.getElementById('resetButton');
resetButton.onclick = resetState;
let logButton = document.getElementById('logButton');
logButton.onclick = logState;
let filterFactorSlider = document.getElementById('filterFactor');
filterFactorSlider.value = state.filterFactor;
filterFactorSlider.oninput = () => state.filterFactor = +filterFactorSlider.value;
let displacementModeSelect = document.getElementById('displacementModeSelect');
displacementModeSelect.value = state.displacementMode;
displacementModeSelect.onchange = () => state.displacementMode = displacementModeSelect.value;
let scrollEmulationModeSelect = document.getElementById('scrollEmulationModeSelect');
scrollEmulationModeSelect.value = state.scrollEmulationMode;
document.body.className = state.scrollEmulationMode;
scrollEmulationModeSelect.onchange = () => {
state.scrollEmulationMode = scrollEmulationModeSelect.value;
document.body.className = state.scrollEmulationMode;
};
let minScrollSlider = document.getElementById('minimumScroll');
minScrollSlider.value = state.minimumScroll;
minScrollSlider.oninput = () => state.minimumScroll = +minScrollSlider.value;
let fixedRegionSlider = document.getElementById('fixedRegion');
fixedRegionSlider.value = state.fixedRegion;
fixedRegionSlider.oninput = () => state.fixedRegion = +fixedRegionSlider.value;
let accelerationPowerFactorSlider = document.getElementById('accelerationPowerFactor');
accelerationPowerFactorSlider.value = state.accelerationPowerFactor;
accelerationPowerFactorSlider.oninput = () => state.accelerationPowerFactor = +accelerationPowerFactorSlider.value;
let accelerationMultiplierSlider = document.getElementById('accelerationMultiplier');
accelerationMultiplierSlider.value = state.accelerationMultiplier;
accelerationMultiplierSlider.oninput = () => state.accelerationMultiplier = +accelerationMultiplierSlider.value;
let accBoundarySlider = document.getElementById('accumulatorBoundary');
accBoundarySlider.value = state.accumulatorBoundary;
accBoundarySlider.oninput = () => state.accumulatorBoundary = +accBoundarySlider.value;
let ratchetScrollSlider = document.getElementById('ratchetScroll');
ratchetScrollSlider.value = state.ratchetScroll;
ratchetScrollSlider.oninput = () => state.ratchetScroll = +ratchetScrollSlider.value;
let pointerLockCheckbox = document.getElementById('pointerLockCheckbox');
pointerLockCheckbox.onchange = () => state.usePointerLock = pointerLockCheckbox.checked;
let logScalePlotCheckbox = document.getElementById('logScalePlotCheckbox');
logScalePlotCheckbox.checked = state.logScalePlot;
logScalePlotCheckbox.onchange = () => state.logScalePlot = logScalePlotCheckbox.checked;
}
function draw() {
background(255);
stroke('#ccc');
line(graph1BaseX, graph1BaseY - 100, graph1BaseX, graph1BaseY + 100);
line(graph1BaseX - 100, graph1BaseY, graph1BaseX + 100, graph1BaseY);
line(graph2BaseX, graph2BaseY - 100, graph2BaseX, graph2BaseY + 100);
line(graph2BaseX, graph2BaseY, graph2BaseX + 200, graph2BaseY);
line(graph3BaseX, graph3BaseY - 200, graph3BaseX, graph3BaseY);
line(graph3BaseX, graph3BaseY, graph3BaseX + 200, graph3BaseY);
line(graph3BaseX, graph3BaseY, graph3BaseX + 200*0.707, graph3BaseY - 200*0.707);
drawBar('#090', '#ccc', graph0BaseX + 0, graph0BaseY, 20, 200, 2, state.valMovement1PerEvent.value);
drawBar('#090', '#ccc', graph0BaseX + 50, graph0BaseY, 20, 200, 2, state.valMovementByAmount.value);
drawBar('#900', '#ccc', graph0BaseX + 100, graph0BaseY, 20, 200, 2, state.valEmulatedScroll1PerEvent.value);
drawBar('#900', '#ccc', graph0BaseX + 150, graph0BaseY, 20, 200, 2, state.valEmulatedScrollByAmount.value);
drawBar('#009', '#ccc', graph0BaseX + 200, graph0BaseY, 20, 200, 2, state.valMouseScroll1PerEvent.value);
drawBar('#009', '#ccc', graph0BaseX + 250, graph0BaseY, 20, 200, 2, state.valMouseScrollByAmount.value);
// xs,ys
let xs = state.xs.getFilteredArray();
let ys = state.ys.getFilteredArray();
if(xs && validNumber(xs[0])) {
drawXYPlot('#bbb', graph1BaseX, graph1BaseY, xs.map(logZoom2), ys.map(logZoom2));
drawXYPlot('#090', graph1BaseX, graph1BaseY, xs, ys);
text(`${xs[0]}, ${ys[0]}`, graph1BaseX - 100, graph1BaseY + 120, 200);
}
// ds
let ds = state.ds.getFilteredArray();
if (ds && validNumber(ds[0])) {
drawPlot(
'#090',
graph2BaseX,
graph2BaseY,
state.logScalePlot ? ds.slice(0, 99).map(logZoom1) : ds,
state.logScalePlot ? 2 : 1
);
textAlign(LEFT);
text(`displacement: ${ds[0].toFixed()}`, graph2BaseX, graph2BaseY + 120, 200);
}
// rs
let rs = state.rs.getFilteredArray();
if (rs && validNumber(rs[0])) {
drawPlot(
'#009',
graph2BaseX,
graph2BaseY,
state.logScalePlot ? rs.slice(0, 99).map(logZoom1) : rs,
state.logScalePlot ? 2 : 1
);
textAlign(LEFT);
text(`scroll: ${rs[0].toFixed()}`, graph2BaseX, graph2BaseY + 140, 200);
}
// es
let es = state.es.getFilteredArray();
if (es && validNumber(es[0])) {
drawPlot(
'#900',
graph2BaseX,
graph2BaseY,
state.logScalePlot ? es.slice(0, 99).map(logZoom1) : es,
state.logScalePlot ? 2 : 1
);
textAlign(LEFT);
text(`emulated scroll: ${es[0].toFixed()}`, graph2BaseX + 120, graph2BaseY + 120, 200);
}
// acc curve
textAlign(LEFT);
if (['ratchet_wheel', 'combined'].includes(state.scrollEmulationMode)) {
stroke('#ccc');
fill('#ccc');
rect(graph3BaseX, graph3BaseY + 3, state.accumulator * 10.0, 5);
noFill();
stroke('#777');
rect(graph3BaseX, graph3BaseY + 3, state.accumulatorBoundary * 10.0, 5);
text(`accumulator: ${state.accumulator.toFixed(2)} / ${state.accumulatorBoundary}`, graph3BaseX, graph3BaseY + 30, 200);
if (state.scrollEmulationMode == 'ratchet_wheel') {
text(`ratchet scroll: ${state.ratchetScroll}`, graph3BaseX + 150, graph3BaseY + 30, 200);
}
}
if (['touchpad', 'combined'].includes(state.scrollEmulationMode)) {
stroke(0);
noFill();
beginShape();
for (let i = 1; i <= 200; i++) {
let displacement = i / 10.0;
let scroll = touchpadAccelerationCurve(state, displacement);
if (scroll < 20.0) {
vertex(graph3BaseX + i, graph3BaseY - scroll * 10.0);
}
}
endShape();
stroke('#ccc');
let movement = Math.abs(xyToMovementAmount(state, xs[0], ys[0], state.ds.peekLastAdded()));
line(graph3BaseX + movement*10, graph3BaseY - 200, graph3BaseX + movement*10, graph3BaseY);
// let accelerated = touchpadAccelerationCurve(state, movement);
// ellipse(graph3BaseX + movement*10, graph3BaseY - accelerated*10, 6);
stroke('#777');
text(`minimum scroll: ${state.minimumScroll}`, graph3BaseX, graph3BaseY + 50, 200);
text(`fixed region: ${state.fixedRegion}`, graph3BaseX, graph3BaseY + 70, 200);
text(`acceleration factor: ${state.accelerationPowerFactor.toFixed(3)}`, graph3BaseX, graph3BaseY + 90, 200);
text(`acceleration multiplier: ${state.accelerationMultiplier.toFixed(3)}`, graph3BaseX, graph3BaseY + 110, 200);
}
stroke('#aaa');
text(`scroll emulation mode: ${state.scrollEmulationMode}`, 50, 20, 200);
text(`displacement mode: ${state.displacementMode}`, 300, 20, 200);
text(`filter factor: ${state.filterFactor.toFixed(2)}`, 500, 20, 200);
// mouse buttons
if (mouseIsPressed) {
stroke('#777');
if (mouseButton == LEFT) { text('M1', 50, 100, 50); }
if (mouseButton == RIGHT) { text('M2', 50, 150, 50); }
if (mouseButton == CENTER) { text('M3', 50, 200, 50); }
}
}
/**
* Returns scroll amount for given displacement.
* Shape of acceleration curve is defined through config parameters.
*
* If displacement is in `fixedRegion`, then `minimumScroll` is returned.
* Outside of fixedRegion, acceleration curve is defined by `accelerationPowerFactor`
* and `accelerationMultiplier`.
*
* @param {any} config Object that holds configuration.
* @param {number} displacement Amount of movement (signed, after filtering if any).
* @returns {number}
*/
function touchpadAccelerationCurve(config, displacement) {
if (displacement < 0) { throw 'must be used with absolute values'; }
return (displacement < config.fixedRegion)
? config.minimumScroll
: Math.pow((displacement - config.fixedRegion), config.accelerationPowerFactor) * config.accelerationMultiplier + config.minimumScroll;
// Power then multiply or multiply then power - that's the question.
// Current variant is less likely to explode.
// Just multiplication doesn't give proper acceleration.
// It might be possible to go with power and minimum scroll only, without multiplication.
// But the point is to obtain reasonable curve without changing minimum scroll
// - system default is 2 and it is expected to be that on small scroll.
}
/**
* Takes movement amount, converts it into scroll amount and calls scroll callback when needed.
* Scroll type is defined through config parameters (`scrollEmulationMode` is the mode selector).
*
* * **touchpad** - every movement is directly translated into scroll with {@link touchpadAccelerationCurve}.
*
* * **ratchet_wheel** - accumulates travel distance. Calls scroll callback for every unit distance. Distance to travel is defined by `accumulatorBoundary`. Emitted scroll amount is defined by `ratchetScroll`.
*
* * **combined** - works almost like ratchet_wheel for small movements (when displacement is in fixedRegion), but emitted scroll amount is defined by `minimumScroll` instead. Works like touchpad for bigger movements.
*
* @param {any} state Object that holds configuration and accumulator state.
* @param {number} displacement Amount of movement (signed, after filtering if any).
* @param {function(number): void} callback Continuation that will be called with computed scroll amount when needed.
*/
function emulateScroll(state, displacement, callback) {
if (!displacement) { return; }
let d_abs = Math.abs(displacement);
let direction = Math.sign(displacement);
switch (state.scrollEmulationMode) {
case 'touchpad':
let scrollAmount = touchpadAccelerationCurve(state, d_abs);
if (scrollAmount) { callback(scrollAmount * direction); }
break;
case 'ratchet_wheel':
state.accumulator += d_abs;
while (state.accumulator > state.accumulatorBoundary) {
state.accumulator -= state.accumulatorBoundary;
callback(state.ratchetScroll * direction);
}
break;
case 'combined':
if (d_abs > state.fixedRegion) {
callback(touchpadAccelerationCurve(state, d_abs) * direction);
} else {
state.accumulator += d_abs;
while (state.accumulator > state.accumulatorBoundary) {
state.accumulator -= state.accumulatorBoundary;
callback(state.minimumScroll * direction);
}
}
break;
default:
throw 'unknown mode';
break;
}
}
/**
* Flatten 2d displacement values to a single value and apply some filtering if needed.
*
* `displacementMode` and `filterFactor` are configuration properties used here.
*
* @param {any} config Object that holds configuration.
* @param {number} dx Amount of movement on x axis, from mouse event.
* @param {number} dy Amount of movement on y axis, from mouse event.
* @returns {number} Result amount of movement for further processing (signed, filtered if enabled in config).
*/
function xyToMovementAmount(config, dx, dy, prevMovement) {
let direction = Math.sign(dy);
// Amount of movement:
// either displacement vector length, or vertical part only,
// or vertical part with half of horizontal part.
let movement = (config.displacementMode === 'vector_len')
? direction * vectorLength(dx, dy)
: (config.displacementMode === 'weighted')
? direction * vectorLength(dx / 2, dy)
: dy;
// Reduce input noise by mixing current input with previous one.
// Even slightest addition of previous value can introduce significant overshoot
// when changing scroll direction in some cases (on small movements).
// Seems fine in combined mode though, since small movements treated differently there.
if (validNumber(prevMovement)) {
movement = proportion(config.filterFactor, prevMovement, movement);
}
return movement;
}
function mouseDragged(event) {
let movementX = event.movementX;
let movementY = event.movementY;
let movement = xyToMovementAmount(state, movementX, movementY, state.ds.peekLastAdded());
// Pass preprocessed value into scrolling function.
// It may or may not fire scroll event (or call a callback in this case).
// Being able not to spam scroll events on every smallest move is really important.
emulateScroll(state, movement, emulatedScrollAmount => {
state.valEmulatedScrollByAmount.add(emulatedScrollAmount);
state.valEmulatedScroll1PerEvent.add(Math.sign(emulatedScrollAmount));
state.es.push(emulatedScrollAmount);
});
state.valMovementByAmount.add(movement);
state.valMovement1PerEvent.add(Math.sign(movement));
state.xs.push(movementX);
state.ys.push(movementY);
state.ds.push(movement);
}
function mouseReleased() {
// Reset accumulator in between uses.
state.accumulator = 0;
}
function mouseWheel(event) {
state.valMouseScrollByAmount.add(event.delta);
state.valMouseScroll1PerEvent.add(Math.sign(event.delta));
state.rs.push(event.delta);
}
function mousePressed() {
setTimeout(() => {
if (state.usePointerLock && !PL.isEnabled()) {
try {
PL.requestPointerLock(
canvas.elt,
e => document.body.classList.add('locked'),
e => {
document.body.classList.remove('locked');
let pointerLockCheckbox = document.getElementById('pointerLockCheckbox');
state.usePointerLock = false;
pointerLockCheckbox.checked = false;
},
e => console.log(e)
);
} catch (e) {
console.log(e);
}
}
}, 100);
}
function drawBar(color, color2, baseX, baseY, w, h, h2, value) {
stroke(color2);
let midX = baseX + w / 2;
let midY = baseY + h / 2;
line(midX, baseY, midX, baseY + h);
stroke(color);
fill(color);
rect(baseX, midY - h2/2 + value, w, h2);
textAlign(CENTER);
textSize(12);
text(`${value.toFixed(0)}`, baseX, baseY + h + 20, w);
}
function drawPlot(color, baseX, baseY, values, step=1) {
stroke(color);
noFill();
beginShape();
values.forEach((x,i) => {
vertex(baseX + i*step, baseY + clamp100(x));
});
endShape();
}
function drawXYPlot(color, baseX, baseY, valuesX, valuesY) {
stroke(color);
noFill();
beginShape();
for (let i = 0; i < valuesX.length; i++) {
vertex(baseX + clamp100(valuesX[i]), baseY + clamp100(valuesY[i]));
}
endShape();
fill(color);
ellipse(baseX + clamp100(valuesX[0]), baseY + clamp100(valuesY[0]), 5);
}
function resetState() {
state.xs.reset();
state.ys.reset();
state.ds.reset();
state.rs.reset();
state.es.reset();
state.valMovement1PerEvent.reset();
state.valMovementByAmount.reset();
state.valEmulatedScroll1PerEvent.reset();
state.valEmulatedScrollByAmount.reset();
state.valMouseScroll1PerEvent.reset();
state.valMouseScrollByAmount.reset();
state.accumulator = 0;
}
function logState() {
let logObject = {
xs : state.xs.getFilteredArray(),
ys : state.ys.getFilteredArray(),
ds : state.ds.getFilteredArray(),
rs : state.rs.getFilteredArray(),
es : state.es.getFilteredArray(),
scrollEmulationMode : state.scrollEmulationMode,
displacementMode : state.displacementMode,
accumulator : state.accumulator,
accumulatorBoundary : state.accumulatorBoundary,
minimumScroll : state.minimumScroll,
fixedRegion : state.fixedRegion,
accelerationPowerFactor: state.accelerationPowerFactor,
accelerationMultiplier : state.accelerationMultiplier,
filterFactor : state.filterFactor
}
console.log(JSON.stringify(logObject, undefined, 2));
console.log(logObject);
}
html {
box-sizing: border-box;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
#controls {
border: 1px solid #ccc;
margin: 10px;
display: flex;
flex-flow: column;
justify-content: space-between;
float: right;
}
#controls > div {
display: flex;
flex-direction: column;
margin: 20px 10px;
}
.controlsSubtitle {
font-weight: 600;
}
#controls label {
display: flex;
justify-content: space-between;
}
#controls input,
#controls select,
#controls button {
margin: 2px;
}
#controls input[type="range"] {
width: 200px;
}
#controls select {
min-width: 110px;
}
body.combined .control:not(.for_combined),
body.touchpad .control:not(.for_touchpad),
body.ratchet_wheel .control:not(.for_ratchet_wheel) {
visibility: collapse;
display: none;
}
.control.p {
max-width: 300px;
color: #777;
font-style: italic;
}
body:not(.locked) #lockedNotification {
display:none;
}
#lockedNotification {
color: #b00;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment