waveform-analysis 0.1.46 → 0.1.48

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.
@@ -3,11 +3,8 @@ import { computed, nextTick, shallowRef, type ComputedRef, type Ref, type Shallo
3
3
  import { MINIMUM_SELECTION_SIZE } from '../core/constants'
4
4
  import type { DisplayTrack, TrackLayout } from '../core/types'
5
5
  import { hasFixedYDomainForTrack } from '../core/yDomain'
6
- import type {
7
- ResolvedWaveformChartProps,
8
- ViewportSelectionState,
9
- WaveformChartEmit,
10
- } from '../core/waveformChartTypes'
6
+ import type { ResolvedWaveformChartProps, ViewportSelectionState } from '../core/waveformChartTypes'
7
+ import type { WaveformChartEmit } from '../core/waveformChartTypes'
11
8
  import type { AnnotationSeriesCandidate } from '../annotation'
12
9
  import { tryReleasePointerCapture } from './pointerCapture'
13
10
  import { transitionViewportInteraction } from './viewportInteractionState'
@@ -104,10 +101,12 @@ export function useWaveformViewport(context: ViewportContext) {
104
101
  }
105
102
  const currentYDomains = (): Record<string, [number, number]> =>
106
103
  Object.fromEntries(
107
- trackLayouts.value.map((track) => [
108
- track.series.trackId ?? track.series.id,
109
- track.yScale.domain() as [number, number],
110
- ]),
104
+ trackLayouts.value
105
+ .filter((track) => track.hasVisibleSeries)
106
+ .map((track) => [
107
+ track.series?.trackId ?? track.series?.id ?? track.id,
108
+ track.yScale.domain() as [number, number],
109
+ ]),
111
110
  )
112
111
  const beginViewportDrag = (event: PointerEvent, trackIndex: number, independent: boolean) => {
113
112
  if (isPresentationMode.value) return
@@ -142,8 +141,10 @@ export function useWaveformViewport(context: ViewportContext) {
142
141
  const beginSharedViewportDrag = (event: PointerEvent) => {
143
142
  if (!sharedOverlayElement.value) return
144
143
  const [x, y] = pointer(event, sharedOverlayElement.value)
144
+ const pointedTrack = resolveTrackAtPointer(x, y)
145
145
  const track =
146
- resolveTrackAtPointer(x, y) ?? trackLayouts.value.find((item) => item.hasVisibleSeries)
146
+ (pointedTrack?.hasVisibleSeries ? pointedTrack : undefined) ??
147
+ trackLayouts.value.find((item) => item.hasVisibleSeries)
147
148
  if (track) beginViewportDrag(event, track.index, false)
148
149
  }
149
150
  const applyPan = (active: ViewportSelectionState, track: TrackLayout) => {
@@ -166,7 +167,9 @@ export function useWaveformViewport(context: ViewportContext) {
166
167
  } else {
167
168
  sharedTransform.value = transformForDomain(nextX, sourceXDomain, innerWidth.value)
168
169
  }
169
- const targets = active.independent ? [track] : trackLayouts.value
170
+ const targets = active.independent
171
+ ? [track]
172
+ : trackLayouts.value.filter((target) => target.hasVisibleSeries)
170
173
  const nextIndependentDomains = { ...independentYDomains.value }
171
174
  const nextSharedDomains = { ...sharedYDomains.value }
172
175
  targets.forEach((target) => {
@@ -174,7 +177,7 @@ export function useWaveformViewport(context: ViewportContext) {
174
177
  if (chartTrack && hasFixedYDomainForTrack(chartTrack, props.yDomain, props.yDomains)) {
175
178
  return
176
179
  }
177
- const key = target.series.trackId ?? target.series.id
180
+ const key = target.series?.trackId ?? target.series?.id ?? target.id
178
181
  const source = active.yDomains[key] ?? (target.yScale.domain() as [number, number])
179
182
  const boundary = chartTrack?.yDomain ?? source
180
183
  const ySpan = source[1] - source[0]
@@ -251,16 +254,18 @@ export function useWaveformViewport(context: ViewportContext) {
251
254
  } else {
252
255
  sharedTransform.value = transformForDomain(xDomain, baseXDomain, innerWidth.value)
253
256
  }
254
- const targets = active.independent ? [track] : trackLayouts.value
257
+ const targets = active.independent
258
+ ? [track]
259
+ : trackLayouts.value.filter((target) => target.hasVisibleSeries)
255
260
  const yRanges = Object.fromEntries(
256
261
  targets.map((target) => [
257
- target.series.trackId ?? target.series.id,
262
+ target.series?.trackId ?? target.series?.id ?? target.id,
258
263
  target.yScale.domain() as [number, number],
259
264
  ]),
260
265
  )
261
266
  emit('zoom-change', xDomain)
262
267
  if (active.independent) {
263
- const yDomain = yRanges[track.series.trackId ?? track.series.id]
268
+ const yDomain = yRanges[track.series?.trackId ?? track.series?.id ?? track.id]
264
269
  emit('zoom-end', {
265
270
  start: xDomain[0],
266
271
  end: xDomain[1],
@@ -177,7 +177,7 @@ export function useWaveformZoom(context: ZoomContext) {
177
177
  end: domain[1],
178
178
  yRanges: Object.fromEntries(
179
179
  visibleTracks.map((track) => [
180
- track.series.trackId ?? track.series.id,
180
+ track.series?.trackId ?? track.series?.id ?? track.id,
181
181
  track.yScale.domain() as [number, number],
182
182
  ]),
183
183
  ),
@@ -67,6 +67,7 @@ const resolvedFrameStyle = computed(() => {
67
67
  class="waveform-track waveform-chart__track"
68
68
  :class="{ 'waveform-track--empty waveform-chart__track--empty': track.isEmpty }"
69
69
  :data-track-index="track.index"
70
+ :data-track-id="track.id"
70
71
  :data-track-empty="track.isEmpty || undefined"
71
72
  :data-track-left="track.left"
72
73
  :data-track-width="track.width"
@@ -164,6 +164,7 @@ watch(
164
164
  track.hasVisibleSeries &&
165
165
  track.seriesList.length === 1 &&
166
166
  track.showYAxisLabel &&
167
+ track.series &&
167
168
  resolveYAxisLabel(track.series) &&
168
169
  shouldShowYAxisLabel(track.height, track.index)
169
170
  "
@@ -178,12 +179,12 @@ watch(
178
179
  />
179
180
  <text
180
181
  class="waveform-track__y-axis-label waveform-chart__y-axis-label"
181
- :fill="track.series.color"
182
+ :fill="track.series?.color"
182
183
  :transform="`translate(${track.yAxisLabelX}, ${track.height / 2}) rotate(-90)`"
183
184
  text-anchor="middle"
184
185
  dominant-baseline="central"
185
186
  >
186
- {{ resolveYAxisLabel(track.series) }}
187
+ {{ track.series ? resolveYAxisLabel(track.series) : '' }}
187
188
  </text>
188
189
  </g>
189
190
 
@@ -144,6 +144,55 @@ describe('WaveformChart', () => {
144
144
  expect(wrapper.get('.ant-pagination-next').classes()).toContain('ant-pagination-disabled')
145
145
  })
146
146
 
147
+ it('keeps ordered empty tracks in their page slots after channels are merged', async () => {
148
+ const wrapper = await mountSizedChart(
149
+ {
150
+ kind: 'series',
151
+ series: ['frame-1', 'frame-1', 'frame-3', 'frame-4', 'frame-5', 'frame-6'].map(
152
+ (trackId, index) => ({
153
+ id: `channel-${index + 1}`,
154
+ trackId,
155
+ name: `channel-${index + 1}`,
156
+ data: {
157
+ kind: 'points' as const,
158
+ points: [
159
+ { x: 0, y: index },
160
+ { x: 1, y: index + 1 },
161
+ ],
162
+ },
163
+ }),
164
+ ),
165
+ },
166
+ {
167
+ grid: {
168
+ rowCount: 2,
169
+ columnCount: 1,
170
+ trackOrder: ['frame-1', 'frame-2', 'frame-3', 'frame-4', 'frame-5', 'frame-6'],
171
+ },
172
+ },
173
+ )
174
+ const trackIds = () =>
175
+ wrapper.findAll('.waveform-chart__track').map((track) => track.attributes('data-track-id'))
176
+ const lineIds = () =>
177
+ wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id'))
178
+ const nextButton = () => wrapper.get('.ant-pagination-next button')
179
+
180
+ expect(trackIds()).toEqual(['frame-1', 'frame-2'])
181
+ expect(wrapper.findAll('.waveform-chart__track--empty')).toHaveLength(1)
182
+ expect(lineIds()).toEqual(['channel-1', 'channel-2'])
183
+ expect(wrapper.findAll('.waveform-chart__legend')).toHaveLength(1)
184
+
185
+ await nextButton().trigger('click')
186
+ expect(trackIds()).toEqual(['frame-3', 'frame-4'])
187
+ expect(lineIds()).toEqual(['channel-3', 'channel-4'])
188
+ expect(wrapper.emitted('page-change')?.at(-1)).toEqual([2, 3])
189
+
190
+ await nextButton().trigger('click')
191
+ expect(trackIds()).toEqual(['frame-5', 'frame-6'])
192
+ expect(lineIds()).toEqual(['channel-5', 'channel-6'])
193
+ expect(wrapper.emitted('page-change')?.at(-1)).toEqual([3, 3])
194
+ })
195
+
147
196
  it('overlays series with the same track ID without changing the next frame', async () => {
148
197
  const wrapper = await mountSizedChart(
149
198
  {
@@ -0,0 +1,261 @@
1
+ import { flushPromises } 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
+ describe('WaveformChart track order', () => {
8
+ const points = (offset: number) => ({
9
+ kind: 'points' as const,
10
+ points: [
11
+ { x: 0, y: offset },
12
+ { x: 1, y: offset + 1 },
13
+ ],
14
+ })
15
+ const orderedSeries = (trackIds: string[]) => ({
16
+ kind: 'series' as const,
17
+ series: trackIds.map((trackId, index) => ({
18
+ id: trackId,
19
+ trackId,
20
+ name: trackId,
21
+ data: points(index + 1),
22
+ })),
23
+ })
24
+ const mergedFourTrackSeries = () => ({
25
+ kind: 'series' as const,
26
+ series: [
27
+ { id: 'frame-1-a', trackId: 'frame-1', name: 'frame-1-a', data: points(1) },
28
+ { id: 'frame-1-b', trackId: 'frame-1', name: 'frame-1-b', data: points(2) },
29
+ { id: 'frame-3', trackId: 'frame-3', name: 'frame-3', data: points(3) },
30
+ { id: 'frame-4', trackId: 'frame-4', name: 'frame-4', data: points(4) },
31
+ ],
32
+ })
33
+
34
+ it('keeps a merged source slot empty without adding legend or tooltip data', async () => {
35
+ const wrapper = await mountSizedChart(
36
+ {
37
+ kind: 'series',
38
+ series: [
39
+ {
40
+ id: 'frame-1-a',
41
+ trackId: 'frame-1',
42
+ name: '合并前通道',
43
+ data: {
44
+ kind: 'points',
45
+ points: [
46
+ { x: 0, y: 1 },
47
+ { x: 1, y: 2 },
48
+ ],
49
+ },
50
+ },
51
+ {
52
+ id: 'frame-1-b',
53
+ trackId: 'frame-1',
54
+ name: '合并后通道',
55
+ data: {
56
+ kind: 'points',
57
+ points: [
58
+ { x: 0, y: 3 },
59
+ { x: 1, y: 4 },
60
+ ],
61
+ },
62
+ },
63
+ {
64
+ id: 'frame-3',
65
+ trackId: 'frame-3',
66
+ name: '后续通道',
67
+ data: {
68
+ kind: 'points',
69
+ points: [
70
+ { x: 0, y: 5 },
71
+ { x: 1, y: 6 },
72
+ ],
73
+ },
74
+ },
75
+ ],
76
+ },
77
+ {
78
+ displayMode: 'separated',
79
+ grid: { rowCount: 3, trackOrder: ['frame-1', 'frame-2', 'frame-3'] },
80
+ },
81
+ )
82
+ const trackIds = () =>
83
+ wrapper.findAll('.waveform-chart__track').map((track) => track.attributes('data-track-id'))
84
+
85
+ expect(trackIds()).toEqual(['frame-1', 'frame-2', 'frame-3'])
86
+ expect(wrapper.findAll('.waveform-chart__track--empty')).toHaveLength(1)
87
+ expect(
88
+ wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
89
+ ).toEqual(['frame-1-a', 'frame-1-b', 'frame-3'])
90
+ expect(wrapper.findAll('.waveform-chart__legend')).toHaveLength(1)
91
+
92
+ const overlay = wrapper.get('.waveform-chart__overlay--shared')
93
+ Object.defineProperty(overlay.element, 'getBoundingClientRect', {
94
+ value: () => ({ left: 0, top: 0, width: 712, height: 290 }),
95
+ })
96
+ overlay.element.dispatchEvent(
97
+ new MouseEvent('pointermove', { clientX: 356, clientY: 150, bubbles: true }),
98
+ )
99
+ flushAnimationFrames()
100
+ await flushPromises()
101
+
102
+ expect(wrapper.findAll('.waveform-chart__tooltip-series')).toHaveLength(3)
103
+ expect(wrapper.get('.waveform-chart__tooltip').text()).not.toContain('frame-2')
104
+ expect(wrapper.findAll('.waveform-chart__crosshair line')).toHaveLength(2)
105
+ })
106
+
107
+ it('appends data tracks absent from trackOrder in input order', async () => {
108
+ const wrapper = await mountSizedChart(
109
+ {
110
+ kind: 'series',
111
+ series: [
112
+ {
113
+ id: 'later-a',
114
+ trackId: 'later-a',
115
+ name: 'later-a',
116
+ data: {
117
+ kind: 'points',
118
+ points: [
119
+ { x: 0, y: 1 },
120
+ { x: 1, y: 2 },
121
+ ],
122
+ },
123
+ },
124
+ {
125
+ id: 'frame-1',
126
+ trackId: 'frame-1',
127
+ name: 'frame-1',
128
+ data: {
129
+ kind: 'points',
130
+ points: [
131
+ { x: 0, y: 3 },
132
+ { x: 1, y: 4 },
133
+ ],
134
+ },
135
+ },
136
+ {
137
+ id: 'later-b',
138
+ trackId: 'later-b',
139
+ name: 'later-b',
140
+ data: {
141
+ kind: 'points',
142
+ points: [
143
+ { x: 0, y: 5 },
144
+ { x: 1, y: 6 },
145
+ ],
146
+ },
147
+ },
148
+ ],
149
+ },
150
+ { grid: { rowCount: 2, columnCount: 2, trackOrder: ['frame-2', 'frame-1'] } },
151
+ )
152
+
153
+ expect(
154
+ wrapper.findAll('.waveform-chart__track').map((track) => track.attributes('data-track-id')),
155
+ ).toEqual(['frame-2', 'frame-1', 'later-a', 'later-b'])
156
+ })
157
+
158
+ it.each(['independent', 'separated', 'compact'] as const)(
159
+ 'reclaims merged empty slots in %s mode when incomplete rows fill',
160
+ async (displayMode) => {
161
+ const wrapper = await mountSizedChart(mergedFourTrackSeries(), {
162
+ displayMode,
163
+ grid: {
164
+ rowCount: 4,
165
+ columnCount: 1,
166
+ fillIncompleteLastRow: true,
167
+ trackOrder: ['frame-1', 'frame-2', 'frame-3', 'frame-4'],
168
+ },
169
+ })
170
+ const tracks = wrapper.findAll('.waveform-chart__track')
171
+ const heights = tracks.map((track) => Number(track.attributes('data-track-height')))
172
+
173
+ expect(tracks.map((track) => track.attributes('data-track-id'))).toEqual([
174
+ 'frame-1',
175
+ 'frame-3',
176
+ 'frame-4',
177
+ ])
178
+ expect(wrapper.findAll('.waveform-chart__track--empty')).toHaveLength(0)
179
+ expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(4)
180
+ expect(heights[0]).toBeCloseTo(heights[1] ?? Number.NaN)
181
+ expect(heights[1]).toBeCloseTo(heights[2] ?? Number.NaN)
182
+ expect(wrapper.findAll('.waveform-chart__legend')).toHaveLength(1)
183
+ expect(wrapper.get('.waveform-chart__legend').text()).not.toContain('frame-2')
184
+ },
185
+ )
186
+
187
+ it('reclaims only the current page without pulling tracks from the next page', async () => {
188
+ const wrapper = await mountSizedChart(
189
+ orderedSeries(['frame-1', 'frame-3', 'frame-4', 'frame-5', 'frame-6', 'frame-7', 'frame-8']),
190
+ {
191
+ grid: {
192
+ rowCount: 4,
193
+ columnCount: 1,
194
+ fillIncompleteLastRow: true,
195
+ trackOrder: [
196
+ 'frame-1',
197
+ 'frame-2',
198
+ 'frame-3',
199
+ 'frame-4',
200
+ 'frame-5',
201
+ 'frame-6',
202
+ 'frame-7',
203
+ 'frame-8',
204
+ ],
205
+ },
206
+ },
207
+ )
208
+ const trackIds = () =>
209
+ wrapper.findAll('.waveform-chart__track').map((track) => track.attributes('data-track-id'))
210
+
211
+ expect(trackIds()).toEqual(['frame-1', 'frame-3', 'frame-4'])
212
+ await wrapper.get('.ant-pagination-next button').trigger('click')
213
+ expect(trackIds()).toEqual(['frame-5', 'frame-6', 'frame-7', 'frame-8'])
214
+ expect(wrapper.emitted('page-change')?.at(-1)).toEqual([2, 2])
215
+ })
216
+
217
+ it('retains ordered empty slots when incomplete rows do not fill', async () => {
218
+ const wrapper = await mountSizedChart(orderedSeries(['frame-1', 'frame-3', 'frame-4']), {
219
+ grid: {
220
+ rowCount: 4,
221
+ columnCount: 1,
222
+ fillIncompleteLastRow: false,
223
+ trackOrder: ['frame-1', 'frame-2', 'frame-3', 'frame-4'],
224
+ },
225
+ })
226
+
227
+ expect(
228
+ wrapper.findAll('.waveform-chart__track').map((track) => track.attributes('data-track-id')),
229
+ ).toEqual(['frame-1', 'frame-2', 'frame-3', 'frame-4'])
230
+ expect(wrapper.findAll('.waveform-chart__track--empty')).toHaveLength(1)
231
+ })
232
+
233
+ it('keeps an entirely empty page addressable when incomplete rows fill', async () => {
234
+ const wrapper = await mountSizedChart(
235
+ orderedSeries(['frame-1', 'frame-2', 'frame-3', 'frame-4']),
236
+ {
237
+ grid: {
238
+ rowCount: 4,
239
+ columnCount: 1,
240
+ fillIncompleteLastRow: true,
241
+ trackOrder: [
242
+ 'frame-1',
243
+ 'frame-2',
244
+ 'frame-3',
245
+ 'frame-4',
246
+ 'frame-5',
247
+ 'frame-6',
248
+ 'frame-7',
249
+ 'frame-8',
250
+ ],
251
+ },
252
+ },
253
+ )
254
+
255
+ await wrapper.get('.ant-pagination-next button').trigger('click')
256
+
257
+ expect(wrapper.findAll('.waveform-chart__track--empty')).toHaveLength(4)
258
+ expect(wrapper.emitted('page-change')?.at(-1)).toEqual([2, 2])
259
+ expect(wrapper.get('.ant-pagination-item-active').text()).toBe('2')
260
+ })
261
+ })
@@ -82,5 +82,5 @@ describe('X-axis label demo controls', () => {
82
82
  expect(chart.props('axes')?.x?.labelFormatter).toBeUndefined()
83
83
  expect(wrapper.find('[aria-label="X 轴 Label 运算倍率"]').exists()).toBe(false)
84
84
  wrapper.unmount()
85
- })
85
+ }, 10_000)
86
86
  })
@@ -176,6 +176,8 @@ export interface WaveformAxesOptions {
176
176
  lineVisible?: boolean
177
177
  /** Target total number of equal Y-axis ticks, including endpoints. Defaults to 5. */
178
178
  splitNumber?: number
179
+ /** Expands Y-axis domains to equal, human-friendly intervals. Defaults to true. */
180
+ nice?: boolean
179
181
  }
180
182
  }
181
183