waveform-analysis 0.1.58 → 0.1.59

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 (44) hide show
  1. package/README.md +9 -6
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.js +705 -644
  4. package/dist/types/components/WaveformChart.vue.d.ts +2 -0
  5. package/dist/types/components/core/useWaveformChartLifecycle.d.ts +1 -2
  6. package/dist/types/components/core/waveformChartTypes.d.ts +2 -1
  7. package/dist/types/components/data/types.d.ts +1 -1
  8. package/dist/types/components/index.d.ts +1 -1
  9. package/dist/types/components/interaction/useWaveformZoom.d.ts +2 -1
  10. package/dist/types/components/interaction/zoomEventPayload.d.ts +7 -0
  11. package/dist/types/core/rendering.d.ts +1 -1
  12. package/dist/types/core/renderingOptions.d.ts +1 -1
  13. package/dist/types/core/wasmSampling.d.ts +1 -1
  14. package/dist/types/core/workerSampling/multiresolution.d.ts +1 -1
  15. package/dist/types/core/workerSampling/protocol.d.ts +1 -1
  16. package/dist/types/core/workerSampling/repositoryDataset.d.ts +1 -1
  17. package/dist/types/core/workerSampling/repositorySupport.d.ts +1 -1
  18. package/dist/types/index.d.ts +1 -1
  19. package/dist/types/types/chart.d.ts +8 -0
  20. package/dist/types/types/index.d.ts +1 -1
  21. package/dist/types/utils/geometry.d.ts +1 -1
  22. package/dist/types/wasm/waveform_sampling_wasm.d.ts +74 -0
  23. package/dist/types/wasm/waveform_sampling_wasm_bg.wasm.d.ts +19 -0
  24. package/package.json +5 -4
  25. package/src/components/core/useWaveformChartController.ts +6 -1
  26. package/src/components/core/useWaveformChartLifecycle.ts +15 -9
  27. package/src/components/core/waveformChartTypes.ts +2 -0
  28. package/src/components/data/types.ts +1 -0
  29. package/src/components/index.ts +1 -0
  30. package/src/components/interaction/useWaveformViewport.ts +2 -0
  31. package/src/components/interaction/useWaveformZoom.ts +58 -61
  32. package/src/components/interaction/zoomEventPayload.ts +88 -0
  33. package/src/components/waveformChartCases/zoomIntentAndDataRebind.test.ts +213 -0
  34. package/src/core/rendering.ts +1 -1
  35. package/src/core/renderingOptions.ts +1 -1
  36. package/src/core/workerSampling/multiresolution.ts +1 -1
  37. package/src/core/workerSampling/protocol.ts +1 -1
  38. package/src/core/workerSampling/repository.ts +1 -1
  39. package/src/core/workerSampling/repositoryDataset.ts +1 -1
  40. package/src/core/workerSampling/repositorySupport.ts +1 -1
  41. package/src/index.ts +1 -0
  42. package/src/types/chart.ts +9 -0
  43. package/src/types/index.ts +1 -0
  44. package/src/utils/geometry.ts +1 -1
@@ -21,6 +21,11 @@ import {
21
21
  transformForDomain,
22
22
  type ZoomSeriesGroup,
23
23
  } from './zoomConstraints'
24
+ import {
25
+ emitIndependentWheelZoomEnds,
26
+ emitSharedWheelZoomEnd,
27
+ seriesIdentity,
28
+ } from './zoomEventPayload'
24
29
 
25
30
  interface ZoomContext {
26
31
  props: ResolvedWaveformChartProps
@@ -70,9 +75,10 @@ export function useWaveformZoom(context: ZoomContext) {
70
75
  let lastSharedZoomGesture: ZoomGestureKind | null = null
71
76
  const pendingIndependentZoomTransforms = new Map<number, ZoomTransform>()
72
77
  const pendingIndependentZoomGestures = new Map<number, ZoomGestureKind>()
73
- const lastIndependentZoomGestures = new Map<number, ZoomGestureKind>()
74
- const lastZoomedTrackIndexes = new Set<number>()
78
+ const lastIndependentWheelSeriesIds = new Map<string, string[]>()
75
79
  let wheelZoomEndTimer: ReturnType<typeof setTimeout> | undefined
80
+ let preservePendingWheelDuringDataRebind = false
81
+ let zoomBindingEpoch = 0
76
82
 
77
83
  const scheduleZoomCommit = () => zoomThrottle.schedule(commitPendingZoom)
78
84
  const handleSharedZoom = (event: D3ZoomEvent<SVGRectElement, unknown>) => {
@@ -80,8 +86,12 @@ export function useWaveformZoom(context: ZoomContext) {
80
86
  cancelPendingHover()
81
87
  pendingSharedZoomTransform = event.transform
82
88
  pendingSharedZoomGesture = 'wheel'
83
- scheduleZoomCommit()
84
89
  scheduleWheelZoomEnd()
90
+ const domain = event.transform
91
+ .rescaleX(scaleLinear(initialXDomain.value, [0, innerWidth.value]))
92
+ .domain()
93
+ emit('zoom-intent', { start: domain[0], end: domain[1], gesture: 'wheel' })
94
+ scheduleZoomCommit()
85
95
  }
86
96
  const handleIndependentZoom = (
87
97
  event: D3ZoomEvent<SVGRectElement, unknown>,
@@ -91,8 +101,21 @@ export function useWaveformZoom(context: ZoomContext) {
91
101
  cancelPendingHover()
92
102
  pendingIndependentZoomTransforms.set(trackIndex, event.transform)
93
103
  pendingIndependentZoomGestures.set(trackIndex, 'wheel')
94
- scheduleZoomCommit()
95
104
  scheduleWheelZoomEnd()
105
+ const track = trackLayouts.value.find((item) => item.index === trackIndex)
106
+ if (track) {
107
+ const domain = event.transform
108
+ .rescaleX(scaleLinear(resolveInitialTrackDomain(track), [0, track.width]))
109
+ .domain()
110
+ emit('zoom-intent', {
111
+ start: domain[0],
112
+ end: domain[1],
113
+ trackIndex,
114
+ seriesIds: track.seriesList.map((series) => series.id),
115
+ gesture: 'wheel',
116
+ })
117
+ }
118
+ scheduleZoomCommit()
96
119
  }
97
120
  function commitPendingZoom() {
98
121
  if (isPresentationMode.value) {
@@ -113,12 +136,7 @@ export function useWaveformZoom(context: ZoomContext) {
113
136
  if (!pendingIndependentZoomTransforms.size) return
114
137
  const nextTransforms = [...independentTransforms.value]
115
138
  const changedTrackIndexes = Array.from(pendingIndependentZoomTransforms.keys())
116
- lastZoomedTrackIndexes.clear()
117
- changedTrackIndexes.forEach((trackIndex) => lastZoomedTrackIndexes.add(trackIndex))
118
- changedTrackIndexes.forEach((trackIndex) => {
119
- const gesture = pendingIndependentZoomGestures.get(trackIndex)
120
- if (gesture) lastIndependentZoomGestures.set(trackIndex, gesture)
121
- })
139
+ const changedGestures = new Map(pendingIndependentZoomGestures)
122
140
  pendingIndependentZoomTransforms.forEach((transform, trackIndex) => {
123
141
  nextTransforms[trackIndex] = transform
124
142
  })
@@ -129,69 +147,37 @@ export function useWaveformZoom(context: ZoomContext) {
129
147
  changedTrackIndexes.forEach((trackIndex) => {
130
148
  const track = tracksByIndex.get(trackIndex)
131
149
  if (!track) return
150
+ if (changedGestures.get(trackIndex) === 'wheel') {
151
+ const seriesIds = track.seriesList.map((series) => series.id)
152
+ lastIndependentWheelSeriesIds.set(seriesIdentity(seriesIds), seriesIds)
153
+ }
132
154
  const domain = track.xScale.domain()
133
155
  emit('zoom-change', [domain[0], domain[1]])
134
156
  })
135
157
  }
136
158
  const emitZoomEnd = () => {
137
159
  if (props.displayMode === 'independent') {
138
- const tracksByIndex = new Map(trackLayouts.value.map((track) => [track.index, track]))
139
- lastZoomedTrackIndexes.forEach((trackIndex) => {
140
- if (lastIndependentZoomGestures.get(trackIndex) !== 'wheel') return
141
- const track = tracksByIndex.get(trackIndex)
142
- if (!track) return
143
- const domain = track.xScale.domain() as [number, number]
144
- const yDomain = track.yScale.domain()
145
- emit('zoom-end', {
146
- start: domain[0],
147
- end: domain[1],
148
- yStart: yDomain[0],
149
- yEnd: yDomain[1],
150
- trackIndex,
151
- seriesIds: track.seriesList.map((series) => series.id),
152
- gesture: 'wheel',
153
- })
154
- })
155
- lastZoomedTrackIndexes.clear()
156
- lastIndependentZoomGestures.clear()
160
+ emitIndependentWheelZoomEnds(emit, trackLayouts.value, lastIndependentWheelSeriesIds.values())
161
+ lastIndependentWheelSeriesIds.clear()
157
162
  return
158
163
  }
159
164
  if (lastSharedZoomGesture !== 'wheel') {
160
165
  lastSharedZoomGesture = null
161
166
  return
162
167
  }
163
- const domain = sharedZoomDomain.value
164
- const visibleTracks = trackLayouts.value.filter((track) => track.hasVisibleSeries)
165
- if (visibleTracks.length === 1) {
166
- const yDomain = visibleTracks[0]?.yScale.domain()
167
- emit('zoom-end', {
168
- start: domain[0],
169
- end: domain[1],
170
- yStart: yDomain?.[0],
171
- yEnd: yDomain?.[1],
172
- gesture: 'wheel',
173
- })
174
- } else {
175
- emit('zoom-end', {
176
- start: domain[0],
177
- end: domain[1],
178
- yRanges: Object.fromEntries(
179
- visibleTracks.map((track) => [
180
- track.series?.trackId ?? track.series?.id ?? track.id,
181
- track.yScale.domain() as [number, number],
182
- ]),
183
- ),
184
- gesture: 'wheel',
185
- })
186
- }
168
+ emitSharedWheelZoomEnd(emit, sharedZoomDomain.value, trackLayouts.value)
187
169
  lastSharedZoomGesture = null
188
170
  }
189
171
  const flushPendingZoom = () => {
190
172
  zoomThrottle.flush()
191
173
  commitPendingZoom()
192
- if (lastSharedZoomGesture === 'wheel' || lastIndependentZoomGestures.size) return
174
+ if (lastSharedZoomGesture === 'wheel' || lastIndependentWheelSeriesIds.size) return
193
175
  emitZoomEnd()
194
176
  }
177
+ const handleD3ZoomEnd = (bindingEpoch: number) => {
178
+ if (bindingEpoch !== zoomBindingEpoch) return
179
+ flushPendingZoom()
180
+ }
195
181
  function scheduleWheelZoomEnd() {
196
182
  if (wheelZoomEndTimer !== undefined) clearTimeout(wheelZoomEndTimer)
197
183
  wheelZoomEndTimer = setTimeout(() => {
@@ -199,24 +185,32 @@ export function useWaveformZoom(context: ZoomContext) {
199
185
  zoomThrottle.flush()
200
186
  commitPendingZoom()
201
187
  emitZoomEnd()
188
+ preservePendingWheelDuringDataRebind = false
202
189
  }, WHEEL_ZOOM_DEBOUNCE_MS)
203
190
  }
191
+ const prepareForDataChange = () => {
192
+ if (wheelZoomEndTimer === undefined) return
193
+ zoomThrottle.flush()
194
+ commitPendingZoom()
195
+ preservePendingWheelDuringDataRebind = true
196
+ }
204
197
  const cancelPendingZoom = () => {
205
198
  pendingSharedZoomTransform = null
206
199
  pendingSharedZoomGesture = null
207
200
  lastSharedZoomGesture = null
208
201
  pendingIndependentZoomTransforms.clear()
209
202
  pendingIndependentZoomGestures.clear()
210
- lastZoomedTrackIndexes.clear()
211
- lastIndependentZoomGestures.clear()
203
+ lastIndependentWheelSeriesIds.clear()
212
204
  zoomThrottle.cancel()
205
+ preservePendingWheelDuringDataRebind = false
213
206
  if (wheelZoomEndTimer !== undefined) {
214
207
  clearTimeout(wheelZoomEndTimer)
215
208
  wheelZoomEndTimer = undefined
216
209
  }
217
210
  }
218
- const clearZoomBindings = () => {
219
- cancelPendingZoom()
211
+ const clearZoomBindings = (preservePendingWheel = false) => {
212
+ zoomBindingEpoch += 1
213
+ if (!preservePendingWheel) cancelPendingZoom()
220
214
  svgElement.value
221
215
  ?.querySelectorAll<SVGRectElement>('.waveform-chart__overlay')
222
216
  .forEach((overlay) => select(overlay).on('.zoom', null))
@@ -268,7 +262,9 @@ export function useWaveformZoom(context: ZoomContext) {
268
262
  return false
269
263
  }
270
264
  const configureZoom = () => {
271
- clearZoomBindings()
265
+ const preservePendingWheel = preservePendingWheelDuringDataRebind
266
+ clearZoomBindings(preservePendingWheel)
267
+ const bindingEpoch = zoomBindingEpoch
272
268
  if (
273
269
  isPresentationMode.value ||
274
270
  !props.zoomable ||
@@ -303,7 +299,7 @@ export function useWaveformZoom(context: ZoomContext) {
303
299
  [track.width, track.height],
304
300
  ])
305
301
  .on('zoom', (event) => handleIndependentZoom(event, track.index))
306
- .on('end', flushPendingZoom)
302
+ .on('end', () => handleD3ZoomEnd(bindingEpoch))
307
303
  zoomBehaviors.set(track.index, behavior)
308
304
  synchronizingZoomTransform = true
309
305
  try {
@@ -337,7 +333,7 @@ export function useWaveformZoom(context: ZoomContext) {
337
333
  [innerWidth.value, innerHeight.value],
338
334
  ])
339
335
  .on('zoom', handleSharedZoom)
340
- .on('end', flushPendingZoom)
336
+ .on('end', () => handleD3ZoomEnd(bindingEpoch))
341
337
  zoomBehaviors.set('shared', behavior)
342
338
  synchronizingZoomTransform = true
343
339
  try {
@@ -356,5 +352,6 @@ export function useWaveformZoom(context: ZoomContext) {
356
352
  canZoomTrack,
357
353
  canZoomSharedTracks,
358
354
  configureZoom,
355
+ prepareForDataChange,
359
356
  }
360
357
  }
@@ -0,0 +1,88 @@
1
+ import type { TrackLayout } from '../core/types'
2
+ import type { WaveformChartEmit } from '../core/waveformChartTypes'
3
+
4
+ export function seriesIdentity(seriesIds: readonly string[]) {
5
+ return [...seriesIds].sort().join('\u0000')
6
+ }
7
+
8
+ export function findTrackBySeriesIds(
9
+ trackLayouts: readonly TrackLayout[],
10
+ seriesIds: readonly string[],
11
+ ) {
12
+ const identity = seriesIdentity(seriesIds)
13
+ return trackLayouts.find(
14
+ (track) => seriesIdentity(track.seriesList.map((series) => series.id)) === identity,
15
+ )
16
+ }
17
+
18
+ export function emitBoxZoomIntent(
19
+ emit: WaveformChartEmit,
20
+ domain: [number, number],
21
+ track: TrackLayout,
22
+ independent: boolean,
23
+ ) {
24
+ emit(
25
+ 'zoom-intent',
26
+ independent
27
+ ? {
28
+ start: domain[0],
29
+ end: domain[1],
30
+ trackIndex: track.index,
31
+ seriesIds: track.seriesList.map((series) => series.id),
32
+ gesture: 'box',
33
+ }
34
+ : { start: domain[0], end: domain[1], gesture: 'box' },
35
+ )
36
+ }
37
+
38
+ export function emitIndependentWheelZoomEnds(
39
+ emit: WaveformChartEmit,
40
+ trackLayouts: readonly TrackLayout[],
41
+ pendingSeriesIds: Iterable<readonly string[]>,
42
+ ) {
43
+ for (const seriesIds of pendingSeriesIds) {
44
+ const track = findTrackBySeriesIds(trackLayouts, seriesIds)
45
+ if (!track) continue
46
+ const domain = track.xScale.domain() as [number, number]
47
+ const yDomain = track.yScale.domain()
48
+ emit('zoom-end', {
49
+ start: domain[0],
50
+ end: domain[1],
51
+ yStart: yDomain[0],
52
+ yEnd: yDomain[1],
53
+ trackIndex: track.index,
54
+ seriesIds: [...seriesIds],
55
+ gesture: 'wheel',
56
+ })
57
+ }
58
+ }
59
+
60
+ export function emitSharedWheelZoomEnd(
61
+ emit: WaveformChartEmit,
62
+ domain: [number, number],
63
+ trackLayouts: readonly TrackLayout[],
64
+ ) {
65
+ const visibleTracks = trackLayouts.filter((track) => track.hasVisibleSeries)
66
+ if (visibleTracks.length === 1) {
67
+ const yDomain = visibleTracks[0]?.yScale.domain()
68
+ emit('zoom-end', {
69
+ start: domain[0],
70
+ end: domain[1],
71
+ yStart: yDomain?.[0],
72
+ yEnd: yDomain?.[1],
73
+ gesture: 'wheel',
74
+ })
75
+ return
76
+ }
77
+ emit('zoom-end', {
78
+ start: domain[0],
79
+ end: domain[1],
80
+ yRanges: Object.fromEntries(
81
+ visibleTracks.map((track) => [
82
+ track.series?.trackId ?? track.series?.id ?? track.id,
83
+ track.yScale.domain() as [number, number],
84
+ ]),
85
+ ),
86
+ gesture: 'wheel',
87
+ })
88
+ }
@@ -0,0 +1,213 @@
1
+ import { flushPromises, mount } from '@vue/test-utils'
2
+ import { defineComponent, h, ref } from 'vue'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+
5
+ import WaveformChart from '../WaveformChart.vue'
6
+ import type { WaveformData } from '../waveform'
7
+ import { resizeObservers } from '../../test/setup'
8
+ import { gridSeries, mountSizedChart } from '../../test/waveformChart'
9
+
10
+ function seriesWindow(ids: string[]) {
11
+ return {
12
+ kind: 'series' as const,
13
+ series: ids.map((id, index) => ({
14
+ id,
15
+ name: id,
16
+ data: {
17
+ kind: 'points' as const,
18
+ points: [
19
+ { x: 40, y: index },
20
+ { x: 50, y: index + 1 },
21
+ { x: 60, y: index + 2 },
22
+ ],
23
+ },
24
+ })),
25
+ }
26
+ }
27
+
28
+ function fullSeries(ids: string[]): WaveformData {
29
+ return {
30
+ kind: 'series',
31
+ series: ids.map((id, index) => ({
32
+ id,
33
+ name: id,
34
+ data: {
35
+ kind: 'points',
36
+ points: [
37
+ { x: 0, y: index },
38
+ { x: 50, y: index + 1 },
39
+ { x: 100, y: index + 2 },
40
+ ],
41
+ },
42
+ })),
43
+ }
44
+ }
45
+
46
+ function configureOverlay(overlay: ReturnType<typeof mount>['element']) {
47
+ const width = Number(overlay.getAttribute('width'))
48
+ const height = Number(overlay.getAttribute('height'))
49
+ Object.defineProperty(overlay, 'getBoundingClientRect', {
50
+ value: () => ({ left: 0, top: 0, width, height }),
51
+ })
52
+ return { width, height }
53
+ }
54
+
55
+ function dispatchWheel(overlay: Element, deltaY: number, width: number, height: number) {
56
+ overlay.dispatchEvent(
57
+ new WheelEvent('wheel', {
58
+ deltaY,
59
+ clientX: width / 2,
60
+ clientY: height / 2,
61
+ bubbles: true,
62
+ cancelable: true,
63
+ }),
64
+ )
65
+ }
66
+
67
+ describe('WaveformChart zoom intent and data rebind', () => {
68
+ it('keeps one shared wheel end after an immediate intent-driven data replacement', async () => {
69
+ vi.useFakeTimers()
70
+ try {
71
+ const data = ref<WaveformData>(fullSeries(['shared']))
72
+ const host = mount(
73
+ defineComponent({
74
+ setup() {
75
+ return () =>
76
+ h(WaveformChart, {
77
+ data: data.value,
78
+ displayMode: 'separated',
79
+ initialXDomain: [0, 100],
80
+ onZoomIntent: () => {
81
+ data.value = seriesWindow(['shared'])
82
+ },
83
+ })
84
+ },
85
+ }),
86
+ )
87
+ resizeObservers.at(-1)?.resize(800, 360)
88
+ await flushPromises()
89
+
90
+ const chart = host.findComponent(WaveformChart)
91
+ const overlay = chart.get('.waveform-chart__overlay')
92
+ const { width, height } = configureOverlay(overlay.element)
93
+ dispatchWheel(overlay.element, -4_000, width, height)
94
+
95
+ const firstIntent = chart.emitted('zoom-intent')?.[0]?.[0] as {
96
+ start: number
97
+ end: number
98
+ }
99
+ expect(firstIntent.end - firstIntent.start).toBeCloseTo(2.5)
100
+ expect(chart.emitted('zoom-change')).toBeUndefined()
101
+
102
+ await flushPromises()
103
+ dispatchWheel(overlay.element, 1_000, width, height)
104
+ await flushPromises()
105
+ await vi.advanceTimersByTimeAsync(199)
106
+ expect(chart.emitted('zoom-end')).toBeUndefined()
107
+
108
+ await vi.advanceTimersByTimeAsync(1)
109
+ await flushPromises()
110
+ const zoomEnd = chart.emitted('zoom-end')?.[0]?.[0] as { start: number; end: number }
111
+ expect(chart.emitted('zoom-intent')).toHaveLength(2)
112
+ expect(chart.emitted('zoom-end')).toHaveLength(1)
113
+ expect(zoomEnd.end - zoomEnd.start).toBeGreaterThan(firstIntent.end - firstIntent.start)
114
+ } finally {
115
+ vi.useRealTimers()
116
+ }
117
+ })
118
+
119
+ it('retains a pending independent wheel gesture by stable series identity', async () => {
120
+ vi.useFakeTimers()
121
+ try {
122
+ const data = ref<WaveformData>(fullSeries(['channel-a', 'channel-b']))
123
+ const host = mount(
124
+ defineComponent({
125
+ setup() {
126
+ return () =>
127
+ h(WaveformChart, {
128
+ data: data.value,
129
+ displayMode: 'independent',
130
+ grid: { rowCount: 1, columnCount: 2 },
131
+ initialXDomain: [0, 100],
132
+ onZoomIntent: () => {
133
+ data.value = seriesWindow(['channel-b', 'channel-a'])
134
+ },
135
+ })
136
+ },
137
+ }),
138
+ )
139
+ resizeObservers.at(-1)?.resize(800, 360)
140
+ await flushPromises()
141
+
142
+ const chart = host.findComponent(WaveformChart)
143
+ const overlay = chart.findAll('.waveform-chart__overlay--independent')[0]!
144
+ const { width, height } = configureOverlay(overlay.element)
145
+ dispatchWheel(overlay.element, -4_000, width, height)
146
+ await flushPromises()
147
+ await vi.advanceTimersByTimeAsync(200)
148
+ await flushPromises()
149
+
150
+ const zoomEnd = chart.emitted('zoom-end')?.[0]?.[0] as {
151
+ trackIndex: number
152
+ seriesIds: string[]
153
+ }
154
+ expect(chart.emitted('zoom-intent')?.[0]?.[0]).toMatchObject({
155
+ trackIndex: 0,
156
+ seriesIds: ['channel-a'],
157
+ gesture: 'wheel',
158
+ })
159
+ expect(zoomEnd).toMatchObject({ trackIndex: 1, seriesIds: ['channel-a'], gesture: 'wheel' })
160
+ } finally {
161
+ vi.useRealTimers()
162
+ }
163
+ })
164
+
165
+ it('emits box intent with the selected independent series', async () => {
166
+ const wrapper = await mountSizedChart(fullSeries(['series-a']), { initialXDomain: [0, 100] })
167
+ const overlay = wrapper.get('.waveform-chart__overlay--independent')
168
+ const { width, height } = configureOverlay(overlay.element)
169
+ const dispatchPointer = (type: string, clientX: number, clientY: number) => {
170
+ const event = new MouseEvent(type, { button: 0, clientX, clientY, bubbles: true })
171
+ Object.defineProperty(event, 'pointerId', { value: 11 })
172
+ overlay.element.dispatchEvent(event)
173
+ }
174
+
175
+ dispatchPointer('pointerdown', width * 0.25, height / 2)
176
+ dispatchPointer('pointermove', width * 0.75, height / 2)
177
+ dispatchPointer('pointerup', width * 0.75, height / 2)
178
+ await flushPromises()
179
+
180
+ expect(wrapper.emitted('zoom-intent')?.[0]?.[0]).toMatchObject({
181
+ trackIndex: 0,
182
+ seriesIds: ['series-a'],
183
+ gesture: 'box',
184
+ })
185
+ })
186
+
187
+ it('cancels pending wheel ends when reset or pagination changes the viewport', async () => {
188
+ vi.useFakeTimers()
189
+ try {
190
+ const reset = await mountSizedChart(fullSeries(['reset']), { initialXDomain: [0, 100] })
191
+ const resetOverlay = reset.get('.waveform-chart__overlay--independent')
192
+ const resetBounds = configureOverlay(resetOverlay.element)
193
+ dispatchWheel(resetOverlay.element, -4_000, resetBounds.width, resetBounds.height)
194
+ ;(reset.vm as unknown as { resetViewport: () => void }).resetViewport()
195
+ await vi.advanceTimersByTimeAsync(200)
196
+ expect(reset.emitted('zoom-end')).toBeUndefined()
197
+
198
+ const paged = await mountSizedChart(gridSeries(2), {
199
+ displayMode: 'independent',
200
+ grid: { rowCount: 1, columnCount: 1 },
201
+ })
202
+ const pageOverlay = paged.get('.waveform-chart__overlay--independent')
203
+ const pageBounds = configureOverlay(pageOverlay.element)
204
+ dispatchWheel(pageOverlay.element, -4_000, pageBounds.width, pageBounds.height)
205
+ await paged.get('.ant-pagination-next button').trigger('click')
206
+ await flushPromises()
207
+ await vi.advanceTimersByTimeAsync(200)
208
+ expect(paged.emitted('zoom-end')).toBeUndefined()
209
+ } finally {
210
+ vi.useRealTimers()
211
+ }
212
+ })
213
+ })
@@ -1,6 +1,6 @@
1
1
  import { bisector } from 'd3'
2
2
 
3
- import type { WaveformPoint } from '@/types'
3
+ import type { WaveformPoint } from '../types'
4
4
  import { resolveWaveformPointErrors } from './data'
5
5
  import type { ResolvedWaveformRenderingOptions } from './renderingOptions'
6
6
  import {
@@ -3,7 +3,7 @@ import type {
3
3
  WaveformSamplingMode,
4
4
  WaveformSamplingOptions,
5
5
  WaveformSamplingStrategy,
6
- } from '@/types'
6
+ } from '../types'
7
7
 
8
8
  export interface ResolvedWaveformSamplingOptions {
9
9
  mode: WaveformSamplingMode
@@ -1,4 +1,4 @@
1
- import type { WaveformPoint } from '@/types'
1
+ import type { WaveformPoint } from '../../types'
2
2
 
3
3
  import type { WorkerSamplingOutput } from './protocol'
4
4
 
@@ -6,7 +6,7 @@ import type {
6
6
  WaveformSamplingDiagnostics,
7
7
  WaveformSamplingMode,
8
8
  WaveformSamplingStrategy,
9
- } from '@/types'
9
+ } from '../../types'
10
10
  import type { WorkerSamplingDataset } from '../waveformPointSource'
11
11
 
12
12
  export const DEFAULT_WORKER_SAMPLING_AUTO_THRESHOLD = 1_000
@@ -1,4 +1,4 @@
1
- import type { WaveformPoint } from '@/types'
1
+ import type { WaveformPoint } from '../../types'
2
2
  import type { WorkerSamplingDataset } from '../waveformPointSource'
3
3
  import { javascriptSamplingBackend } from './javascriptBackend'
4
4
  import {
@@ -1,4 +1,4 @@
1
- import type { WaveformPoint } from '@/types'
1
+ import type { WaveformPoint } from '../../types'
2
2
  import type { WorkerSamplingDataset } from '../waveformPointSource'
3
3
  import type { WasmDatasetSamplingBackend } from '../wasmSampling'
4
4
  import { MultiResolutionSamplingIndex } from './multiresolution'
@@ -1,6 +1,6 @@
1
1
  import { bisector } from 'd3'
2
2
 
3
- import type { WaveformPoint } from '@/types'
3
+ import type { WaveformPoint } from '../../types'
4
4
  import {
5
5
  DEFAULT_WORKER_SAMPLING_AUTO_THRESHOLD,
6
6
  DEFAULT_WORKER_SAMPLING_MAX_POINTS_PER_PIXEL,
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export type {
14
14
  WaveformOverlayMode,
15
15
  WaveformInteractionMode,
16
16
  WaveformXDomainStrategy,
17
+ WaveformZoomIntentPayload,
17
18
  WaveformZoomEndPayload,
18
19
  WaveformZoomResetPayload,
19
20
  WaveformAnnotationStyle,
@@ -49,6 +49,15 @@ export interface WaveformZoomEndPayload {
49
49
  gesture?: 'wheel' | 'box'
50
50
  }
51
51
 
52
+ /** Describes the X-axis viewport targeted by a user zoom gesture before rendering settles. */
53
+ export interface WaveformZoomIntentPayload {
54
+ start: number
55
+ end: number
56
+ gesture: 'wheel' | 'box'
57
+ trackIndex?: number
58
+ seriesIds?: string[]
59
+ }
60
+
52
61
  /** Identifies the viewport reset by a double-click gesture. */
53
62
  export interface WaveformZoomResetPayload {
54
63
  trackIndex?: number
@@ -9,6 +9,7 @@ export type {
9
9
  WaveformOverlayMode,
10
10
  WaveformInteractionMode,
11
11
  WaveformXDomainStrategy,
12
+ WaveformZoomIntentPayload,
12
13
  WaveformZoomEndPayload,
13
14
  WaveformZoomResetPayload,
14
15
  WaveformAnnotationStyle,
@@ -1,4 +1,4 @@
1
- import type { WaveformDisplayMode } from '@/types'
1
+ import type { WaveformDisplayMode } from '../types'
2
2
 
3
3
  /**
4
4
  * 轨道几何信息