waveform-analysis 0.1.48 → 0.1.51
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 +120 -5
- package/dist/assets/workerRuntime-Dt94jbqq.js +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.js +2683 -1153
- 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/trackLayoutBuilder.d.ts +3 -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 +56 -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/trackLayoutBuilder.ts +3 -0
- 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 +4 -4
- 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/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/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/demoRoutes.css +177 -0
- package/src/index.ts +18 -0
- package/src/router.ts +5 -0
- package/src/types/chart.ts +63 -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,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
|
+
}
|
|
@@ -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
|
+
]
|