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