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.
- package/README.md +127 -5
- package/dist/assets/workerRuntime-Dt94jbqq.js +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.js +2701 -1165
- package/dist/style.css +1 -1
- package/dist/types/components/WaveformChart.vue.d.ts +14 -0
- package/dist/types/components/annotation/WaveformAnnotationEditor.vue.d.ts +0 -1
- package/dist/types/components/annotation/markup.d.ts +5 -6
- package/dist/types/components/annotation/types.d.ts +2 -0
- package/dist/types/components/core/latestTaskScheduler.d.ts +19 -0
- package/dist/types/components/core/layout.d.ts +2 -2
- package/dist/types/components/core/trackLayoutBuilder.d.ts +4 -1
- package/dist/types/components/core/types.d.ts +2 -0
- package/dist/types/components/core/useWaveformChartController.d.ts +1 -0
- package/dist/types/components/core/useWaveformData.d.ts +2 -0
- package/dist/types/components/core/useWaveformLayout.d.ts +2 -0
- package/dist/types/components/core/useWaveformRenderSampling.d.ts +19 -0
- package/dist/types/components/core/waveformChartTypes.d.ts +8 -1
- package/dist/types/components/core/workerSamplingClient.d.ts +8 -0
- package/dist/types/components/data/types.d.ts +1 -1
- package/dist/types/components/interaction/zoomConstraints.d.ts +2 -0
- package/dist/types/components/waveform.d.ts +1 -1
- package/dist/types/core/compactSamplePointSource.d.ts +3 -0
- package/dist/types/core/data.d.ts +7 -1
- package/dist/types/core/index.d.ts +2 -1
- package/dist/types/core/rendering.d.ts +3 -1
- package/dist/types/core/renderingOptions.d.ts +11 -1
- package/dist/types/core/samplingReference.d.ts +36 -0
- package/dist/types/core/samplingReferenceValues.d.ts +4 -0
- package/dist/types/core/samplingReferenceVectors.d.ts +17 -0
- package/dist/types/core/wasmSampling.d.ts +48 -0
- package/dist/types/core/waveformPointSource.d.ts +53 -0
- package/dist/types/core/workerSampling/index.d.ts +5 -0
- package/dist/types/core/workerSampling/javascriptBackend.d.ts +3 -0
- package/dist/types/core/workerSampling/multiresolution.d.ts +29 -0
- package/dist/types/core/workerSampling/protocol.d.ts +153 -0
- package/dist/types/core/workerSampling/repository.d.ts +36 -0
- package/dist/types/core/workerSampling/repositoryDataset.d.ts +29 -0
- package/dist/types/core/workerSampling/repositorySupport.d.ts +27 -0
- package/dist/types/core/workerSampling/samplingCache.d.ts +24 -0
- package/dist/types/core/workerSampling/workerRuntime.d.ts +1 -0
- package/dist/types/index.d.ts +2 -2
- package/dist/types/types/chart.d.ts +58 -0
- package/dist/types/types/data.d.ts +20 -2
- package/dist/types/types/index.d.ts +2 -2
- package/dist/types/views/WasmSamplingDemo.vue.d.ts +3 -0
- package/package.json +5 -4
- package/src/App.test.ts +11 -7
- package/src/App.vue +2 -2
- package/src/DemoRouterApp.vue +1 -0
- package/src/components/WaveformChart.css +2 -0
- package/src/components/annotation/WaveformAnnotationEditor.css +2 -2
- package/src/components/annotation/WaveformAnnotationEditor.vue +8 -4
- package/src/components/annotation/markup.ts +45 -26
- package/src/components/annotation/types.ts +2 -0
- package/src/components/annotation/useWaveformChartAnnotations.ts +12 -15
- package/src/components/core/latestTaskScheduler.test.ts +51 -0
- package/src/components/core/latestTaskScheduler.ts +75 -0
- package/src/components/core/layout.ts +15 -6
- package/src/components/core/trackLayoutBuilder.ts +8 -2
- package/src/components/core/types.ts +2 -0
- package/src/components/core/useWaveformChartController.ts +14 -0
- package/src/components/core/useWaveformData.test.ts +95 -0
- package/src/components/core/useWaveformData.ts +23 -32
- package/src/components/core/useWaveformLayout.ts +16 -10
- package/src/components/core/useWaveformRenderSampling.test.ts +17 -0
- package/src/components/core/useWaveformRenderSampling.ts +394 -0
- package/src/components/core/waveformChartTypes.ts +13 -0
- package/src/components/core/workerSamplingClient.ts +148 -0
- package/src/components/core/yAxisNice.test.ts +174 -0
- package/src/components/data/types.ts +9 -0
- package/src/components/interaction/WaveformTooltip.test.ts +1 -3
- package/src/components/interaction/WaveformTooltip.vue +13 -12
- package/src/components/interaction/useWaveformHover.ts +7 -7
- package/src/components/interaction/zoomConstraints.ts +5 -1
- package/src/components/waveform.ts +9 -0
- package/src/components/waveformChartCases/compactAndSharedHover.test.ts +15 -4
- package/src/components/waveformChartCases/dataAndHoverPerformance.test.ts +11 -6
- package/src/components/waveformChartCases/dataLifecycle.test.ts +1 -3
- package/src/components/waveformChartCases/trackOrder.test.ts +130 -0
- package/src/components/waveformChartCases/workerSamplingIntegration.test.ts +117 -0
- package/src/components/waveformChartCases/xDomainStrategy.test.ts +14 -2
- package/src/core/compactSamplePointSource.ts +76 -0
- package/src/core/data.test.ts +114 -0
- package/src/core/data.ts +177 -75
- package/src/core/index.ts +11 -0
- package/src/core/rendering.test.ts +87 -12
- package/src/core/rendering.ts +35 -1
- package/src/core/renderingOptions.ts +124 -15
- package/src/core/samplingReference.test.ts +124 -0
- package/src/core/samplingReference.ts +243 -0
- package/src/core/samplingReferenceValues.ts +169 -0
- package/src/core/samplingReferenceVectors.ts +46 -0
- package/src/core/wasmSampling.test.ts +205 -0
- package/src/core/wasmSampling.ts +230 -0
- package/src/core/waveformPointSource.ts +395 -0
- package/src/core/workerSampling/index.ts +30 -0
- package/src/core/workerSampling/javascriptBackend.ts +15 -0
- package/src/core/workerSampling/multiresolution.test.ts +69 -0
- package/src/core/workerSampling/multiresolution.ts +300 -0
- package/src/core/workerSampling/protocol.test.ts +34 -0
- package/src/core/workerSampling/protocol.ts +207 -0
- package/src/core/workerSampling/repository.test.ts +400 -0
- package/src/core/workerSampling/repository.ts +400 -0
- package/src/core/workerSampling/repositoryCache.test.ts +163 -0
- package/src/core/workerSampling/repositoryDataset.ts +194 -0
- package/src/core/workerSampling/repositorySupport.ts +225 -0
- package/src/core/workerSampling/repositoryTypedDataset.test.ts +89 -0
- package/src/core/workerSampling/repositoryWasmDataset.test.ts +70 -0
- package/src/core/workerSampling/samplingCache.ts +94 -0
- package/src/core/workerSampling/workerRuntime.ts +40 -0
- package/src/data/simulatedWaveforms.test.ts +19 -0
- package/src/data/simulatedWaveforms.ts +11 -8
- package/src/demo/xAxisLabelControls.test.ts +1 -1
- package/src/demoRoutes.css +177 -0
- package/src/index.ts +18 -0
- package/src/router.ts +5 -0
- package/src/types/chart.ts +65 -0
- package/src/types/data.ts +24 -1
- package/src/types/index.ts +9 -0
- package/src/views/WasmSamplingDemo.test.ts +13 -0
- 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
|
+
}
|