refactor(chart): introduce hybrid domain architecture
This commit is contained in:
36
docs/architecture.md
Normal file
36
docs/architecture.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
Waveform Analysis uses a hybrid architecture. Vue Composition API remains the orchestration
|
||||||
|
boundary, while classes are reserved for domain objects with lifecycle or algorithm-selection
|
||||||
|
invariants.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- `useWaveformChartController` is the facade for the chart. It composes composables and exposes the
|
||||||
|
existing reactive controller surface; it owns no independent copy of component props.
|
||||||
|
- `useWaveformViewport` keeps refs, computed values, pointer events, DOM capture, D3 coordinates,
|
||||||
|
and emitted events. `ViewportInteractionStateMachine` is its domain collaborator: it has no Vue
|
||||||
|
or DOM dependency and accepts only legal `begin`, `move`, `finish`, `cancel`, and `reset`
|
||||||
|
transitions. Its state is a `box`/`pan` discriminated union, with `null` representing idle.
|
||||||
|
- `RenderablePointSelectionStrategy` defines the replaceable rendering algorithm boundary.
|
||||||
|
`CompletePointSelectionStrategy` preserves the complete visible source range and
|
||||||
|
`PeakPreservingPointSelectionStrategy` preserves first/minimum/maximum/last points per bucket.
|
||||||
|
`resolveRenderablePointSelectionStrategy` resolves and reuses the strategy from rendering options. The
|
||||||
|
existing `selectRenderablePoints` function remains the compatibility facade used by rendering.
|
||||||
|
- `normalizeWaveformData` and `normalizeWaveformSeries` are functional adapters from public data
|
||||||
|
shapes to the internal series model. `buildTrackLayouts` remains a functional builder because
|
||||||
|
layout construction is a stateless calculation, not a long-lived object.
|
||||||
|
|
||||||
|
## Vue Integration
|
||||||
|
|
||||||
|
The state-machine instance is stored in `shallowRef(markRaw(...))`. Vue receives defensive state
|
||||||
|
snapshots through a shallow ref, while SVG overlay elements remain in the composable as DOM
|
||||||
|
resources. Presentation mode, annotation editing, scales, domains, ticks, formatting, and other
|
||||||
|
stateless calculations stay in their existing computed/composable or function boundaries.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
Do not create classes solely to wrap Composition API refs, props, lifecycle hooks, D3 selections, or
|
||||||
|
pure mathematical helpers. Do not add inheritance trees, global event buses, service locators, or
|
||||||
|
duplicate prop state. A new class must own a real invariant or replaceable algorithm and must be
|
||||||
|
used by a production path with isolated tests.
|
||||||
@@ -110,7 +110,7 @@ const {
|
|||||||
{
|
{
|
||||||
'waveform-chart--clean': isCleanView,
|
'waveform-chart--clean': isCleanView,
|
||||||
'waveform-chart--presentation': isPresentationMode,
|
'waveform-chart--presentation': isPresentationMode,
|
||||||
'waveform-chart--panning': selection?.mode === 'pan',
|
'waveform-chart--panning': selection?.kind === 'pan',
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
:style="containerStyle"
|
:style="containerStyle"
|
||||||
@@ -247,7 +247,7 @@ const {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<rect
|
<rect
|
||||||
v-if="selectionBox && selection?.mode === 'box'"
|
v-if="selectionBox && selection?.kind === 'box'"
|
||||||
class="waveform-chart__zoom-selection"
|
class="waveform-chart__zoom-selection"
|
||||||
:x="selectionBox.x"
|
:x="selectionBox.x"
|
||||||
:y="selectionBox.y"
|
:y="selectionBox.y"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { useWaveformChartAnnotations } from '../annotation/useWaveformChartAnnot
|
|||||||
import { useWaveformHover } from '../interaction/useWaveformHover'
|
import { useWaveformHover } from '../interaction/useWaveformHover'
|
||||||
import { useWaveformViewport } from '../interaction/useWaveformViewport'
|
import { useWaveformViewport } from '../interaction/useWaveformViewport'
|
||||||
import { useWaveformZoom } from '../interaction/useWaveformZoom'
|
import { useWaveformZoom } from '../interaction/useWaveformZoom'
|
||||||
|
import { ViewportInteractionStateMachine } from '../interaction/viewportInteractionState'
|
||||||
import { useAnimationFrameThrottle } from '../utils/useAnimationFrameThrottle'
|
import { useAnimationFrameThrottle } from '../utils/useAnimationFrameThrottle'
|
||||||
import { margin } from './constants'
|
import { margin } from './constants'
|
||||||
import { getPageSize } from './grid'
|
import { getPageSize } from './grid'
|
||||||
@@ -78,7 +79,8 @@ export function useWaveformChartController(
|
|||||||
},
|
},
|
||||||
{ deep: true },
|
{ 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 spacePressed = ref(false)
|
||||||
const pointerInsideChart = ref(false)
|
const pointerInsideChart = ref(false)
|
||||||
let handleDataReferenceChange: () => void = () => undefined
|
let handleDataReferenceChange: () => void = () => undefined
|
||||||
@@ -193,6 +195,7 @@ export function useWaveformChartController(
|
|||||||
props,
|
props,
|
||||||
emit,
|
emit,
|
||||||
selection,
|
selection,
|
||||||
|
viewportInteraction,
|
||||||
spacePressed,
|
spacePressed,
|
||||||
trackLayouts,
|
trackLayouts,
|
||||||
chartTracks,
|
chartTracks,
|
||||||
|
|||||||
@@ -95,16 +95,17 @@ export interface WaveformChartEmit {
|
|||||||
(event: 'page-change', page: number, pageCount: number): void
|
(event: 'page-change', page: number, pageCount: number): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ViewportSelectionState {
|
interface ViewportSelectionBase {
|
||||||
trackIndex: number
|
trackIndex: number
|
||||||
independent: boolean
|
independent: boolean
|
||||||
overlay: SVGRectElement
|
|
||||||
startX: number
|
startX: number
|
||||||
startY: number
|
startY: number
|
||||||
currentX: number
|
currentX: number
|
||||||
currentY: number
|
currentY: number
|
||||||
pointerId: number
|
pointerId: number
|
||||||
mode: 'box' | 'pan'
|
|
||||||
xDomain: [number, number]
|
xDomain: [number, number]
|
||||||
yDomains: Record<string, [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) => {
|
const handleSharedPointerMove = (event: PointerEvent) => {
|
||||||
if (isPresentationMode.value) return
|
if (isPresentationMode.value) return
|
||||||
if (selection.value?.overlay === event.currentTarget) {
|
if (selection.value && !selection.value.independent) {
|
||||||
updateViewportDrag(event)
|
updateViewportDrag(event)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { pointer, scaleLinear, zoomIdentity, type ZoomTransform } from 'd3'
|
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 { MINIMUM_SELECTION_SIZE } from '../core/constants'
|
||||||
import type { DisplayTrack, TrackLayout } from '../core/types'
|
import type { DisplayTrack, TrackLayout } from '../core/types'
|
||||||
@@ -10,11 +10,13 @@ import type {
|
|||||||
WaveformChartEmit,
|
WaveformChartEmit,
|
||||||
} from '../core/waveformChartTypes'
|
} from '../core/waveformChartTypes'
|
||||||
import type { AnnotationSeriesCandidate } from '../annotation'
|
import type { AnnotationSeriesCandidate } from '../annotation'
|
||||||
|
import type { ViewportInteractionStateMachine } from './viewportInteractionState'
|
||||||
|
|
||||||
interface ViewportContext {
|
interface ViewportContext {
|
||||||
props: ResolvedWaveformChartProps
|
props: ResolvedWaveformChartProps
|
||||||
emit: WaveformChartEmit
|
emit: WaveformChartEmit
|
||||||
selection: Ref<ViewportSelectionState | null>
|
selection: Ref<ViewportSelectionState | null>
|
||||||
|
viewportInteraction: ShallowRef<ViewportInteractionStateMachine>
|
||||||
spacePressed: Ref<boolean>
|
spacePressed: Ref<boolean>
|
||||||
trackLayouts: ComputedRef<TrackLayout[]>
|
trackLayouts: ComputedRef<TrackLayout[]>
|
||||||
chartTracks: ComputedRef<DisplayTrack[]>
|
chartTracks: ComputedRef<DisplayTrack[]>
|
||||||
@@ -43,6 +45,7 @@ export function useWaveformViewport(context: ViewportContext) {
|
|||||||
props,
|
props,
|
||||||
emit,
|
emit,
|
||||||
selection,
|
selection,
|
||||||
|
viewportInteraction,
|
||||||
spacePressed,
|
spacePressed,
|
||||||
trackLayouts,
|
trackLayouts,
|
||||||
chartTracks,
|
chartTracks,
|
||||||
@@ -65,6 +68,10 @@ export function useWaveformViewport(context: ViewportContext) {
|
|||||||
clearHover,
|
clearHover,
|
||||||
resolveTrackAtPointer,
|
resolveTrackAtPointer,
|
||||||
} = context
|
} = context
|
||||||
|
const activeOverlay = shallowRef<SVGRectElement>()
|
||||||
|
const syncSelection = () => {
|
||||||
|
selection.value = viewportInteraction.value.state
|
||||||
|
}
|
||||||
const selectionBox = computed(() => {
|
const selectionBox = computed(() => {
|
||||||
const active = selection.value
|
const active = selection.value
|
||||||
if (!active) return null
|
if (!active) return null
|
||||||
@@ -143,19 +150,19 @@ export function useWaveformViewport(context: ViewportContext) {
|
|||||||
const [rawX, rawY] = pointer(event, overlay)
|
const [rawX, rawY] = pointer(event, overlay)
|
||||||
const x = Math.max(0, Math.min(independent ? track.width : innerWidth.value, rawX))
|
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))
|
const y = Math.max(0, Math.min(independent ? track.height : innerHeight.value, rawY))
|
||||||
selection.value = {
|
const started = viewportInteraction.value.begin({
|
||||||
trackIndex,
|
trackIndex,
|
||||||
independent,
|
independent,
|
||||||
overlay,
|
|
||||||
startX: x,
|
startX: x,
|
||||||
startY: y,
|
startY: y,
|
||||||
currentX: x,
|
|
||||||
currentY: y,
|
|
||||||
pointerId: event.pointerId,
|
pointerId: event.pointerId,
|
||||||
mode: panRequested ? 'pan' : 'box',
|
kind: panRequested ? 'pan' : 'box',
|
||||||
xDomain: track.xScale.domain() as [number, number],
|
xDomain: track.xScale.domain() as [number, number],
|
||||||
yDomains: currentYDomains(),
|
yDomains: currentYDomains(),
|
||||||
}
|
})
|
||||||
|
if (!started) return
|
||||||
|
activeOverlay.value = overlay
|
||||||
|
syncSelection()
|
||||||
overlay.setPointerCapture?.(event.pointerId)
|
overlay.setPointerCapture?.(event.pointerId)
|
||||||
clearHover()
|
clearHover()
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -213,27 +220,33 @@ export function useWaveformViewport(context: ViewportContext) {
|
|||||||
const updateViewportDrag = (event: PointerEvent) => {
|
const updateViewportDrag = (event: PointerEvent) => {
|
||||||
if (isPresentationMode.value) return
|
if (isPresentationMode.value) return
|
||||||
const active = selection.value
|
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)
|
const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
|
||||||
if (!track) return
|
if (!track) return
|
||||||
const [rawX, rawY] = pointer(event, active.overlay)
|
const [rawX, rawY] = pointer(event, overlay)
|
||||||
active.currentX = Math.max(
|
const currentX = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(active.independent ? track.width : innerWidth.value, rawX),
|
Math.min(active.independent ? track.width : innerWidth.value, rawX),
|
||||||
)
|
)
|
||||||
active.currentY = Math.max(
|
const currentY = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(active.independent ? track.height : innerHeight.value, rawY),
|
Math.min(active.independent ? track.height : innerHeight.value, rawY),
|
||||||
)
|
)
|
||||||
selection.value = { ...active }
|
const next = viewportInteraction.value.move(event.pointerId, { currentX, currentY })
|
||||||
if (active.mode === 'pan') applyPan(active, track)
|
if (!next) return
|
||||||
|
selection.value = next
|
||||||
|
if (next.kind === 'pan') applyPan(next, track)
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
}
|
}
|
||||||
const cancelViewportDrag = (event?: PointerEvent) => {
|
const cancelViewportDrag = (event?: PointerEvent) => {
|
||||||
const active = selection.value
|
const active = selection.value
|
||||||
if (!active || (event && event.pointerId !== active.pointerId)) return
|
if (!active || (event && event.pointerId !== active.pointerId)) return
|
||||||
active.overlay.releasePointerCapture?.(active.pointerId)
|
activeOverlay.value?.releasePointerCapture?.(active.pointerId)
|
||||||
selection.value = null
|
if (viewportInteraction.value.cancel(event?.pointerId)) {
|
||||||
|
activeOverlay.value = undefined
|
||||||
|
syncSelection()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const applyBoxZoom = (active: ViewportSelectionState) => {
|
const applyBoxZoom = (active: ViewportSelectionState) => {
|
||||||
const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
|
const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
|
||||||
@@ -300,15 +313,32 @@ export function useWaveformViewport(context: ViewportContext) {
|
|||||||
}
|
}
|
||||||
const active = selection.value
|
const active = selection.value
|
||||||
if (!active || event.pointerId !== active.pointerId) return
|
if (!active || event.pointerId !== active.pointerId) return
|
||||||
updateViewportDrag(event)
|
const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
|
||||||
active.overlay.releasePointerCapture?.(active.pointerId)
|
if (!track) return
|
||||||
selection.value = null
|
const overlay = activeOverlay.value
|
||||||
if (active.mode === 'pan') {
|
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)
|
void nextTick(configureZoom)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (Math.abs(active.currentX - active.startX) >= MINIMUM_SELECTION_SIZE) {
|
if (Math.abs(completed.currentX - completed.startX) >= MINIMUM_SELECTION_SIZE) {
|
||||||
applyBoxZoom(active)
|
applyBoxZoom(completed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const resetViewport = (trackIndex?: number) => {
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,10 @@ import { bisector } from 'd3'
|
|||||||
import type { WaveformPoint } from '@/types'
|
import type { WaveformPoint } from '@/types'
|
||||||
import { resolveWaveformPointErrors } from './data'
|
import { resolveWaveformPointErrors } from './data'
|
||||||
import type { ResolvedWaveformRenderingOptions } from './renderingOptions'
|
import type { ResolvedWaveformRenderingOptions } from './renderingOptions'
|
||||||
|
import {
|
||||||
|
resolveRenderablePointSelectionStrategy,
|
||||||
|
type VisiblePointRange,
|
||||||
|
} from './renderingStrategies'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DEFAULT_WAVEFORM_RENDERING_OPTIONS,
|
DEFAULT_WAVEFORM_RENDERING_OPTIONS,
|
||||||
@@ -13,11 +17,6 @@ export {
|
|||||||
const pointBisector = bisector((point: WaveformPoint) => point.x)
|
const pointBisector = bisector((point: WaveformPoint) => point.x)
|
||||||
const acceptAllPoints = () => true
|
const acceptAllPoints = () => true
|
||||||
|
|
||||||
interface VisiblePointRange {
|
|
||||||
start: number
|
|
||||||
end: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PointSeriesSource {
|
interface PointSeriesSource {
|
||||||
points: WaveformPoint[]
|
points: WaveformPoint[]
|
||||||
}
|
}
|
||||||
@@ -65,10 +64,6 @@ export function hasMinimumVisibleXValues(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushUniquePoint(target: WaveformPoint[], point: WaveformPoint | undefined) {
|
|
||||||
if (point && target[target.length - 1] !== point) target.push(point)
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectRenderablePointsInRange(
|
function selectRenderablePointsInRange(
|
||||||
points: WaveformPoint[],
|
points: WaveformPoint[],
|
||||||
range: VisiblePointRange,
|
range: VisiblePointRange,
|
||||||
@@ -76,82 +71,17 @@ function selectRenderablePointsInRange(
|
|||||||
width: number,
|
width: number,
|
||||||
options: ResolvedWaveformRenderingOptions,
|
options: ResolvedWaveformRenderingOptions,
|
||||||
): WaveformPoint[] {
|
): WaveformPoint[] {
|
||||||
const domainStart = Math.min(domain[0], domain[1])
|
|
||||||
const domainEnd = Math.max(domain[0], domain[1])
|
|
||||||
const start = Math.max(0, range.start - 1)
|
const start = Math.max(0, range.start - 1)
|
||||||
const end = Math.min(points.length, range.end + 1)
|
const end = Math.min(points.length, range.end + 1)
|
||||||
const visibleCount = end - start
|
const visibleCount = end - start
|
||||||
if (visibleCount <= 0) return []
|
if (visibleCount <= 0) return []
|
||||||
if (!options.downsample || visibleCount <= options.downsampleThreshold) {
|
return resolveRenderablePointSelectionStrategy({ visibleCount, width, options }).select({
|
||||||
return points.slice(start, end)
|
points,
|
||||||
}
|
range,
|
||||||
|
domain,
|
||||||
const maximumPointCount = Math.max(4, Math.floor(width * options.maxPointsPerPixel))
|
width,
|
||||||
const bucketCount = Math.max(1, Math.floor(maximumPointCount / 4))
|
options,
|
||||||
if (visibleCount <= maximumPointCount) return points.slice(start, end)
|
})
|
||||||
|
|
||||||
const result: WaveformPoint[] = []
|
|
||||||
const span = domainEnd - domainStart || 1
|
|
||||||
const bucketIndexes = Array.from({ length: 4 }, () => -1)
|
|
||||||
let activeBucket = -1
|
|
||||||
let firstIndex = -1
|
|
||||||
let lastIndex = -1
|
|
||||||
let minimumIndex = -1
|
|
||||||
let maximumIndex = -1
|
|
||||||
|
|
||||||
const addBucketIndex = (index: number, count: number) => {
|
|
||||||
if (index < 0) return count
|
|
||||||
for (let position = 0; position < count; position += 1) {
|
|
||||||
if (bucketIndexes[position] === index) return count
|
|
||||||
}
|
|
||||||
bucketIndexes[count] = index
|
|
||||||
return count + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
const flushBucket = () => {
|
|
||||||
if (firstIndex < 0) return
|
|
||||||
let count = 0
|
|
||||||
count = addBucketIndex(firstIndex, count)
|
|
||||||
count = addBucketIndex(minimumIndex, count)
|
|
||||||
count = addBucketIndex(maximumIndex, count)
|
|
||||||
count = addBucketIndex(lastIndex, count)
|
|
||||||
for (let index = 1; index < count; index += 1) {
|
|
||||||
const value = bucketIndexes[index]
|
|
||||||
let position = index - 1
|
|
||||||
while (position >= 0 && bucketIndexes[position] > value) {
|
|
||||||
bucketIndexes[position + 1] = bucketIndexes[position]
|
|
||||||
position -= 1
|
|
||||||
}
|
|
||||||
bucketIndexes[position + 1] = value
|
|
||||||
}
|
|
||||||
for (let index = 0; index < count; index += 1) {
|
|
||||||
pushUniquePoint(result, points[bucketIndexes[index]])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pushUniquePoint(result, points[start])
|
|
||||||
for (let index = range.start; index < range.end; index += 1) {
|
|
||||||
const point = points[index]
|
|
||||||
const bucket = Math.min(
|
|
||||||
bucketCount - 1,
|
|
||||||
Math.max(0, Math.floor(((point.x - domainStart) / span) * bucketCount)),
|
|
||||||
)
|
|
||||||
if (bucket !== activeBucket) {
|
|
||||||
flushBucket()
|
|
||||||
activeBucket = bucket
|
|
||||||
firstIndex = index
|
|
||||||
lastIndex = index
|
|
||||||
minimumIndex = index
|
|
||||||
maximumIndex = index
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lastIndex = index
|
|
||||||
if (point.y < points[minimumIndex].y) minimumIndex = index
|
|
||||||
if (point.y > points[maximumIndex].y) maximumIndex = index
|
|
||||||
}
|
|
||||||
flushBucket()
|
|
||||||
pushUniquePoint(result, points[end - 1])
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
78
src/core/renderingStrategies.test.ts
Normal file
78
src/core/renderingStrategies.test.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import type { WaveformPoint } from '../types'
|
||||||
|
import { DEFAULT_WAVEFORM_RENDERING_OPTIONS } from './renderingOptions'
|
||||||
|
import {
|
||||||
|
CompletePointSelectionStrategy,
|
||||||
|
PeakPreservingPointSelectionStrategy,
|
||||||
|
resolveRenderablePointSelectionStrategy,
|
||||||
|
} from './renderingStrategies'
|
||||||
|
|
||||||
|
const denseOptions = {
|
||||||
|
...DEFAULT_WAVEFORM_RENDERING_OPTIONS,
|
||||||
|
downsampleThreshold: 0,
|
||||||
|
maxPointsPerPixel: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('renderable point selection strategies', () => {
|
||||||
|
it('resolves complete-point selection at the configured boundaries', () => {
|
||||||
|
const complete = resolveRenderablePointSelectionStrategy({
|
||||||
|
visibleCount: 100,
|
||||||
|
width: 100,
|
||||||
|
options: DEFAULT_WAVEFORM_RENDERING_OPTIONS,
|
||||||
|
})
|
||||||
|
const disabled = resolveRenderablePointSelectionStrategy({
|
||||||
|
visibleCount: 10_000,
|
||||||
|
width: 100,
|
||||||
|
options: { ...denseOptions, downsample: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(complete).toBeInstanceOf(CompletePointSelectionStrategy)
|
||||||
|
expect(disabled).toBeInstanceOf(CompletePointSelectionStrategy)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves peak-preserving selection for dense visible data', () => {
|
||||||
|
const strategy = resolveRenderablePointSelectionStrategy({
|
||||||
|
visibleCount: 1_000,
|
||||||
|
width: 100,
|
||||||
|
options: denseOptions,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(strategy).toBeInstanceOf(PeakPreservingPointSelectionStrategy)
|
||||||
|
expect(strategy.name).toBe('peak-preserving')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reuses the resolved strategy instance across selections', () => {
|
||||||
|
const request = { visibleCount: 100, width: 100, options: DEFAULT_WAVEFORM_RENDERING_OPTIONS }
|
||||||
|
const peakRequest = { visibleCount: 1_000, width: 100, options: denseOptions }
|
||||||
|
|
||||||
|
expect(resolveRenderablePointSelectionStrategy(request)).toBe(
|
||||||
|
resolveRenderablePointSelectionStrategy(request),
|
||||||
|
)
|
||||||
|
expect(resolveRenderablePointSelectionStrategy(peakRequest)).toBe(
|
||||||
|
resolveRenderablePointSelectionStrategy(peakRequest),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retains first, last, minimum, and maximum points in a peak bucket', () => {
|
||||||
|
const points: WaveformPoint[] = [
|
||||||
|
{ x: 0, y: 5 },
|
||||||
|
{ x: 1, y: 1 },
|
||||||
|
{ x: 2, y: 10 },
|
||||||
|
{ x: 3, y: 3 },
|
||||||
|
{ x: 4, y: 7 },
|
||||||
|
]
|
||||||
|
const strategy = new PeakPreservingPointSelectionStrategy()
|
||||||
|
const selected = strategy.select({
|
||||||
|
points,
|
||||||
|
range: { start: 0, end: points.length },
|
||||||
|
domain: [0, 4],
|
||||||
|
width: 4,
|
||||||
|
options: denseOptions,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(selected[0]).toBe(points[0])
|
||||||
|
expect(selected.at(-1)).toBe(points.at(-1))
|
||||||
|
expect(selected.map((point) => point.y)).toEqual(expect.arrayContaining([1, 10]))
|
||||||
|
})
|
||||||
|
})
|
||||||
149
src/core/renderingStrategies.ts
Normal file
149
src/core/renderingStrategies.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import type { WaveformPoint } from '../types'
|
||||||
|
import type { ResolvedWaveformRenderingOptions } from './renderingOptions'
|
||||||
|
|
||||||
|
export interface VisiblePointRange {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderablePointSelectionContext {
|
||||||
|
points: WaveformPoint[]
|
||||||
|
range: VisiblePointRange
|
||||||
|
domain: [number, number]
|
||||||
|
width: number
|
||||||
|
options: ResolvedWaveformRenderingOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderablePointSelectionStrategy {
|
||||||
|
readonly name: 'complete' | 'peak-preserving'
|
||||||
|
select(context: RenderablePointSelectionContext): WaveformPoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectionBounds(range: VisiblePointRange, pointCount: number) {
|
||||||
|
return {
|
||||||
|
start: Math.max(0, range.start - 1),
|
||||||
|
end: Math.min(pointCount, range.end + 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushUniquePoint(target: WaveformPoint[], point: WaveformPoint | undefined) {
|
||||||
|
if (point && target[target.length - 1] !== point) target.push(point)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CompletePointSelectionStrategy implements RenderablePointSelectionStrategy {
|
||||||
|
readonly name = 'complete' as const
|
||||||
|
|
||||||
|
select(context: RenderablePointSelectionContext): WaveformPoint[] {
|
||||||
|
const { start, end } = selectionBounds(context.range, context.points.length)
|
||||||
|
return context.points.slice(start, end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PeakPreservingPointSelectionStrategy implements RenderablePointSelectionStrategy {
|
||||||
|
readonly name = 'peak-preserving' as const
|
||||||
|
|
||||||
|
select(context: RenderablePointSelectionContext): WaveformPoint[] {
|
||||||
|
const { points, range, domain, width, options } = context
|
||||||
|
const { start, end } = selectionBounds(range, points.length)
|
||||||
|
const visibleCount = end - start
|
||||||
|
if (visibleCount <= 0) return []
|
||||||
|
|
||||||
|
const domainStart = Math.min(domain[0], domain[1])
|
||||||
|
const domainEnd = Math.max(domain[0], domain[1])
|
||||||
|
const maximumPointCount = Math.max(4, Math.floor(width * options.maxPointsPerPixel))
|
||||||
|
const bucketCount = Math.max(1, Math.floor(maximumPointCount / 4))
|
||||||
|
const result: WaveformPoint[] = []
|
||||||
|
const span = domainEnd - domainStart || 1
|
||||||
|
const bucketIndexes = Array.from({ length: 4 }, () => -1)
|
||||||
|
let activeBucket = -1
|
||||||
|
let firstIndex = -1
|
||||||
|
let lastIndex = -1
|
||||||
|
let minimumIndex = -1
|
||||||
|
let maximumIndex = -1
|
||||||
|
|
||||||
|
const addBucketIndex = (index: number, count: number) => {
|
||||||
|
if (index < 0) return count
|
||||||
|
for (let position = 0; position < count; position += 1) {
|
||||||
|
if (bucketIndexes[position] === index) return count
|
||||||
|
}
|
||||||
|
bucketIndexes[count] = index
|
||||||
|
return count + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const flushBucket = () => {
|
||||||
|
if (firstIndex < 0) return
|
||||||
|
let count = 0
|
||||||
|
count = addBucketIndex(firstIndex, count)
|
||||||
|
count = addBucketIndex(minimumIndex, count)
|
||||||
|
count = addBucketIndex(maximumIndex, count)
|
||||||
|
count = addBucketIndex(lastIndex, count)
|
||||||
|
for (let index = 1; index < count; index += 1) {
|
||||||
|
const value = bucketIndexes[index]
|
||||||
|
let position = index - 1
|
||||||
|
while (position >= 0 && bucketIndexes[position] > value) {
|
||||||
|
bucketIndexes[position + 1] = bucketIndexes[position]
|
||||||
|
position -= 1
|
||||||
|
}
|
||||||
|
bucketIndexes[position + 1] = value
|
||||||
|
}
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
pushUniquePoint(result, points[bucketIndexes[index]])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pushUniquePoint(result, points[start])
|
||||||
|
for (let index = range.start; index < range.end; index += 1) {
|
||||||
|
const point = points[index]
|
||||||
|
const bucket = Math.min(
|
||||||
|
bucketCount - 1,
|
||||||
|
Math.max(0, Math.floor(((point.x - domainStart) / span) * bucketCount)),
|
||||||
|
)
|
||||||
|
if (bucket !== activeBucket) {
|
||||||
|
flushBucket()
|
||||||
|
activeBucket = bucket
|
||||||
|
firstIndex = index
|
||||||
|
lastIndex = index
|
||||||
|
minimumIndex = index
|
||||||
|
maximumIndex = index
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lastIndex = index
|
||||||
|
if (point.y < points[minimumIndex].y) minimumIndex = index
|
||||||
|
if (point.y > points[maximumIndex].y) maximumIndex = index
|
||||||
|
}
|
||||||
|
flushBucket()
|
||||||
|
pushUniquePoint(result, points[end - 1])
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderablePointSelectionStrategyRequest {
|
||||||
|
visibleCount: number
|
||||||
|
width: number
|
||||||
|
options: ResolvedWaveformRenderingOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RenderablePointSelectionStrategyFactory {
|
||||||
|
private readonly complete = new CompletePointSelectionStrategy()
|
||||||
|
private readonly peakPreserving = new PeakPreservingPointSelectionStrategy()
|
||||||
|
|
||||||
|
resolve(request: RenderablePointSelectionStrategyRequest): RenderablePointSelectionStrategy {
|
||||||
|
const maximumPointCount = Math.max(
|
||||||
|
4,
|
||||||
|
Math.floor(request.width * request.options.maxPointsPerPixel),
|
||||||
|
)
|
||||||
|
const shouldUseCompletePoints =
|
||||||
|
!request.options.downsample ||
|
||||||
|
request.visibleCount <= request.options.downsampleThreshold ||
|
||||||
|
request.visibleCount <= maximumPointCount
|
||||||
|
return shouldUseCompletePoints ? this.complete : this.peakPreserving
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultRenderablePointSelectionStrategyFactory = new RenderablePointSelectionStrategyFactory()
|
||||||
|
|
||||||
|
export function resolveRenderablePointSelectionStrategy(
|
||||||
|
request: RenderablePointSelectionStrategyRequest,
|
||||||
|
): RenderablePointSelectionStrategy {
|
||||||
|
return defaultRenderablePointSelectionStrategyFactory.resolve(request)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user