From 3ff588d439dbe86da3fd5b2f433f302078bb0222 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 12 Oct 2026 05:32:52 -0700 Subject: 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. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- src/app.js | 60 +++++++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 43 insertions(+), 17 deletions(-) (limited to 'src/app.js') 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: [] }; }); -- cgit v1.2.3