waveform-analysis 0.1.30 → 0.1.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +8 -0
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.js +815 -601
  4. package/dist/style.css +1 -1
  5. package/dist/types/components/core/useWaveformChartController.d.ts +2 -14
  6. package/dist/types/components/core/useWaveformChartLifecycle.d.ts +4 -1
  7. package/dist/types/components/core/useWaveformData.d.ts +1 -1
  8. package/dist/types/components/core/waveformChartTypes.d.ts +6 -3
  9. package/dist/types/components/interaction/pointerCapture.d.ts +1 -0
  10. package/dist/types/components/interaction/viewportInteractionState.d.ts +39 -0
  11. package/dist/types/components/interaction/zoomConstraints.d.ts +14 -0
  12. package/dist/types/core/rendering.d.ts +1 -4
  13. package/dist/types/core/renderingStrategies.d.ts +22 -0
  14. package/dist/types/demo/types.d.ts +0 -1
  15. package/package.json +5 -1
  16. package/src/App.vue +0 -4
  17. package/src/components/WaveformChartView.vue +2 -2
  18. package/src/components/core/useWaveformChartController.ts +7 -2
  19. package/src/components/core/useWaveformChartLifecycle.ts +47 -3
  20. package/src/components/core/useWaveformData.ts +6 -1
  21. package/src/components/core/waveformChartTypes.ts +4 -3
  22. package/src/components/interaction/WaveformTooltip.test.ts +39 -0
  23. package/src/components/interaction/WaveformTooltip.vue +72 -22
  24. package/src/components/interaction/pointerCapture.ts +10 -0
  25. package/src/components/interaction/useWaveformHover.ts +1 -1
  26. package/src/components/interaction/useWaveformViewport.ts +86 -74
  27. package/src/components/interaction/useWaveformZoom.ts +46 -14
  28. package/src/components/interaction/viewportInteractionState.test.ts +101 -0
  29. package/src/components/interaction/viewportInteractionState.ts +89 -0
  30. package/src/components/interaction/zoomConstraints.ts +167 -0
  31. package/src/components/waveformChartCases/viewportLifecycle.test.ts +132 -0
  32. package/src/components/waveformChartCases/zoomPointConstraints.test.ts +248 -0
  33. package/src/core/rendering.ts +11 -81
  34. package/src/core/renderingStrategies.test.ts +90 -0
  35. package/src/core/renderingStrategies.ts +131 -0
  36. package/src/data/simulatedWaveforms.ts +9 -10
  37. package/src/demo/DemoChartHost.vue +1 -2
  38. package/src/demo/types.ts +0 -1
  39. package/src/utils/formatters.test.ts +4 -3
  40. package/src/utils/formatters.ts +1 -2
  41. package/src/utils/sampling.ts +2 -1
@@ -0,0 +1,89 @@
1
+ import type { ViewportSelectionState } from '../core/waveformChartTypes'
2
+
3
+ export interface ViewportGestureStart {
4
+ trackIndex: number
5
+ independent: boolean
6
+ startX: number
7
+ startY: number
8
+ pointerId: number
9
+ kind: 'box' | 'pan'
10
+ xDomain: [number, number]
11
+ yDomains: Record<string, [number, number]>
12
+ }
13
+
14
+ export interface ViewportGesturePosition {
15
+ currentX: number
16
+ currentY: number
17
+ }
18
+
19
+ export type ViewportInteractionEvent =
20
+ | { type: 'begin'; gesture: ViewportGestureStart }
21
+ | { type: 'move'; pointerId: number; position: ViewportGesturePosition }
22
+ | { type: 'finish'; pointerId: number; position: ViewportGesturePosition }
23
+ | { type: 'cancel'; pointerId?: number }
24
+ | { type: 'reset' }
25
+
26
+ export interface ViewportInteractionTransition {
27
+ state: ViewportSelectionState | null
28
+ accepted: boolean
29
+ completed: ViewportSelectionState | null
30
+ }
31
+
32
+ function createSelectionState(input: ViewportGestureStart): ViewportSelectionState {
33
+ return {
34
+ ...input,
35
+ currentX: input.startX,
36
+ currentY: input.startY,
37
+ xDomain: [...input.xDomain],
38
+ yDomains: Object.fromEntries(
39
+ Object.entries(input.yDomains).map(([key, domain]) => [key, [...domain] as [number, number]]),
40
+ ),
41
+ }
42
+ }
43
+
44
+ function withPosition(
45
+ state: ViewportSelectionState,
46
+ position: ViewportGesturePosition,
47
+ ): ViewportSelectionState {
48
+ return { ...state, ...position }
49
+ }
50
+
51
+ function rejectedTransition(state: ViewportSelectionState | null): ViewportInteractionTransition {
52
+ return { state, accepted: false, completed: null }
53
+ }
54
+
55
+ export function transitionViewportInteraction(
56
+ state: ViewportSelectionState | null,
57
+ event: ViewportInteractionEvent,
58
+ ): ViewportInteractionTransition {
59
+ switch (event.type) {
60
+ case 'begin':
61
+ return state
62
+ ? rejectedTransition(state)
63
+ : { state: createSelectionState(event.gesture), accepted: true, completed: null }
64
+ case 'move':
65
+ if (!state || state.pointerId !== event.pointerId) return rejectedTransition(state)
66
+ return { state: withPosition(state, event.position), accepted: true, completed: null }
67
+ case 'finish':
68
+ if (!state || state.pointerId !== event.pointerId) return rejectedTransition(state)
69
+ return {
70
+ state: null,
71
+ accepted: true,
72
+ completed: withPosition(state, event.position),
73
+ }
74
+ case 'cancel':
75
+ if (!state || (event.pointerId !== undefined && state.pointerId !== event.pointerId)) {
76
+ return rejectedTransition(state)
77
+ }
78
+ return { state: null, accepted: true, completed: null }
79
+ case 'reset':
80
+ return { state: null, accepted: true, completed: null }
81
+ }
82
+ }
83
+
84
+ export function reduceViewportInteraction(
85
+ state: ViewportSelectionState | null,
86
+ event: ViewportInteractionEvent,
87
+ ): ViewportSelectionState | null {
88
+ return transitionViewportInteraction(state, event).state
89
+ }
@@ -0,0 +1,167 @@
1
+ import { scaleLinear, zoomIdentity, type ZoomTransform } from 'd3'
2
+
3
+ import type { WaveformPoint } from '../../types'
4
+ import { ZOOM_CONSTRAINTS } from '../core/constants'
5
+
6
+ interface PointSeriesSource {
7
+ points: WaveformPoint[]
8
+ }
9
+
10
+ export type ZoomSeriesGroup = readonly PointSeriesSource[]
11
+
12
+ interface ZoomConstraintOptions {
13
+ minZoomSpan?: number
14
+ minVisiblePoints?: number
15
+ }
16
+
17
+ const xValueCache = new WeakMap<object, Map<string, number[]>>()
18
+
19
+ function normalizedBoundary(boundary: [number, number]): [number, number] {
20
+ return boundary[0] <= boundary[1] ? [...boundary] : [boundary[1], boundary[0]]
21
+ }
22
+
23
+ function resolveRequiredPointCount(minimum: number | undefined): number {
24
+ return Number.isFinite(minimum) && (minimum ?? 0) > 0 ? Math.ceil(minimum as number) : 0
25
+ }
26
+
27
+ function uniqueXValues(group: ZoomSeriesGroup, boundary: [number, number]): number[] {
28
+ const boundaryKey = `${boundary[0]}\u0000${boundary[1]}`
29
+ const cached = xValueCache.get(group)?.get(boundaryKey)
30
+ if (cached) return cached
31
+ const values = new Set<number>()
32
+ for (const series of group) {
33
+ for (const point of series.points) {
34
+ if (point.x >= boundary[0] && point.x <= boundary[1]) values.add(point.x)
35
+ }
36
+ }
37
+ const sorted = Array.from(values).sort((left, right) => left - right)
38
+ const groupCache = xValueCache.get(group) ?? new Map<string, number[]>()
39
+ groupCache.set(boundaryKey, sorted)
40
+ xValueCache.set(group, groupCache)
41
+ return sorted
42
+ }
43
+
44
+ function minimumPointSpan(
45
+ groups: readonly ZoomSeriesGroup[],
46
+ boundary: [number, number],
47
+ required: number,
48
+ ): number {
49
+ if (required <= 1) return 0
50
+ let minimumSpan = 0
51
+ for (const group of groups) {
52
+ const values = uniqueXValues(group, boundary)
53
+ if (values.length < required) return boundary[1] - boundary[0]
54
+ let groupSpan = Number.POSITIVE_INFINITY
55
+ for (let index = 0; index + required <= values.length; index += 1) {
56
+ groupSpan = Math.min(groupSpan, values[index + required - 1] - values[index])
57
+ }
58
+ const endpointTolerance =
59
+ Number.EPSILON * Math.max(1, Math.abs(boundary[0]), Math.abs(boundary[1])) * 16
60
+ minimumSpan = Math.max(minimumSpan, groupSpan + endpointTolerance)
61
+ }
62
+ return minimumSpan
63
+ }
64
+
65
+ export function resolveMinimumZoomSpan(
66
+ boundary: [number, number],
67
+ groups: readonly ZoomSeriesGroup[],
68
+ options: ZoomConstraintOptions,
69
+ ): number {
70
+ const normalized = normalizedBoundary(boundary)
71
+ const boundarySpan = normalized[1] - normalized[0]
72
+ if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return 0
73
+ const configuredSpan =
74
+ Number.isFinite(options.minZoomSpan) && (options.minZoomSpan ?? 0) > 0
75
+ ? Math.min(boundarySpan, options.minZoomSpan as number)
76
+ : 0
77
+ const required = resolveRequiredPointCount(options.minVisiblePoints)
78
+ const pointSpan = minimumPointSpan(groups, normalized, required)
79
+ if (configuredSpan > 0 || required > 0) {
80
+ return Math.max(configuredSpan, pointSpan)
81
+ }
82
+ return boundarySpan / ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE
83
+ }
84
+
85
+ function expandToMinimumPoints(
86
+ domain: [number, number],
87
+ boundary: [number, number],
88
+ groups: readonly ZoomSeriesGroup[],
89
+ required: number,
90
+ ): [number, number] {
91
+ if (required <= 0) return domain
92
+ const endpointTolerance =
93
+ Number.EPSILON * Math.max(1, Math.abs(boundary[0]), Math.abs(boundary[1])) * 16
94
+ let expanded = domain
95
+ for (const group of groups) {
96
+ const values = uniqueXValues(group, boundary)
97
+ if (values.length < required) return [...boundary]
98
+ const visibleCount = values.filter(
99
+ (value) => value >= expanded[0] && value <= expanded[1],
100
+ ).length
101
+ if (visibleCount >= required) continue
102
+ let best: [number, number] | undefined
103
+ let bestSpan = Number.POSITIVE_INFINITY
104
+ let bestCenterDistance = Number.POSITIVE_INFINITY
105
+ const requestedCenter = (expanded[0] + expanded[1]) / 2
106
+ for (let index = 0; index + required <= values.length; index += 1) {
107
+ const candidate: [number, number] = [
108
+ Math.max(boundary[0], Math.min(expanded[0], values[index] - endpointTolerance)),
109
+ Math.min(
110
+ boundary[1],
111
+ Math.max(expanded[1], values[index + required - 1] + endpointTolerance),
112
+ ),
113
+ ]
114
+ const span = candidate[1] - candidate[0]
115
+ const centerDistance = Math.abs((candidate[0] + candidate[1]) / 2 - requestedCenter)
116
+ if (span < bestSpan || (span === bestSpan && centerDistance < bestCenterDistance)) {
117
+ best = candidate
118
+ bestSpan = span
119
+ bestCenterDistance = centerDistance
120
+ }
121
+ }
122
+ expanded = best ?? [...boundary]
123
+ }
124
+ return expanded
125
+ }
126
+
127
+ export function constrainZoomDomain(
128
+ domain: [number, number],
129
+ boundary: [number, number],
130
+ groups: readonly ZoomSeriesGroup[],
131
+ options: ZoomConstraintOptions,
132
+ ): [number, number] {
133
+ const normalized = normalizedBoundary(boundary)
134
+ const boundarySpan = normalized[1] - normalized[0]
135
+ if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return normalized
136
+ if (Math.abs(domain[1] - domain[0]) >= boundarySpan * (1 - 1e-12)) return normalized
137
+ const requested: [number, number] = [
138
+ Math.max(normalized[0], Math.min(domain[0], domain[1])),
139
+ Math.min(normalized[1], Math.max(domain[0], domain[1])),
140
+ ]
141
+ const expanded = expandToMinimumPoints(
142
+ requested,
143
+ normalized,
144
+ groups,
145
+ resolveRequiredPointCount(options.minVisiblePoints),
146
+ )
147
+ const minimumSpan = resolveMinimumZoomSpan(normalized, groups, options)
148
+ const span = Math.max(minimumSpan, Math.min(boundarySpan, expanded[1] - expanded[0]))
149
+ const center = (expanded[0] + expanded[1]) / 2
150
+ const start = Math.max(normalized[0], Math.min(center - span / 2, normalized[1] - span))
151
+ return [start, start + span]
152
+ }
153
+
154
+ export function transformForDomain(
155
+ domain: [number, number],
156
+ baseDomain: [number, number],
157
+ width: number,
158
+ ): ZoomTransform {
159
+ const baseSpan = baseDomain[1] - baseDomain[0]
160
+ const span = domain[1] - domain[0]
161
+ if (!Number.isFinite(baseSpan) || !Number.isFinite(span) || baseSpan <= 0 || span <= 0) {
162
+ return zoomIdentity
163
+ }
164
+ const scale = baseSpan / span
165
+ const baseScale = scaleLinear(baseDomain, [0, width])
166
+ return zoomIdentity.translate(-scale * baseScale(domain[0]), 0).scale(scale)
167
+ }
@@ -0,0 +1,132 @@
1
+ import { flushPromises } from '@vue/test-utils'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+
4
+ import { gridSeries, mountSizedChart } from '../../test/waveformChart'
5
+ import WaveformChartView from '../WaveformChartView.vue'
6
+
7
+ describe('WaveformChart viewport lifecycle', () => {
8
+ it('cleans an active shared gesture when its track is removed', async () => {
9
+ const wrapper = await mountSizedChart(gridSeries(2), {
10
+ displayMode: 'separated',
11
+ grid: { rowCount: 2, columnCount: 1 },
12
+ })
13
+ const overlay = wrapper.get('.waveform-chart__overlay--shared')
14
+ const width = Number(overlay.attributes('width'))
15
+ const height = Number(overlay.attributes('height'))
16
+ const releasePointerCapture = vi.fn()
17
+ Object.defineProperty(overlay.element, 'getBoundingClientRect', {
18
+ value: () => ({ left: 0, top: 0, width, height }),
19
+ })
20
+ Object.defineProperty(overlay.element, 'setPointerCapture', { value: vi.fn() })
21
+ Object.defineProperty(overlay.element, 'releasePointerCapture', {
22
+ value: releasePointerCapture,
23
+ })
24
+
25
+ const dispatchPointer = (type: string, pointerId: number, clientY: number) => {
26
+ const event = new MouseEvent(type, {
27
+ button: 0,
28
+ clientX: width / 2,
29
+ clientY,
30
+ bubbles: true,
31
+ })
32
+ Object.defineProperty(event, 'pointerId', { value: pointerId })
33
+ overlay.element.dispatchEvent(event)
34
+ }
35
+
36
+ dispatchPointer('pointerdown', 41, height * 0.75)
37
+ await flushPromises()
38
+ expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(true)
39
+
40
+ await wrapper.setProps({ data: gridSeries(1) })
41
+ await flushPromises()
42
+ dispatchPointer('pointerup', 41, height * 0.75)
43
+ await flushPromises()
44
+
45
+ expect(releasePointerCapture).toHaveBeenCalledWith(41)
46
+ expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(false)
47
+
48
+ dispatchPointer('pointerdown', 42, height / 2)
49
+ dispatchPointer('pointermove', 42, height / 2 + 20)
50
+ await flushPromises()
51
+ expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(true)
52
+ })
53
+
54
+ it('cleans an active gesture when its overlay leaves the DOM', async () => {
55
+ const wrapper = await mountSizedChart(
56
+ {
57
+ kind: 'points',
58
+ points: [
59
+ { x: 0, y: 0 },
60
+ { x: 1, y: 1 },
61
+ ],
62
+ },
63
+ { displayMode: 'separated' },
64
+ )
65
+ const overlay = wrapper.get('.waveform-chart__overlay--shared')
66
+ const width = Number(overlay.attributes('width'))
67
+ const height = Number(overlay.attributes('height'))
68
+ const releasePointerCapture = vi.fn(() => {
69
+ throw new DOMException('Pointer capture is no longer available', 'NotFoundError')
70
+ })
71
+ Object.defineProperty(overlay.element, 'getBoundingClientRect', {
72
+ value: () => ({ left: 0, top: 0, width, height }),
73
+ })
74
+ Object.defineProperty(overlay.element, 'setPointerCapture', { value: vi.fn() })
75
+ Object.defineProperty(overlay.element, 'releasePointerCapture', {
76
+ value: releasePointerCapture,
77
+ })
78
+
79
+ const down = new MouseEvent('pointerdown', {
80
+ button: 0,
81
+ clientX: width / 2,
82
+ clientY: height / 2,
83
+ bubbles: true,
84
+ })
85
+ Object.defineProperty(down, 'pointerId', { value: 43 })
86
+ overlay.element.dispatchEvent(down)
87
+ await wrapper.setProps({ hiddenSeriesIds: ['series-0'] })
88
+ await flushPromises()
89
+
90
+ expect(wrapper.find('.waveform-chart__overlay--shared').exists()).toBe(false)
91
+ const view = wrapper.findComponent(WaveformChartView)
92
+ const finishViewportDrag = (
93
+ view.vm as unknown as { finishViewportDrag: (event: PointerEvent) => void }
94
+ ).finishViewportDrag
95
+ const up = new MouseEvent('pointerup', {
96
+ clientX: width / 2,
97
+ clientY: height / 2,
98
+ bubbles: true,
99
+ })
100
+ Object.defineProperty(up, 'pointerId', { value: 43 })
101
+ finishViewportDrag(up as unknown as PointerEvent)
102
+ await flushPromises()
103
+
104
+ expect(releasePointerCapture).toHaveBeenCalledWith(43)
105
+ expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(false)
106
+
107
+ await wrapper.setProps({ hiddenSeriesIds: [] })
108
+ await flushPromises()
109
+ const nextOverlay = wrapper.get('.waveform-chart__overlay--shared')
110
+ Object.defineProperty(nextOverlay.element, 'getBoundingClientRect', {
111
+ value: () => ({ left: 0, top: 0, width, height }),
112
+ })
113
+ const nextDown = new MouseEvent('pointerdown', {
114
+ button: 0,
115
+ clientX: width / 2,
116
+ clientY: height / 2,
117
+ bubbles: true,
118
+ })
119
+ Object.defineProperty(nextDown, 'pointerId', { value: 44 })
120
+ nextOverlay.element.dispatchEvent(nextDown)
121
+ const nextMove = new MouseEvent('pointermove', {
122
+ clientX: width * 0.75,
123
+ clientY: height / 2,
124
+ bubbles: true,
125
+ })
126
+ Object.defineProperty(nextMove, 'pointerId', { value: 44 })
127
+ nextOverlay.element.dispatchEvent(nextMove)
128
+ await flushPromises()
129
+
130
+ expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(true)
131
+ })
132
+ })
@@ -0,0 +1,248 @@
1
+ import { flushPromises, type VueWrapper } from '@vue/test-utils'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { flushAnimationFrames } from '../../test/setup'
5
+ import { mountSizedChart } from '../../test/waveformChart'
6
+
7
+ const regularPoints = Array.from({ length: 1_200 }, (_, index) => ({ x: index, y: index % 11 }))
8
+
9
+ function visibleXCount(domain: [number, number], values = regularPoints): number {
10
+ return new Set(
11
+ values.filter((point) => point.x >= domain[0] && point.x <= domain[1]).map((point) => point.x),
12
+ ).size
13
+ }
14
+
15
+ function prepareOverlay(wrapper: VueWrapper, selector: string) {
16
+ const overlay = wrapper.get(selector)
17
+ const width = Number(overlay.attributes('width'))
18
+ const height = Number(overlay.attributes('height'))
19
+ Object.defineProperty(overlay.element, 'getBoundingClientRect', {
20
+ value: () => ({ left: 0, top: 0, width, height }),
21
+ })
22
+ return { overlay, width, height }
23
+ }
24
+
25
+ async function dispatchWheel(
26
+ overlay: ReturnType<VueWrapper['get']>,
27
+ width: number,
28
+ height: number,
29
+ deltaY: number,
30
+ ) {
31
+ overlay.element.dispatchEvent(
32
+ new WheelEvent('wheel', {
33
+ deltaY,
34
+ clientX: width / 2,
35
+ clientY: height / 2,
36
+ bubbles: true,
37
+ cancelable: true,
38
+ }),
39
+ )
40
+ flushAnimationFrames()
41
+ await flushPromises()
42
+ }
43
+
44
+ function dispatchBox(
45
+ overlay: ReturnType<VueWrapper['get']>,
46
+ startX: number,
47
+ endX: number,
48
+ height: number,
49
+ pointerId: number,
50
+ ) {
51
+ for (const [type, clientX] of [
52
+ ['pointerdown', startX],
53
+ ['pointermove', endX],
54
+ ['pointerup', endX],
55
+ ] as const) {
56
+ const event = new MouseEvent(type, {
57
+ button: 0,
58
+ clientX,
59
+ clientY: height / 2,
60
+ bubbles: true,
61
+ })
62
+ Object.defineProperty(event, 'pointerId', { value: pointerId })
63
+ overlay.element.dispatchEvent(event)
64
+ }
65
+ }
66
+
67
+ describe('WaveformChart point-aware zoom constraints', () => {
68
+ it('wheel-zooms 1,200 samples to two distinct X values, stops, and still zooms out', async () => {
69
+ const wrapper = await mountSizedChart(
70
+ { kind: 'points', points: regularPoints },
71
+ { minVisiblePoints: 2 },
72
+ )
73
+ const { overlay, width, height } = prepareOverlay(
74
+ wrapper,
75
+ '.waveform-chart__overlay--independent',
76
+ )
77
+
78
+ await dispatchWheel(overlay, width, height, -4_000)
79
+ await dispatchWheel(overlay, width, height, -4_000)
80
+ const twoPointDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
81
+ expect(visibleXCount(twoPointDomain)).toBe(2)
82
+
83
+ const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
84
+ await dispatchWheel(overlay, width, height, -1_000)
85
+ expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount)
86
+
87
+ await dispatchWheel(overlay, width, height, 4_000)
88
+ const partiallyZoomedOut = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
89
+ expect(partiallyZoomedOut[1] - partiallyZoomedOut[0]).toBeGreaterThan(
90
+ twoPointDomain[1] - twoPointDomain[0],
91
+ )
92
+ await dispatchWheel(overlay, width, height, 4_000)
93
+ const fullyZoomedOut = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
94
+ expect(fullyZoomedOut).toEqual([0, 1_199])
95
+ })
96
+
97
+ it('keeps two fractional samples when the wheel focus falls between sample positions', async () => {
98
+ const points = Array.from({ length: 1_000 }, (_, index) => ({
99
+ x: -5 + (index * 10) / 999,
100
+ y: index,
101
+ }))
102
+ const wrapper = await mountSizedChart(
103
+ { kind: 'points', points },
104
+ { minVisiblePoints: 2, initialXDomain: [-5, 5] },
105
+ )
106
+ const { overlay, width, height } = prepareOverlay(
107
+ wrapper,
108
+ '.waveform-chart__overlay--independent',
109
+ )
110
+
111
+ for (let index = 0; index < 3; index += 1) {
112
+ overlay.element.dispatchEvent(
113
+ new WheelEvent('wheel', {
114
+ deltaY: -4_000,
115
+ clientX: width * 0.5032,
116
+ clientY: height / 2,
117
+ bubbles: true,
118
+ cancelable: true,
119
+ }),
120
+ )
121
+ flushAnimationFrames()
122
+ await flushPromises()
123
+ }
124
+
125
+ const domain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
126
+ expect(visibleXCount(domain, points)).toBe(2)
127
+ })
128
+
129
+ it('uses repeated box zooms to reach exactly two distinct X values', async () => {
130
+ const wrapper = await mountSizedChart(
131
+ { kind: 'points', points: regularPoints },
132
+ { minVisiblePoints: 2 },
133
+ )
134
+ const { overlay, width, height } = prepareOverlay(
135
+ wrapper,
136
+ '.waveform-chart__overlay--independent',
137
+ )
138
+
139
+ for (let index = 0; index < 3; index += 1) {
140
+ dispatchBox(overlay, width / 2 - 4, width / 2 + 4, height, index + 1)
141
+ await flushPromises()
142
+ }
143
+
144
+ const domain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
145
+ expect(visibleXCount(domain)).toBe(2)
146
+ const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
147
+ dispatchBox(overlay, width / 2 - 4, width / 2 + 4, height, 10)
148
+ await flushPromises()
149
+ const constrainedDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
150
+ expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount)
151
+ expect(visibleXCount(constrainedDomain)).toBe(2)
152
+ })
153
+
154
+ it('retains the default 40x maximum when no explicit limit is configured', async () => {
155
+ const wrapper = await mountSizedChart({ kind: 'points', points: regularPoints })
156
+ const { overlay, width, height } = prepareOverlay(
157
+ wrapper,
158
+ '.waveform-chart__overlay--independent',
159
+ )
160
+
161
+ await dispatchWheel(overlay, width, height, -4_000)
162
+ const domain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
163
+ expect(domain[1] - domain[0]).toBeCloseTo(1_199 / 40)
164
+ })
165
+
166
+ it('retains two endpoint samples while replacing an independent data window', async () => {
167
+ const sourcePoints = Array.from({ length: 1_000 }, (_, index) => ({
168
+ x: -5 + (index * 10) / 999,
169
+ y: index,
170
+ }))
171
+ const wrapper = await mountSizedChart(
172
+ { kind: 'points', points: sourcePoints },
173
+ { minVisiblePoints: 2, initialXDomain: [-5, 5] },
174
+ )
175
+ const { overlay, width, height } = prepareOverlay(
176
+ wrapper,
177
+ '.waveform-chart__overlay--independent',
178
+ )
179
+ for (let index = 0; index < 10; index += 1) {
180
+ const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
181
+ overlay.element.dispatchEvent(
182
+ new WheelEvent('wheel', {
183
+ deltaY: -1_000,
184
+ clientX: width * 0.5032,
185
+ clientY: height / 2,
186
+ bubbles: true,
187
+ cancelable: true,
188
+ }),
189
+ )
190
+ flushAnimationFrames()
191
+ await flushPromises()
192
+ if ((wrapper.emitted('zoom-change')?.length ?? 0) === eventCount) break
193
+ const domain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
194
+ const windowPoints = sourcePoints.filter(
195
+ (point) => point.x >= domain[0] && point.x <= domain[1],
196
+ )
197
+ await wrapper.setProps({ data: { kind: 'points', points: windowPoints } })
198
+ await flushPromises()
199
+ }
200
+
201
+ const finalDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
202
+ const endpointDomain = wrapper
203
+ .findAll('.waveform-chart__axis-endpoint')
204
+ .slice(0, 2)
205
+ .map((endpoint) => Number(endpoint.text()) / 1_000) as [number, number]
206
+ expect(visibleXCount(finalDomain, sourcePoints)).toBe(2)
207
+ expect(visibleXCount(endpointDomain, sourcePoints)).toBe(2)
208
+ const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
209
+ await dispatchWheel(overlay, width, height, -1_000)
210
+ expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount)
211
+ })
212
+
213
+ it('recomputes irregular point constraints for shared tracks after data replacement', async () => {
214
+ const createData = (offset: number) => ({
215
+ kind: 'series' as const,
216
+ series: [
217
+ {
218
+ id: 'first',
219
+ name: 'First',
220
+ data: {
221
+ kind: 'points' as const,
222
+ points: [0, 1, 4, 20].map((x) => ({ x: x + offset, y: x })),
223
+ },
224
+ },
225
+ {
226
+ id: 'second',
227
+ name: 'Second',
228
+ data: {
229
+ kind: 'points' as const,
230
+ points: [0, 3, 9, 20].map((x) => ({ x: x + offset, y: x })),
231
+ },
232
+ },
233
+ ],
234
+ })
235
+ const wrapper = await mountSizedChart(createData(0), {
236
+ displayMode: 'separated',
237
+ minVisiblePoints: 2,
238
+ })
239
+ const { overlay, width, height } = prepareOverlay(wrapper, '.waveform-chart__overlay')
240
+
241
+ await dispatchWheel(overlay, width, height, -4_000)
242
+ await wrapper.setProps({ data: createData(100) })
243
+ await flushPromises()
244
+ await dispatchWheel(overlay, width, height, 4_000)
245
+ const domain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
246
+ expect(domain).toEqual([100, 120])
247
+ })
248
+ })