waveform-analysis 0.1.43 → 0.1.47

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 (42) hide show
  1. package/README.md +37 -30
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.js +456 -461
  4. package/dist/style.css +1 -1
  5. package/dist/types/components/core/constants.d.ts +2 -2
  6. package/dist/types/components/core/grid.d.ts +3 -0
  7. package/dist/types/components/core/types.d.ts +4 -2
  8. package/dist/types/components/core/yAxisConstants.d.ts +2 -2
  9. package/dist/types/components/interaction/WaveformTooltip.vue.d.ts +1 -1
  10. package/dist/types/components/interaction/useWaveformViewport.d.ts +2 -1
  11. package/package.json +1 -1
  12. package/src/App.test.ts +2 -7
  13. package/src/components/WaveformChart.css +1 -1
  14. package/src/components/WaveformChartView.vue +2 -2
  15. package/src/components/core/constants.ts +3 -3
  16. package/src/components/core/grid.test.ts +13 -1
  17. package/src/components/core/grid.ts +14 -4
  18. package/src/components/core/layout.test.ts +3 -3
  19. package/src/components/core/layout.ts +2 -2
  20. package/src/components/core/trackLayoutBuilder.ts +14 -23
  21. package/src/components/core/types.ts +4 -2
  22. package/src/components/core/useWaveformChartController.ts +1 -0
  23. package/src/components/core/useWaveformLayout.ts +12 -6
  24. package/src/components/core/yAxisConstants.ts +2 -2
  25. package/src/components/interaction/WaveformHoverHost.vue +3 -1
  26. package/src/components/interaction/WaveformTooltip.test.ts +23 -0
  27. package/src/components/interaction/WaveformTooltip.vue +9 -6
  28. package/src/components/interaction/useWaveformHover.ts +21 -15
  29. package/src/components/interaction/useWaveformViewport.ts +20 -15
  30. package/src/components/interaction/useWaveformZoom.ts +1 -1
  31. package/src/components/rendering/WaveformHoverLayer.vue +5 -11
  32. package/src/components/rendering/WaveformTrack.vue +1 -0
  33. package/src/components/rendering/WaveformTrackAxes.vue +8 -6
  34. package/src/components/waveformChartCases/dataLifecycle.test.ts +1 -1
  35. package/src/components/waveformChartCases/frameGridAndFormatting.test.ts +10 -0
  36. package/src/components/waveformChartCases/incompleteDataHover.test.ts +67 -0
  37. package/src/components/waveformChartCases/layoutAndDomains.test.ts +5 -5
  38. package/src/components/waveformChartCases/legendVisibility.test.ts +1 -1
  39. package/src/components/waveformChartCases/multiAxisAndPagination.test.ts +50 -1
  40. package/src/components/waveformChartCases/trackOrder.test.ts +131 -0
  41. package/src/data/simulatedWaveforms.test.ts +7 -2
  42. package/src/data/simulatedWaveforms.ts +12 -7
@@ -62,18 +62,21 @@ export function useWaveformHover(context: HoverContext) {
62
62
  const commitHover = (
63
63
  nextPoints: HoveredSeriesPoint[],
64
64
  trackIndex: number | null,
65
+ queryX: number,
65
66
  position: { x: number; y: number },
66
67
  ) => {
67
68
  if (isPresentationMode.value) return
68
69
  if (!hoveredPointsMatch(nextPoints)) hoverState.points = nextPoints
69
70
  hoverState.trackIndex = trackIndex
71
+ hoverState.queryX = queryX
70
72
  hoverState.position = position
71
- emit('point-hover', hoverState.points[0]?.point ?? null)
73
+ emit('point-hover', hoverState.points.find((point) => point.point)?.point ?? null)
72
74
  }
73
75
  const clearHover = () => {
74
76
  cancelPendingHover()
75
77
  hoverState.points = []
76
78
  hoverState.trackIndex = null
79
+ hoverState.queryX = null
77
80
  emit('point-hover', null)
78
81
  }
79
82
  const handlePointerLeave = () => {
@@ -82,7 +85,7 @@ export function useWaveformHover(context: HoverContext) {
82
85
  const createHoveredSeriesPoint = (
83
86
  series: DisplaySeries,
84
87
  trackIndex: number,
85
- point: WaveformPoint,
88
+ point: WaveformPoint | null,
86
89
  ): HoveredSeriesPoint => ({
87
90
  id: series.id,
88
91
  shotNo: series.shotNo,
@@ -106,8 +109,12 @@ export function useWaveformHover(context: HoverContext) {
106
109
  clearHover()
107
110
  return true
108
111
  }
109
- const nearestPoint = (series: DisplaySeries, xValue: number): WaveformPoint | undefined =>
110
- series.points[pointBisector.center(series.points, xValue)]
112
+ const nearestPoint = (series: DisplaySeries, xValue: number): WaveformPoint | undefined => {
113
+ const firstPoint = series.points[0]
114
+ const lastPoint = series.points.at(-1)
115
+ if (!firstPoint || !lastPoint || xValue < firstPoint.x || xValue > lastPoint.x) return undefined
116
+ return series.points[pointBisector.center(series.points, xValue)]
117
+ }
111
118
 
112
119
  const handleIndependentPointerMove = (event: PointerEvent, trackIndex: number) => {
113
120
  if (isPresentationMode.value) return
@@ -125,11 +132,10 @@ export function useWaveformHover(context: HoverContext) {
125
132
  const currentTrack = trackLayouts.value[trackIndex]
126
133
  if (!currentTrack || !currentTrack.hasVisibleSeries || currentTrack !== track) return
127
134
  const xValue = track.xScale.invert(Math.max(0, Math.min(innerWidth.value, pointerX)))
128
- const nextPoints = track.seriesList.flatMap((series) => {
129
- const point = nearestPoint(series, xValue)
130
- return point ? [createHoveredSeriesPoint(series, trackIndex, point)] : []
131
- })
132
- commitHover(nextPoints, trackIndex, {
135
+ const nextPoints = track.seriesList.map((series) =>
136
+ createHoveredSeriesPoint(series, trackIndex, nearestPoint(series, xValue) ?? null),
137
+ )
138
+ commitHover(nextPoints, trackIndex, xValue, {
133
139
  x: resolvedChartLeftMargin.value + track.left + pointerX,
134
140
  y: titleAreaHeight.value + chartTopMargin.value + track.top + pointerY,
135
141
  })
@@ -147,7 +153,8 @@ export function useWaveformHover(context: HoverContext) {
147
153
  scheduleHover(() => {
148
154
  const resolvedTrack = resolveTrackAtPointer(pointerX, pointerY)
149
155
  const referenceTrack =
150
- resolvedTrack ?? trackLayouts.value.find((track) => track.hasVisibleSeries)
156
+ (resolvedTrack?.hasVisibleSeries ? resolvedTrack : undefined) ??
157
+ trackLayouts.value.find((track) => track.hasVisibleSeries)
151
158
  if (!referenceTrack) return
152
159
  const localPointerX = Math.max(
153
160
  0,
@@ -155,12 +162,11 @@ export function useWaveformHover(context: HoverContext) {
155
162
  )
156
163
  const xValue = referenceTrack.xScale.invert(localPointerX)
157
164
  const nextPoints = trackLayouts.value.flatMap((track) =>
158
- track.seriesList.flatMap((series) => {
159
- const point = nearestPoint(series, xValue)
160
- return point ? [createHoveredSeriesPoint(series, track.index, point)] : []
161
- }),
165
+ track.seriesList.map((series) =>
166
+ createHoveredSeriesPoint(series, track.index, nearestPoint(series, xValue) ?? null),
167
+ ),
162
168
  )
163
- commitHover(nextPoints, null, {
169
+ commitHover(nextPoints, null, xValue, {
164
170
  x: resolvedChartLeftMargin.value + pointerX,
165
171
  y: titleAreaHeight.value + chartTopMargin.value + pointerY,
166
172
  })
@@ -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
  ),
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue'
3
3
 
4
- import type { HoveredSeriesPoint, TrackLayout, WaveformHoverState } from '../core/types'
4
+ import type { TrackLayout, WaveformHoverState } from '../core/types'
5
5
 
6
6
  const props = defineProps<{
7
7
  state: WaveformHoverState
@@ -11,23 +11,17 @@ const props = defineProps<{
11
11
  }>()
12
12
 
13
13
  interface Crosshair {
14
- point: HoveredSeriesPoint
15
14
  track: TrackLayout
16
15
  x: number
17
16
  }
18
17
 
19
18
  const crosshairs = computed<Crosshair[]>(() => {
20
- if (!props.visible) return []
21
-
22
- const pointByTrack = new Map<number, HoveredSeriesPoint>()
23
- props.state.points.forEach((point) => {
24
- if (!pointByTrack.has(point.trackIndex)) pointByTrack.set(point.trackIndex, point)
25
- })
19
+ if (!props.visible || props.state.queryX === null) return []
26
20
 
27
21
  return props.tracks.flatMap((track) => {
28
- const point = pointByTrack.get(track.index)
29
- return point && !track.isEmpty && track.hasVisibleSeries
30
- ? [{ point, track, x: track.xScale(point.point.x) }]
22
+ const trackIsActive = props.state.trackIndex === null || props.state.trackIndex === track.index
23
+ return trackIsActive && !track.isEmpty && track.hasVisibleSeries
24
+ ? [{ track, x: track.xScale(props.state.queryX!) }]
31
25
  : []
32
26
  })
33
27
  })
@@ -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"
@@ -5,6 +5,7 @@ import { nextTick, onMounted, ref, watch } from 'vue'
5
5
  import type { WaveformAxesOptions } from '../../types'
6
6
  import { formatScientificAxisLabel, formatXAxisLabel } from '../../utils'
7
7
  import type { DisplaySeries, TrackLayout, WaveformYAxisLayout } from '../core/types'
8
+ import { Y_AXIS_LABEL_BAND_WIDTH } from '../core/yAxisConstants'
8
9
 
9
10
  interface Props {
10
11
  track: TrackLayout
@@ -163,26 +164,27 @@ watch(
163
164
  track.hasVisibleSeries &&
164
165
  track.seriesList.length === 1 &&
165
166
  track.showYAxisLabel &&
167
+ track.series &&
166
168
  resolveYAxisLabel(track.series) &&
167
169
  shouldShowYAxisLabel(track.height, track.index)
168
170
  "
169
171
  >
170
172
  <rect
171
173
  class="waveform-track__y-axis-label-bg waveform-chart__y-axis-label-bg"
172
- :x="track.yAxisLabelX - 12"
174
+ :x="track.yAxisLabelX - Y_AXIS_LABEL_BAND_WIDTH / 2"
173
175
  :y="track.height / 2 - 40"
174
- width="24"
176
+ :width="Y_AXIS_LABEL_BAND_WIDTH"
175
177
  height="80"
176
178
  rx="2"
177
179
  />
178
180
  <text
179
181
  class="waveform-track__y-axis-label waveform-chart__y-axis-label"
180
- :fill="track.series.color"
182
+ :fill="track.series?.color"
181
183
  :transform="`translate(${track.yAxisLabelX}, ${track.height / 2}) rotate(-90)`"
182
184
  text-anchor="middle"
183
185
  dominant-baseline="central"
184
186
  >
185
- {{ resolveYAxisLabel(track.series) }}
187
+ {{ track.series ? resolveYAxisLabel(track.series) : '' }}
186
188
  </text>
187
189
  </g>
188
190
 
@@ -194,9 +196,9 @@ watch(
194
196
  >
195
197
  <rect
196
198
  class="waveform-track__y-axis-label-bg waveform-chart__y-axis-label-bg"
197
- :x="axis.labelX - 12"
199
+ :x="axis.labelX - Y_AXIS_LABEL_BAND_WIDTH / 2"
198
200
  :y="track.height / 2 - 40"
199
- width="24"
201
+ :width="Y_AXIS_LABEL_BAND_WIDTH"
200
202
  height="80"
201
203
  rx="2"
202
204
  />
@@ -170,7 +170,7 @@ describe('WaveformChart', () => {
170
170
  )
171
171
  expect(labelX).toBeLessThan(-46)
172
172
  expect(Number(wrapper.get('.waveform-chart__y-axis-label-bg').attributes('x'))).toBe(
173
- labelX - 12,
173
+ labelX - 6,
174
174
  )
175
175
  expect(yAxisLabels.every((label) => !label.text().includes('(T)'))).toBe(true)
176
176
  expect(wrapper.findAll('.waveform-chart__track-label')).toHaveLength(0)
@@ -22,6 +22,16 @@ describe('WaveformChart', () => {
22
22
 
23
23
  expect(wrapper.findAll('.waveform-chart__grid--major line').length).toBeGreaterThan(0)
24
24
  expect(wrapper.findAll('.waveform-chart__grid--minor line').length).toBeGreaterThan(0)
25
+ expect(
26
+ wrapper.findAll('.waveform-chart__grid--major [data-grid-direction="horizontal"]'),
27
+ ).toHaveLength(5)
28
+ expect(
29
+ wrapper.findAll('.waveform-chart__grid--minor [data-grid-direction="horizontal"]'),
30
+ ).toHaveLength(4)
31
+ expect(
32
+ wrapper.findAll('.waveform-chart__grid--minor [data-grid-direction="vertical"]'),
33
+ ).not.toHaveLength(0)
34
+ expect(getComputedStyle(wrapper.element).borderTopWidth).toBe('1.2px')
25
35
  expect(wrapper.find('.waveform-chart__plot-frame').exists()).toBe(true)
26
36
  expect(wrapper.get('.waveform-chart__plot-frame').attributes()).toMatchObject({
27
37
  fill: 'none',
@@ -0,0 +1,67 @@
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 incomplete data hover', () => {
8
+ it('keeps the shared crosshair at the query time and marks out-of-range series unavailable', async () => {
9
+ const wrapper = await mountSizedChart(
10
+ {
11
+ kind: 'series',
12
+ series: [
13
+ {
14
+ name: '完整数据',
15
+ data: {
16
+ kind: 'points',
17
+ points: [
18
+ { x: -1, y: 1 },
19
+ { x: -0.5, y: 2 },
20
+ { x: 1, y: 3 },
21
+ ],
22
+ },
23
+ },
24
+ {
25
+ name: '从零开始',
26
+ data: {
27
+ kind: 'points',
28
+ points: [
29
+ { x: 0, y: 10 },
30
+ { x: 1, y: 20 },
31
+ ],
32
+ },
33
+ },
34
+ ],
35
+ },
36
+ { displayMode: 'separated' },
37
+ )
38
+ const overlay = wrapper.get('.waveform-chart__overlay--shared')
39
+ const overlayWidth = Number(overlay.attributes('width'))
40
+ Object.defineProperty(overlay.element, 'getBoundingClientRect', {
41
+ value: () => ({ left: 0, top: 0, width: overlayWidth, height: 290 }),
42
+ })
43
+
44
+ overlay.element.dispatchEvent(
45
+ new MouseEvent('pointermove', {
46
+ clientX: overlayWidth / 4,
47
+ clientY: 120,
48
+ bubbles: true,
49
+ }),
50
+ )
51
+ flushAnimationFrames()
52
+ await flushPromises()
53
+
54
+ const crosshairPositions = wrapper
55
+ .findAll('.waveform-chart__crosshair line')
56
+ .map((line) => Number(line.attributes('x1')))
57
+ expect(crosshairPositions).toHaveLength(2)
58
+ expect(crosshairPositions[0]).toBeCloseTo(overlayWidth / 4)
59
+ expect(crosshairPositions[1]).toBeCloseTo(overlayWidth / 4)
60
+ const tooltipRows = wrapper.findAll('.waveform-chart__tooltip-series')
61
+ expect(tooltipRows).toHaveLength(2)
62
+ expect(tooltipRows[0].text()).toContain('(x:-500 y:2)')
63
+ expect(tooltipRows[1].text()).toContain('无数据')
64
+ expect(tooltipRows[1].text()).not.toContain('x:')
65
+ expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([{ x: -0.5, y: 2 }])
66
+ })
67
+ })
@@ -53,10 +53,10 @@ describe('WaveformChart', () => {
53
53
  tracks[0].get('.waveform-chart__y-axis-label-bg').attributes('x'),
54
54
  )
55
55
 
56
- expect(labelX).toBe(-74)
57
- expect(labelBackgroundX).toBe(labelX - 12)
58
- expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(90)
59
- expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(90)
56
+ expect(labelX).toBe(-62)
57
+ expect(labelBackgroundX).toBe(labelX - 6)
58
+ expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(80)
59
+ expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(72)
60
60
  })
61
61
 
62
62
  it('keeps a tick-only gutter when channel labels are empty', async () => {
@@ -82,7 +82,7 @@ describe('WaveformChart', () => {
82
82
  const secondLeft = Number(tracks[1].attributes('data-track-left'))
83
83
 
84
84
  expect(wrapper.findAll('.waveform-chart__y-axis-label')).toHaveLength(0)
85
- expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(64)
85
+ expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(80)
86
86
  expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(60)
87
87
  })
88
88
 
@@ -343,7 +343,7 @@ describe('WaveformChart', () => {
343
343
  const secondLeft = Number(tracks[1].attributes('data-track-left'))
344
344
  const labelX = Number(tracks[1].attributes('data-y-axis-label-x'))
345
345
 
346
- expect(secondLeft - (firstLeft + firstWidth)).toBeGreaterThanOrEqual(Math.abs(labelX) + 16)
346
+ expect(secondLeft - (firstLeft + firstWidth)).toBeGreaterThanOrEqual(Math.abs(labelX) + 6)
347
347
  expect(tracks[1].find('.waveform-chart__y-axis-label-bg').exists()).toBe(true)
348
348
  }
349
349
  })
@@ -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
  {
@@ -346,6 +395,6 @@ describe('WaveformChart', () => {
346
395
  'waveform-legend__panel--horizontal',
347
396
  )
348
397
 
349
- expect(wrapper.attributes('data-chart-left-margin')).toBe('64')
398
+ expect(wrapper.attributes('data-chart-left-margin')).toBe('80')
350
399
  })
351
400
  })
@@ -0,0 +1,131 @@
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
+ it('keeps a merged source slot empty without adding legend or tooltip data', async () => {
9
+ const wrapper = await mountSizedChart(
10
+ {
11
+ kind: 'series',
12
+ series: [
13
+ {
14
+ id: 'frame-1-a',
15
+ trackId: 'frame-1',
16
+ name: '合并前通道',
17
+ data: {
18
+ kind: 'points',
19
+ points: [
20
+ { x: 0, y: 1 },
21
+ { x: 1, y: 2 },
22
+ ],
23
+ },
24
+ },
25
+ {
26
+ id: 'frame-1-b',
27
+ trackId: 'frame-1',
28
+ name: '合并后通道',
29
+ data: {
30
+ kind: 'points',
31
+ points: [
32
+ { x: 0, y: 3 },
33
+ { x: 1, y: 4 },
34
+ ],
35
+ },
36
+ },
37
+ {
38
+ id: 'frame-3',
39
+ trackId: 'frame-3',
40
+ name: '后续通道',
41
+ data: {
42
+ kind: 'points',
43
+ points: [
44
+ { x: 0, y: 5 },
45
+ { x: 1, y: 6 },
46
+ ],
47
+ },
48
+ },
49
+ ],
50
+ },
51
+ {
52
+ displayMode: 'separated',
53
+ grid: { rowCount: 3, trackOrder: ['frame-1', 'frame-2', 'frame-3'] },
54
+ },
55
+ )
56
+ const trackIds = () =>
57
+ wrapper.findAll('.waveform-chart__track').map((track) => track.attributes('data-track-id'))
58
+
59
+ expect(trackIds()).toEqual(['frame-1', 'frame-2', 'frame-3'])
60
+ expect(wrapper.findAll('.waveform-chart__track--empty')).toHaveLength(1)
61
+ expect(
62
+ wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
63
+ ).toEqual(['frame-1-a', 'frame-1-b', 'frame-3'])
64
+ expect(wrapper.findAll('.waveform-chart__legend')).toHaveLength(1)
65
+
66
+ const overlay = wrapper.get('.waveform-chart__overlay--shared')
67
+ Object.defineProperty(overlay.element, 'getBoundingClientRect', {
68
+ value: () => ({ left: 0, top: 0, width: 712, height: 290 }),
69
+ })
70
+ overlay.element.dispatchEvent(
71
+ new MouseEvent('pointermove', { clientX: 356, clientY: 150, bubbles: true }),
72
+ )
73
+ flushAnimationFrames()
74
+ await flushPromises()
75
+
76
+ expect(wrapper.findAll('.waveform-chart__tooltip-series')).toHaveLength(3)
77
+ expect(wrapper.get('.waveform-chart__tooltip').text()).not.toContain('frame-2')
78
+ expect(wrapper.findAll('.waveform-chart__crosshair line')).toHaveLength(2)
79
+ })
80
+
81
+ it('appends data tracks absent from trackOrder in input order', async () => {
82
+ const wrapper = await mountSizedChart(
83
+ {
84
+ kind: 'series',
85
+ series: [
86
+ {
87
+ id: 'later-a',
88
+ trackId: 'later-a',
89
+ name: 'later-a',
90
+ data: {
91
+ kind: 'points',
92
+ points: [
93
+ { x: 0, y: 1 },
94
+ { x: 1, y: 2 },
95
+ ],
96
+ },
97
+ },
98
+ {
99
+ id: 'frame-1',
100
+ trackId: 'frame-1',
101
+ name: 'frame-1',
102
+ data: {
103
+ kind: 'points',
104
+ points: [
105
+ { x: 0, y: 3 },
106
+ { x: 1, y: 4 },
107
+ ],
108
+ },
109
+ },
110
+ {
111
+ id: 'later-b',
112
+ trackId: 'later-b',
113
+ name: 'later-b',
114
+ data: {
115
+ kind: 'points',
116
+ points: [
117
+ { x: 0, y: 5 },
118
+ { x: 1, y: 6 },
119
+ ],
120
+ },
121
+ },
122
+ ],
123
+ },
124
+ { grid: { rowCount: 2, columnCount: 2, trackOrder: ['frame-2', 'frame-1'] } },
125
+ )
126
+
127
+ expect(
128
+ wrapper.findAll('.waveform-chart__track').map((track) => track.attributes('data-track-id')),
129
+ ).toEqual(['frame-2', 'frame-1', 'later-a', 'later-b'])
130
+ })
131
+ })
@@ -21,8 +21,13 @@ describe('simulated waveform data', () => {
21
21
  first.series.forEach((series) => {
22
22
  expect(series.data.kind).toBe('points')
23
23
  if (series.data.kind !== 'points') return
24
- expect(series.data.points).toHaveLength(1_000)
25
- expect(series.data.points[0]?.x).toBe(-5)
24
+ if (series.id === 'simulated-step') {
25
+ expect(series.data.points).toHaveLength(500)
26
+ expect(series.data.points.every((point) => point.x >= 0)).toBe(true)
27
+ } else {
28
+ expect(series.data.points).toHaveLength(1_000)
29
+ expect(series.data.points[0]?.x).toBe(-5)
30
+ }
26
31
  expect(series.data.points.at(-1)?.x).toBe(5)
27
32
  series.data.points.forEach((point) => {
28
33
  expect(Number.isFinite(point.x)).toBe(true)
@@ -17,6 +17,7 @@ interface SimulatedSeriesDefinition extends Pick<
17
17
  > {
18
18
  signal: SignalGenerator
19
19
  errors?: ErrorGenerator
20
+ minimumX?: number
20
21
  }
21
22
 
22
23
  function createSeededNoise(seed: number) {
@@ -100,6 +101,7 @@ const seriesDefinitions: SimulatedSeriesDefinition[] = [
100
101
  unit: 'V',
101
102
  lineType: 'linear',
102
103
  pointType: 'none',
104
+ minimumX: 0,
103
105
  signal: (time) => (time < 0 ? 0 : 1 - Math.exp(-time * 2.4)),
104
106
  },
105
107
  {
@@ -128,12 +130,15 @@ export function createSimulatedWaveformData(): WaveformData {
128
130
  const noise = createSeededNoise(0x5eed1234)
129
131
  return {
130
132
  kind: 'series',
131
- series: seriesDefinitions.map(({ signal, errors, ...series }) => ({
132
- ...series,
133
- data: {
134
- kind: 'points',
135
- points: createPoints(signal, noise, errors),
136
- },
137
- })),
133
+ series: seriesDefinitions.map(({ signal, errors, minimumX, ...series }) => {
134
+ const points = createPoints(signal, noise, errors)
135
+ return {
136
+ ...series,
137
+ data: {
138
+ kind: 'points' as const,
139
+ points: minimumX === undefined ? points : points.filter((point) => point.x >= minimumX),
140
+ },
141
+ }
142
+ }),
138
143
  }
139
144
  }