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,169 @@
1
+ import type { ReferenceSamplingStrategy } from './samplingReference'
2
+ import type { WorkerSamplingOutput } from './workerSampling/protocol'
3
+
4
+ interface Bounds {
5
+ start: number
6
+ end: number
7
+ }
8
+
9
+ function targetCount(value: number, pointCount: number) {
10
+ if (pointCount === 0) return 0
11
+ if (!Number.isFinite(value)) return pointCount
12
+ return Math.min(pointCount, Math.max(1, Math.floor(value)))
13
+ }
14
+
15
+ function bucketBounds(bucket: number, bucketCount: number, start: number, end: number): Bounds {
16
+ const span = end - start
17
+ return {
18
+ start: start + Math.floor((bucket * span) / bucketCount),
19
+ end: start + Math.floor(((bucket + 1) * span) / bucketCount),
20
+ }
21
+ }
22
+
23
+ function addUnique(indexes: number[], index: number) {
24
+ if (indexes[indexes.length - 1] !== index) indexes.push(index)
25
+ }
26
+
27
+ function withEndpoints(length: number, target: number, selectInterior: () => number[]) {
28
+ if (length <= target) return Array.from({ length }, (_, index) => index)
29
+ if (target === 1) return [0]
30
+ if (target === 2) return [0, length - 1]
31
+ const indexes = [0]
32
+ selectInterior().forEach((index) => addUnique(indexes, index))
33
+ addUnique(indexes, length - 1)
34
+ return indexes
35
+ }
36
+
37
+ function extremaIndexes(y: Float64Array, target: number, strategy: 'min' | 'max' | 'minmax') {
38
+ const bucketCount = strategy === 'minmax' ? Math.floor((target - 2) / 2) : Math.max(0, target - 2)
39
+ return withEndpoints(y.length, target, () => {
40
+ if (!bucketCount) return []
41
+ const result: number[] = []
42
+ for (let bucket = 0; bucket < bucketCount; bucket += 1) {
43
+ const bounds = bucketBounds(bucket, bucketCount, 1, y.length - 1)
44
+ let minimum = bounds.start
45
+ let maximum = bounds.start
46
+ for (let index = bounds.start + 1; index < bounds.end; index += 1) {
47
+ if (y[index]! < y[minimum]!) minimum = index
48
+ if (y[index]! > y[maximum]!) maximum = index
49
+ }
50
+ if (strategy === 'min') addUnique(result, minimum)
51
+ else if (strategy === 'max') addUnique(result, maximum)
52
+ else if (minimum <= maximum) {
53
+ addUnique(result, minimum)
54
+ addUnique(result, maximum)
55
+ } else {
56
+ addUnique(result, maximum)
57
+ addUnique(result, minimum)
58
+ }
59
+ }
60
+ return result
61
+ })
62
+ }
63
+
64
+ function peakIndexes(y: Float64Array, target: number) {
65
+ const bucketCount = Math.floor((target - 2) / 4)
66
+ return withEndpoints(y.length, target, () => {
67
+ if (!bucketCount) return []
68
+ const result: number[] = []
69
+ for (let bucket = 0; bucket < bucketCount; bucket += 1) {
70
+ const bounds = bucketBounds(bucket, bucketCount, 1, y.length - 1)
71
+ let minimum = bounds.start
72
+ let maximum = bounds.start
73
+ for (let index = bounds.start + 1; index < bounds.end; index += 1) {
74
+ if (y[index]! < y[minimum]!) minimum = index
75
+ if (y[index]! > y[maximum]!) maximum = index
76
+ }
77
+ ;[bounds.start, minimum, maximum, bounds.end - 1]
78
+ .sort((left, right) => left - right)
79
+ .forEach((index) => addUnique(result, index))
80
+ }
81
+ return result
82
+ })
83
+ }
84
+
85
+ function lttbIndexes(x: Float64Array, y: Float64Array, target: number) {
86
+ return withEndpoints(x.length, target, () => {
87
+ const bucketSize = (x.length - 2) / (target - 2)
88
+ const result: number[] = []
89
+ let previous = 0
90
+ for (let bucket = 0; bucket < target - 2; bucket += 1) {
91
+ const averageStart = Math.min(x.length, Math.floor((bucket + 1) * bucketSize) + 1)
92
+ const averageEnd = Math.min(x.length, Math.floor((bucket + 2) * bucketSize) + 1)
93
+ let averageX = 0
94
+ let averageY = 0
95
+ for (let index = averageStart; index < averageEnd; index += 1) {
96
+ averageX += x[index]!
97
+ averageY += y[index]!
98
+ }
99
+ const averageCount = averageEnd - averageStart
100
+ if (averageCount) {
101
+ averageX /= averageCount
102
+ averageY /= averageCount
103
+ } else {
104
+ averageX = x[x.length - 1]!
105
+ averageY = y[y.length - 1]!
106
+ }
107
+ const bounds = bucketBounds(bucket, target - 2, 1, x.length - 1)
108
+ let selected = bounds.start
109
+ let maximumArea = -1
110
+ for (let index = bounds.start; index < bounds.end; index += 1) {
111
+ const area = Math.abs(
112
+ (x[previous]! - averageX) * (y[index]! - y[previous]!) -
113
+ (x[previous]! - x[index]!) * (averageY - y[previous]!),
114
+ )
115
+ if (area > maximumArea) {
116
+ maximumArea = area
117
+ selected = index
118
+ }
119
+ }
120
+ result.push(selected)
121
+ previous = selected
122
+ }
123
+ return result
124
+ })
125
+ }
126
+
127
+ function aggregates(
128
+ x: Float64Array,
129
+ y: Float64Array,
130
+ target: number,
131
+ strategy: 'average' | 'sum',
132
+ ): WorkerSamplingOutput {
133
+ const count = targetCount(target, x.length)
134
+ const outputX = new Float64Array(count)
135
+ const outputY = new Float64Array(count)
136
+ for (let bucket = 0; bucket < count; bucket += 1) {
137
+ const bounds = bucketBounds(bucket, count, 0, x.length)
138
+ for (let index = bounds.start; index < bounds.end; index += 1) {
139
+ outputX[bucket] += x[index]!
140
+ outputY[bucket] += y[index]!
141
+ }
142
+ const size = bounds.end - bounds.start
143
+ outputX[bucket] /= size
144
+ if (strategy === 'average') outputY[bucket] /= size
145
+ }
146
+ return { kind: 'aggregates', x: outputX, y: outputY }
147
+ }
148
+
149
+ /** Numeric JavaScript fallback matching the WASM source-index and aggregate contracts. */
150
+ export function sampleWaveformReferenceValues(
151
+ x: Float64Array,
152
+ y: Float64Array,
153
+ strategy: ReferenceSamplingStrategy,
154
+ targetPointCount: number,
155
+ ): WorkerSamplingOutput {
156
+ if (x.length !== y.length) throw new Error('x and y must have the same length')
157
+ if (strategy === 'average' || strategy === 'sum')
158
+ return aggregates(x, y, targetPointCount, strategy)
159
+ const target = targetCount(targetPointCount, x.length)
160
+ const indexes =
161
+ strategy === 'none' || x.length <= target
162
+ ? Array.from({ length: x.length }, (_, index) => index)
163
+ : strategy === 'peak'
164
+ ? peakIndexes(y, target)
165
+ : strategy === 'lttb'
166
+ ? lttbIndexes(x, y, target)
167
+ : extremaIndexes(y, target, strategy)
168
+ return { kind: 'source-indexes', sourceIndexes: Uint32Array.from(indexes) }
169
+ }
@@ -0,0 +1,46 @@
1
+ import type { WaveformPoint } from '../types'
2
+ import type { ReferenceSamplingStrategy } from './samplingReference'
3
+
4
+ /** Stable source data shared by the TypeScript reference and future WASM conformance suites. */
5
+ export const REFERENCE_SAMPLING_POINTS: readonly WaveformPoint[] = [
6
+ { x: 0, y: 4 },
7
+ { x: 1, y: 1 },
8
+ { x: 2, y: 8 },
9
+ { x: 3, y: 2 },
10
+ { x: 4, y: 7 },
11
+ { x: 5, y: 0 },
12
+ { x: 6, y: 9 },
13
+ { x: 7, y: 3 },
14
+ ]
15
+
16
+ export interface SourceIndexSamplingVector {
17
+ strategy: Exclude<ReferenceSamplingStrategy, 'average' | 'sum'>
18
+ targetPointCount: number
19
+ expectedSourceIndexes: readonly number[]
20
+ }
21
+
22
+ export const REFERENCE_SOURCE_INDEX_VECTORS: readonly SourceIndexSamplingVector[] = [
23
+ { strategy: 'none', targetPointCount: 3, expectedSourceIndexes: [0, 1, 2, 3, 4, 5, 6, 7] },
24
+ { strategy: 'peak', targetPointCount: 6, expectedSourceIndexes: [0, 1, 5, 6, 7] },
25
+ { strategy: 'min', targetPointCount: 6, expectedSourceIndexes: [0, 1, 3, 4, 5, 7] },
26
+ { strategy: 'max', targetPointCount: 6, expectedSourceIndexes: [0, 1, 2, 4, 6, 7] },
27
+ { strategy: 'minmax', targetPointCount: 6, expectedSourceIndexes: [0, 1, 2, 5, 6, 7] },
28
+ { strategy: 'lttb', targetPointCount: 5, expectedSourceIndexes: [0, 2, 3, 6, 7] },
29
+ ]
30
+
31
+ export interface AggregateSamplingVector {
32
+ strategy: Extract<ReferenceSamplingStrategy, 'average' | 'sum'>
33
+ targetPointCount: number
34
+ expectedX: readonly number[]
35
+ expectedY: readonly number[]
36
+ }
37
+
38
+ export const REFERENCE_AGGREGATE_VECTORS: readonly AggregateSamplingVector[] = [
39
+ {
40
+ strategy: 'average',
41
+ targetPointCount: 3,
42
+ expectedX: [0.5, 3, 6],
43
+ expectedY: [2.5, 17 / 3, 4],
44
+ },
45
+ { strategy: 'sum', targetPointCount: 3, expectedX: [0.5, 3, 6], expectedY: [5, 17, 12] },
46
+ ]
@@ -0,0 +1,205 @@
1
+ /// <reference types="node" />
2
+
3
+ import { readFile } from 'node:fs/promises'
4
+ import { resolve } from 'node:path'
5
+
6
+ import { beforeAll, describe, expect, it } from 'vitest'
7
+
8
+ import { sampleWaveformReference } from './samplingReference'
9
+ import {
10
+ REFERENCE_AGGREGATE_VECTORS,
11
+ REFERENCE_SAMPLING_POINTS,
12
+ REFERENCE_SOURCE_INDEX_VECTORS,
13
+ } from './samplingReferenceVectors'
14
+ import {
15
+ calculateWasmRange,
16
+ createInitializedWasmSamplingBackend,
17
+ findWasmVisibleRange,
18
+ initializeWasmSampling,
19
+ sampleWaveformWasm,
20
+ } from './wasmSampling'
21
+
22
+ const x = Float64Array.from(REFERENCE_SAMPLING_POINTS, (point) => point.x)
23
+ const y = Float64Array.from(REFERENCE_SAMPLING_POINTS, (point) => point.y)
24
+
25
+ describe('sampleWaveformWasm', () => {
26
+ beforeAll(async () => {
27
+ await initializeWasmSampling(
28
+ await readFile(resolve(process.cwd(), 'wasm/pkg/waveform_sampling_wasm_bg.wasm')),
29
+ )
30
+ })
31
+
32
+ it('matches the shared source-index vectors and TypeScript reference', async () => {
33
+ for (const vector of REFERENCE_SOURCE_INDEX_VECTORS) {
34
+ const wasm = await sampleWaveformWasm({ x, y, ...vector })
35
+ const reference = sampleWaveformReference({
36
+ points: REFERENCE_SAMPLING_POINTS,
37
+ strategy: vector.strategy,
38
+ targetPointCount: vector.targetPointCount,
39
+ })
40
+
41
+ expect(wasm.kind).toBe('source-indexes')
42
+ expect(reference.kind).toBe('source-indexes')
43
+ if (wasm.kind === 'source-indexes' && reference.kind === 'source-indexes') {
44
+ expect(Array.from(wasm.sourceIndexes)).toEqual(vector.expectedSourceIndexes)
45
+ expect(Array.from(wasm.sourceIndexes)).toEqual(Array.from(reference.sourceIndexes))
46
+ }
47
+ }
48
+ })
49
+
50
+ it('matches the shared aggregate vectors and TypeScript reference', async () => {
51
+ for (const vector of REFERENCE_AGGREGATE_VECTORS) {
52
+ const wasm = await sampleWaveformWasm({ x, y, ...vector })
53
+ const reference = sampleWaveformReference({
54
+ points: REFERENCE_SAMPLING_POINTS,
55
+ strategy: vector.strategy,
56
+ targetPointCount: vector.targetPointCount,
57
+ })
58
+
59
+ expect(wasm.kind).toBe('aggregates')
60
+ expect(reference.kind).toBe('aggregates')
61
+ if (wasm.kind === 'aggregates' && reference.kind === 'aggregates') {
62
+ expect(Array.from(wasm.x)).toEqual(vector.expectedX)
63
+ expect(Array.from(wasm.y)).toEqual(vector.expectedY)
64
+ expect(Array.from(wasm.x)).toEqual(Array.from(reference.x))
65
+ expect(Array.from(wasm.y)).toEqual(Array.from(reference.y))
66
+ }
67
+ }
68
+ })
69
+
70
+ it('preserves reference filtering, stable sorting, and original source indexes', async () => {
71
+ const wasm = await sampleWaveformWasm({
72
+ x: Float64Array.from([2, Number.NaN, 1, 1, 3]),
73
+ y: Float64Array.from([20, 99, 10, 11, Number.POSITIVE_INFINITY]),
74
+ strategy: 'none',
75
+ targetPointCount: 1,
76
+ })
77
+
78
+ expect(wasm).toEqual({ kind: 'source-indexes', sourceIndexes: Uint32Array.from([2, 3, 0]) })
79
+ })
80
+
81
+ it('calculates full domains and normalized visible ranges', async () => {
82
+ const range = await calculateWasmRange(
83
+ Float64Array.from([2, Number.NaN, 1, 1, 3]),
84
+ Float64Array.from([20, 99, 10, 11, Number.POSITIVE_INFINITY]),
85
+ )
86
+ const visible = await findWasmVisibleRange(
87
+ Float64Array.from([2, Number.NaN, 1, 1, 3]),
88
+ Float64Array.from([20, 99, 10, 11, Number.POSITIVE_INFINITY]),
89
+ [1, 1],
90
+ )
91
+
92
+ expect(range).toEqual({ x: [1, 2], y: [10, 20] })
93
+ expect(visible).toEqual([0, 2])
94
+ await expect(calculateWasmRange(Float64Array.of(0), new Float64Array())).rejects.toThrow(
95
+ 'x and y must have the same length',
96
+ )
97
+ })
98
+
99
+ it('matches the reference across strategy and target boundary cases', async () => {
100
+ const points = [
101
+ { x: 3, y: 3 },
102
+ { x: 1, y: -2 },
103
+ { x: 1, y: 4 },
104
+ { x: Number.NaN, y: 6 },
105
+ { x: 2, y: -1 },
106
+ { x: 4, y: Number.POSITIVE_INFINITY },
107
+ { x: 5, y: 0 },
108
+ ]
109
+ const caseX = Float64Array.from(points, (point) => point.x)
110
+ const caseY = Float64Array.from(points, (point) => point.y)
111
+ const strategies = ['none', 'peak', 'lttb', 'average', 'min', 'max', 'minmax', 'sum'] as const
112
+ const targets = [0, 1, 2, 3, 6, 20, Number.POSITIVE_INFINITY]
113
+
114
+ for (const strategy of strategies) {
115
+ for (const targetPointCount of targets) {
116
+ const wasm = await sampleWaveformWasm({
117
+ x: caseX,
118
+ y: caseY,
119
+ strategy,
120
+ targetPointCount,
121
+ })
122
+ const reference = sampleWaveformReference({ points, strategy, targetPointCount })
123
+
124
+ expect(wasm.kind).toBe(reference.kind)
125
+ if (wasm.kind === 'source-indexes' && reference.kind === 'source-indexes') {
126
+ expect(Array.from(wasm.sourceIndexes)).toEqual(Array.from(reference.sourceIndexes))
127
+ }
128
+ if (wasm.kind === 'aggregates' && reference.kind === 'aggregates') {
129
+ expect(Array.from(wasm.x)).toEqual(Array.from(reference.x))
130
+ expect(Array.from(wasm.y)).toEqual(Array.from(reference.y))
131
+ }
132
+ }
133
+ }
134
+ })
135
+
136
+ it('rejects coordinate arrays with unequal lengths', async () => {
137
+ await expect(
138
+ sampleWaveformWasm({
139
+ x: Float64Array.of(0),
140
+ y: new Float64Array(),
141
+ strategy: 'none',
142
+ targetPointCount: 1,
143
+ }),
144
+ ).rejects.toThrow('x and y must have the same length')
145
+ })
146
+
147
+ it('keeps a bounded dataset index in WASM for unaligned viewport queries and releases it', () => {
148
+ const backend = createInitializedWasmSamplingBackend()
149
+ const handle = backend.registerDataset(x, y, 1024 * 1024)
150
+ const visible = REFERENCE_SAMPLING_POINTS.slice(2, 9)
151
+
152
+ for (const strategy of ['peak', 'min', 'max', 'minmax'] as const) {
153
+ const actual = backend.sampleDataset(handle, 2, 9, strategy, 6)
154
+ const expected = sampleWaveformReference({
155
+ points: visible,
156
+ strategy,
157
+ targetPointCount: 6,
158
+ })
159
+ expect(actual.kind).toBe('source-indexes')
160
+ expect(expected.kind).toBe('source-indexes')
161
+ if (actual.kind === 'source-indexes' && expected.kind === 'source-indexes') {
162
+ expect(Array.from(actual.sourceIndexes)).toEqual(
163
+ Array.from(expected.sourceIndexes, (index) => index + 2),
164
+ )
165
+ }
166
+ }
167
+
168
+ const aggregates = backend.sampleDataset(handle, 2, 9, 'average', 4)
169
+ const expectedAggregates = sampleWaveformReference({
170
+ points: visible,
171
+ strategy: 'average',
172
+ targetPointCount: 4,
173
+ })
174
+ expect(aggregates.kind).toBe('aggregates')
175
+ expect(expectedAggregates.kind).toBe('aggregates')
176
+ if (aggregates.kind === 'aggregates' && expectedAggregates.kind === 'aggregates') {
177
+ aggregates.x.forEach((value, index) =>
178
+ expect(value).toBeCloseTo(expectedAggregates.x[index]!, 12),
179
+ )
180
+ aggregates.y.forEach((value, index) =>
181
+ expect(value).toBeCloseTo(expectedAggregates.y[index]!, 12),
182
+ )
183
+ }
184
+ expect(backend.datasetIndexBytes(handle)).toBeGreaterThan(0)
185
+ backend.disposeDataset(handle)
186
+ expect(backend.datasetIndexBytes(handle)).toBe(0)
187
+ })
188
+
189
+ it('registers evenly sampled values without an explicit X column', () => {
190
+ const backend = createInitializedWasmSamplingBackend()
191
+ const handle = backend.registerSamples(
192
+ new Float64Array([1, 9, 2]),
193
+ new Uint32Array([0, 2, 3]),
194
+ 10,
195
+ 2,
196
+ 1024 * 1024,
197
+ )
198
+
199
+ expect(backend.sampleDataset(handle, 0, 3, 'max', 3)).toMatchObject({
200
+ kind: 'source-indexes',
201
+ sourceIndexes: Uint32Array.from([0, 1, 2]),
202
+ })
203
+ backend.disposeDataset(handle)
204
+ })
205
+ })
@@ -0,0 +1,230 @@
1
+ import initWasm, {
2
+ calculate_range,
3
+ dataset_index_bytes,
4
+ dispose_all_datasets,
5
+ dispose_dataset,
6
+ find_visible_range,
7
+ register_dataset,
8
+ register_sample_dataset,
9
+ sample_aggregates,
10
+ sample_dataset_aggregates,
11
+ sample_dataset_indexes,
12
+ sample_indexes,
13
+ } from '../../wasm/pkg/waveform_sampling_wasm'
14
+ import type { InitInput } from '../../wasm/pkg/waveform_sampling_wasm'
15
+
16
+ import type { ReferenceSamplingStrategy } from './samplingReference'
17
+ import type { WorkerSamplingBackend, WorkerSamplingOutput } from './workerSampling/protocol'
18
+
19
+ export type WasmSamplingStrategy = ReferenceSamplingStrategy
20
+
21
+ export interface WasmSamplingRequest {
22
+ x: Float64Array
23
+ y: Float64Array
24
+ strategy: WasmSamplingStrategy
25
+ targetPointCount: number
26
+ }
27
+
28
+ export interface WasmSourceIndexSamplingResult {
29
+ kind: 'source-indexes'
30
+ sourceIndexes: Uint32Array
31
+ }
32
+
33
+ export interface WasmAggregateSamplingResult {
34
+ kind: 'aggregates'
35
+ x: Float64Array
36
+ y: Float64Array
37
+ }
38
+
39
+ export type WasmSamplingResult = WasmSourceIndexSamplingResult | WasmAggregateSamplingResult
40
+
41
+ export interface WasmDatasetSamplingBackend extends WorkerSamplingBackend {
42
+ registerDataset(x: Float64Array, y: Float64Array, indexMaxBytes?: number): number
43
+ registerSamples(
44
+ y: Float64Array,
45
+ sourceIndexes: Uint32Array | undefined,
46
+ startTime: number,
47
+ sampleRate: number,
48
+ indexMaxBytes?: number,
49
+ ): number
50
+ disposeDataset(handle: number): void
51
+ disposeAllDatasets(): void
52
+ datasetIndexBytes(handle: number): number
53
+ sampleDataset(
54
+ handle: number,
55
+ start: number,
56
+ end: number,
57
+ strategy: WasmSamplingStrategy,
58
+ targetPointCount: number,
59
+ ): WorkerSamplingOutput
60
+ }
61
+
62
+ export interface WasmRangeMetrics {
63
+ x: [number, number]
64
+ y: [number, number]
65
+ }
66
+
67
+ const strategyCodes: Record<WasmSamplingStrategy, number> = {
68
+ none: 0,
69
+ peak: 1,
70
+ lttb: 2,
71
+ average: 3,
72
+ min: 4,
73
+ max: 5,
74
+ minmax: 6,
75
+ sum: 7,
76
+ }
77
+
78
+ let initialization: Promise<void> | undefined
79
+
80
+ function targetPointCountForWasm(value: number, pointCount: number) {
81
+ if (pointCount === 0) return 0
82
+ if (!Number.isFinite(value)) return pointCount
83
+ return Math.min(pointCount, Math.max(1, Math.floor(value)))
84
+ }
85
+
86
+ function validateRequest(request: WasmSamplingRequest) {
87
+ if (request.x.length !== request.y.length) {
88
+ throw new Error('x and y must have the same length')
89
+ }
90
+ }
91
+
92
+ /** Initializes the bundled WebAssembly module once for the current JavaScript realm. */
93
+ export function initializeWasmSampling(input?: InitInput | Promise<InitInput>): Promise<void> {
94
+ initialization ??= Promise.resolve(initWasm(input)).then(() => undefined)
95
+ return initialization
96
+ }
97
+
98
+ /**
99
+ * Runs the pure numeric sampler on flat Float64Array coordinates. The caller owns source-data
100
+ * normalization and receives either original input indexes or newly allocated aggregate arrays.
101
+ */
102
+ export async function sampleWaveformWasm(
103
+ request: WasmSamplingRequest,
104
+ ): Promise<WasmSamplingResult> {
105
+ validateRequest(request)
106
+ await initializeWasmSampling()
107
+ const target = targetPointCountForWasm(request.targetPointCount, request.x.length)
108
+ const strategy = strategyCodes[request.strategy]
109
+ if (request.strategy !== 'average' && request.strategy !== 'sum') {
110
+ return {
111
+ kind: 'source-indexes',
112
+ sourceIndexes: sample_indexes(request.x, request.y, strategy, target),
113
+ }
114
+ }
115
+
116
+ const values = sample_aggregates(request.x, request.y, strategy, target)
117
+ const x = new Float64Array(values.length / 2)
118
+ const y = new Float64Array(values.length / 2)
119
+ for (let index = 0; index < x.length; index += 1) {
120
+ x[index] = values[index * 2]!
121
+ y[index] = values[index * 2 + 1]!
122
+ }
123
+ return { kind: 'aggregates', x, y }
124
+ }
125
+
126
+ /**
127
+ * Creates the synchronous backend used only after this realm has finished WASM initialization.
128
+ * Worker code calls `initializeWasmSampling` before exposing this backend to its repository.
129
+ */
130
+ export function createInitializedWasmSamplingBackend(): WasmDatasetSamplingBackend {
131
+ const aggregateOutput = (values: Float64Array): WasmAggregateSamplingResult => {
132
+ const x = new Float64Array(values.length / 2)
133
+ const y = new Float64Array(values.length / 2)
134
+ for (let index = 0; index < x.length; index += 1) {
135
+ x[index] = values[index * 2]!
136
+ y[index] = values[index * 2 + 1]!
137
+ }
138
+ return { kind: 'aggregates', x, y }
139
+ }
140
+ const backend: WasmDatasetSamplingBackend = {
141
+ kind: 'wasm',
142
+ sample({ x, y, strategy, targetPointCount }) {
143
+ const request = { x, y, strategy, targetPointCount }
144
+ validateRequest(request)
145
+ const target = targetPointCountForWasm(request.targetPointCount, request.x.length)
146
+ if (request.strategy !== 'average' && request.strategy !== 'sum') {
147
+ return {
148
+ kind: 'source-indexes' as const,
149
+ sourceIndexes: sample_indexes(
150
+ request.x,
151
+ request.y,
152
+ strategyCodes[request.strategy],
153
+ target,
154
+ ),
155
+ }
156
+ }
157
+ const values = sample_aggregates(
158
+ request.x,
159
+ request.y,
160
+ strategyCodes[request.strategy],
161
+ target,
162
+ )
163
+ return aggregateOutput(values)
164
+ },
165
+ registerDataset(x, y, indexMaxBytes) {
166
+ validateRequest({ x, y, strategy: 'peak', targetPointCount: x.length })
167
+ return register_dataset(
168
+ x,
169
+ y,
170
+ Math.max(0, Math.min(0xffffffff, Math.floor(indexMaxBytes ?? 8 * 1024 * 1024))),
171
+ )
172
+ },
173
+ registerSamples(y, sourceIndexes, startTime, sampleRate, indexMaxBytes) {
174
+ return register_sample_dataset(
175
+ y,
176
+ sourceIndexes ?? new Uint32Array(),
177
+ startTime,
178
+ sampleRate,
179
+ Math.max(0, Math.min(0xffffffff, Math.floor(indexMaxBytes ?? 8 * 1024 * 1024))),
180
+ )
181
+ },
182
+ disposeDataset(handle) {
183
+ dispose_dataset(handle)
184
+ },
185
+ disposeAllDatasets() {
186
+ dispose_all_datasets()
187
+ },
188
+ datasetIndexBytes(handle) {
189
+ return dataset_index_bytes(handle)
190
+ },
191
+ sampleDataset(handle, start, end, strategy, targetPointCount) {
192
+ const target = Math.max(0, Math.floor(targetPointCount))
193
+ if (strategy === 'average' || strategy === 'sum') {
194
+ return aggregateOutput(
195
+ sample_dataset_aggregates(handle, start, end, strategyCodes[strategy], target),
196
+ )
197
+ }
198
+ return {
199
+ kind: 'source-indexes',
200
+ sourceIndexes: sample_dataset_indexes(handle, start, end, strategyCodes[strategy], target),
201
+ }
202
+ },
203
+ }
204
+ return backend
205
+ }
206
+
207
+ /** Calculates domains from valid points without changing the input arrays. */
208
+ export async function calculateWasmRange(
209
+ x: Float64Array,
210
+ y: Float64Array,
211
+ ): Promise<WasmRangeMetrics | undefined> {
212
+ if (x.length !== y.length) throw new Error('x and y must have the same length')
213
+ await initializeWasmSampling()
214
+ const result = calculate_range(x, y)
215
+ return result.length === 4
216
+ ? { x: [result[0]!, result[1]!], y: [result[2]!, result[3]!] }
217
+ : undefined
218
+ }
219
+
220
+ /** Locates a half-open visible range in the filtered, stable ascending-X point sequence. */
221
+ export async function findWasmVisibleRange(
222
+ x: Float64Array,
223
+ y: Float64Array,
224
+ domain: [number, number],
225
+ ): Promise<[number, number]> {
226
+ if (x.length !== y.length) throw new Error('x and y must have the same length')
227
+ await initializeWasmSampling()
228
+ const result = find_visible_range(x, y, domain[0], domain[1])
229
+ return [result[0]!, result[1]!]
230
+ }