refactor(chart): introduce hybrid domain architecture
This commit is contained in:
@@ -110,7 +110,7 @@ const {
|
||||
{
|
||||
'waveform-chart--clean': isCleanView,
|
||||
'waveform-chart--presentation': isPresentationMode,
|
||||
'waveform-chart--panning': selection?.mode === 'pan',
|
||||
'waveform-chart--panning': selection?.kind === 'pan',
|
||||
},
|
||||
]"
|
||||
:style="containerStyle"
|
||||
@@ -247,7 +247,7 @@ const {
|
||||
/>
|
||||
|
||||
<rect
|
||||
v-if="selectionBox && selection?.mode === 'box'"
|
||||
v-if="selectionBox && selection?.kind === 'box'"
|
||||
class="waveform-chart__zoom-selection"
|
||||
:x="selectionBox.x"
|
||||
:y="selectionBox.y"
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useWaveformChartAnnotations } from '../annotation/useWaveformChartAnnot
|
||||
import { useWaveformHover } from '../interaction/useWaveformHover'
|
||||
import { useWaveformViewport } from '../interaction/useWaveformViewport'
|
||||
import { useWaveformZoom } from '../interaction/useWaveformZoom'
|
||||
import { ViewportInteractionStateMachine } from '../interaction/viewportInteractionState'
|
||||
import { useAnimationFrameThrottle } from '../utils/useAnimationFrameThrottle'
|
||||
import { margin } from './constants'
|
||||
import { getPageSize } from './grid'
|
||||
@@ -78,7 +79,8 @@ export function useWaveformChartController(
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
const selection = ref<ViewportSelectionState | null>(null)
|
||||
const selection = shallowRef<ViewportSelectionState | null>(null)
|
||||
const viewportInteraction = shallowRef(markRaw(new ViewportInteractionStateMachine()))
|
||||
const spacePressed = ref(false)
|
||||
const pointerInsideChart = ref(false)
|
||||
let handleDataReferenceChange: () => void = () => undefined
|
||||
@@ -193,6 +195,7 @@ export function useWaveformChartController(
|
||||
props,
|
||||
emit,
|
||||
selection,
|
||||
viewportInteraction,
|
||||
spacePressed,
|
||||
trackLayouts,
|
||||
chartTracks,
|
||||
|
||||
@@ -95,16 +95,17 @@ export interface WaveformChartEmit {
|
||||
(event: 'page-change', page: number, pageCount: number): void
|
||||
}
|
||||
|
||||
export interface ViewportSelectionState {
|
||||
interface ViewportSelectionBase {
|
||||
trackIndex: number
|
||||
independent: boolean
|
||||
overlay: SVGRectElement
|
||||
startX: number
|
||||
startY: number
|
||||
currentX: number
|
||||
currentY: number
|
||||
pointerId: number
|
||||
mode: 'box' | 'pan'
|
||||
xDomain: [number, number]
|
||||
yDomains: Record<string, [number, number]>
|
||||
}
|
||||
|
||||
export type ViewportSelectionState =
|
||||
(ViewportSelectionBase & { kind: 'box' }) | (ViewportSelectionBase & { kind: 'pan' })
|
||||
|
||||
@@ -136,7 +136,7 @@ export function useWaveformHover(context: HoverContext) {
|
||||
}
|
||||
const handleSharedPointerMove = (event: PointerEvent) => {
|
||||
if (isPresentationMode.value) return
|
||||
if (selection.value?.overlay === event.currentTarget) {
|
||||
if (selection.value && !selection.value.independent) {
|
||||
updateViewportDrag(event)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { pointer, scaleLinear, zoomIdentity, type ZoomTransform } from 'd3'
|
||||
import { computed, nextTick, type ComputedRef, type Ref, type ShallowRef } from 'vue'
|
||||
import { computed, nextTick, shallowRef, type ComputedRef, type Ref, type ShallowRef } from 'vue'
|
||||
|
||||
import { MINIMUM_SELECTION_SIZE } from '../core/constants'
|
||||
import type { DisplayTrack, TrackLayout } from '../core/types'
|
||||
@@ -10,11 +10,13 @@ import type {
|
||||
WaveformChartEmit,
|
||||
} from '../core/waveformChartTypes'
|
||||
import type { AnnotationSeriesCandidate } from '../annotation'
|
||||
import type { ViewportInteractionStateMachine } from './viewportInteractionState'
|
||||
|
||||
interface ViewportContext {
|
||||
props: ResolvedWaveformChartProps
|
||||
emit: WaveformChartEmit
|
||||
selection: Ref<ViewportSelectionState | null>
|
||||
viewportInteraction: ShallowRef<ViewportInteractionStateMachine>
|
||||
spacePressed: Ref<boolean>
|
||||
trackLayouts: ComputedRef<TrackLayout[]>
|
||||
chartTracks: ComputedRef<DisplayTrack[]>
|
||||
@@ -43,6 +45,7 @@ export function useWaveformViewport(context: ViewportContext) {
|
||||
props,
|
||||
emit,
|
||||
selection,
|
||||
viewportInteraction,
|
||||
spacePressed,
|
||||
trackLayouts,
|
||||
chartTracks,
|
||||
@@ -65,6 +68,10 @@ export function useWaveformViewport(context: ViewportContext) {
|
||||
clearHover,
|
||||
resolveTrackAtPointer,
|
||||
} = context
|
||||
const activeOverlay = shallowRef<SVGRectElement>()
|
||||
const syncSelection = () => {
|
||||
selection.value = viewportInteraction.value.state
|
||||
}
|
||||
const selectionBox = computed(() => {
|
||||
const active = selection.value
|
||||
if (!active) return null
|
||||
@@ -143,19 +150,19 @@ export function useWaveformViewport(context: ViewportContext) {
|
||||
const [rawX, rawY] = pointer(event, overlay)
|
||||
const x = Math.max(0, Math.min(independent ? track.width : innerWidth.value, rawX))
|
||||
const y = Math.max(0, Math.min(independent ? track.height : innerHeight.value, rawY))
|
||||
selection.value = {
|
||||
const started = viewportInteraction.value.begin({
|
||||
trackIndex,
|
||||
independent,
|
||||
overlay,
|
||||
startX: x,
|
||||
startY: y,
|
||||
currentX: x,
|
||||
currentY: y,
|
||||
pointerId: event.pointerId,
|
||||
mode: panRequested ? 'pan' : 'box',
|
||||
kind: panRequested ? 'pan' : 'box',
|
||||
xDomain: track.xScale.domain() as [number, number],
|
||||
yDomains: currentYDomains(),
|
||||
}
|
||||
})
|
||||
if (!started) return
|
||||
activeOverlay.value = overlay
|
||||
syncSelection()
|
||||
overlay.setPointerCapture?.(event.pointerId)
|
||||
clearHover()
|
||||
event.preventDefault()
|
||||
@@ -213,27 +220,33 @@ export function useWaveformViewport(context: ViewportContext) {
|
||||
const updateViewportDrag = (event: PointerEvent) => {
|
||||
if (isPresentationMode.value) return
|
||||
const active = selection.value
|
||||
if (!active || event.pointerId !== active.pointerId) return
|
||||
const overlay = activeOverlay.value
|
||||
if (!active || !overlay || event.pointerId !== active.pointerId) return
|
||||
const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
|
||||
if (!track) return
|
||||
const [rawX, rawY] = pointer(event, active.overlay)
|
||||
active.currentX = Math.max(
|
||||
const [rawX, rawY] = pointer(event, overlay)
|
||||
const currentX = Math.max(
|
||||
0,
|
||||
Math.min(active.independent ? track.width : innerWidth.value, rawX),
|
||||
)
|
||||
active.currentY = Math.max(
|
||||
const currentY = Math.max(
|
||||
0,
|
||||
Math.min(active.independent ? track.height : innerHeight.value, rawY),
|
||||
)
|
||||
selection.value = { ...active }
|
||||
if (active.mode === 'pan') applyPan(active, track)
|
||||
const next = viewportInteraction.value.move(event.pointerId, { currentX, currentY })
|
||||
if (!next) return
|
||||
selection.value = next
|
||||
if (next.kind === 'pan') applyPan(next, track)
|
||||
event.preventDefault()
|
||||
}
|
||||
const cancelViewportDrag = (event?: PointerEvent) => {
|
||||
const active = selection.value
|
||||
if (!active || (event && event.pointerId !== active.pointerId)) return
|
||||
active.overlay.releasePointerCapture?.(active.pointerId)
|
||||
selection.value = null
|
||||
activeOverlay.value?.releasePointerCapture?.(active.pointerId)
|
||||
if (viewportInteraction.value.cancel(event?.pointerId)) {
|
||||
activeOverlay.value = undefined
|
||||
syncSelection()
|
||||
}
|
||||
}
|
||||
const applyBoxZoom = (active: ViewportSelectionState) => {
|
||||
const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
|
||||
@@ -300,15 +313,32 @@ export function useWaveformViewport(context: ViewportContext) {
|
||||
}
|
||||
const active = selection.value
|
||||
if (!active || event.pointerId !== active.pointerId) return
|
||||
updateViewportDrag(event)
|
||||
active.overlay.releasePointerCapture?.(active.pointerId)
|
||||
selection.value = null
|
||||
if (active.mode === 'pan') {
|
||||
const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
|
||||
if (!track) return
|
||||
const overlay = activeOverlay.value
|
||||
if (!overlay) return
|
||||
const [rawX, rawY] = pointer(event, overlay)
|
||||
const currentX = Math.max(
|
||||
0,
|
||||
Math.min(active.independent ? track.width : innerWidth.value, rawX),
|
||||
)
|
||||
const currentY = Math.max(
|
||||
0,
|
||||
Math.min(active.independent ? track.height : innerHeight.value, rawY),
|
||||
)
|
||||
const completed = viewportInteraction.value.finish(event.pointerId, { currentX, currentY })
|
||||
if (!completed) return
|
||||
overlay.releasePointerCapture?.(completed.pointerId)
|
||||
activeOverlay.value = undefined
|
||||
syncSelection()
|
||||
event.preventDefault()
|
||||
if (completed.kind === 'pan') {
|
||||
applyPan(completed, track)
|
||||
void nextTick(configureZoom)
|
||||
return
|
||||
}
|
||||
if (Math.abs(active.currentX - active.startX) >= MINIMUM_SELECTION_SIZE) {
|
||||
applyBoxZoom(active)
|
||||
if (Math.abs(completed.currentX - completed.startX) >= MINIMUM_SELECTION_SIZE) {
|
||||
applyBoxZoom(completed)
|
||||
}
|
||||
}
|
||||
const resetViewport = (trackIndex?: number) => {
|
||||
|
||||
76
src/components/interaction/viewportInteractionState.test.ts
Normal file
76
src/components/interaction/viewportInteractionState.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { ViewportInteractionStateMachine } from './viewportInteractionState'
|
||||
|
||||
const gesture = {
|
||||
trackIndex: 2,
|
||||
independent: true,
|
||||
startX: 10,
|
||||
startY: 20,
|
||||
pointerId: 7,
|
||||
kind: 'box' as const,
|
||||
xDomain: [0, 100] as [number, number],
|
||||
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,
|
||||
})
|
||||
expect(machine.finish(7, { currentX: 50, currentY: 60 })).toMatchObject({
|
||||
kind: 'pan',
|
||||
currentX: 50,
|
||||
currentY: 60,
|
||||
})
|
||||
expect(machine.state).toBeNull()
|
||||
})
|
||||
|
||||
it('only cancels the owning pointer and supports explicit reset', () => {
|
||||
const machine = new ViewportInteractionStateMachine()
|
||||
machine.begin(gesture)
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
expect(machine.state).toMatchObject({ currentX: 10, xDomain: [0, 100] })
|
||||
expect(machine.state?.yDomains.track).toEqual([-1, 1])
|
||||
})
|
||||
})
|
||||
80
src/components/interaction/viewportInteractionState.ts
Normal file
80
src/components/interaction/viewportInteractionState.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { ViewportSelectionState } from '../core/waveformChartTypes'
|
||||
|
||||
export interface ViewportGestureStart {
|
||||
trackIndex: number
|
||||
independent: boolean
|
||||
startX: number
|
||||
startY: number
|
||||
pointerId: number
|
||||
kind: 'box' | 'pan'
|
||||
xDomain: [number, number]
|
||||
yDomains: Record<string, [number, number]>
|
||||
}
|
||||
|
||||
export interface ViewportGesturePosition {
|
||||
currentX: number
|
||||
currentY: number
|
||||
}
|
||||
|
||||
function cloneState(state: ViewportSelectionState | null): ViewportSelectionState | null {
|
||||
if (!state) return null
|
||||
return {
|
||||
...state,
|
||||
xDomain: [...state.xDomain],
|
||||
yDomains: Object.fromEntries(
|
||||
Object.entries(state.yDomains).map(([key, domain]) => [key, [...domain] as [number, number]]),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/** Owns the legal lifecycle of one active viewport pointer gesture. */
|
||||
export class ViewportInteractionStateMachine {
|
||||
private current: ViewportSelectionState | null = null
|
||||
|
||||
get state(): ViewportSelectionState | null {
|
||||
return cloneState(this.current)
|
||||
}
|
||||
|
||||
begin(input: ViewportGestureStart): boolean {
|
||||
if (this.current) return false
|
||||
this.current = {
|
||||
...input,
|
||||
currentX: input.startX,
|
||||
currentY: input.startY,
|
||||
xDomain: [...input.xDomain],
|
||||
yDomains: Object.fromEntries(
|
||||
Object.entries(input.yDomains).map(([key, domain]) => [
|
||||
key,
|
||||
[...domain] as [number, number],
|
||||
]),
|
||||
),
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
move(pointerId: number, position: ViewportGesturePosition): ViewportSelectionState | null {
|
||||
if (!this.current || this.current.pointerId !== pointerId) return null
|
||||
this.current = { ...this.current, ...position }
|
||||
return this.state
|
||||
}
|
||||
|
||||
finish(pointerId: number, position: ViewportGesturePosition): ViewportSelectionState | null {
|
||||
if (!this.current || this.current.pointerId !== pointerId) return null
|
||||
this.current = { ...this.current, ...position }
|
||||
const completed = this.state
|
||||
this.current = null
|
||||
return completed
|
||||
}
|
||||
|
||||
cancel(pointerId?: number): boolean {
|
||||
if (!this.current || (pointerId !== undefined && this.current.pointerId !== pointerId)) {
|
||||
return false
|
||||
}
|
||||
this.current = null
|
||||
return true
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.current = null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user