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
@@ -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
  }
@@ -0,0 +1,124 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import type { WaveformPoint } from '../types'
4
+ import {
5
+ sampleWaveformReference,
6
+ type ReferenceSamplingStrategy,
7
+ type SourceIndexSamplingResult,
8
+ } from './samplingReference'
9
+ import {
10
+ REFERENCE_AGGREGATE_VECTORS,
11
+ REFERENCE_SAMPLING_POINTS,
12
+ REFERENCE_SOURCE_INDEX_VECTORS,
13
+ } from './samplingReferenceVectors'
14
+
15
+ function indexes(strategy: Exclude<ReferenceSamplingStrategy, 'average' | 'sum'>, target: number) {
16
+ const result = sampleWaveformReference({
17
+ points: REFERENCE_SAMPLING_POINTS,
18
+ strategy,
19
+ targetPointCount: target,
20
+ })
21
+ expect(result.kind).toBe('source-indexes')
22
+ return Array.from((result as SourceIndexSamplingResult).sourceIndexes)
23
+ }
24
+
25
+ describe('sampleWaveformReference', () => {
26
+ it('locks the source-index strategy vectors', () => {
27
+ for (const vector of REFERENCE_SOURCE_INDEX_VECTORS) {
28
+ expect(indexes(vector.strategy, vector.targetPointCount)).toEqual(
29
+ vector.expectedSourceIndexes,
30
+ )
31
+ }
32
+ })
33
+
34
+ it('locks the aggregate bucket and synthesized-X semantics', () => {
35
+ for (const vector of REFERENCE_AGGREGATE_VECTORS) {
36
+ const result = sampleWaveformReference({
37
+ points: REFERENCE_SAMPLING_POINTS,
38
+ strategy: vector.strategy,
39
+ targetPointCount: vector.targetPointCount,
40
+ })
41
+
42
+ expect(result).toMatchObject({ kind: 'aggregates', validPointCount: 8 })
43
+ expect(Array.from(result.kind === 'aggregates' ? result.x : [])).toEqual(vector.expectedX)
44
+ expect(Array.from(result.kind === 'aggregates' ? result.y : [])).toEqual(vector.expectedY)
45
+ }
46
+ })
47
+
48
+ it('uses the earliest point for equal extrema and removes duplicate peak entries', () => {
49
+ const tied = [
50
+ { x: 0, y: 0 },
51
+ { x: 1, y: -5 },
52
+ { x: 2, y: -5 },
53
+ { x: 3, y: 5 },
54
+ { x: 4, y: 5 },
55
+ { x: 5, y: 0 },
56
+ { x: 6, y: 0 },
57
+ ]
58
+
59
+ const minmax = sampleWaveformReference({
60
+ points: tied,
61
+ strategy: 'minmax',
62
+ targetPointCount: 4,
63
+ })
64
+ const peak = sampleWaveformReference({ points: tied, strategy: 'peak', targetPointCount: 6 })
65
+
66
+ expect(Array.from(minmax.kind === 'source-indexes' ? minmax.sourceIndexes : [])).toEqual([
67
+ 0, 1, 3, 6,
68
+ ])
69
+ expect(Array.from(peak.kind === 'source-indexes' ? peak.sourceIndexes : [])).toEqual([
70
+ 0, 1, 3, 5, 6,
71
+ ])
72
+ })
73
+
74
+ it('filters invalid points, stably orders X, and returns indexes into the original input', () => {
75
+ const input: WaveformPoint[] = [
76
+ { x: 2, y: 20 },
77
+ { x: Number.NaN, y: 99 },
78
+ { x: 1, y: 10 },
79
+ { x: 1, y: 11 },
80
+ { x: 3, y: Number.POSITIVE_INFINITY },
81
+ ]
82
+ const result = sampleWaveformReference({ points: input, strategy: 'none', targetPointCount: 1 })
83
+
84
+ expect(result).toMatchObject({ inputPointCount: 5, validPointCount: 3, kind: 'source-indexes' })
85
+ expect(Array.from(result.kind === 'source-indexes' ? result.sourceIndexes : [])).toEqual([
86
+ 2, 3, 0,
87
+ ])
88
+ expect(input).toEqual([
89
+ { x: 2, y: 20 },
90
+ { x: Number.NaN, y: 99 },
91
+ { x: 1, y: 10 },
92
+ { x: 1, y: 11 },
93
+ { x: 3, y: Number.POSITIVE_INFINITY },
94
+ ])
95
+ })
96
+
97
+ it('defines empty, short, and low-target behavior without exceeding the target', () => {
98
+ const strategies: ReferenceSamplingStrategy[] = [
99
+ 'none',
100
+ 'peak',
101
+ 'lttb',
102
+ 'average',
103
+ 'min',
104
+ 'max',
105
+ 'minmax',
106
+ 'sum',
107
+ ]
108
+ for (const strategy of strategies) {
109
+ const empty = sampleWaveformReference({ points: [], strategy, targetPointCount: 3 })
110
+ expect(empty.kind === 'aggregates' ? empty.x : empty.sourceIndexes).toHaveLength(0)
111
+
112
+ const short = sampleWaveformReference({
113
+ points: REFERENCE_SAMPLING_POINTS.slice(0, 2),
114
+ strategy,
115
+ targetPointCount: 10,
116
+ })
117
+ expect(short.kind === 'aggregates' ? short.x : short.sourceIndexes).toHaveLength(2)
118
+ }
119
+
120
+ expect(indexes('lttb', 2)).toEqual([0, 7])
121
+ expect(indexes('minmax', 3)).toEqual([0, 7])
122
+ expect(indexes('peak', 6)).toHaveLength(5)
123
+ })
124
+ })
@@ -0,0 +1,243 @@
1
+ import type { WaveformPoint } from '../types'
2
+
3
+ /** Sampling algorithms that can be implemented equivalently by the future WASM core. */
4
+ export type ReferenceSamplingStrategy =
5
+ 'none' | 'peak' | 'lttb' | 'average' | 'min' | 'max' | 'minmax' | 'sum'
6
+
7
+ /**
8
+ * A pure TypeScript reference request. Source indexes in the result address the original
9
+ * `points` array, after filtering non-finite points and applying a stable ascending-X order.
10
+ */
11
+ export interface ReferenceSamplingRequest {
12
+ points: readonly WaveformPoint[]
13
+ strategy: ReferenceSamplingStrategy
14
+ targetPointCount: number
15
+ }
16
+
17
+ interface ReferenceSamplingResultBase {
18
+ strategy: ReferenceSamplingStrategy
19
+ inputPointCount: number
20
+ validPointCount: number
21
+ }
22
+
23
+ /** A result made of real points from the input sequence. */
24
+ export interface SourceIndexSamplingResult extends ReferenceSamplingResultBase {
25
+ kind: 'source-indexes'
26
+ sourceIndexes: Uint32Array
27
+ }
28
+
29
+ /** A result whose points are synthesized from a contiguous source bucket. */
30
+ export interface AggregateSamplingResult extends ReferenceSamplingResultBase {
31
+ kind: 'aggregates'
32
+ x: Float64Array
33
+ y: Float64Array
34
+ }
35
+
36
+ export type ReferenceSamplingResult = SourceIndexSamplingResult | AggregateSamplingResult
37
+
38
+ interface IndexedPoint {
39
+ sourceIndex: number
40
+ x: number
41
+ y: number
42
+ }
43
+
44
+ function normalizedPoints(points: readonly WaveformPoint[]): IndexedPoint[] {
45
+ const result: IndexedPoint[] = []
46
+ let ordered = true
47
+ let previousX = Number.NEGATIVE_INFINITY
48
+
49
+ for (let sourceIndex = 0; sourceIndex < points.length; sourceIndex += 1) {
50
+ const point = points[sourceIndex]
51
+ if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) continue
52
+ if (point.x < previousX) ordered = false
53
+ previousX = point.x
54
+ result.push({ sourceIndex, x: point.x, y: point.y })
55
+ }
56
+
57
+ // ES2019 guarantees stable Array#sort, preserving duplicate-X source order.
58
+ if (!ordered) result.sort((left, right) => left.x - right.x)
59
+ return result
60
+ }
61
+
62
+ function targetCount(value: number, pointCount: number) {
63
+ if (pointCount === 0) return 0
64
+ if (!Number.isFinite(value)) return pointCount
65
+ return Math.min(pointCount, Math.max(1, Math.floor(value)))
66
+ }
67
+
68
+ function bucketBounds(bucket: number, bucketCount: number, start: number, end: number) {
69
+ const span = end - start
70
+ return {
71
+ start: start + Math.floor((bucket * span) / bucketCount),
72
+ end: start + Math.floor(((bucket + 1) * span) / bucketCount),
73
+ }
74
+ }
75
+
76
+ function addUniqueIndex(indexes: number[], index: number) {
77
+ if (indexes[indexes.length - 1] !== index) indexes.push(index)
78
+ }
79
+
80
+ function withEndpoints(points: IndexedPoint[], target: number, selectInterior: () => number[]) {
81
+ if (points.length <= target) return points.map((_, index) => index)
82
+ if (target === 1) return [0]
83
+ if (target === 2) return [0, points.length - 1]
84
+
85
+ const indexes = [0]
86
+ for (const index of selectInterior()) addUniqueIndex(indexes, index)
87
+ addUniqueIndex(indexes, points.length - 1)
88
+ return indexes
89
+ }
90
+
91
+ function selectExtrema(points: IndexedPoint[], target: number, kind: 'min' | 'max' | 'minmax') {
92
+ const bucketCount = kind === 'minmax' ? Math.floor((target - 2) / 2) : Math.max(0, target - 2)
93
+
94
+ return withEndpoints(points, target, () => {
95
+ if (bucketCount === 0) return []
96
+ const result: number[] = []
97
+ for (let bucket = 0; bucket < bucketCount; bucket += 1) {
98
+ const bounds = bucketBounds(bucket, bucketCount, 1, points.length - 1)
99
+ let minimum = bounds.start
100
+ let maximum = bounds.start
101
+ for (let index = bounds.start + 1; index < bounds.end; index += 1) {
102
+ if (points[index]!.y < points[minimum]!.y) minimum = index
103
+ if (points[index]!.y > points[maximum]!.y) maximum = index
104
+ }
105
+ if (kind === 'min') {
106
+ addUniqueIndex(result, minimum)
107
+ } else if (kind === 'max') {
108
+ addUniqueIndex(result, maximum)
109
+ } else if (minimum <= maximum) {
110
+ addUniqueIndex(result, minimum)
111
+ addUniqueIndex(result, maximum)
112
+ } else {
113
+ addUniqueIndex(result, maximum)
114
+ addUniqueIndex(result, minimum)
115
+ }
116
+ }
117
+ return result
118
+ })
119
+ }
120
+
121
+ function selectPeak(points: IndexedPoint[], target: number) {
122
+ const bucketCount = Math.floor((target - 2) / 4)
123
+ return withEndpoints(points, target, () => {
124
+ if (bucketCount === 0) return []
125
+ const result: number[] = []
126
+ for (let bucket = 0; bucket < bucketCount; bucket += 1) {
127
+ const bounds = bucketBounds(bucket, bucketCount, 1, points.length - 1)
128
+ let minimum = bounds.start
129
+ let maximum = bounds.start
130
+ for (let index = bounds.start + 1; index < bounds.end; index += 1) {
131
+ if (points[index]!.y < points[minimum]!.y) minimum = index
132
+ if (points[index]!.y > points[maximum]!.y) maximum = index
133
+ }
134
+ for (const index of [bounds.start, minimum, maximum, bounds.end - 1].sort((a, b) => a - b)) {
135
+ addUniqueIndex(result, index)
136
+ }
137
+ }
138
+ return result
139
+ })
140
+ }
141
+
142
+ function selectLttb(points: IndexedPoint[], target: number) {
143
+ return withEndpoints(points, target, () => {
144
+ const bucketSize = (points.length - 2) / (target - 2)
145
+ const result: number[] = []
146
+ let previous = 0
147
+
148
+ for (let bucket = 0; bucket < target - 2; bucket += 1) {
149
+ const averageStart = Math.min(points.length, Math.floor((bucket + 1) * bucketSize) + 1)
150
+ const averageEnd = Math.min(points.length, Math.floor((bucket + 2) * bucketSize) + 1)
151
+ let averageX = 0
152
+ let averageY = 0
153
+ for (let index = averageStart; index < averageEnd; index += 1) {
154
+ averageX += points[index]!.x
155
+ averageY += points[index]!.y
156
+ }
157
+ const averageCount = averageEnd - averageStart
158
+ if (averageCount > 0) {
159
+ averageX /= averageCount
160
+ averageY /= averageCount
161
+ } else {
162
+ averageX = points[points.length - 1]!.x
163
+ averageY = points[points.length - 1]!.y
164
+ }
165
+
166
+ const bounds = bucketBounds(bucket, target - 2, 1, points.length - 1)
167
+ let selected = bounds.start
168
+ let maximumArea = -1
169
+ for (let index = bounds.start; index < bounds.end; index += 1) {
170
+ const area = Math.abs(
171
+ (points[previous]!.x - averageX) * (points[index]!.y - points[previous]!.y) -
172
+ (points[previous]!.x - points[index]!.x) * (averageY - points[previous]!.y),
173
+ )
174
+ if (area > maximumArea) {
175
+ maximumArea = area
176
+ selected = index
177
+ }
178
+ }
179
+ result.push(selected)
180
+ previous = selected
181
+ }
182
+ return result
183
+ })
184
+ }
185
+
186
+ function aggregate(points: IndexedPoint[], target: number, strategy: 'average' | 'sum') {
187
+ const count = targetCount(target, points.length)
188
+ const x = new Float64Array(count)
189
+ const y = new Float64Array(count)
190
+ for (let bucket = 0; bucket < count; bucket += 1) {
191
+ const bounds = bucketBounds(bucket, count, 0, points.length)
192
+ for (let index = bounds.start; index < bounds.end; index += 1) {
193
+ x[bucket] += points[index]!.x
194
+ y[bucket] += points[index]!.y
195
+ }
196
+ const bucketSize = bounds.end - bounds.start
197
+ x[bucket] /= bucketSize
198
+ if (strategy === 'average') y[bucket] /= bucketSize
199
+ }
200
+ return { x, y }
201
+ }
202
+
203
+ /**
204
+ * Samples an immutable point sequence without changing its objects or order. Aggregate X values
205
+ * are the arithmetic mean of each contiguous bucket's X values; extrema ties select the earliest
206
+ * source point. Source-point strategies preserve first and last points whenever the target is 2+.
207
+ */
208
+ export function sampleWaveformReference(
209
+ request: ReferenceSamplingRequest,
210
+ ): ReferenceSamplingResult {
211
+ const points = normalizedPoints(request.points)
212
+ const base = {
213
+ strategy: request.strategy,
214
+ inputPointCount: request.points.length,
215
+ validPointCount: points.length,
216
+ }
217
+
218
+ if (request.strategy === 'average' || request.strategy === 'sum') {
219
+ return {
220
+ ...base,
221
+ kind: 'aggregates',
222
+ ...aggregate(points, request.targetPointCount, request.strategy),
223
+ }
224
+ }
225
+
226
+ const target = targetCount(request.targetPointCount, points.length)
227
+ let indexes: number[]
228
+ if (request.strategy === 'none' || points.length <= target) {
229
+ indexes = points.map((_, index) => index)
230
+ } else if (request.strategy === 'peak') {
231
+ indexes = selectPeak(points, target)
232
+ } else if (request.strategy === 'lttb') {
233
+ indexes = selectLttb(points, target)
234
+ } else {
235
+ indexes = selectExtrema(points, target, request.strategy)
236
+ }
237
+
238
+ return {
239
+ ...base,
240
+ kind: 'source-indexes',
241
+ sourceIndexes: Uint32Array.from(indexes, (index) => points[index]!.sourceIndex),
242
+ }
243
+ }