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.
- package/README.md +8 -0
- package/dist/index.cjs +1 -1
- package/dist/index.js +815 -601
- package/dist/style.css +1 -1
- package/dist/types/components/core/useWaveformChartController.d.ts +2 -14
- package/dist/types/components/core/useWaveformChartLifecycle.d.ts +4 -1
- package/dist/types/components/core/useWaveformData.d.ts +1 -1
- package/dist/types/components/core/waveformChartTypes.d.ts +6 -3
- package/dist/types/components/interaction/pointerCapture.d.ts +1 -0
- package/dist/types/components/interaction/viewportInteractionState.d.ts +39 -0
- package/dist/types/components/interaction/zoomConstraints.d.ts +14 -0
- package/dist/types/core/rendering.d.ts +1 -4
- package/dist/types/core/renderingStrategies.d.ts +22 -0
- package/dist/types/demo/types.d.ts +0 -1
- package/package.json +5 -1
- package/src/App.vue +0 -4
- package/src/components/WaveformChartView.vue +2 -2
- package/src/components/core/useWaveformChartController.ts +7 -2
- package/src/components/core/useWaveformChartLifecycle.ts +47 -3
- package/src/components/core/useWaveformData.ts +6 -1
- package/src/components/core/waveformChartTypes.ts +4 -3
- package/src/components/interaction/WaveformTooltip.test.ts +39 -0
- package/src/components/interaction/WaveformTooltip.vue +72 -22
- package/src/components/interaction/pointerCapture.ts +10 -0
- package/src/components/interaction/useWaveformHover.ts +1 -1
- package/src/components/interaction/useWaveformViewport.ts +86 -74
- package/src/components/interaction/useWaveformZoom.ts +46 -14
- package/src/components/interaction/viewportInteractionState.test.ts +101 -0
- package/src/components/interaction/viewportInteractionState.ts +89 -0
- package/src/components/interaction/zoomConstraints.ts +167 -0
- package/src/components/waveformChartCases/viewportLifecycle.test.ts +132 -0
- package/src/components/waveformChartCases/zoomPointConstraints.test.ts +248 -0
- package/src/core/rendering.ts +11 -81
- package/src/core/renderingStrategies.test.ts +90 -0
- package/src/core/renderingStrategies.ts +131 -0
- package/src/data/simulatedWaveforms.ts +9 -10
- package/src/demo/DemoChartHost.vue +1 -2
- package/src/demo/types.ts +0 -1
- package/src/utils/formatters.test.ts +4 -3
- package/src/utils/formatters.ts +1 -2
- 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
|
+
})
|