waveform-analysis 0.1.48 → 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 (117) hide show
  1. package/README.md +120 -5
  2. package/dist/assets/workerRuntime-Dt94jbqq.js +1 -0
  3. package/dist/index.cjs +1 -1
  4. package/dist/index.js +2683 -1153
  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/trackLayoutBuilder.d.ts +3 -1
  12. package/dist/types/components/core/types.d.ts +2 -0
  13. package/dist/types/components/core/useWaveformChartController.d.ts +1 -0
  14. package/dist/types/components/core/useWaveformData.d.ts +2 -0
  15. package/dist/types/components/core/useWaveformLayout.d.ts +2 -0
  16. package/dist/types/components/core/useWaveformRenderSampling.d.ts +19 -0
  17. package/dist/types/components/core/waveformChartTypes.d.ts +8 -1
  18. package/dist/types/components/core/workerSamplingClient.d.ts +8 -0
  19. package/dist/types/components/data/types.d.ts +1 -1
  20. package/dist/types/components/interaction/zoomConstraints.d.ts +2 -0
  21. package/dist/types/components/waveform.d.ts +1 -1
  22. package/dist/types/core/compactSamplePointSource.d.ts +3 -0
  23. package/dist/types/core/data.d.ts +7 -1
  24. package/dist/types/core/index.d.ts +2 -1
  25. package/dist/types/core/rendering.d.ts +3 -1
  26. package/dist/types/core/renderingOptions.d.ts +11 -1
  27. package/dist/types/core/samplingReference.d.ts +36 -0
  28. package/dist/types/core/samplingReferenceValues.d.ts +4 -0
  29. package/dist/types/core/samplingReferenceVectors.d.ts +17 -0
  30. package/dist/types/core/wasmSampling.d.ts +48 -0
  31. package/dist/types/core/waveformPointSource.d.ts +53 -0
  32. package/dist/types/core/workerSampling/index.d.ts +5 -0
  33. package/dist/types/core/workerSampling/javascriptBackend.d.ts +3 -0
  34. package/dist/types/core/workerSampling/multiresolution.d.ts +29 -0
  35. package/dist/types/core/workerSampling/protocol.d.ts +153 -0
  36. package/dist/types/core/workerSampling/repository.d.ts +36 -0
  37. package/dist/types/core/workerSampling/repositoryDataset.d.ts +29 -0
  38. package/dist/types/core/workerSampling/repositorySupport.d.ts +27 -0
  39. package/dist/types/core/workerSampling/samplingCache.d.ts +24 -0
  40. package/dist/types/core/workerSampling/workerRuntime.d.ts +1 -0
  41. package/dist/types/index.d.ts +2 -2
  42. package/dist/types/types/chart.d.ts +56 -0
  43. package/dist/types/types/data.d.ts +20 -2
  44. package/dist/types/types/index.d.ts +2 -2
  45. package/dist/types/views/WasmSamplingDemo.vue.d.ts +3 -0
  46. package/package.json +5 -4
  47. package/src/App.test.ts +11 -7
  48. package/src/App.vue +2 -2
  49. package/src/DemoRouterApp.vue +1 -0
  50. package/src/components/WaveformChart.css +2 -0
  51. package/src/components/annotation/WaveformAnnotationEditor.css +2 -2
  52. package/src/components/annotation/WaveformAnnotationEditor.vue +8 -4
  53. package/src/components/annotation/markup.ts +45 -26
  54. package/src/components/annotation/types.ts +2 -0
  55. package/src/components/annotation/useWaveformChartAnnotations.ts +12 -15
  56. package/src/components/core/latestTaskScheduler.test.ts +51 -0
  57. package/src/components/core/latestTaskScheduler.ts +75 -0
  58. package/src/components/core/trackLayoutBuilder.ts +3 -0
  59. package/src/components/core/types.ts +2 -0
  60. package/src/components/core/useWaveformChartController.ts +14 -0
  61. package/src/components/core/useWaveformData.test.ts +95 -0
  62. package/src/components/core/useWaveformData.ts +23 -32
  63. package/src/components/core/useWaveformLayout.ts +4 -4
  64. package/src/components/core/useWaveformRenderSampling.test.ts +17 -0
  65. package/src/components/core/useWaveformRenderSampling.ts +394 -0
  66. package/src/components/core/waveformChartTypes.ts +13 -0
  67. package/src/components/core/workerSamplingClient.ts +148 -0
  68. package/src/components/data/types.ts +9 -0
  69. package/src/components/interaction/WaveformTooltip.test.ts +1 -3
  70. package/src/components/interaction/WaveformTooltip.vue +13 -12
  71. package/src/components/interaction/useWaveformHover.ts +7 -7
  72. package/src/components/interaction/zoomConstraints.ts +5 -1
  73. package/src/components/waveform.ts +9 -0
  74. package/src/components/waveformChartCases/compactAndSharedHover.test.ts +15 -4
  75. package/src/components/waveformChartCases/dataAndHoverPerformance.test.ts +11 -6
  76. package/src/components/waveformChartCases/dataLifecycle.test.ts +1 -3
  77. package/src/components/waveformChartCases/workerSamplingIntegration.test.ts +117 -0
  78. package/src/components/waveformChartCases/xDomainStrategy.test.ts +14 -2
  79. package/src/core/compactSamplePointSource.ts +76 -0
  80. package/src/core/data.test.ts +114 -0
  81. package/src/core/data.ts +177 -75
  82. package/src/core/index.ts +11 -0
  83. package/src/core/rendering.test.ts +87 -12
  84. package/src/core/rendering.ts +35 -1
  85. package/src/core/renderingOptions.ts +124 -15
  86. package/src/core/samplingReference.test.ts +124 -0
  87. package/src/core/samplingReference.ts +243 -0
  88. package/src/core/samplingReferenceValues.ts +169 -0
  89. package/src/core/samplingReferenceVectors.ts +46 -0
  90. package/src/core/wasmSampling.test.ts +205 -0
  91. package/src/core/wasmSampling.ts +230 -0
  92. package/src/core/waveformPointSource.ts +395 -0
  93. package/src/core/workerSampling/index.ts +30 -0
  94. package/src/core/workerSampling/javascriptBackend.ts +15 -0
  95. package/src/core/workerSampling/multiresolution.test.ts +69 -0
  96. package/src/core/workerSampling/multiresolution.ts +300 -0
  97. package/src/core/workerSampling/protocol.test.ts +34 -0
  98. package/src/core/workerSampling/protocol.ts +207 -0
  99. package/src/core/workerSampling/repository.test.ts +400 -0
  100. package/src/core/workerSampling/repository.ts +400 -0
  101. package/src/core/workerSampling/repositoryCache.test.ts +163 -0
  102. package/src/core/workerSampling/repositoryDataset.ts +194 -0
  103. package/src/core/workerSampling/repositorySupport.ts +225 -0
  104. package/src/core/workerSampling/repositoryTypedDataset.test.ts +89 -0
  105. package/src/core/workerSampling/repositoryWasmDataset.test.ts +70 -0
  106. package/src/core/workerSampling/samplingCache.ts +94 -0
  107. package/src/core/workerSampling/workerRuntime.ts +40 -0
  108. package/src/data/simulatedWaveforms.test.ts +19 -0
  109. package/src/data/simulatedWaveforms.ts +11 -8
  110. package/src/demoRoutes.css +177 -0
  111. package/src/index.ts +18 -0
  112. package/src/router.ts +5 -0
  113. package/src/types/chart.ts +63 -0
  114. package/src/types/data.ts +24 -1
  115. package/src/types/index.ts +9 -0
  116. package/src/views/WasmSamplingDemo.test.ts +13 -0
  117. package/src/views/WasmSamplingDemo.vue +180 -0
package/src/core/data.ts CHANGED
@@ -4,8 +4,10 @@ import type {
4
4
  WaveformPoint,
5
5
  WaveformLineStyle,
6
6
  NormalizedWaveformSeries,
7
+ WaveformTypedValues,
7
8
  } from '../types'
8
9
  import { ERROR_BAR_DEFAULTS } from '../components/core/constants'
10
+ import { createWaveformPointSource, type WaveformPointSource } from './waveformPointSource'
9
11
 
10
12
  const DEFAULT_ERROR_BAR_WIDTH = ERROR_BAR_DEFAULTS.WIDTH
11
13
  const DEFAULT_ERROR_BAR_CAP_WIDTH = ERROR_BAR_DEFAULTS.CAP_WIDTH
@@ -31,6 +33,95 @@ function normalizeWaveformPoint(point: WaveformPoint): WaveformPoint {
31
33
  }
32
34
  }
33
35
 
36
+ function isWaveformTypedValues(value: unknown): value is WaveformTypedValues {
37
+ return value instanceof Float32Array || value instanceof Float64Array
38
+ }
39
+
40
+ function normalizeSampleValues(
41
+ values: ArrayLike<number>,
42
+ sampleRate: number,
43
+ startTime: number | undefined,
44
+ ): WaveformPoint[] {
45
+ if (!Number.isFinite(sampleRate) || sampleRate <= 0) return []
46
+
47
+ const normalizedStartTime = Number.isFinite(startTime) ? (startTime ?? 0) : 0
48
+ const points: WaveformPoint[] = []
49
+ for (let index = 0; index < values.length; index += 1) {
50
+ const value = values[index]
51
+ if (!Number.isFinite(value)) continue
52
+ points.push({ x: normalizedStartTime + index / sampleRate, y: value })
53
+ }
54
+ return points
55
+ }
56
+
57
+ function normalizeTypedSamples(data: {
58
+ values: unknown
59
+ sampleRate: number
60
+ startTime?: number
61
+ }): WaveformPoint[] {
62
+ if (
63
+ !isWaveformTypedValues(data.values) ||
64
+ !Number.isFinite(data.sampleRate) ||
65
+ data.sampleRate <= 0 ||
66
+ (data.startTime !== undefined && !Number.isFinite(data.startTime))
67
+ ) {
68
+ return []
69
+ }
70
+ return normalizeSampleValues(data.values, data.sampleRate, data.startTime)
71
+ }
72
+
73
+ function typedPointValuesHaveMatchingLengths(data: {
74
+ x: unknown
75
+ y: unknown
76
+ error?: unknown
77
+ lowerError?: unknown
78
+ upperError?: unknown
79
+ }): data is {
80
+ x: Float64Array
81
+ y: WaveformTypedValues
82
+ error?: WaveformTypedValues
83
+ lowerError?: WaveformTypedValues
84
+ upperError?: WaveformTypedValues
85
+ } {
86
+ if (!(data.x instanceof Float64Array) || !isWaveformTypedValues(data.y)) return false
87
+ const length = data.x.length
88
+ const values = [data.error, data.lowerError, data.upperError]
89
+ return (
90
+ values.every(
91
+ (value) => value === undefined || (isWaveformTypedValues(value) && value.length === length),
92
+ ) && data.y.length === length
93
+ )
94
+ }
95
+
96
+ function normalizeTypedPoints(data: {
97
+ x: Float64Array
98
+ y: WaveformTypedValues
99
+ error?: WaveformTypedValues
100
+ lowerError?: WaveformTypedValues
101
+ upperError?: WaveformTypedValues
102
+ }): WaveformPoint[] {
103
+ const points: WaveformPoint[] = []
104
+ let previousX = Number.NEGATIVE_INFINITY
105
+ let sorted = true
106
+ for (let index = 0; index < data.x.length; index += 1) {
107
+ const x = data.x[index]
108
+ const y = data.y[index]
109
+ if (!Number.isFinite(x) || !Number.isFinite(y)) continue
110
+ const normalized = normalizeWaveformPoint({
111
+ x,
112
+ y,
113
+ error: data.error?.[index],
114
+ lowerError: data.lowerError?.[index],
115
+ upperError: data.upperError?.[index],
116
+ })
117
+ if (normalized.x < previousX) sorted = false
118
+ previousX = normalized.x
119
+ points.push(normalized)
120
+ }
121
+ if (!sorted) points.sort((left, right) => left.x - right.x)
122
+ return points
123
+ }
124
+
34
125
  export function resolveWaveformPointErrors(point: WaveformPoint): {
35
126
  lower: number
36
127
  upper: number
@@ -44,24 +135,21 @@ export function resolveWaveformPointErrors(point: WaveformPoint): {
44
135
 
45
136
  /**
46
137
  * 规范化单波形数据
47
- * @param data 输入数据(samples 或 points 格式)
138
+ * @param data 输入数据(对象数组或 TypedArray 格式)
48
139
  * @returns 规范化后的点数组
49
140
  */
50
141
  export function normalizeWaveformData(data: SingleWaveformData): WaveformPoint[] {
51
142
  if (data.kind === 'samples') {
52
- if (!Number.isFinite(data.sampleRate) || data.sampleRate <= 0) return []
53
-
54
- const startTime = Number.isFinite(data.startTime) ? (data.startTime ?? 0) : 0
55
- const points: WaveformPoint[] = []
56
- for (let index = 0; index < data.values.length; index += 1) {
57
- const value = data.values[index]
58
- if (!Number.isFinite(value)) continue
59
- points.push({ x: startTime + index / data.sampleRate, y: value })
60
- }
143
+ return normalizeSampleValues(data.values, data.sampleRate, data.startTime)
144
+ }
61
145
 
62
- return points
146
+ if (data.kind === 'typed-samples') {
147
+ return normalizeTypedSamples(data)
63
148
  }
64
149
 
150
+ if (data.kind === 'typed-points')
151
+ return typedPointValuesHaveMatchingLengths(data) ? normalizeTypedPoints(data) : []
152
+
65
153
  const points: WaveformPoint[] = []
66
154
  let previousX = Number.NEGATIVE_INFINITY
67
155
  let sorted = true
@@ -77,78 +165,92 @@ export function normalizeWaveformData(data: SingleWaveformData): WaveformPoint[]
77
165
  return points
78
166
  }
79
167
 
80
- /**
81
- * 规范化波形系列数据
82
- * @param data 输入数据(单波形或多通道)
83
- * @returns 规范化后的系列数组
84
- */
85
- export function normalizeWaveformSeries(data: WaveformData): NormalizedWaveformSeries[] {
168
+ export interface NormalizedWaveformSourceSeries extends Omit<NormalizedWaveformSeries, 'points'> {
169
+ source: WaveformPointSource
170
+ }
171
+
172
+ function normalizedSeriesOptions(
173
+ id: string,
174
+ series: {
175
+ shotNo?: string
176
+ trackId?: string
177
+ name: string
178
+ unit?: string
179
+ color?: string
180
+ lineType?: NormalizedWaveformSeries['lineType']
181
+ lineStyle?: WaveformLineStyle
182
+ pointType?: NormalizedWaveformSeries['pointType']
183
+ errorBar?: { visible?: boolean; color?: string; width?: number; capWidth?: number }
184
+ },
185
+ ) {
186
+ const requestedLineType = series.lineType ?? 'linear'
187
+ const lineStyle = normalizeLineStyle(series.lineStyle)
188
+ const requestedPointType = series.pointType ?? 'none'
189
+ const errorBarVisible = series.errorBar?.visible === true
190
+ const lineType =
191
+ requestedLineType === 'none' && requestedPointType === 'none' && !errorBarVisible
192
+ ? 'linear'
193
+ : requestedLineType
194
+ const width = Number(series.errorBar?.width)
195
+ const capWidth = Number(series.errorBar?.capWidth)
196
+ return {
197
+ id,
198
+ shotNo: series.shotNo?.trim() || undefined,
199
+ trackId: series.trackId?.trim() || undefined,
200
+ name: series.name,
201
+ unit: series.unit,
202
+ color: series.color,
203
+ lineType,
204
+ lineStyle,
205
+ pointType: requestedPointType,
206
+ errorBar: {
207
+ visible: errorBarVisible,
208
+ color: series.errorBar?.color,
209
+ width: Number.isFinite(width) && width > 0 ? width : DEFAULT_ERROR_BAR_WIDTH,
210
+ capWidth: Number.isFinite(capWidth) && capWidth > 0 ? capWidth : DEFAULT_ERROR_BAR_CAP_WIDTH,
211
+ },
212
+ }
213
+ }
214
+
215
+ /** Internal normalized series which preserves compact TypedArray storage for chart rendering. */
216
+ export function normalizeWaveformSeriesSources(
217
+ data: WaveformData,
218
+ ): NormalizedWaveformSourceSeries[] {
86
219
  if (data.kind !== 'series') {
87
- const points = normalizeWaveformData(data)
88
- return points.length > 0
220
+ const source = createWaveformPointSource(data, normalizeWaveformData)
221
+ return source.length
89
222
  ? [
90
223
  {
91
- id: 'series-0',
92
- name: '',
93
- lineType: 'linear',
94
- lineStyle: 'solid',
95
- pointType: 'none',
96
- errorBar: {
97
- visible: false,
98
- width: DEFAULT_ERROR_BAR_WIDTH,
99
- capWidth: DEFAULT_ERROR_BAR_CAP_WIDTH,
100
- },
101
- points,
224
+ ...normalizedSeriesOptions('series-0', { name: '' }),
225
+ source,
102
226
  },
103
227
  ]
104
228
  : []
105
229
  }
106
230
 
107
231
  const usedIds = new Set<string>()
232
+ return data.series.flatMap((series, index) => {
233
+ const id = series.id?.trim() || `series-${index}`
234
+ let uniqueId = id
235
+ let suffix = 1
236
+ while (usedIds.has(uniqueId)) {
237
+ uniqueId = `${id}-${suffix}`
238
+ suffix += 1
239
+ }
240
+ usedIds.add(uniqueId)
241
+ const source = createWaveformPointSource(series.data, normalizeWaveformData)
242
+ return source.length ? [{ ...normalizedSeriesOptions(uniqueId, series), source }] : []
243
+ })
244
+ }
108
245
 
109
- return data.series
110
- .map((series, index) => {
111
- const id = series.id?.trim() || `series-${index}`
112
-
113
- // 确保 ID 唯一,如果重复则添加后缀
114
- let uniqueId = id
115
- let suffix = 1
116
- while (usedIds.has(uniqueId)) {
117
- uniqueId = `${id}-${suffix}`
118
- suffix++
119
- }
120
- usedIds.add(uniqueId)
121
-
122
- const requestedLineType = series.lineType ?? 'linear'
123
- const lineStyle = normalizeLineStyle((series as { lineStyle?: unknown }).lineStyle)
124
- const requestedPointType = series.pointType ?? 'none'
125
- const errorBarVisible = series.errorBar?.visible === true
126
- const lineType =
127
- requestedLineType === 'none' && requestedPointType === 'none' && !errorBarVisible
128
- ? 'linear'
129
- : requestedLineType
130
- const width = Number(series.errorBar?.width)
131
- const capWidth = Number(series.errorBar?.capWidth)
132
-
133
- return {
134
- id: uniqueId,
135
- shotNo: series.shotNo?.trim() || undefined,
136
- trackId: series.trackId?.trim() || undefined,
137
- name: series.name,
138
- unit: series.unit,
139
- color: series.color,
140
- lineType,
141
- lineStyle,
142
- pointType: requestedPointType,
143
- errorBar: {
144
- visible: errorBarVisible,
145
- color: series.errorBar?.color,
146
- width: Number.isFinite(width) && width > 0 ? width : DEFAULT_ERROR_BAR_WIDTH,
147
- capWidth:
148
- Number.isFinite(capWidth) && capWidth > 0 ? capWidth : DEFAULT_ERROR_BAR_CAP_WIDTH,
149
- },
150
- points: normalizeWaveformData(series.data),
151
- }
152
- })
153
- .filter((series) => series.points.length > 0)
246
+ /**
247
+ * 规范化波形系列数据
248
+ * @param data 输入数据(单波形或多通道)
249
+ * @returns 规范化后的系列数组
250
+ */
251
+ export function normalizeWaveformSeries(data: WaveformData): NormalizedWaveformSeries[] {
252
+ return normalizeWaveformSeriesSources(data).map(({ source, ...series }) => ({
253
+ ...series,
254
+ points: source.pointsInRange(),
255
+ }))
154
256
  }
package/src/core/index.ts CHANGED
@@ -10,4 +10,15 @@ export {
10
10
  selectDecorationPoints,
11
11
  selectRenderablePoints,
12
12
  type ResolvedWaveformRenderingOptions,
13
+ type ResolvedWaveformSamplingOptions,
13
14
  } from './rendering'
15
+ export {
16
+ calculateWasmRange,
17
+ findWasmVisibleRange,
18
+ initializeWasmSampling,
19
+ sampleWaveformWasm,
20
+ type WasmRangeMetrics,
21
+ type WasmSamplingRequest,
22
+ type WasmSamplingResult,
23
+ type WasmSamplingStrategy,
24
+ } from './wasmSampling'
@@ -1,7 +1,8 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
 
3
- import type { WaveformPoint } from '../types'
3
+ import type { WaveformPoint, WaveformRenderingOptions } from '../types'
4
4
  import {
5
+ DEFAULT_WAVEFORM_RENDERING_OPTIONS,
5
6
  hasMinimumVisibleXValues,
6
7
  resolveWaveformRenderingOptions,
7
8
  selectDecorationPoints,
@@ -49,25 +50,80 @@ describe('waveform rendering selection', () => {
49
50
  pointMinSpacing: -1,
50
51
  errorBarMinSpacing: Number.POSITIVE_INFINITY,
51
52
  }),
52
- ).toEqual({
53
- downsample: true,
54
- downsampleThreshold: 2_000,
55
- maxPointsPerPixel: 4,
56
- pointMinSpacing: 10,
57
- errorBarMinSpacing: 12,
58
- })
53
+ ).toEqual(DEFAULT_WAVEFORM_RENDERING_OPTIONS)
59
54
  })
60
55
 
61
56
  it('accepts custom decoration spacing and uses zero to disable it', () => {
62
57
  expect(resolveWaveformRenderingOptions({ pointMinSpacing: 6, errorBarMinSpacing: 0 })).toEqual({
63
- downsample: true,
64
- downsampleThreshold: 2_000,
65
- maxPointsPerPixel: 4,
58
+ ...DEFAULT_WAVEFORM_RENDERING_OPTIONS,
66
59
  pointMinSpacing: 6,
67
60
  errorBarMinSpacing: 0,
68
61
  })
69
62
  })
70
63
 
64
+ it('normalizes sampling options with strict runtime validation', () => {
65
+ const options: WaveformRenderingOptions = {
66
+ sampling: {
67
+ mode: 'wasm',
68
+ autoThreshold: 1_001.9,
69
+ autoHysteresis: 20.8,
70
+ strategy: 'minmax',
71
+ maxPointsPerPixel: 2.5,
72
+ rawPointLimit: 50_000.2,
73
+ wasmFailureFallback: 'javascript',
74
+ },
75
+ }
76
+
77
+ expect(resolveWaveformRenderingOptions(options)).toMatchObject({
78
+ downsample: true,
79
+ maxPointsPerPixel: 2.5,
80
+ sampling: {
81
+ mode: 'wasm',
82
+ autoThreshold: 1_001,
83
+ autoHysteresis: 20,
84
+ strategy: 'minmax',
85
+ maxPointsPerPixel: 2.5,
86
+ rawPointLimit: 50_000,
87
+ wasmFailureFallback: 'javascript',
88
+ },
89
+ })
90
+
91
+ expect(
92
+ resolveWaveformRenderingOptions({
93
+ sampling: {
94
+ mode: 'invalid',
95
+ autoThreshold: 0,
96
+ autoHysteresis: -1,
97
+ strategy: 'invalid',
98
+ maxPointsPerPixel: Number.POSITIVE_INFINITY,
99
+ rawPointLimit: 0,
100
+ wasmFailureFallback: 'fallback',
101
+ },
102
+ } as unknown as WaveformRenderingOptions),
103
+ ).toMatchObject({ sampling: DEFAULT_WAVEFORM_RENDERING_OPTIONS.sampling })
104
+ })
105
+
106
+ it('maps legacy downsample flags and gives explicit sampling mode precedence', () => {
107
+ expect(resolveWaveformRenderingOptions({ downsample: false }).sampling.mode).toBe('raw')
108
+ expect(resolveWaveformRenderingOptions({ downsample: true }).sampling.mode).toBe('auto')
109
+ expect(
110
+ resolveWaveformRenderingOptions({ downsample: false, sampling: { mode: 'wasm' } }),
111
+ ).toMatchObject({ downsample: true, sampling: { mode: 'wasm' } })
112
+ })
113
+
114
+ it('prefers nested sampling density over the legacy rendering field', () => {
115
+ const legacy = resolveWaveformRenderingOptions({ maxPointsPerPixel: 2 })
116
+ const nested = resolveWaveformRenderingOptions({
117
+ maxPointsPerPixel: 2,
118
+ sampling: { maxPointsPerPixel: 3 },
119
+ })
120
+
121
+ expect(legacy.maxPointsPerPixel).toBe(2)
122
+ expect(legacy.sampling.maxPointsPerPixel).toBe(2)
123
+ expect(nested.maxPointsPerPixel).toBe(3)
124
+ expect(nested.sampling.maxPointsPerPixel).toBe(3)
125
+ })
126
+
71
127
  it('selects evenly distributed source points for dense decorations', () => {
72
128
  const selected = selectDecorationPoints(points, [0, 9_999], 100, 10, true)
73
129
 
@@ -135,7 +191,10 @@ describe('waveform rendering selection', () => {
135
191
  densePoints,
136
192
  [99_999, 0],
137
193
  500,
138
- resolveWaveformRenderingOptions({ downsampleThreshold: 100 }),
194
+ resolveWaveformRenderingOptions({
195
+ downsampleThreshold: 100,
196
+ sampling: { autoThreshold: 200_000 },
197
+ }),
139
198
  {
140
199
  lineVisible: true,
141
200
  pointVisible: true,
@@ -152,6 +211,22 @@ describe('waveform rendering selection', () => {
152
211
  expect(selected.errorBarRenderPoints.every((point) => (point.error ?? 0) > 0)).toBe(true)
153
212
  })
154
213
 
214
+ it('uses a width-bounded placeholder while large-series Worker sampling is pending', () => {
215
+ const rendering = resolveWaveformRenderingOptions({
216
+ sampling: { mode: 'auto', autoThreshold: 1_000, maxPointsPerPixel: 2 },
217
+ })
218
+ const selected = selectSeriesRenderPoints(points, [0, 9_999], 100, rendering, {
219
+ lineVisible: true,
220
+ pointVisible: false,
221
+ errorBarVisible: false,
222
+ hasErrorPoints: false,
223
+ })
224
+
225
+ expect(selected.linePoints).toHaveLength(200)
226
+ expect(selected.linePoints[0]).toBe(points[0])
227
+ expect(selected.linePoints.at(-1)).toBe(points.at(-1))
228
+ })
229
+
155
230
  it('counts unique visible x values across series and reversed domains', () => {
156
231
  const first = {
157
232
  points: [
@@ -12,6 +12,7 @@ export {
12
12
  DEFAULT_WAVEFORM_RENDERING_OPTIONS,
13
13
  resolveWaveformRenderingOptions,
14
14
  type ResolvedWaveformRenderingOptions,
15
+ type ResolvedWaveformSamplingOptions,
15
16
  } from './renderingOptions'
16
17
 
17
18
  const pointBisector = bisector((point: WaveformPoint) => point.x)
@@ -23,6 +24,8 @@ interface PointSeriesSource {
23
24
 
24
25
  interface SeriesRenderSelectionOptions {
25
26
  lineVisible: boolean
27
+ /** A current Worker result used only for the SVG line. */
28
+ linePointOverride?: WaveformPoint[]
26
29
  pointVisible: boolean
27
30
  errorBarVisible: boolean
28
31
  hasErrorPoints: boolean
@@ -84,6 +87,34 @@ function selectRenderablePointsInRange(
84
87
  })
85
88
  }
86
89
 
90
+ function shouldDeferLineSampling(
91
+ visibleCount: number,
92
+ options: ResolvedWaveformRenderingOptions,
93
+ ): boolean {
94
+ if (options.sampling.mode === 'wasm') return true
95
+ return options.sampling.mode === 'auto' && visibleCount > options.sampling.autoThreshold
96
+ }
97
+
98
+ function selectSamplingPlaceholderPoints(
99
+ points: WaveformPoint[],
100
+ range: VisiblePointRange,
101
+ width: number,
102
+ options: ResolvedWaveformRenderingOptions,
103
+ ): WaveformPoint[] {
104
+ const start = Math.max(0, range.start - 1)
105
+ const end = Math.min(points.length, range.end + 1)
106
+ const count = end - start
107
+ if (count <= 0) return []
108
+ if (options.sampling.strategy === 'none') return points.slice(start, end)
109
+ const target = Math.max(1, Math.floor(width * options.sampling.maxPointsPerPixel))
110
+ if (count <= target) return points.slice(start, end)
111
+ if (target === 1) return [points[start]!]
112
+ return Array.from({ length: target }, (_, index) => {
113
+ const offset = Math.round((index * (count - 1)) / (target - 1))
114
+ return points[start + offset]!
115
+ })
116
+ }
117
+
87
118
  /**
88
119
  * Select the visible source range and preserve first/min/max/last values in each X bucket.
89
120
  * Source points must be sorted by X.
@@ -247,7 +278,10 @@ export function selectSeriesRenderPoints(
247
278
  }
248
279
  const range = resolveVisiblePointRange(points, domain)
249
280
  const linePoints = selection.lineVisible
250
- ? selectRenderablePointsInRange(points, range, domain, width, rendering)
281
+ ? (selection.linePointOverride ??
282
+ (shouldDeferLineSampling(range.end - range.start, rendering)
283
+ ? selectSamplingPlaceholderPoints(points, range, width, rendering)
284
+ : selectRenderablePointsInRange(points, range, domain, width, rendering)))
251
285
  : []
252
286
  const errorBarVisible = selection.errorBarVisible && selection.hasErrorPoints
253
287
  if (selection.pointVisible && errorBarVisible) {
@@ -1,4 +1,19 @@
1
- import type { WaveformRenderingOptions } from '@/types'
1
+ import type {
2
+ WaveformRenderingOptions,
3
+ WaveformSamplingMode,
4
+ WaveformSamplingOptions,
5
+ WaveformSamplingStrategy,
6
+ } from '@/types'
7
+
8
+ export interface ResolvedWaveformSamplingOptions {
9
+ mode: WaveformSamplingMode
10
+ autoThreshold: number
11
+ autoHysteresis: number
12
+ strategy: WaveformSamplingStrategy
13
+ maxPointsPerPixel: number
14
+ rawPointLimit: number
15
+ wasmFailureFallback: 'error' | 'javascript'
16
+ }
2
17
 
3
18
  export interface ResolvedWaveformRenderingOptions {
4
19
  downsample: boolean
@@ -6,6 +21,7 @@ export interface ResolvedWaveformRenderingOptions {
6
21
  maxPointsPerPixel: number
7
22
  pointMinSpacing: number
8
23
  errorBarMinSpacing: number
24
+ sampling: ResolvedWaveformSamplingOptions
9
25
  }
10
26
 
11
27
  export const DEFAULT_WAVEFORM_RENDERING_OPTIONS: ResolvedWaveformRenderingOptions = {
@@ -14,32 +30,125 @@ export const DEFAULT_WAVEFORM_RENDERING_OPTIONS: ResolvedWaveformRenderingOption
14
30
  maxPointsPerPixel: 4,
15
31
  pointMinSpacing: 10,
16
32
  errorBarMinSpacing: 12,
33
+ sampling: {
34
+ mode: 'auto',
35
+ autoThreshold: 1_000,
36
+ autoHysteresis: 0,
37
+ strategy: 'peak',
38
+ maxPointsPerPixel: 4,
39
+ rawPointLimit: 100_000,
40
+ wasmFailureFallback: 'error',
41
+ },
42
+ }
43
+
44
+ const samplingModes: readonly WaveformSamplingMode[] = ['auto', 'wasm', 'raw']
45
+ const samplingStrategies: readonly WaveformSamplingStrategy[] = [
46
+ 'auto',
47
+ 'none',
48
+ 'peak',
49
+ 'lttb',
50
+ 'average',
51
+ 'min',
52
+ 'max',
53
+ 'minmax',
54
+ 'sum',
55
+ ]
56
+
57
+ function isSamplingMode(value: unknown): value is WaveformSamplingMode {
58
+ return typeof value === 'string' && samplingModes.includes(value as WaveformSamplingMode)
59
+ }
60
+
61
+ function isSamplingStrategy(value: unknown): value is WaveformSamplingStrategy {
62
+ return typeof value === 'string' && samplingStrategies.includes(value as WaveformSamplingStrategy)
63
+ }
64
+
65
+ function resolveFiniteInteger(value: unknown, minimum: number, fallback: number): number {
66
+ return typeof value === 'number' && Number.isFinite(value) && value >= minimum
67
+ ? Math.floor(value)
68
+ : fallback
69
+ }
70
+
71
+ function resolvePositiveNumber(value: unknown, fallback: number): number {
72
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback
73
+ }
74
+
75
+ function resolveNonNegativeNumber(value: number, fallback: number): number {
76
+ return Number.isFinite(value) && value >= 0 ? value : fallback
77
+ }
78
+
79
+ function resolveSamplingOptions(
80
+ options: WaveformSamplingOptions | undefined,
81
+ legacyDownsample: boolean | undefined,
82
+ legacyMaxPointsPerPixel: number,
83
+ ): ResolvedWaveformSamplingOptions {
84
+ const mode = isSamplingMode(options?.mode)
85
+ ? options.mode
86
+ : legacyDownsample === false
87
+ ? 'raw'
88
+ : DEFAULT_WAVEFORM_RENDERING_OPTIONS.sampling.mode
89
+ const maxPointsPerPixel = resolvePositiveNumber(
90
+ options?.maxPointsPerPixel,
91
+ legacyMaxPointsPerPixel,
92
+ )
93
+ return {
94
+ mode,
95
+ autoThreshold: resolveFiniteInteger(
96
+ options?.autoThreshold,
97
+ 1,
98
+ DEFAULT_WAVEFORM_RENDERING_OPTIONS.sampling.autoThreshold,
99
+ ),
100
+ autoHysteresis: resolveFiniteInteger(
101
+ options?.autoHysteresis,
102
+ 0,
103
+ DEFAULT_WAVEFORM_RENDERING_OPTIONS.sampling.autoHysteresis,
104
+ ),
105
+ strategy: isSamplingStrategy(options?.strategy)
106
+ ? options.strategy
107
+ : DEFAULT_WAVEFORM_RENDERING_OPTIONS.sampling.strategy,
108
+ maxPointsPerPixel,
109
+ rawPointLimit: resolveFiniteInteger(
110
+ options?.rawPointLimit,
111
+ 1,
112
+ DEFAULT_WAVEFORM_RENDERING_OPTIONS.sampling.rawPointLimit,
113
+ ),
114
+ wasmFailureFallback:
115
+ options?.wasmFailureFallback === 'javascript' || options?.wasmFailureFallback === 'error'
116
+ ? options.wasmFailureFallback
117
+ : DEFAULT_WAVEFORM_RENDERING_OPTIONS.sampling.wasmFailureFallback,
118
+ }
17
119
  }
18
120
 
19
121
  export function resolveWaveformRenderingOptions(
20
122
  options?: WaveformRenderingOptions,
21
123
  ): ResolvedWaveformRenderingOptions {
22
124
  const threshold = Number(options?.downsampleThreshold)
23
- const pointsPerPixel = Number(options?.maxPointsPerPixel)
125
+ const legacyMaxPointsPerPixel = Number(options?.maxPointsPerPixel)
24
126
  const pointMinSpacing = Number(options?.pointMinSpacing)
25
127
  const errorBarMinSpacing = Number(options?.errorBarMinSpacing)
128
+ const resolvedLegacyMaxPointsPerPixel =
129
+ Number.isFinite(legacyMaxPointsPerPixel) && legacyMaxPointsPerPixel > 0
130
+ ? legacyMaxPointsPerPixel
131
+ : DEFAULT_WAVEFORM_RENDERING_OPTIONS.maxPointsPerPixel
132
+ const sampling = resolveSamplingOptions(
133
+ options?.sampling,
134
+ typeof options?.downsample === 'boolean' ? options.downsample : undefined,
135
+ resolvedLegacyMaxPointsPerPixel,
136
+ )
26
137
  return {
27
- downsample: options?.downsample ?? DEFAULT_WAVEFORM_RENDERING_OPTIONS.downsample,
138
+ downsample: sampling.mode !== 'raw',
28
139
  downsampleThreshold:
29
140
  Number.isFinite(threshold) && threshold >= 2
30
141
  ? Math.floor(threshold)
31
142
  : DEFAULT_WAVEFORM_RENDERING_OPTIONS.downsampleThreshold,
32
- maxPointsPerPixel:
33
- Number.isFinite(pointsPerPixel) && pointsPerPixel > 0
34
- ? pointsPerPixel
35
- : DEFAULT_WAVEFORM_RENDERING_OPTIONS.maxPointsPerPixel,
36
- pointMinSpacing:
37
- Number.isFinite(pointMinSpacing) && pointMinSpacing >= 0
38
- ? pointMinSpacing
39
- : DEFAULT_WAVEFORM_RENDERING_OPTIONS.pointMinSpacing,
40
- errorBarMinSpacing:
41
- Number.isFinite(errorBarMinSpacing) && errorBarMinSpacing >= 0
42
- ? errorBarMinSpacing
43
- : DEFAULT_WAVEFORM_RENDERING_OPTIONS.errorBarMinSpacing,
143
+ maxPointsPerPixel: sampling.maxPointsPerPixel,
144
+ pointMinSpacing: resolveNonNegativeNumber(
145
+ pointMinSpacing,
146
+ DEFAULT_WAVEFORM_RENDERING_OPTIONS.pointMinSpacing,
147
+ ),
148
+ errorBarMinSpacing: resolveNonNegativeNumber(
149
+ errorBarMinSpacing,
150
+ DEFAULT_WAVEFORM_RENDERING_OPTIONS.errorBarMinSpacing,
151
+ ),
152
+ sampling,
44
153
  }
45
154
  }