From 1ccac024a4a1fd225315c66d7ecca05ac6ff38d8 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Thu, 5 Mar 2026 14:55:10 -0800 Subject: [PATCH] feat(avocet): add velocity-based fling detection to toss gesture (option B: speed + alignment) --- web/src/components/EmailCardStack.test.ts | 69 +++++++++++++++++++++++ web/src/components/EmailCardStack.vue | 37 ++++++++++++ 2 files changed, 106 insertions(+) diff --git a/web/src/components/EmailCardStack.test.ts b/web/src/components/EmailCardStack.test.ts index ed8f614..b20338f 100644 --- a/web/src/components/EmailCardStack.test.ts +++ b/web/src/components/EmailCardStack.test.ts @@ -111,4 +111,73 @@ describe('EmailCardStack', () => { expect(w.emitted('skip')).toBeFalsy() expect(w.emitted('label')).toBeFalsy() }) + + // Fling tests — mock performance.now() to control timestamps between events + it('emits discard on fast leftward fling (option B: speed + alignment)', async () => { + let mockTime = 0 + vi.spyOn(performance, 'now').mockImplementation(() => mockTime) + const w = mount(EmailCardStack, { props: { item, isBucketMode: false } }) + const el = w.find('.card-wrapper').element + mockPointerCapture(el) + mockTime = 0; fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 }) + mockTime = 30; fire(el, 'pointermove', { pointerId: 1, clientX: 400, clientY: 310 }) + mockTime = 50; fire(el, 'pointermove', { pointerId: 1, clientX: 288, clientY: 320 }) + // vx = (288-400)/(50-30)*1000 = -5600 px/s, vy ≈ 500 px/s + // speed ≈ 5622 px/s > 600, alignment = 5600/5622 ≈ 0.996 > 0.707 ✓ + fire(el, 'pointerup', { pointerId: 1, clientX: 288, clientY: 320 }) + await w.vm.$nextTick() + expect(w.emitted('discard')).toBeTruthy() + vi.restoreAllMocks() + }) + + it('emits skip on fast rightward fling', async () => { + let mockTime = 0 + vi.spyOn(performance, 'now').mockImplementation(() => mockTime) + const w = mount(EmailCardStack, { props: { item, isBucketMode: false } }) + const el = w.find('.card-wrapper').element + mockPointerCapture(el) + mockTime = 0; fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 }) + mockTime = 30; fire(el, 'pointermove', { pointerId: 1, clientX: 624, clientY: 310 }) + mockTime = 50; fire(el, 'pointermove', { pointerId: 1, clientX: 736, clientY: 320 }) + // vx = (736-624)/(50-30)*1000 = 5600 px/s — mirror of discard case + fire(el, 'pointerup', { pointerId: 1, clientX: 736, clientY: 320 }) + await w.vm.$nextTick() + expect(w.emitted('skip')).toBeTruthy() + vi.restoreAllMocks() + }) + + it('does not fling on diagonal swipe (alignment < 0.707)', async () => { + let mockTime = 0 + vi.spyOn(performance, 'now').mockImplementation(() => mockTime) + const w = mount(EmailCardStack, { props: { item, isBucketMode: false } }) + const el = w.find('.card-wrapper').element + mockPointerCapture(el) + mockTime = 0; fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 }) + mockTime = 30; fire(el, 'pointermove', { pointerId: 1, clientX: 400, clientY: 150 }) + mockTime = 50; fire(el, 'pointermove', { pointerId: 1, clientX: 288, clientY: 0 }) + // vx = -5600 px/s, vy = -7500 px/s, speed ≈ 9356 px/s + // alignment = 5600/9356 ≈ 0.598 < 0.707 — too diagonal ✓ + fire(el, 'pointerup', { pointerId: 1, clientX: 288, clientY: 0 }) + await w.vm.$nextTick() + expect(w.emitted('discard')).toBeFalsy() + expect(w.emitted('skip')).toBeFalsy() + vi.restoreAllMocks() + }) + + it('does not fling on slow movement (speed < threshold)', async () => { + let mockTime = 0 + vi.spyOn(performance, 'now').mockImplementation(() => mockTime) + const w = mount(EmailCardStack, { props: { item, isBucketMode: false } }) + const el = w.find('.card-wrapper').element + mockPointerCapture(el) + mockTime = 0; fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 }) + mockTime = 100; fire(el, 'pointermove', { pointerId: 1, clientX: 480, clientY: 300 }) + mockTime = 200; fire(el, 'pointermove', { pointerId: 1, clientX: 450, clientY: 300 }) + // vx = (450-480)/(200-100)*1000 = -300 px/s < 600 threshold + fire(el, 'pointerup', { pointerId: 1, clientX: 450, clientY: 300 }) + await w.vm.$nextTick() + expect(w.emitted('discard')).toBeFalsy() + expect(w.emitted('skip')).toBeFalsy() + vi.restoreAllMocks() + }) }) diff --git a/web/src/components/EmailCardStack.vue b/web/src/components/EmailCardStack.vue index 3f6a5a6..185b8cd 100644 --- a/web/src/components/EmailCardStack.vue +++ b/web/src/components/EmailCardStack.vue @@ -66,6 +66,13 @@ const hoveredBucketName = ref(null) // Zone threshold: 7% of viewport width on each side const ZONE_PCT = 0.07 +// Fling detection — Option B: speed + direction alignment +// Plain array (not ref) — drives no template state, updates on every pointermove +const FLING_SPEED_PX_S = 600 // px/s minimum to qualify as a fling +const FLING_ALIGN = 0.707 // cos(45°) — velocity must point within 45° of horizontal +const FLING_WINDOW_MS = 50 // rolling sample window in ms +let velocityBuf: { x: number; y: number; t: number }[] = [] + function onPointerDown(e: PointerEvent) { if (!motion.rich.value) return ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId) @@ -76,6 +83,7 @@ function onPointerDown(e: PointerEvent) { isHeld.value = true hoveredZone.value = null hoveredBucketName.value = null + velocityBuf = [] emit('drag-start') } @@ -84,6 +92,13 @@ function onPointerMove(e: PointerEvent) { deltaX.value = e.clientX - pickupX.value deltaY.value = e.clientY - pickupY.value + // Rolling velocity buffer — keep only the last FLING_WINDOW_MS of samples + const now = performance.now() + velocityBuf.push({ x: e.clientX, y: e.clientY, t: now }) + while (velocityBuf.length > 1 && now - velocityBuf[0].t > FLING_WINDOW_MS) { + velocityBuf.shift() + } + const vw = window.innerWidth const zone: 'discard' | 'skip' | null = e.clientX < vw * ZONE_PCT ? 'discard' : @@ -113,6 +128,27 @@ function onPointerUp(e: PointerEvent) { emit('zone-hover', null) emit('bucket-hover', null) + // Fling detection (Option B): fires before zone check so a fast fling + // resolves even if the pointer didn't reach the 7% edge zone + if (hoveredZone.value === null && hoveredBucketName.value === null + && velocityBuf.length >= 2) { + const oldest = velocityBuf[0] + const newest = velocityBuf[velocityBuf.length - 1] + const dt = (newest.t - oldest.t) / 1000 // seconds + if (dt > 0) { + const vx = (newest.x - oldest.x) / dt + const vy = (newest.y - oldest.y) / dt + const speed = Math.sqrt(vx * vx + vy * vy) + // Require: fast enough AND velocity points within 45° of horizontal + if (speed >= FLING_SPEED_PX_S && Math.abs(vx) / speed >= FLING_ALIGN) { + velocityBuf = [] + emit(vx < 0 ? 'discard' : 'skip') + return + } + } + } + velocityBuf = [] + if (hoveredZone.value === 'discard') { hoveredZone.value = null hoveredBucketName.value = null @@ -143,6 +179,7 @@ function onPointerCancel(e: PointerEvent) { deltaY.value = 0 hoveredZone.value = null hoveredBucketName.value = null + velocityBuf = [] emit('drag-end') emit('zone-hover', null) emit('bucket-hover', null)