aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-12 05:32:52 -0700
committerAdam Soltys <[email protected]>2026-10-12 05:32:52 -0700
commit3ff588d439dbe86da3fd5b2f433f302078bb0222 (patch)
tree1b39836d41eababe83810ad589a88997a75bb895
parent2b83c3f46c0dc2da1bbd9854443d57de54b979ea (diff)
downloadcoinosv3-3ff588d439dbe86da3fd5b2f433f302078bb0222.tar.gz
coinosv3-3ff588d439dbe86da3fd5b2f433f302078bb0222.zip
Feed wheel: a turn back against the coast hunts, in short steps that grow. Flying down the feed and turning the wheel back for the post just passed used to send the page flying the other way (a full notch's coast, 300px) the moment it caught — the reader overshoots again, and hunts by overshooting. Now a reversal against a coast (or one that died within 400 ms) catches the page and takes that notch as a quarter of its travel, over a quick coast (110 ms, not 325); each notch on in the same direction grows the step — 0.35, 0.5, 0.7, 0.85 of a notch — and the sixth is a full push with the usual wind-up, so a few flicks have the wheel flying again. Another turn back while hunting starts the small steps over, and two seconds without a notch ends the hunt: a notch from rest is the push it has always been. Keys, a touch or a grab end it too. tools/feed-wheel-reverse-test.js (short step on reversal, the ramp, the restart, the pause, the just-died coast, the full push from rest); feed-momentum-test still passes.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
-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)');