aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/app.js60
-rw-r--r--tools/feed-wheel-reverse-test.js25
2 files changed, 65 insertions, 20 deletions
diff --git a/src/app.js b/src/app.js
index a0a5300..8af6f75 100644
--- a/src/app.js
+++ b/src/app.js
@@ -6589,7 +6589,8 @@ if (INTENT_URI) {
}
return true;
};
- let v = 0, acc = 0, raf = 0, last = 0;
+ let v = 0, acc = 0, raf = 0, last = 0, tau = TAU;
+ let lastDir = 0, lastMoveAt = 0; // which way the coast last moved the page, and when
const stop = () => { v = 0; acc = 0; if (raf) cancelAnimationFrame(raf); raf = 0; };
const tick = (now) => {
const dt = Math.max(0, Math.min(40, now - last)); last = Math.max(last, now); // a frame stamp can predate the push
@@ -6599,50 +6600,75 @@ if (INTENT_URI) {
const y = window.scrollY;
window.scrollBy({ top: step, behavior: 'instant' });
acc -= step;
+ lastDir = Math.sign(step); lastMoveAt = now;
if (window.scrollY === y) return stop(); // an end of the page
}
- v *= Math.exp(-dt / TAU);
+ v *= Math.exp(-dt / tau);
if (Math.abs(v) < 0.02) return stop();
raf = requestAnimationFrame(tick);
};
- const coast = (nv) => {
- v = Math.max(-VMAX, Math.min(VMAX, nv));
+ const coast = (nv, t = TAU) => {
+ v = Math.max(-VMAX, Math.min(VMAX, nv)); tau = t;
if (!raf && v) { last = performance.now(); raf = requestAnimationFrame(tick); }
};
// ---- the wheel
+ // A turn back against the coast is a reader who flew past the post they
+ // wanted and is hunting for it: the page is caught at once, and that notch
+ // and the next few are short, quick steps (a quarter of a notch's travel,
+ // settling fast) rather than a fresh flight the other way. Each notch on
+ // in the same direction grows the step back towards a full one, so a few
+ // flicks have the wheel flying again; a turn back while hunting starts the
+ // small steps over, and a pause ends the hunt — the next notch is the
+ // plain push from rest it has always been.
+ const HUNT = [0.25, 0.35, 0.5, 0.7, 0.85]; // of a notch's travel, notch by notch
+ const HUNT_TAU = 110; // ms: a hunting step settles quickly
+ const HUNT_IDLE = 2000; // ms without a notch, and the hunt is over
+ let hunt = -1, huntDir = 0, huntAt = 0; // the next HUNT entry while hunting
+ const halt = () => { stop(); hunt = -1; };
let smoothUntil = 0; // a trackpad's events were just seen: hands off
window.addEventListener('wheel', (e) => {
if (e.ctrlKey || e.shiftKey || e.defaultPrevented) return;
const notch = e.deltaMode === 1 || (e.wheelDeltaY && e.wheelDeltaY % 120 === 0 && !e.deltaX);
- if (!notch) { smoothUntil = e.timeStamp + 400; stop(); return; }
+ if (!notch) { smoothUntil = e.timeStamp + 400; halt(); return; }
if (e.timeStamp < smoothUntil || matchMedia('(pointer: coarse)').matches || !inScope(e.target)) return;
e.preventDefault();
const px = e.deltaMode === 1 ? e.deltaY * 40 : e.deltaY;
- // A reversal catches the old coast immediately. Discard its fractional
- // step and frame clock too: after a busy frame, applying the NEW velocity
- // to the OLD elapsed time made the first reverse step jump. Give the
- // first notch a small response now, then coast the remaining distance.
- if (v && px && Math.sign(v) !== Math.sign(px)) {
- stop();
- const first = Math.sign(px) * Math.min(16, Math.abs(px * 3 / TAU) * 16);
+ const dir = Math.sign(px);
+ if (!dir) return;
+ const now = e.timeStamp;
+ if (hunt >= 0 && now - huntAt > HUNT_IDLE) hunt = -1;
+ // the way the page is going: its coast, the coast that just died, or the hunt
+ const going = v ? Math.sign(v) : now - lastMoveAt < 400 ? lastDir : hunt >= 0 ? huntDir : 0;
+ if (going && dir !== going) { stop(); hunt = 0; huntDir = dir; }
+ else if (hunt >= 0 && ++hunt >= HUNT.length) hunt = -1;
+ if (hunt >= 0) {
+ // A reversal catches the old coast immediately. Discard its fractional
+ // step and frame clock too: after a busy frame, applying the NEW velocity
+ // to the OLD elapsed time made the first reverse step jump. Give the
+ // notch a small response now, then a quick coast over the rest of its
+ // (short) travel; quick flicks still add up.
+ huntAt = now;
+ const travel = px * 3 * HUNT[hunt];
+ const first = dir * Math.min(16, Math.abs(travel / HUNT_TAU) * 16);
window.scrollBy({ top: first, behavior: 'instant' });
- coast((px * 3 - first) / TAU);
+ lastDir = dir; lastMoveAt = now;
+ coast(v + (travel - first) / HUNT_TAU, HUNT_TAU);
} else {
// A notch into a page already moving pushes harder: the faster it
// goes, the more each turn adds (up to 2.5x), so keeping the wheel
// turning winds the speed up instead of settling at a stroll. From
// rest a notch is the same push as ever.
const gain = 1 + Math.min(1.5, Math.abs(v) / 4);
- coast(v + (px * 3 * gain) / TAU);
+ coast(v + (px * 3 * gain) / TAU, TAU);
}
}, { passive: false });
// anything else that moves the page takes over from a coast
- window.addEventListener('keydown', (e) => { if (/^(Arrow|Page|Home|End| )/.test(e.key)) stop(); });
- window.addEventListener('touchstart', stop, { passive: true });
+ window.addEventListener('keydown', (e) => { if (/^(Arrow|Page|Home|End| )/.test(e.key)) halt(); });
+ window.addEventListener('touchstart', halt, { passive: true });
// ---- grab and flick
let drag = null;
document.addEventListener('pointerdown', (e) => {
- if (raf) stop(); // a click catches a coasting page, like a finger does
+ if (raf) halt(); // a click catches a coasting page, like a finger does
if (e.pointerType !== 'mouse' || e.button !== 0 || !inScope(e.target)) return;
drag = { x: e.clientX, y: e.clientY, ly: e.clientY, on: false, pts: [] };
});
diff --git a/tools/feed-wheel-reverse-test.js b/tools/feed-wheel-reverse-test.js
index d8cc44a..8a00b11 100644
--- a/tools/feed-wheel-reverse-test.js
+++ b/tools/feed-wheel-reverse-test.js
@@ -28,6 +28,7 @@ function fixture() {
frame(stamp = now) { const callbacks = [...frames.values()]; frames.clear(); for (const fn of callbacks) fn(stamp); },
};
}
+const settle = (f, n = 70) => { for (let i = 0; i < n; i++) { f.advance(17); f.frame(); } };
for (const dir of [1, -1]) {
const f = fixture();
f.wheel(dir); f.advance(17); f.frame();
@@ -44,7 +45,25 @@ for (const dir of [1, -1]) {
f.advance(17); f.frame();
assert((f.y - reversed) * dir < 0, 'next frame continues in the new direction');
assert(Math.abs(f.y - reversed) < 20, 'new velocity is not applied to the old frame delay');
- for (let i = 0; i < 100; i++) { f.advance(17); f.frame(); }
- assert(Math.abs(f.y - before) > 280 && Math.abs(f.y - before) < 330, 'one notch retains its coast distance');
+ settle(f);
+ // The turn back is a hunt: a quarter of a notch's travel, not a flight the other way.
+ const back = (before - f.y) * dir;
+ assert(back > 65 && back < 90, 'a reverse notch against a coast is a short step: ' + back);
+ // Each notch on grows the step back towards a full one; the sixth is a plain notch again.
+ const steps = [];
+ for (let i = 0; i < 5; i++) { f.advance(200); const y = f.y; f.wheel(-dir); settle(f); steps.push((y - f.y) * dir); }
+ assert(steps.every((s, i) => i === 0 || s > steps[i - 1] * 1.08), 'hunting steps grow notch by notch: ' + steps.map(Math.round));
+ assert(steps[0] > 95 && steps[0] < 125 && steps[3] > 240 && steps[3] < 285 && steps[4] > 280 && steps[4] < 330, 'from about a third of a notch to a full one: ' + steps.map(Math.round));
+ { f.advance(200); const y = f.y; f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 280 && s < 330, 'a few flicks on, the wheel flies again: ' + s); }
+ // A turn back while hunting starts the small steps over.
+ f.wheel(-dir); f.advance(17); f.frame(); f.advance(17); f.frame();
+ { const y = f.y; f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > 65 && s < 90, 'a reversal while hunting is a short step again: ' + s); }
+ { f.advance(300); const y = f.y; f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > 95 && s < 120, 'and the next grows from there: ' + s); }
+ // A pause ends the hunt: the next notch is the plain push from rest.
+ { f.advance(2200); const y = f.y; f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > 280 && s < 330, 'after a pause a notch is a full push: ' + s); }
+ // A turn back against a coast that only just died hunts too.
+ { f.wheel(dir); settle(f); f.advance(200); const y = f.y; f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 65 && s < 90, 'reversing right after the coast dies is a short step: ' + s); }
+ // ...but a notch from rest the other way, well after, is a full one.
+ { f.advance(2500); const y = f.y; f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > 280 && s < 330, 'from rest a notch either way is a full push: ' + s); }
}
-console.log('✓ immediate reversal both ways, stale frame timestamps, busy frame recovery, and coast distance');
+console.log('✓ immediate reversal both ways, stale frame timestamps, busy frame recovery, precision hunt after a reversal (short steps that grow, restart on another turn back, end on a pause)');