feat(chart): refine viewport interaction and rendering

This commit is contained in:
liqiyuan
2026-08-08 10:43:39 +08:00
parent 7646bf4907
commit 9b42e3797b
21 changed files with 838 additions and 325 deletions

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest'
import { ViewportInteractionStateMachine } from './viewportInteractionState'
import {
reduceViewportInteraction,
transitionViewportInteraction,
type ViewportInteractionEvent,
} from './viewportInteractionState'
const gesture = {
trackIndex: 2,
@@ -13,64 +17,85 @@ const gesture = {
yDomains: { track: [-1, 1] as [number, number] },
}
describe('ViewportInteractionStateMachine', () => {
it('accepts one gesture and rejects a conflicting start', () => {
const machine = new ViewportInteractionStateMachine()
expect(machine.begin(gesture)).toBe(true)
expect(machine.begin({ ...gesture, pointerId: 8 })).toBe(false)
expect(machine.state).toMatchObject({ kind: 'box', pointerId: 7 })
})
it('rejects moves and completion from a different pointer', () => {
const machine = new ViewportInteractionStateMachine()
machine.begin(gesture)
expect(machine.move(8, { currentX: 30, currentY: 40 })).toBeNull()
expect(machine.finish(8, { currentX: 30, currentY: 40 })).toBeNull()
expect(machine.state?.currentX).toBe(10)
})
it('updates a valid pointer, completes it, and returns to idle', () => {
const machine = new ViewportInteractionStateMachine()
machine.begin({ ...gesture, kind: 'pan' })
expect(machine.move(7, { currentX: 30, currentY: 40 })).toMatchObject({
kind: 'pan',
currentX: 30,
currentY: 40,
describe('viewport interaction reducer', () => {
it('accepts one begin and rejects a conflicting begin', () => {
const started = transitionViewportInteraction(null, { type: 'begin', gesture })
const conflicting = transitionViewportInteraction(started.state, {
type: 'begin',
gesture: { ...gesture, pointerId: 8 },
})
expect(machine.finish(7, { currentX: 50, currentY: 60 })).toMatchObject({
kind: 'pan',
currentX: 50,
currentY: 60,
expect(started.accepted).toBe(true)
expect(started.state).toMatchObject({ kind: 'box', pointerId: 7 })
expect(conflicting.accepted).toBe(false)
expect(conflicting.state).toMatchObject({ kind: 'box', pointerId: 7 })
})
it('rejects move and finish from a different pointer without changing state', () => {
const state = reduceViewportInteraction(null, { type: 'begin', gesture })
const move = transitionViewportInteraction(state, {
type: 'move',
pointerId: 8,
position: { currentX: 30, currentY: 40 },
})
expect(machine.state).toBeNull()
const finish = transitionViewportInteraction(move.state, {
type: 'finish',
pointerId: 8,
position: { currentX: 30, currentY: 40 },
})
expect(move.accepted).toBe(false)
expect(finish.accepted).toBe(false)
expect(finish.state).toMatchObject({ currentX: 10, currentY: 20 })
})
it('only cancels the owning pointer and supports explicit reset', () => {
const machine = new ViewportInteractionStateMachine()
machine.begin(gesture)
it('updates a valid pointer and returns the completed snapshot on finish', () => {
const state = reduceViewportInteraction(null, {
type: 'begin',
gesture: { ...gesture, kind: 'pan' },
})
const moved = transitionViewportInteraction(state, {
type: 'move',
pointerId: 7,
position: { currentX: 30, currentY: 40 },
})
const finished = transitionViewportInteraction(moved.state, {
type: 'finish',
pointerId: 7,
position: { currentX: 50, currentY: 60 },
})
expect(machine.cancel(8)).toBe(false)
expect(machine.state).not.toBeNull()
expect(machine.cancel(7)).toBe(true)
expect(machine.state).toBeNull()
machine.begin(gesture)
machine.reset()
expect(machine.state).toBeNull()
expect(moved.state).toMatchObject({ kind: 'pan', currentX: 30, currentY: 40 })
expect(finished.completed).toMatchObject({ kind: 'pan', currentX: 50, currentY: 60 })
expect(finished.state).toBeNull()
})
it('returns defensive copies from its state getter', () => {
const machine = new ViewportInteractionStateMachine()
machine.begin(gesture)
const snapshot = machine.state!
snapshot.currentX = 99
snapshot.xDomain[0] = 50
snapshot.yDomains.track![0] = 50
it('cancels only the owning pointer and supports cancel/reset events', () => {
const state = reduceViewportInteraction(null, { type: 'begin', gesture })
const rejectedCancel = transitionViewportInteraction(state, { type: 'cancel', pointerId: 8 })
const cancelled = transitionViewportInteraction(rejectedCancel.state, {
type: 'cancel',
pointerId: 7,
})
const restarted = reduceViewportInteraction(null, { type: 'begin', gesture })
const reset = transitionViewportInteraction(restarted, { type: 'reset' })
expect(machine.state).toMatchObject({ currentX: 10, xDomain: [0, 100] })
expect(machine.state?.yDomains.track).toEqual([-1, 1])
expect(rejectedCancel.accepted).toBe(false)
expect(rejectedCancel.state).not.toBeNull()
expect(cancelled.accepted).toBe(true)
expect(cancelled.state).toBeNull()
expect(reset.accepted).toBe(true)
expect(reset.state).toBeNull()
})
it('keeps events discriminated and does not mutate the begin input', () => {
const event: ViewportInteractionEvent = { type: 'begin', gesture }
const state = reduceViewportInteraction(null, event)
expect(state).not.toBeNull()
expect(state?.xDomain).toEqual([0, 100])
expect(state?.yDomains.track).toEqual([-1, 1])
expect(event.gesture.xDomain).toEqual([0, 100])
expect(event.gesture.yDomains.track).toEqual([-1, 1])
})
})