waveform-analysis 0.1.47 → 0.1.50

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 (122) hide show
  1. package/README.md +127 -5
  2. package/dist/assets/workerRuntime-Dt94jbqq.js +1 -0
  3. package/dist/index.cjs +1 -1
  4. package/dist/index.js +2701 -1165
  5. package/dist/style.css +1 -1
  6. package/dist/types/components/WaveformChart.vue.d.ts +14 -0
  7. package/dist/types/components/annotation/WaveformAnnotationEditor.vue.d.ts +0 -1
  8. package/dist/types/components/annotation/markup.d.ts +5 -6
  9. package/dist/types/components/annotation/types.d.ts +2 -0
  10. package/dist/types/components/core/latestTaskScheduler.d.ts +19 -0
  11. package/dist/types/components/core/layout.d.ts +2 -2
  12. package/dist/types/components/core/trackLayoutBuilder.d.ts +4 -1
  13. package/dist/types/components/core/types.d.ts +2 -0
  14. package/dist/types/components/core/useWaveformChartController.d.ts +1 -0
  15. package/dist/types/components/core/useWaveformData.d.ts +2 -0
  16. package/dist/types/components/core/useWaveformLayout.d.ts +2 -0
  17. package/dist/types/components/core/useWaveformRenderSampling.d.ts +19 -0
  18. package/dist/types/components/core/waveformChartTypes.d.ts +8 -1
  19. package/dist/types/components/core/workerSamplingClient.d.ts +8 -0
  20. package/dist/types/components/data/types.d.ts +1 -1
  21. package/dist/types/components/interaction/zoomConstraints.d.ts +2 -0
  22. package/dist/types/components/waveform.d.ts +1 -1
  23. package/dist/types/core/compactSamplePointSource.d.ts +3 -0
  24. package/dist/types/core/data.d.ts +7 -1
  25. package/dist/types/core/index.d.ts +2 -1
  26. package/dist/types/core/rendering.d.ts +3 -1
  27. package/dist/types/core/renderingOptions.d.ts +11 -1
  28. package/dist/types/core/samplingReference.d.ts +36 -0
  29. package/dist/types/core/samplingReferenceValues.d.ts +4 -0
  30. package/dist/types/core/samplingReferenceVectors.d.ts +17 -0
  31. package/dist/types/core/wasmSampling.d.ts +48 -0
  32. package/dist/types/core/waveformPointSource.d.ts +53 -0
  33. package/dist/types/core/workerSampling/index.d.ts +5 -0
  34. package/dist/types/core/workerSampling/javascriptBackend.d.ts +3 -0
  35. package/dist/types/core/workerSampling/multiresolution.d.ts +29 -0
  36. package/dist/types/core/workerSampling/protocol.d.ts +153 -0
  37. package/dist/types/core/workerSampling/repository.d.ts +36 -0
  38. package/dist/types/core/workerSampling/repositoryDataset.d.ts +29 -0
  39. package/dist/types/core/workerSampling/repositorySupport.d.ts +27 -0
  40. package/dist/types/core/workerSampling/samplingCache.d.ts +24 -0
  41. package/dist/types/core/workerSampling/workerRuntime.d.ts +1 -0
  42. package/dist/types/index.d.ts +2 -2
  43. package/dist/types/types/chart.d.ts +58 -0
  44. package/dist/types/types/data.d.ts +20 -2
  45. package/dist/types/types/index.d.ts +2 -2
  46. package/dist/types/views/WasmSamplingDemo.vue.d.ts +3 -0
  47. package/package.json +5 -4
  48. package/src/App.test.ts +11 -7
  49. package/src/App.vue +2 -2
  50. package/src/DemoRouterApp.vue +1 -0
  51. package/src/components/WaveformChart.css +2 -0
  52. package/src/components/annotation/WaveformAnnotationEditor.css +2 -2
  53. package/src/components/annotation/WaveformAnnotationEditor.vue +8 -4
  54. package/src/components/annotation/markup.ts +45 -26
  55. package/src/components/annotation/types.ts +2 -0
  56. package/src/components/annotation/useWaveformChartAnnotations.ts +12 -15
  57. package/src/components/core/latestTaskScheduler.test.ts +51 -0
  58. package/src/components/core/latestTaskScheduler.ts +75 -0
  59. package/src/components/core/layout.ts +15 -6
  60. package/src/components/core/trackLayoutBuilder.ts +8 -2
  61. package/src/components/core/types.ts +2 -0
  62. package/src/components/core/useWaveformChartController.ts +14 -0
  63. package/src/components/core/useWaveformData.test.ts +95 -0
  64. package/src/components/core/useWaveformData.ts +23 -32
  65. package/src/components/core/useWaveformLayout.ts +16 -10
  66. package/src/components/core/useWaveformRenderSampling.test.ts +17 -0
  67. package/src/components/core/useWaveformRenderSampling.ts +394 -0
  68. package/src/components/core/waveformChartTypes.ts +13 -0
  69. package/src/components/core/workerSamplingClient.ts +148 -0
  70. package/src/components/core/yAxisNice.test.ts +174 -0
  71. package/src/components/data/types.ts +9 -0
  72. package/src/components/interaction/WaveformTooltip.test.ts +1 -3
  73. package/src/components/interaction/WaveformTooltip.vue +13 -12
  74. package/src/components/interaction/useWaveformHover.ts +7 -7
  75. package/src/components/interaction/zoomConstraints.ts +5 -1
  76. package/src/components/waveform.ts +9 -0
  77. package/src/components/waveformChartCases/compactAndSharedHover.test.ts +15 -4
  78. package/src/components/waveformChartCases/dataAndHoverPerformance.test.ts +11 -6
  79. package/src/components/waveformChartCases/dataLifecycle.test.ts +1 -3
  80. package/src/components/waveformChartCases/trackOrder.test.ts +130 -0
  81. package/src/components/waveformChartCases/workerSamplingIntegration.test.ts +117 -0
  82. package/src/components/waveformChartCases/xDomainStrategy.test.ts +14 -2
  83. package/src/core/compactSamplePointSource.ts +76 -0
  84. package/src/core/data.test.ts +114 -0
  85. package/src/core/data.ts +177 -75
  86. package/src/core/index.ts +11 -0
  87. package/src/core/rendering.test.ts +87 -12
  88. package/src/core/rendering.ts +35 -1
  89. package/src/core/renderingOptions.ts +124 -15
  90. package/src/core/samplingReference.test.ts +124 -0
  91. package/src/core/samplingReference.ts +243 -0
  92. package/src/core/samplingReferenceValues.ts +169 -0
  93. package/src/core/samplingReferenceVectors.ts +46 -0
  94. package/src/core/wasmSampling.test.ts +205 -0
  95. package/src/core/wasmSampling.ts +230 -0
  96. package/src/core/waveformPointSource.ts +395 -0
  97. package/src/core/workerSampling/index.ts +30 -0
  98. package/src/core/workerSampling/javascriptBackend.ts +15 -0
  99. package/src/core/workerSampling/multiresolution.test.ts +69 -0
  100. package/src/core/workerSampling/multiresolution.ts +300 -0
  101. package/src/core/workerSampling/protocol.test.ts +34 -0
  102. package/src/core/workerSampling/protocol.ts +207 -0
  103. package/src/core/workerSampling/repository.test.ts +400 -0
  104. package/src/core/workerSampling/repository.ts +400 -0
  105. package/src/core/workerSampling/repositoryCache.test.ts +163 -0
  106. package/src/core/workerSampling/repositoryDataset.ts +194 -0
  107. package/src/core/workerSampling/repositorySupport.ts +225 -0
  108. package/src/core/workerSampling/repositoryTypedDataset.test.ts +89 -0
  109. package/src/core/workerSampling/repositoryWasmDataset.test.ts +70 -0
  110. package/src/core/workerSampling/samplingCache.ts +94 -0
  111. package/src/core/workerSampling/workerRuntime.ts +40 -0
  112. package/src/data/simulatedWaveforms.test.ts +19 -0
  113. package/src/data/simulatedWaveforms.ts +11 -8
  114. package/src/demo/xAxisLabelControls.test.ts +1 -1
  115. package/src/demoRoutes.css +177 -0
  116. package/src/index.ts +18 -0
  117. package/src/router.ts +5 -0
  118. package/src/types/chart.ts +65 -0
  119. package/src/types/data.ts +24 -1
  120. package/src/types/index.ts +9 -0
  121. package/src/views/WasmSamplingDemo.test.ts +13 -0
  122. package/src/views/WasmSamplingDemo.vue +180 -0
@@ -0,0 +1,174 @@
1
+ import { zoomIdentity } from 'd3'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { DEFAULT_WAVEFORM_RENDERING_OPTIONS } from '../../core'
5
+ import type { WaveformData } from '../../types'
6
+ import { buildTrackLayouts } from './trackLayoutBuilder'
7
+ import type { DisplaySeries, DisplayTrack } from './types'
8
+ import { prepareWaveformSeries } from './useWaveformData'
9
+
10
+ function displaySeries(id: string, domain: [number, number]): DisplaySeries {
11
+ return {
12
+ id,
13
+ name: id,
14
+ color: '#1677ff',
15
+ lineType: 'linear',
16
+ lineStyle: 'solid',
17
+ pointType: 'none',
18
+ errorBar: { visible: false, width: 1.5, capWidth: 8 },
19
+ points: [
20
+ { x: 0, y: domain[0] },
21
+ { x: 1, y: domain[1] },
22
+ ],
23
+ xDomain: [0, 1],
24
+ yDomain: domain,
25
+ hasErrorPoints: false,
26
+ }
27
+ }
28
+
29
+ function layout(
30
+ series: DisplaySeries[],
31
+ options: { overlayMode?: 'single-axis' | 'multi-axis'; fixedYDomain?: [number, number] } = {},
32
+ ) {
33
+ const track: DisplayTrack = {
34
+ id: 'track',
35
+ series,
36
+ visibleSeries: series,
37
+ xDomain: [0, 1],
38
+ yDomain: [
39
+ Math.min(...series.map((item) => item.yDomain[0])),
40
+ Math.max(...series.map((item) => item.yDomain[1])),
41
+ ],
42
+ }
43
+ return buildTrackLayouts({
44
+ cells: [
45
+ {
46
+ slotIndex: 0,
47
+ row: 0,
48
+ column: 0,
49
+ left: 0,
50
+ top: 0,
51
+ width: 120,
52
+ height: 100,
53
+ plotHeight: 100,
54
+ cellHeight: 130,
55
+ xAxisBand: 30,
56
+ series: track,
57
+ },
58
+ ],
59
+ grid: {
60
+ rowCount: 1,
61
+ columnCount: 1,
62
+ showPagination: false,
63
+ fillIncompleteLastRow: false,
64
+ trackLines: {},
65
+ },
66
+ displayMode: 'independent',
67
+ overlayMode: options.overlayMode ?? 'single-axis',
68
+ independentTransforms: [zoomIdentity],
69
+ sharedZoomDomain: [0, 1],
70
+ fixedYDomain: options.fixedYDomain,
71
+ timeUnit: 'ms',
72
+ rendering: DEFAULT_WAVEFORM_RENDERING_OPTIONS,
73
+ hideSecondaryLabels: false,
74
+ yAxisLabelX: -50,
75
+ yAxisNice: false,
76
+ showCompactEmptyTracks: false,
77
+ })[0]!
78
+ }
79
+
80
+ describe('Y-axis nice domains', () => {
81
+ it('defaults to a nice domain and can preserve the actual data endpoints', () => {
82
+ const source = displaySeries('source', [3, 97])
83
+ const track: DisplayTrack = {
84
+ id: 'track',
85
+ series: [source],
86
+ visibleSeries: [source],
87
+ xDomain: [0, 1],
88
+ yDomain: [3, 97],
89
+ }
90
+ const defaults = buildTrackLayouts({
91
+ cells: [
92
+ {
93
+ slotIndex: 0,
94
+ row: 0,
95
+ column: 0,
96
+ left: 0,
97
+ top: 0,
98
+ width: 120,
99
+ height: 100,
100
+ plotHeight: 100,
101
+ cellHeight: 130,
102
+ xAxisBand: 30,
103
+ series: track,
104
+ },
105
+ ],
106
+ grid: {
107
+ rowCount: 1,
108
+ columnCount: 1,
109
+ showPagination: false,
110
+ fillIncompleteLastRow: false,
111
+ trackLines: {},
112
+ },
113
+ displayMode: 'independent',
114
+ overlayMode: 'single-axis',
115
+ independentTransforms: [zoomIdentity],
116
+ sharedZoomDomain: [0, 1],
117
+ timeUnit: 'ms',
118
+ rendering: DEFAULT_WAVEFORM_RENDERING_OPTIONS,
119
+ hideSecondaryLabels: false,
120
+ yAxisLabelX: -50,
121
+ showCompactEmptyTracks: false,
122
+ })[0]!
123
+
124
+ expect(defaults.yScale.domain()).toEqual([0, 100])
125
+ expect(layout([source]).yScale.domain()).toEqual([3, 97])
126
+ })
127
+
128
+ it('merges visible single-axis series but keeps multi-axis series independent', () => {
129
+ const first = displaySeries('first', [3, 40])
130
+ const second = displaySeries('second', [10, 97])
131
+
132
+ expect(layout([first, second]).yScale.domain()).toEqual([3, 97])
133
+ expect(
134
+ layout([first, second], { overlayMode: 'multi-axis' }).yAxes.map((axis) =>
135
+ axis.scale.domain(),
136
+ ),
137
+ ).toEqual([
138
+ [3, 40],
139
+ [10, 97],
140
+ ])
141
+ })
142
+
143
+ it('keeps explicit domains and constant-data safety ranges unchanged', () => {
144
+ expect(
145
+ layout([displaySeries('source', [3, 97])], { fixedYDomain: [3, 97] }).yScale.domain(),
146
+ ).toEqual([3, 97])
147
+ expect(layout([displaySeries('constant', [9.5, 10.5])]).yScale.domain()).toEqual([9.5, 10.5])
148
+ })
149
+
150
+ it('continues to include visible error-bar bounds in the preserved domain', () => {
151
+ const input: WaveformData = {
152
+ kind: 'series',
153
+ series: [
154
+ {
155
+ id: 'error-series',
156
+ name: 'error series',
157
+ errorBar: { visible: true },
158
+ data: {
159
+ kind: 'points',
160
+ points: [
161
+ { x: 0, y: 3, lowerError: 2 },
162
+ { x: 1, y: 7, upperError: 4 },
163
+ ],
164
+ },
165
+ },
166
+ ],
167
+ }
168
+ const [prepared] = prepareWaveformSeries(input)
169
+ const errorSeries: DisplaySeries = { ...prepared!, color: prepared!.color ?? '#1677ff' }
170
+
171
+ expect(prepared?.yDomain).toEqual([1, 11])
172
+ expect(layout([errorSeries]).yScale.domain()).toEqual([1, 11])
173
+ })
174
+ })
@@ -14,6 +14,12 @@ export type {
14
14
  WaveformZoomResetPayload,
15
15
  WaveformAnnotationStyle,
16
16
  WaveformAnnotation,
17
+ WaveformSamplingMode,
18
+ WaveformSamplingStrategy,
19
+ WaveformSamplingOptions,
20
+ WaveformSamplingBackend,
21
+ WaveformSamplingDiagnostics,
22
+ WaveformSamplingError,
17
23
  WaveformRenderingOptions,
18
24
  WaveformPlotMargin,
19
25
  WaveformTitleTextStyle,
@@ -30,6 +36,9 @@ export type {
30
36
  WaveformPointType,
31
37
  WaveformErrorBarOptions,
32
38
  ResolvedWaveformErrorBarOptions,
39
+ WaveformTypedValues,
40
+ TypedSampleData,
41
+ TypedPointData,
33
42
  WaveformSeries,
34
43
  WaveformData,
35
44
  NormalizedWaveformSeries,
@@ -188,9 +188,7 @@ describe('WaveformTooltip', () => {
188
188
  },
189
189
  })
190
190
 
191
- const values = wrapper
192
- .findAll('.waveform-tooltip__value')
193
- .map((value) => value.text())
191
+ const values = wrapper.findAll('.waveform-tooltip__value').map((value) => value.text())
194
192
  expect(values).toEqual(['(x:1 y:12)', '(x:1.25 y:8)', '无数据'])
195
193
  })
196
194
  })
@@ -90,14 +90,10 @@ const tooltipStyle = computed(() => {
90
90
  ...horizontalStyle,
91
91
  top: `${Math.max(
92
92
  8,
93
- Math.min(
94
- props.position.y - 18,
95
- props.containerHeight - estimateTooltipHeight(maxWidth) - 8,
96
- ),
93
+ Math.min(props.position.y - 18, props.containerHeight - estimateTooltipHeight(maxWidth) - 8),
97
94
  )}px`,
98
95
  }
99
96
  })
100
-
101
97
  </script>
102
98
 
103
99
  <template>
@@ -113,12 +109,15 @@ const tooltipStyle = computed(() => {
113
109
  >
114
110
  <i :style="{ backgroundColor: seriesPoint.color }" />
115
111
  <span class="waveform-tooltip__series-content">
116
- <span class="waveform-tooltip__series-label">{{ seriesPoint.shotNo?.trim() || '未配置炮号' }}: {{
117
- seriesPoint.name
118
- }}<template v-if="seriesPoint.unit">({{ seriesPoint.unit }})</template></span>
119
- <span v-if="seriesPoint.point" class="waveform-tooltip__value">(x:{{
120
- formatTooltipTime(seriesPoint.point.x, timeUnit)
121
- }} y:{{ formatTooltipNumber(seriesPoint.point.y) }})</span>
112
+ <span class="waveform-tooltip__series-label"
113
+ >{{ seriesPoint.shotNo?.trim() || '未配置炮号' }}: {{ seriesPoint.name
114
+ }}<template v-if="seriesPoint.unit">({{ seriesPoint.unit }})</template></span
115
+ >
116
+ <span v-if="seriesPoint.point" class="waveform-tooltip__value"
117
+ >(x:{{ formatTooltipTime(seriesPoint.point.x, timeUnit) }} y:{{
118
+ formatTooltipNumber(seriesPoint.point.y)
119
+ }})</span
120
+ >
122
121
  <span v-else class="waveform-tooltip__value waveform-tooltip__value--missing">无数据</span>
123
122
  </span>
124
123
  </span>
@@ -136,7 +135,9 @@ const tooltipStyle = computed(() => {
136
135
  max-width: min(560px, calc(100% - 16px));
137
136
  padding: 9px 12px;
138
137
  color: #505050;
139
- font: 14px/1.35 Arial, sans-serif;
138
+ font:
139
+ 14px/1.35 Arial,
140
+ sans-serif;
140
141
  pointer-events: none;
141
142
  background: #fff;
142
143
  border: 1px solid #e3e7eb;
@@ -1,7 +1,8 @@
1
- import { bisector, pointer } from 'd3'
1
+ import { pointer } from 'd3'
2
2
  import type { ComputedRef, Ref, ShallowRef } from 'vue'
3
3
 
4
4
  import type { WaveformPoint } from '../data/types'
5
+ import { pointSourceFromPoints } from '../../core/waveformPointSource'
5
6
  import type {
6
7
  DisplaySeries,
7
8
  HoveredSeriesPoint,
@@ -28,8 +29,6 @@ interface HoverContext {
28
29
  resolveTrackAtPointer: (pointerX: number, pointerY: number) => TrackLayout | undefined
29
30
  }
30
31
 
31
- const pointBisector = bisector<WaveformPoint, number>((point) => point.x)
32
-
33
32
  export function useWaveformHover(context: HoverContext) {
34
33
  const {
35
34
  emit,
@@ -110,10 +109,11 @@ export function useWaveformHover(context: HoverContext) {
110
109
  return true
111
110
  }
112
111
  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)]
112
+ const source = series.source ?? pointSourceFromPoints(series.points)
113
+ const first = source.pointAt(0)
114
+ const last = source.pointAt(source.length - 1)
115
+ if (!first || !last || xValue < first.x || xValue > last.x) return undefined
116
+ return source.nearestPoint(xValue)
117
117
  }
118
118
 
119
119
  const handleIndependentPointerMove = (event: PointerEvent, trackIndex: number) => {
@@ -1,10 +1,12 @@
1
1
  import { scaleLinear, zoomIdentity, type ZoomTransform } from 'd3'
2
2
 
3
3
  import type { WaveformPoint } from '../../types'
4
+ import { pointSourceFromPoints, type WaveformPointSource } from '../../core/waveformPointSource'
4
5
  import { ZOOM_CONSTRAINTS } from '../core/constants'
5
6
 
6
7
  interface PointSeriesSource {
7
8
  points: WaveformPoint[]
9
+ source?: WaveformPointSource
8
10
  }
9
11
 
10
12
  export type ZoomSeriesGroup = readonly PointSeriesSource[]
@@ -30,7 +32,9 @@ function uniqueXValues(group: ZoomSeriesGroup, boundary: [number, number]): numb
30
32
  if (cached) return cached
31
33
  const values = new Set<number>()
32
34
  for (const series of group) {
33
- for (const point of series.points) {
35
+ const source = series.source ?? pointSourceFromPoints(series.points)
36
+ for (let index = 0; index < source.length; index += 1) {
37
+ const point = source.pointAt(index)!
34
38
  if (point.x >= boundary[0] && point.x <= boundary[1]) values.add(point.x)
35
39
  }
36
40
  }
@@ -12,12 +12,21 @@ export type {
12
12
  WaveformXDomainStrategy,
13
13
  WaveformAnnotationStyle,
14
14
  WaveformAnnotation,
15
+ WaveformSamplingMode,
16
+ WaveformSamplingStrategy,
17
+ WaveformSamplingOptions,
18
+ WaveformSamplingBackend,
19
+ WaveformSamplingDiagnostics,
20
+ WaveformSamplingError,
15
21
  WaveformRenderingOptions,
16
22
  WaveformPlotMargin,
17
23
  WaveformTitleTextStyle,
18
24
  WaveformTitleOptions,
19
25
  WaveformFrameStyle,
20
26
  SingleWaveformData,
27
+ WaveformTypedValues,
28
+ TypedSampleData,
29
+ TypedPointData,
21
30
  WaveformSeries,
22
31
  WaveformLineStyle,
23
32
  WaveformData,
@@ -74,8 +74,7 @@ describe('WaveformChart', () => {
74
74
 
75
75
  it('removes the later compact row top tick when the Y-axis end has a floating-point tail', async () => {
76
76
  const yDomain: [number, number] = [-1, 0.2]
77
- const calculatedAxisEnd =
78
- yDomain[0] + ((yDomain[1] - yDomain[0]) * 4) / 4
77
+ const calculatedAxisEnd = yDomain[0] + ((yDomain[1] - yDomain[0]) * 4) / 4
79
78
  expect(calculatedAxisEnd).not.toBe(yDomain[1])
80
79
 
81
80
  const wrapper = await mountSizedChart(
@@ -84,11 +83,23 @@ describe('WaveformChart', () => {
84
83
  series: [
85
84
  {
86
85
  name: 'top',
87
- data: { kind: 'points', points: [{ x: 0, y: -0.8 }, { x: 1, y: 0.1 }] },
86
+ data: {
87
+ kind: 'points',
88
+ points: [
89
+ { x: 0, y: -0.8 },
90
+ { x: 1, y: 0.1 },
91
+ ],
92
+ },
88
93
  },
89
94
  {
90
95
  name: 'bottom',
91
- data: { kind: 'points', points: [{ x: 0, y: -0.7 }, { x: 1, y: 0 }] },
96
+ data: {
97
+ kind: 'points',
98
+ points: [
99
+ { x: 0, y: -0.7 },
100
+ { x: 1, y: 0 },
101
+ ],
102
+ },
92
103
  },
93
104
  ],
94
105
  },
@@ -99,12 +99,17 @@ describe('WaveformChart', () => {
99
99
  { rendering: { downsampleThreshold: 1_000, maxPointsPerPixel: 4 } },
100
100
  )
101
101
  const overlayWidth = Number(wrapper.get('.waveform-chart__overlay').attributes('width'))
102
- const path = wrapper.get('.waveform-chart__line').attributes('d') ?? ''
103
- const renderedPointCount = path.match(/[ML]/g)?.length ?? 0
104
-
105
- expect(renderedPointCount).toBeGreaterThan(0)
106
- expect(renderedPointCount).toBeLessThanOrEqual(Math.floor(overlayWidth * 4) + 2)
107
- expect(path).toContain(',0')
102
+ await vi.waitFor(
103
+ () => {
104
+ expect(wrapper.emitted('sampling-complete')?.length).toBeGreaterThan(0)
105
+ const path = wrapper.get('.waveform-chart__line').attributes('d') ?? ''
106
+ const renderedPointCount = path.match(/[ML]/g)?.length ?? 0
107
+ expect(renderedPointCount).toBeGreaterThan(0)
108
+ expect(renderedPointCount).toBeLessThanOrEqual(Math.floor(overlayWidth * 4) + 2)
109
+ expect(path).toContain(',0')
110
+ },
111
+ { timeout: 5_000 },
112
+ )
108
113
  })
109
114
 
110
115
  it('bounds dense decorations by pixel spacing while keeping one SVG path per series', async () => {
@@ -169,9 +169,7 @@ describe('WaveformChart', () => {
169
169
  yAxisLabels[0].attributes('transform')?.match(/^translate\(([-\d.]+),/)?.[1],
170
170
  )
171
171
  expect(labelX).toBeLessThan(-46)
172
- expect(Number(wrapper.get('.waveform-chart__y-axis-label-bg').attributes('x'))).toBe(
173
- labelX - 6,
174
- )
172
+ expect(Number(wrapper.get('.waveform-chart__y-axis-label-bg').attributes('x'))).toBe(labelX - 6)
175
173
  expect(yAxisLabels.every((label) => !label.text().includes('(T)'))).toBe(true)
176
174
  expect(wrapper.findAll('.waveform-chart__track-label')).toHaveLength(0)
177
175
  expect(
@@ -5,6 +5,32 @@ import { flushAnimationFrames } from '../../test/setup'
5
5
  import { mountSizedChart } from '../../test/waveformChart'
6
6
 
7
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
+
8
34
  it('keeps a merged source slot empty without adding legend or tooltip data', async () => {
9
35
  const wrapper = await mountSizedChart(
10
36
  {
@@ -128,4 +154,108 @@ describe('WaveformChart track order', () => {
128
154
  wrapper.findAll('.waveform-chart__track').map((track) => track.attributes('data-track-id')),
129
155
  ).toEqual(['frame-2', 'frame-1', 'later-a', 'later-b'])
130
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
+ })
131
261
  })
@@ -0,0 +1,117 @@
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
+ function densePoints(count: number) {
8
+ return Array.from({ length: count }, (_, index) => ({
9
+ x: index,
10
+ y: index === count - 1 ? 100 : Math.sin(index / 10),
11
+ }))
12
+ }
13
+
14
+ async function samplingEvents(count: number, rendering = {}) {
15
+ const wrapper = await mountSizedChart(
16
+ { kind: 'points', points: densePoints(count) },
17
+ { rendering, grid: { rowCount: 1, columnCount: 1 } },
18
+ )
19
+ await flushPromises()
20
+ await new Promise((resolve) => setTimeout(resolve, 120))
21
+ return wrapper
22
+ }
23
+
24
+ async function compactSamplingEvents(count: number, rendering = {}) {
25
+ const wrapper = await mountSizedChart(
26
+ {
27
+ kind: 'typed-points',
28
+ x: Float64Array.from({ length: count }, (_, index) => index),
29
+ y: Float32Array.from({ length: count }, (_, index) =>
30
+ index === count - 1 ? 100 : Math.sin(index / 10),
31
+ ),
32
+ },
33
+ { rendering, grid: { rowCount: 1, columnCount: 1 } },
34
+ )
35
+ await flushPromises()
36
+ await new Promise((resolve) => setTimeout(resolve, 120))
37
+ return wrapper
38
+ }
39
+
40
+ describe('WaveformChart worker sampling integration', () => {
41
+ it('uses raw source points at the auto threshold and JavaScript fallback above it', async () => {
42
+ const threshold = await samplingEvents(1_000)
43
+ const sampled = await samplingEvents(1_001)
44
+
45
+ expect(threshold.emitted('sampling-complete')?.at(-1)?.[0]).toMatchObject({
46
+ mode: 'auto',
47
+ backend: 'raw',
48
+ visiblePointCount: 1_000,
49
+ renderedPointCount: 1_000,
50
+ })
51
+ expect(sampled.emitted('sampling-complete')?.at(-1)?.[0]).toMatchObject({
52
+ mode: 'auto',
53
+ backend: 'javascript',
54
+ visiblePointCount: 1_001,
55
+ })
56
+ const path = sampled.get('.waveform-chart__line').attributes('d') ?? ''
57
+ expect(path.match(/[ML]/g)?.length).toBeLessThan(4_000)
58
+ })
59
+
60
+ it('bypasses worker sampling in raw mode and reports a forced-WASM failure without a fallback', async () => {
61
+ const raw = await samplingEvents(1_001, { sampling: { mode: 'raw' } })
62
+ const wasm = await samplingEvents(1_001, {
63
+ sampling: { mode: 'wasm', wasmFailureFallback: 'error' },
64
+ })
65
+
66
+ expect(raw.emitted('sampling-complete')?.at(-1)?.[0]).toMatchObject({
67
+ mode: 'raw',
68
+ backend: 'raw',
69
+ renderedPointCount: 1_001,
70
+ })
71
+ expect(raw.emitted('sampling-error')).toBeUndefined()
72
+ expect(wasm.emitted('sampling-complete')?.at(-1)?.[0]).toMatchObject({
73
+ mode: 'wasm',
74
+ backend: 'unavailable',
75
+ })
76
+ expect(wasm.emitted('sampling-error')?.at(-1)?.[0]).toMatchObject({
77
+ mode: 'wasm',
78
+ fallback: 'none',
79
+ })
80
+ })
81
+
82
+ it('keeps hover bound to complete source data after sampled SVG geometry is applied', async () => {
83
+ const wrapper = await samplingEvents(1_001)
84
+ const overlay = wrapper.get('.waveform-chart__overlay')
85
+ const width = Number(overlay.attributes('width'))
86
+ Object.defineProperty(overlay.element, 'getBoundingClientRect', {
87
+ value: () => ({ left: 0, top: 0, width, height: 290 }),
88
+ })
89
+ overlay.element.dispatchEvent(
90
+ new MouseEvent('pointermove', { clientX: width, clientY: 120, bubbles: true }),
91
+ )
92
+ flushAnimationFrames()
93
+ await flushPromises()
94
+
95
+ expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([{ x: 1_000, y: 100 }])
96
+ })
97
+
98
+ it('samples dense typed coordinates while retaining the full compact source for hover', async () => {
99
+ const wrapper = await compactSamplingEvents(1_001)
100
+ expect(wrapper.emitted('sampling-complete')?.at(-1)?.[0]).toMatchObject({
101
+ backend: 'javascript',
102
+ sourcePointCount: 1_001,
103
+ visiblePointCount: 1_001,
104
+ })
105
+ const overlay = wrapper.get('.waveform-chart__overlay')
106
+ const width = Number(overlay.attributes('width'))
107
+ Object.defineProperty(overlay.element, 'getBoundingClientRect', {
108
+ value: () => ({ left: 0, top: 0, width, height: 290 }),
109
+ })
110
+ overlay.element.dispatchEvent(
111
+ new MouseEvent('pointermove', { clientX: width, clientY: 120, bubbles: true }),
112
+ )
113
+ flushAnimationFrames()
114
+ await flushPromises()
115
+ expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([{ x: 1_000, y: 100 }])
116
+ })
117
+ })