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