waveform-analysis 0.1.57 → 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 (54) hide show
  1. package/README.md +12 -7
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.js +752 -680
  4. package/dist/types/components/WaveformChart.vue.d.ts +2 -0
  5. package/dist/types/components/core/grid.d.ts +3 -0
  6. package/dist/types/components/core/trackPagination.d.ts +4 -0
  7. package/dist/types/components/core/useWaveformChartController.d.ts +3 -1
  8. package/dist/types/components/core/useWaveformChartLifecycle.d.ts +1 -2
  9. package/dist/types/components/core/useWaveformLayout.d.ts +2 -1
  10. package/dist/types/components/core/waveformChartTypes.d.ts +4 -1
  11. package/dist/types/components/data/types.d.ts +1 -1
  12. package/dist/types/components/index.d.ts +1 -1
  13. package/dist/types/components/interaction/useWaveformZoom.d.ts +2 -1
  14. package/dist/types/components/interaction/zoomEventPayload.d.ts +7 -0
  15. package/dist/types/core/rendering.d.ts +1 -1
  16. package/dist/types/core/renderingOptions.d.ts +1 -1
  17. package/dist/types/core/wasmSampling.d.ts +1 -1
  18. package/dist/types/core/workerSampling/multiresolution.d.ts +1 -1
  19. package/dist/types/core/workerSampling/protocol.d.ts +1 -1
  20. package/dist/types/core/workerSampling/repositoryDataset.d.ts +1 -1
  21. package/dist/types/core/workerSampling/repositorySupport.d.ts +1 -1
  22. package/dist/types/index.d.ts +1 -1
  23. package/dist/types/types/chart.d.ts +8 -0
  24. package/dist/types/types/index.d.ts +1 -1
  25. package/dist/types/utils/geometry.d.ts +1 -1
  26. package/dist/types/wasm/waveform_sampling_wasm.d.ts +74 -0
  27. package/dist/types/wasm/waveform_sampling_wasm_bg.wasm.d.ts +19 -0
  28. package/package.json +5 -4
  29. package/src/components/WaveformChartView.vue +3 -3
  30. package/src/components/core/grid.test.ts +3 -0
  31. package/src/components/core/grid.ts +4 -0
  32. package/src/components/core/trackPagination.ts +40 -0
  33. package/src/components/core/useWaveformChartController.ts +6 -1
  34. package/src/components/core/useWaveformChartLifecycle.ts +15 -9
  35. package/src/components/core/useWaveformLayout.ts +12 -9
  36. package/src/components/core/waveformChartTypes.ts +4 -0
  37. package/src/components/data/types.ts +1 -0
  38. package/src/components/index.ts +1 -0
  39. package/src/components/interaction/useWaveformViewport.ts +2 -0
  40. package/src/components/interaction/useWaveformZoom.ts +58 -61
  41. package/src/components/interaction/zoomEventPayload.ts +88 -0
  42. package/src/components/waveformChartCases/trackOrder.test.ts +62 -0
  43. package/src/components/waveformChartCases/zoomIntentAndDataRebind.test.ts +213 -0
  44. package/src/core/rendering.ts +1 -1
  45. package/src/core/renderingOptions.ts +1 -1
  46. package/src/core/workerSampling/multiresolution.ts +1 -1
  47. package/src/core/workerSampling/protocol.ts +1 -1
  48. package/src/core/workerSampling/repository.ts +1 -1
  49. package/src/core/workerSampling/repositoryDataset.ts +1 -1
  50. package/src/core/workerSampling/repositorySupport.ts +1 -1
  51. package/src/index.ts +1 -0
  52. package/src/types/chart.ts +9 -0
  53. package/src/types/index.ts +1 -0
  54. package/src/utils/geometry.ts +1 -1
@@ -31,6 +31,7 @@ import {
31
31
  resolveYAxisSeriesGroups,
32
32
  } from './layout'
33
33
  import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
34
+ import { createFrameNumberResolver, resolvePageableTracks } from './trackPagination'
34
35
  import type { PreparedWaveformSeries } from './useWaveformData'
35
36
  import type { ResolvedWaveformChartProps } from './waveformChartTypes'
36
37
  import { applyXDomainStrategy } from './xDomain'
@@ -107,9 +108,12 @@ export function useWaveformLayout(context: LayoutContext) {
107
108
  })
108
109
  })
109
110
  const renderingOptions = computed(() => resolveWaveformRenderingOptions(props.rendering))
110
- const pageCount = computed(() => getPageCount(chartTracks.value.length, gridOptions.value))
111
+ const pageableTracks = computed(() =>
112
+ resolvePageableTracks(chartTracks.value, gridOptions.value.hideEmptyTracks),
113
+ )
114
+ const pageCount = computed(() => getPageCount(pageableTracks.value.length, gridOptions.value))
111
115
  const pagedTracks = computed(() =>
112
- paginateSeries(chartTracks.value, currentPage.value, gridOptions.value),
116
+ paginateSeries(pageableTracks.value, currentPage.value, gridOptions.value),
113
117
  )
114
118
  const layoutTracks = computed(() => {
115
119
  const tracksWithSeries = pagedTracks.value.filter((track) => track.series.length > 0)
@@ -357,16 +361,15 @@ export function useWaveformLayout(context: LayoutContext) {
357
361
  }
358
362
  : undefined
359
363
  })
360
- const resolveFrameNumber = (trackIndex: number): string | number | undefined => {
361
- if (props.frameNumber === undefined || props.frameNumber === null) return undefined
362
- if (chartTracks.value.length === 1) return props.frameNumber
363
- return typeof props.frameNumber === 'number'
364
- ? props.frameNumber + trackIndex
365
- : `${props.frameNumber}-${trackIndex + 1}`
366
- }
364
+ const resolveFrameNumber = createFrameNumberResolver(
365
+ () => props.frameNumber,
366
+ () => props.frameNumbers,
367
+ () => chartTracks.value,
368
+ )
367
369
  return {
368
370
  chartSeries,
369
371
  chartTracks,
372
+ pageableTracks,
370
373
  renderingOptions,
371
374
  gridOptions,
372
375
  pageCount,
@@ -16,6 +16,7 @@ import type {
16
16
  WaveformTitleOptions,
17
17
  WaveformXDomainStrategy,
18
18
  WaveformZeroLineOptions,
19
+ WaveformZoomIntentPayload,
19
20
  WaveformZoomEndPayload,
20
21
  WaveformZoomResetPayload,
21
22
  } from '../data/types'
@@ -43,6 +44,8 @@ export interface WaveformChartProps {
43
44
  yDomains?: Record<string, [number, number]>
44
45
  timeUnit?: 's' | 'ms'
45
46
  frameNumber?: string | number
47
+ /** Per-track frame watermark overrides, keyed by stable trackId. */
48
+ frameNumbers?: Record<string, string | number>
46
49
  frameStyle?: WaveformFrameStyle
47
50
  axes?: WaveformAxesOptions
48
51
  annotations?: WaveformAnnotation[]
@@ -88,6 +91,7 @@ export type ResolvedWaveformChartProps = Readonly<
88
91
 
89
92
  export interface WaveformChartEmit {
90
93
  (event: 'point-hover', point: WaveformPoint | null): void
94
+ (event: 'zoom-intent', payload: WaveformZoomIntentPayload): void
91
95
  (event: 'zoom-change', domain: [number, number]): void
92
96
  (event: 'zoom-end', payload: WaveformZoomEndPayload): void
93
97
  (event: 'zoom-reset', payload: WaveformZoomResetPayload): void
@@ -10,6 +10,7 @@ export type {
10
10
  WaveformOverlayMode,
11
11
  WaveformInteractionMode,
12
12
  WaveformXDomainStrategy,
13
+ WaveformZoomIntentPayload,
13
14
  WaveformZoomEndPayload,
14
15
  WaveformZoomResetPayload,
15
16
  WaveformAnnotationStyle,
@@ -7,6 +7,7 @@ export type {
7
7
  WaveformDisplayMode,
8
8
  WaveformOverlayMode,
9
9
  WaveformInteractionMode,
10
+ WaveformZoomIntentPayload,
10
11
  WaveformZoomEndPayload,
11
12
  WaveformAnnotationStyle,
12
13
  WaveformAnnotation,
@@ -10,6 +10,7 @@ import { tryReleasePointerCapture } from './pointerCapture'
10
10
  import { transitionViewportInteraction } from './viewportInteractionState'
11
11
  import { createViewportDomainSetter } from './viewportDomain'
12
12
  import { constrainZoomDomain, transformForDomain } from './zoomConstraints'
13
+ import { emitBoxZoomIntent } from './zoomEventPayload'
13
14
  interface ViewportContext {
14
15
  props: ResolvedWaveformChartProps
15
16
  emit: WaveformChartEmit
@@ -254,6 +255,7 @@ export function useWaveformViewport(context: ViewportContext) {
254
255
  } else {
255
256
  sharedTransform.value = transformForDomain(xDomain, baseXDomain, innerWidth.value)
256
257
  }
258
+ emitBoxZoomIntent(emit, xDomain, track, active.independent)
257
259
  const targets = active.independent
258
260
  ? [track]
259
261
  : trackLayouts.value.filter((target) => target.hasVisibleSeries)
@@ -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
+ }
@@ -104,6 +104,68 @@ describe('WaveformChart track order', () => {
104
104
  expect(wrapper.findAll('.waveform-chart__crosshair line')).toHaveLength(2)
105
105
  })
106
106
 
107
+ it('keeps incremental watermarks stable when empty ordered tracks are hidden', async () => {
108
+ const wrapper = await mountSizedChart(mergedFourTrackSeries(), {
109
+ frameNumber: 1,
110
+ grid: {
111
+ rowCount: 2,
112
+ columnCount: 1,
113
+ hideEmptyTracks: true,
114
+ trackOrder: ['frame-1', 'frame-2', 'frame-3', 'frame-4'],
115
+ },
116
+ })
117
+
118
+ expect(
119
+ wrapper.findAll('.waveform-chart__track').map((track) => track.attributes('data-track-id')),
120
+ ).toEqual(['frame-1', 'frame-3'])
121
+ expect(
122
+ wrapper.findAll('.waveform-chart__watermark').map((watermark) => watermark.text()),
123
+ ).toEqual(['1', '3'])
124
+
125
+ await wrapper.get('.ant-pagination-next button').trigger('click')
126
+
127
+ expect(
128
+ wrapper.findAll('.waveform-chart__track').map((track) => track.attributes('data-track-id')),
129
+ ).toEqual(['frame-4'])
130
+ expect(
131
+ wrapper.findAll('.waveform-chart__watermark').map((watermark) => watermark.text()),
132
+ ).toEqual(['4'])
133
+ })
134
+
135
+ it('uses stable track IDs for explicit frame watermark overrides', async () => {
136
+ const wrapper = await mountSizedChart(mergedFourTrackSeries(), {
137
+ frameNumber: 1,
138
+ frameNumbers: { 'frame-1': 'A', 'frame-3': 'C', 'frame-4': 'D' },
139
+ grid: {
140
+ rowCount: 2,
141
+ columnCount: 1,
142
+ hideEmptyTracks: true,
143
+ trackOrder: ['frame-1', 'frame-2', 'frame-3', 'frame-4'],
144
+ },
145
+ })
146
+
147
+ expect(
148
+ wrapper.findAll('.waveform-chart__watermark').map((watermark) => watermark.text()),
149
+ ).toEqual(['A', 'C'])
150
+
151
+ await wrapper.get('.ant-pagination-next button').trigger('click')
152
+
153
+ expect(
154
+ wrapper.findAll('.waveform-chart__watermark').map((watermark) => watermark.text()),
155
+ ).toEqual(['D'])
156
+ })
157
+
158
+ it('keeps the existing incremental watermark fallback when frame mappings are absent', async () => {
159
+ const wrapper = await mountSizedChart(orderedSeries(['frame-1', 'frame-2']), {
160
+ frameNumber: 7,
161
+ grid: { rowCount: 2, columnCount: 1 },
162
+ })
163
+
164
+ expect(
165
+ wrapper.findAll('.waveform-chart__watermark').map((watermark) => watermark.text()),
166
+ ).toEqual(['7', '8'])
167
+ })
168
+
107
169
  it('appends data tracks absent from trackOrder in input order', async () => {
108
170
  const wrapper = await mountSizedChart(
109
171
  {