aboutsummaryrefslogtreecommitdiffstats
path: root/src
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 /src
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]>
Diffstat (limited to 'src')
-rw-r--r--src/app.js60
1 files changed, 43 insertions, 17 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: [] };
});