feat(avocet): add velocity-based fling detection to toss gesture (option B: speed + alignment)

This commit is contained in:
pyr0ball 2026-03-05 14:55:10 -08:00
parent f8e911c48f
commit 1ccac024a4
2 changed files with 106 additions and 0 deletions

View file

@ -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()
})
})

View file

@ -66,6 +66,13 @@ const hoveredBucketName = ref<string | null>(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)