waveform-analysis 0.1.30 → 0.1.32
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 +12 -2
- package/dist/index.cjs +1 -1
- package/dist/index.js +827 -602
- package/dist/style.css +1 -1
- package/dist/types/components/WaveformChart.vue.d.ts +2 -2
- 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 +8 -5
- package/dist/types/components/data/types.d.ts +1 -1
- 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/dist/types/index.d.ts +1 -1
- package/dist/types/types/chart.d.ts +5 -0
- package/dist/types/types/index.d.ts +1 -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 +6 -4
- package/src/components/data/types.ts +1 -0
- 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 +101 -75
- 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/viewportConstraintsAndReset.test.ts +0 -65
- package/src/components/waveformChartCases/viewportLifecycle.test.ts +132 -0
- package/src/components/waveformChartCases/viewportReset.test.ts +114 -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/index.ts +1 -0
- package/src/types/chart.ts +6 -0
- package/src/types/index.ts +1 -0
- 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
|
+
}
|
|
@@ -308,69 +308,4 @@ describe('WaveformChart', () => {
|
|
|
308
308
|
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount + 1)
|
|
309
309
|
expect(zoomedOutDomain).toEqual([0, 2])
|
|
310
310
|
})
|
|
311
|
-
|
|
312
|
-
it('resets a shared viewport on double-click and emits zoom-reset', async () => {
|
|
313
|
-
const wrapper = await mountSizedChart({
|
|
314
|
-
kind: 'points',
|
|
315
|
-
points: [
|
|
316
|
-
{ x: 0, y: 0 },
|
|
317
|
-
{ x: 2, y: 1 },
|
|
318
|
-
],
|
|
319
|
-
})
|
|
320
|
-
const overlay = wrapper.get('.waveform-chart__overlay')
|
|
321
|
-
const width = Number(overlay.attributes('width'))
|
|
322
|
-
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
|
323
|
-
value: () => ({ left: 0, top: 0, width, height: 290 }),
|
|
324
|
-
})
|
|
325
|
-
overlay.element.dispatchEvent(
|
|
326
|
-
new WheelEvent('wheel', {
|
|
327
|
-
deltaY: -4000,
|
|
328
|
-
clientX: width / 2,
|
|
329
|
-
clientY: 145,
|
|
330
|
-
bubbles: true,
|
|
331
|
-
cancelable: true,
|
|
332
|
-
}),
|
|
333
|
-
)
|
|
334
|
-
flushAnimationFrames()
|
|
335
|
-
await flushPromises()
|
|
336
|
-
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe('0')
|
|
337
|
-
|
|
338
|
-
overlay.element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }))
|
|
339
|
-
await flushPromises()
|
|
340
|
-
|
|
341
|
-
expect(wrapper.emitted('zoom-reset')).toHaveLength(1)
|
|
342
|
-
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0')
|
|
343
|
-
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
|
|
344
|
-
})
|
|
345
|
-
|
|
346
|
-
it('exposes resetViewport for independent tracks', async () => {
|
|
347
|
-
const wrapper = await mountSizedChart(gridSeries(2), {
|
|
348
|
-
displayMode: 'independent',
|
|
349
|
-
grid: { rowCount: 1, columnCount: 2 },
|
|
350
|
-
})
|
|
351
|
-
const overlay = wrapper.findAll('.waveform-chart__overlay--independent')[0]
|
|
352
|
-
const width = Number(overlay.attributes('width'))
|
|
353
|
-
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
|
354
|
-
value: () => ({ left: 0, top: 0, width, height: 260 }),
|
|
355
|
-
})
|
|
356
|
-
overlay.element.dispatchEvent(
|
|
357
|
-
new WheelEvent('wheel', {
|
|
358
|
-
deltaY: -4000,
|
|
359
|
-
clientX: width / 2,
|
|
360
|
-
clientY: 130,
|
|
361
|
-
bubbles: true,
|
|
362
|
-
cancelable: true,
|
|
363
|
-
}),
|
|
364
|
-
)
|
|
365
|
-
flushAnimationFrames()
|
|
366
|
-
await flushPromises()
|
|
367
|
-
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).not.toBe('0')
|
|
368
|
-
|
|
369
|
-
const chart = wrapper.vm as unknown as { resetViewport: () => void }
|
|
370
|
-
chart.resetViewport()
|
|
371
|
-
await flushPromises()
|
|
372
|
-
|
|
373
|
-
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).toBe('0')
|
|
374
|
-
expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1000')
|
|
375
|
-
})
|
|
376
311
|
})
|
|
@@ -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,114 @@
|
|
|
1
|
+
import { flushPromises } from '@vue/test-utils'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { flushAnimationFrames } from '../../test/setup'
|
|
5
|
+
import { gridSeries, mountSizedChart } from '../../test/waveformChart'
|
|
6
|
+
|
|
7
|
+
describe('WaveformChart viewport reset', () => {
|
|
8
|
+
it('resets a shared viewport on double-click and emits a global payload', async () => {
|
|
9
|
+
const wrapper = await mountSizedChart(
|
|
10
|
+
{
|
|
11
|
+
kind: 'points',
|
|
12
|
+
points: [
|
|
13
|
+
{ x: 0, y: 0 },
|
|
14
|
+
{ x: 2, y: 1 },
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
{ displayMode: 'separated' },
|
|
18
|
+
)
|
|
19
|
+
const overlay = wrapper.get('.waveform-chart__overlay')
|
|
20
|
+
const width = Number(overlay.attributes('width'))
|
|
21
|
+
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
|
22
|
+
value: () => ({ left: 0, top: 0, width, height: 290 }),
|
|
23
|
+
})
|
|
24
|
+
overlay.element.dispatchEvent(
|
|
25
|
+
new WheelEvent('wheel', {
|
|
26
|
+
deltaY: -4000,
|
|
27
|
+
clientX: width / 2,
|
|
28
|
+
clientY: 145,
|
|
29
|
+
bubbles: true,
|
|
30
|
+
cancelable: true,
|
|
31
|
+
}),
|
|
32
|
+
)
|
|
33
|
+
flushAnimationFrames()
|
|
34
|
+
await flushPromises()
|
|
35
|
+
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe('0')
|
|
36
|
+
|
|
37
|
+
overlay.element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }))
|
|
38
|
+
await flushPromises()
|
|
39
|
+
|
|
40
|
+
expect(wrapper.emitted('zoom-reset')).toEqual([[{}]])
|
|
41
|
+
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0')
|
|
42
|
+
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('resets only the double-clicked independent track and identifies it', async () => {
|
|
46
|
+
const wrapper = await mountSizedChart(gridSeries(2), {
|
|
47
|
+
displayMode: 'independent',
|
|
48
|
+
grid: { rowCount: 1, columnCount: 2 },
|
|
49
|
+
})
|
|
50
|
+
const overlays = wrapper.findAll('.waveform-chart__overlay--independent')
|
|
51
|
+
for (const overlay of overlays) {
|
|
52
|
+
const width = Number(overlay.attributes('width'))
|
|
53
|
+
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
|
54
|
+
value: () => ({ left: 0, top: 0, width, height: 260 }),
|
|
55
|
+
})
|
|
56
|
+
overlay.element.dispatchEvent(
|
|
57
|
+
new WheelEvent('wheel', {
|
|
58
|
+
deltaY: -4000,
|
|
59
|
+
clientX: width / 2,
|
|
60
|
+
clientY: 130,
|
|
61
|
+
bubbles: true,
|
|
62
|
+
cancelable: true,
|
|
63
|
+
}),
|
|
64
|
+
)
|
|
65
|
+
flushAnimationFrames()
|
|
66
|
+
await flushPromises()
|
|
67
|
+
}
|
|
68
|
+
const startsBeforeReset = wrapper.findAll('.waveform-chart__axis-endpoint--start')
|
|
69
|
+
expect(startsBeforeReset[0].text()).not.toBe('0')
|
|
70
|
+
expect(startsBeforeReset[1].text()).not.toBe('0')
|
|
71
|
+
const secondTrackStart = startsBeforeReset[1].text()
|
|
72
|
+
|
|
73
|
+
overlays[0].element.dispatchEvent(
|
|
74
|
+
new MouseEvent('dblclick', { bubbles: true, cancelable: true }),
|
|
75
|
+
)
|
|
76
|
+
await flushPromises()
|
|
77
|
+
|
|
78
|
+
const startsAfterReset = wrapper.findAll('.waveform-chart__axis-endpoint--start')
|
|
79
|
+
expect(startsAfterReset[0].text()).toBe('0')
|
|
80
|
+
expect(startsAfterReset[1].text()).toBe(secondTrackStart)
|
|
81
|
+
expect(wrapper.emitted('zoom-reset')).toEqual([[{ trackIndex: 0, seriesIds: ['channel-0'] }]])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('keeps the exposed no-argument resetViewport global', async () => {
|
|
85
|
+
const wrapper = await mountSizedChart(gridSeries(2), {
|
|
86
|
+
displayMode: 'independent',
|
|
87
|
+
grid: { rowCount: 1, columnCount: 2 },
|
|
88
|
+
})
|
|
89
|
+
const overlay = wrapper.findAll('.waveform-chart__overlay--independent')[0]
|
|
90
|
+
const width = Number(overlay.attributes('width'))
|
|
91
|
+
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
|
92
|
+
value: () => ({ left: 0, top: 0, width, height: 260 }),
|
|
93
|
+
})
|
|
94
|
+
overlay.element.dispatchEvent(
|
|
95
|
+
new WheelEvent('wheel', {
|
|
96
|
+
deltaY: -4000,
|
|
97
|
+
clientX: width / 2,
|
|
98
|
+
clientY: 130,
|
|
99
|
+
bubbles: true,
|
|
100
|
+
cancelable: true,
|
|
101
|
+
}),
|
|
102
|
+
)
|
|
103
|
+
flushAnimationFrames()
|
|
104
|
+
await flushPromises()
|
|
105
|
+
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).not.toBe('0')
|
|
106
|
+
|
|
107
|
+
const chart = wrapper.vm as unknown as { resetViewport: () => void }
|
|
108
|
+
chart.resetViewport()
|
|
109
|
+
await flushPromises()
|
|
110
|
+
|
|
111
|
+
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).toBe('0')
|
|
112
|
+
expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1000')
|
|
113
|
+
})
|
|
114
|
+
})
|