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,300 @@
|
|
|
1
|
+
import type { WaveformPoint } from '@/types'
|
|
2
|
+
|
|
3
|
+
import type { WorkerSamplingOutput } from './protocol'
|
|
4
|
+
|
|
5
|
+
export type IndexedSamplingStrategy =
|
|
6
|
+
'none' | 'peak' | 'lttb' | 'average' | 'min' | 'max' | 'minmax' | 'sum'
|
|
7
|
+
|
|
8
|
+
interface ExtremaLayer {
|
|
9
|
+
minimumIndexes: Uint32Array
|
|
10
|
+
maximumIndexes: Uint32Array
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SumLayer {
|
|
14
|
+
x: Float64Array
|
|
15
|
+
y: Float64Array
|
|
16
|
+
count: Uint32Array
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface MultiResolutionSamplingIndexOptions {
|
|
20
|
+
/** Bounds one accessed dataset's lazily-built indexes; source points are not included. */
|
|
21
|
+
maxBytes?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DEFAULT_MAX_BYTES = 8 * 1024 * 1024
|
|
25
|
+
|
|
26
|
+
function targetCount(value: number, pointCount: number) {
|
|
27
|
+
if (pointCount === 0) return 0
|
|
28
|
+
if (!Number.isFinite(value)) return pointCount
|
|
29
|
+
return Math.min(pointCount, Math.max(1, Math.floor(value)))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function bucketBounds(bucket: number, bucketCount: number, start: number, end: number) {
|
|
33
|
+
const span = end - start
|
|
34
|
+
return {
|
|
35
|
+
start: start + Math.floor((bucket * span) / bucketCount),
|
|
36
|
+
end: start + Math.floor(((bucket + 1) * span) / bucketCount),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function addUnique(indexes: number[], index: number) {
|
|
41
|
+
if (indexes[indexes.length - 1] !== index) indexes.push(index)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function byteLength(layer: ExtremaLayer | SumLayer) {
|
|
45
|
+
return 'minimumIndexes' in layer
|
|
46
|
+
? layer.minimumIndexes.byteLength + layer.maximumIndexes.byteLength
|
|
47
|
+
: layer.x.byteLength + layer.y.byteLength + layer.count.byteLength
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function largestAlignedBlock(start: number, remaining: number) {
|
|
51
|
+
let size = 1
|
|
52
|
+
while (size * 2 <= remaining && start % (size * 2) === 0) size *= 2
|
|
53
|
+
return size
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class MultiResolutionSamplingIndex {
|
|
57
|
+
private readonly extremaLayers: Array<ExtremaLayer | undefined> = []
|
|
58
|
+
private readonly sumLayers: Array<SumLayer | undefined> = []
|
|
59
|
+
private indexBytes = 0
|
|
60
|
+
private readonly maxBytes: number
|
|
61
|
+
|
|
62
|
+
constructor(
|
|
63
|
+
private readonly points: readonly WaveformPoint[],
|
|
64
|
+
options: MultiResolutionSamplingIndexOptions = {},
|
|
65
|
+
) {
|
|
66
|
+
this.maxBytes = Number.isFinite(options.maxBytes)
|
|
67
|
+
? Math.max(0, Math.floor(options.maxBytes ?? 0))
|
|
68
|
+
: DEFAULT_MAX_BYTES
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get byteLength() {
|
|
72
|
+
return this.indexBytes
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
dispose() {
|
|
76
|
+
this.extremaLayers.length = 0
|
|
77
|
+
this.sumLayers.length = 0
|
|
78
|
+
this.indexBytes = 0
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
sample(
|
|
82
|
+
start: number,
|
|
83
|
+
end: number,
|
|
84
|
+
strategy: IndexedSamplingStrategy,
|
|
85
|
+
requestedTarget: number,
|
|
86
|
+
): WorkerSamplingOutput | undefined {
|
|
87
|
+
const rangeStart = Math.max(0, Math.min(this.points.length, Math.floor(start)))
|
|
88
|
+
const rangeEnd = Math.max(rangeStart, Math.min(this.points.length, Math.floor(end)))
|
|
89
|
+
const count = rangeEnd - rangeStart
|
|
90
|
+
const target = targetCount(requestedTarget, count)
|
|
91
|
+
if (strategy === 'lttb') return undefined
|
|
92
|
+
if (strategy === 'average' || strategy === 'sum') {
|
|
93
|
+
return this.aggregate(rangeStart, rangeEnd, target, strategy)
|
|
94
|
+
}
|
|
95
|
+
if (strategy === 'none' || count <= target) return this.allIndexes(rangeStart, rangeEnd)
|
|
96
|
+
if (target === 1) return this.indexes([rangeStart])
|
|
97
|
+
if (target === 2) return this.indexes([rangeStart, rangeEnd - 1])
|
|
98
|
+
return this.extrema(rangeStart, rangeEnd, target, strategy)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private allIndexes(start: number, end: number): WorkerSamplingOutput {
|
|
102
|
+
return {
|
|
103
|
+
kind: 'source-indexes',
|
|
104
|
+
sourceIndexes: Uint32Array.from({ length: end - start }, (_, index) => start + index),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private indexes(indexes: number[]): WorkerSamplingOutput {
|
|
109
|
+
return { kind: 'source-indexes', sourceIndexes: Uint32Array.from(indexes) }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private extrema(
|
|
113
|
+
start: number,
|
|
114
|
+
end: number,
|
|
115
|
+
target: number,
|
|
116
|
+
strategy: Exclude<IndexedSamplingStrategy, 'none' | 'lttb' | 'average' | 'sum'>,
|
|
117
|
+
): WorkerSamplingOutput | undefined {
|
|
118
|
+
const bucketCount =
|
|
119
|
+
strategy === 'peak'
|
|
120
|
+
? Math.floor((target - 2) / 4)
|
|
121
|
+
: strategy === 'minmax'
|
|
122
|
+
? Math.floor((target - 2) / 2)
|
|
123
|
+
: Math.max(0, target - 2)
|
|
124
|
+
if (bucketCount === 0) return this.indexes([start, end - 1])
|
|
125
|
+
const indexes = [start]
|
|
126
|
+
for (let bucket = 0; bucket < bucketCount; bucket += 1) {
|
|
127
|
+
const bounds = bucketBounds(bucket, bucketCount, start + 1, end - 1)
|
|
128
|
+
const range = this.queryExtrema(bounds.start, bounds.end)
|
|
129
|
+
if (!range) return undefined
|
|
130
|
+
if (strategy === 'min') addUnique(indexes, range.minimum)
|
|
131
|
+
else if (strategy === 'max') addUnique(indexes, range.maximum)
|
|
132
|
+
else if (strategy === 'minmax') {
|
|
133
|
+
if (range.minimum <= range.maximum) {
|
|
134
|
+
addUnique(indexes, range.minimum)
|
|
135
|
+
addUnique(indexes, range.maximum)
|
|
136
|
+
} else {
|
|
137
|
+
addUnique(indexes, range.maximum)
|
|
138
|
+
addUnique(indexes, range.minimum)
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
for (const index of [bounds.start, range.minimum, range.maximum, bounds.end - 1].sort(
|
|
142
|
+
(left, right) => left - right,
|
|
143
|
+
)) {
|
|
144
|
+
addUnique(indexes, index)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
addUnique(indexes, end - 1)
|
|
149
|
+
return this.indexes(indexes)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private aggregate(
|
|
153
|
+
start: number,
|
|
154
|
+
end: number,
|
|
155
|
+
target: number,
|
|
156
|
+
strategy: 'average' | 'sum',
|
|
157
|
+
): WorkerSamplingOutput | undefined {
|
|
158
|
+
const x = new Float64Array(target)
|
|
159
|
+
const y = new Float64Array(target)
|
|
160
|
+
for (let bucket = 0; bucket < target; bucket += 1) {
|
|
161
|
+
const bounds = bucketBounds(bucket, target, start, end)
|
|
162
|
+
const aggregate = this.querySum(bounds.start, bounds.end)
|
|
163
|
+
if (!aggregate || aggregate.count === 0) return undefined
|
|
164
|
+
x[bucket] = aggregate.x / aggregate.count
|
|
165
|
+
y[bucket] = strategy === 'average' ? aggregate.y / aggregate.count : aggregate.y
|
|
166
|
+
}
|
|
167
|
+
return { kind: 'aggregates', x, y }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private queryExtrema(start: number, end: number) {
|
|
171
|
+
const level = Math.floor(Math.log2(Math.max(1, end - start)))
|
|
172
|
+
if (!this.ensureExtrema(level)) return undefined
|
|
173
|
+
let minimum = start
|
|
174
|
+
let maximum = start
|
|
175
|
+
for (let position = start; position < end;) {
|
|
176
|
+
const size = largestAlignedBlock(position, end - position)
|
|
177
|
+
const blockLevel = Math.floor(Math.log2(size))
|
|
178
|
+
const block = position / size
|
|
179
|
+
const layer = this.extremaLayers[blockLevel]
|
|
180
|
+
const nextMinimum = layer ? layer.minimumIndexes[block]! : position
|
|
181
|
+
const nextMaximum = layer ? layer.maximumIndexes[block]! : position
|
|
182
|
+
if (this.isMinimum(nextMinimum, minimum)) minimum = nextMinimum
|
|
183
|
+
if (this.isMaximum(nextMaximum, maximum)) maximum = nextMaximum
|
|
184
|
+
position += size
|
|
185
|
+
}
|
|
186
|
+
return { minimum, maximum }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private querySum(start: number, end: number) {
|
|
190
|
+
const level = Math.floor(Math.log2(Math.max(1, end - start)))
|
|
191
|
+
if (!this.ensureSum(level)) return undefined
|
|
192
|
+
let x = 0
|
|
193
|
+
let y = 0
|
|
194
|
+
let count = 0
|
|
195
|
+
for (let position = start; position < end;) {
|
|
196
|
+
const size = largestAlignedBlock(position, end - position)
|
|
197
|
+
const blockLevel = Math.floor(Math.log2(size))
|
|
198
|
+
const block = position / size
|
|
199
|
+
const layer = this.sumLayers[blockLevel]
|
|
200
|
+
if (layer) {
|
|
201
|
+
x += layer.x[block]!
|
|
202
|
+
y += layer.y[block]!
|
|
203
|
+
count += layer.count[block]!
|
|
204
|
+
} else {
|
|
205
|
+
const point = this.points[position]!
|
|
206
|
+
x += point.x
|
|
207
|
+
y += point.y
|
|
208
|
+
count += 1
|
|
209
|
+
}
|
|
210
|
+
position += size
|
|
211
|
+
}
|
|
212
|
+
return { x, y, count }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private ensureExtrema(level: number) {
|
|
216
|
+
for (let current = 1; current <= level; current += 1) {
|
|
217
|
+
if (this.extremaLayers[current]) continue
|
|
218
|
+
const length = Math.ceil(this.points.length / 2 ** current)
|
|
219
|
+
const layer: ExtremaLayer = {
|
|
220
|
+
minimumIndexes: new Uint32Array(length),
|
|
221
|
+
maximumIndexes: new Uint32Array(length),
|
|
222
|
+
}
|
|
223
|
+
if (!this.reserve(byteLength(layer))) return false
|
|
224
|
+
const previous = this.extremaLayers[current - 1]
|
|
225
|
+
for (let block = 0; block < length; block += 1) {
|
|
226
|
+
const left = block * 2
|
|
227
|
+
const right = left + 1
|
|
228
|
+
const leftMinimum = previous ? previous.minimumIndexes[left]! : left
|
|
229
|
+
const leftMaximum = previous ? previous.maximumIndexes[left]! : left
|
|
230
|
+
const hasRight = previous
|
|
231
|
+
? right < previous.minimumIndexes.length
|
|
232
|
+
: right < this.points.length
|
|
233
|
+
const rightMinimum = previous && hasRight ? previous.minimumIndexes[right]! : right
|
|
234
|
+
const rightMaximum = previous && hasRight ? previous.maximumIndexes[right]! : right
|
|
235
|
+
layer.minimumIndexes[block] =
|
|
236
|
+
hasRight && this.isMinimum(rightMinimum, leftMinimum) ? rightMinimum : leftMinimum
|
|
237
|
+
layer.maximumIndexes[block] =
|
|
238
|
+
hasRight && this.isMaximum(rightMaximum, leftMaximum) ? rightMaximum : leftMaximum
|
|
239
|
+
}
|
|
240
|
+
this.extremaLayers[current] = layer
|
|
241
|
+
}
|
|
242
|
+
return true
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private ensureSum(level: number) {
|
|
246
|
+
for (let current = 1; current <= level; current += 1) {
|
|
247
|
+
if (this.sumLayers[current]) continue
|
|
248
|
+
const length = Math.ceil(this.points.length / 2 ** current)
|
|
249
|
+
const layer: SumLayer = {
|
|
250
|
+
x: new Float64Array(length),
|
|
251
|
+
y: new Float64Array(length),
|
|
252
|
+
count: new Uint32Array(length),
|
|
253
|
+
}
|
|
254
|
+
if (!this.reserve(byteLength(layer))) return false
|
|
255
|
+
const previous = this.sumLayers[current - 1]
|
|
256
|
+
for (let block = 0; block < length; block += 1) {
|
|
257
|
+
const left = block * 2
|
|
258
|
+
const right = left + 1
|
|
259
|
+
if (previous) {
|
|
260
|
+
layer.x[block] = previous.x[left]! + (right < previous.x.length ? previous.x[right]! : 0)
|
|
261
|
+
layer.y[block] = previous.y[left]! + (right < previous.y.length ? previous.y[right]! : 0)
|
|
262
|
+
layer.count[block] =
|
|
263
|
+
previous.count[left]! + (right < previous.count.length ? previous.count[right]! : 0)
|
|
264
|
+
} else {
|
|
265
|
+
const leftPoint = this.points[left]!
|
|
266
|
+
const rightPoint = this.points[right]
|
|
267
|
+
layer.x[block] = leftPoint.x + (rightPoint?.x ?? 0)
|
|
268
|
+
layer.y[block] = leftPoint.y + (rightPoint?.y ?? 0)
|
|
269
|
+
layer.count[block] = rightPoint ? 2 : 1
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
this.sumLayers[current] = layer
|
|
273
|
+
}
|
|
274
|
+
return true
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private reserve(bytes: number) {
|
|
278
|
+
if (this.indexBytes + bytes > this.maxBytes) return false
|
|
279
|
+
this.indexBytes += bytes
|
|
280
|
+
return true
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private isMinimum(candidate: number, current: number) {
|
|
284
|
+
const candidatePoint = this.points[candidate]!
|
|
285
|
+
const currentPoint = this.points[current]!
|
|
286
|
+
return (
|
|
287
|
+
candidatePoint.y < currentPoint.y ||
|
|
288
|
+
(candidatePoint.y === currentPoint.y && candidate < current)
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private isMaximum(candidate: number, current: number) {
|
|
293
|
+
const candidatePoint = this.points[candidate]!
|
|
294
|
+
const currentPoint = this.points[current]!
|
|
295
|
+
return (
|
|
296
|
+
candidatePoint.y > currentPoint.y ||
|
|
297
|
+
(candidatePoint.y === currentPoint.y && candidate < current)
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { isCurrentWorkerSamplingResponse } from './protocol'
|
|
4
|
+
|
|
5
|
+
describe('worker sampling response validation', () => {
|
|
6
|
+
it('rejects old request ids and outdated per-series revisions before UI state is committed', () => {
|
|
7
|
+
const current = { requestId: 12, revisions: { alpha: 2, beta: 1 } }
|
|
8
|
+
const response = {
|
|
9
|
+
type: 'sample-viewport-response' as const,
|
|
10
|
+
requestId: 12,
|
|
11
|
+
results: [
|
|
12
|
+
{ datasetId: 'alpha', revision: 2 },
|
|
13
|
+
{ datasetId: 'beta', revision: 0 },
|
|
14
|
+
],
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
expect(isCurrentWorkerSamplingResponse({ ...response, requestId: 11 } as never, current)).toBe(
|
|
18
|
+
false,
|
|
19
|
+
)
|
|
20
|
+
expect(isCurrentWorkerSamplingResponse(response as never, current)).toBe(false)
|
|
21
|
+
expect(
|
|
22
|
+
isCurrentWorkerSamplingResponse(
|
|
23
|
+
{
|
|
24
|
+
...response,
|
|
25
|
+
results: [
|
|
26
|
+
{ datasetId: 'alpha', revision: 2 },
|
|
27
|
+
{ datasetId: 'beta', revision: 1 },
|
|
28
|
+
],
|
|
29
|
+
} as never,
|
|
30
|
+
current,
|
|
31
|
+
),
|
|
32
|
+
).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
WaveformPoint,
|
|
3
|
+
WaveformLineType,
|
|
4
|
+
WaveformPointType,
|
|
5
|
+
WaveformSamplingBackend,
|
|
6
|
+
WaveformSamplingDiagnostics,
|
|
7
|
+
WaveformSamplingMode,
|
|
8
|
+
WaveformSamplingStrategy,
|
|
9
|
+
} from '@/types'
|
|
10
|
+
import type { WorkerSamplingDataset } from '../waveformPointSource'
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_WORKER_SAMPLING_AUTO_THRESHOLD = 1_000
|
|
13
|
+
export const DEFAULT_WORKER_SAMPLING_MAX_POINTS_PER_PIXEL = 4
|
|
14
|
+
|
|
15
|
+
export type WorkerSamplingBackendKind = WaveformSamplingBackend
|
|
16
|
+
export type WorkerSamplingStatus = 'ok' | 'stale-revision' | 'wasm-unavailable' | 'not-found'
|
|
17
|
+
|
|
18
|
+
export interface WorkerSamplingDatasetMetrics {
|
|
19
|
+
inputPointCount: number
|
|
20
|
+
validPointCount: number
|
|
21
|
+
xDomain: [number, number] | null
|
|
22
|
+
yDomain: [number, number] | null
|
|
23
|
+
/** Current lazily-built multiresolution index allocation for this dataset. */
|
|
24
|
+
indexBytes?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface WorkerSamplingSourceIndexes {
|
|
28
|
+
kind: 'source-indexes'
|
|
29
|
+
sourceIndexes: Uint32Array
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface WorkerSamplingAggregates {
|
|
33
|
+
kind: 'aggregates'
|
|
34
|
+
x: Float64Array
|
|
35
|
+
y: Float64Array
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type WorkerSamplingOutput = WorkerSamplingSourceIndexes | WorkerSamplingAggregates
|
|
39
|
+
|
|
40
|
+
export interface WorkerSamplingBackendRequest {
|
|
41
|
+
x: Float64Array
|
|
42
|
+
y: Float64Array
|
|
43
|
+
/** Empty for compact datasets; retained for custom legacy backend compatibility. */
|
|
44
|
+
points: readonly WaveformPoint[]
|
|
45
|
+
strategy: Exclude<WaveformSamplingStrategy, 'auto'>
|
|
46
|
+
targetPointCount: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface WorkerSamplingBackend {
|
|
50
|
+
readonly kind: Extract<WorkerSamplingBackendKind, 'javascript' | 'wasm'>
|
|
51
|
+
sample(request: WorkerSamplingBackendRequest): WorkerSamplingOutput
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type WorkerSamplingDiagnostics = WaveformSamplingDiagnostics
|
|
55
|
+
|
|
56
|
+
export interface RegisterDatasetRequest {
|
|
57
|
+
type: 'register-dataset'
|
|
58
|
+
requestId: number
|
|
59
|
+
datasetId: string
|
|
60
|
+
revision: 0
|
|
61
|
+
/** Legacy object points remain accepted for repository consumers. */
|
|
62
|
+
points?: readonly WaveformPoint[]
|
|
63
|
+
/** Compact numeric columns used by TypedArray chart inputs. */
|
|
64
|
+
dataset?: WorkerSamplingDataset
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ReplaceDatasetRequest {
|
|
68
|
+
type: 'replace-dataset'
|
|
69
|
+
requestId: number
|
|
70
|
+
datasetId: string
|
|
71
|
+
revision: number
|
|
72
|
+
points?: readonly WaveformPoint[]
|
|
73
|
+
dataset?: WorkerSamplingDataset
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface WorkerSamplingSeriesRequest {
|
|
77
|
+
seriesId: string
|
|
78
|
+
datasetId: string
|
|
79
|
+
revision: number
|
|
80
|
+
xDomain: [number, number]
|
|
81
|
+
/** Optional main-thread hint; the Worker recomputes this from xDomain before sampling. */
|
|
82
|
+
visibleStartIndex?: number
|
|
83
|
+
/** Exclusive visible-range endpoint. */
|
|
84
|
+
visibleEndIndex?: number
|
|
85
|
+
plotWidth: number
|
|
86
|
+
mode: WaveformSamplingMode
|
|
87
|
+
autoThreshold?: number
|
|
88
|
+
/** Interaction-only hysteresis. Omit previousSelectedMode after the interaction for exact bounds. */
|
|
89
|
+
autoHysteresis?: number
|
|
90
|
+
previousSelectedMode?: 'raw' | 'sampled'
|
|
91
|
+
strategy: WaveformSamplingStrategy
|
|
92
|
+
maxPointsPerPixel?: number
|
|
93
|
+
rawPointLimit?: number
|
|
94
|
+
wasmFailureFallback?: 'error' | 'javascript'
|
|
95
|
+
/** Rendering inputs that affect the cache key, even when only line geometry is overridden. */
|
|
96
|
+
lineType?: WaveformLineType
|
|
97
|
+
pointType?: WaveformPointType
|
|
98
|
+
errorBarVisible?: boolean
|
|
99
|
+
pointMinSpacing?: number
|
|
100
|
+
errorBarMinSpacing?: number
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface SampleViewportRequest {
|
|
104
|
+
type: 'sample-viewport'
|
|
105
|
+
requestId: number
|
|
106
|
+
series: readonly WorkerSamplingSeriesRequest[]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface FindNearestPointRequest {
|
|
110
|
+
type: 'find-nearest-point'
|
|
111
|
+
requestId: number
|
|
112
|
+
datasetId: string
|
|
113
|
+
revision: number
|
|
114
|
+
x: number
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface GetDatasetMetricsRequest {
|
|
118
|
+
type: 'get-dataset-metrics'
|
|
119
|
+
requestId: number
|
|
120
|
+
datasetId: string
|
|
121
|
+
revision: number
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface DisposeDatasetRequest {
|
|
125
|
+
type: 'dispose-dataset'
|
|
126
|
+
requestId: number
|
|
127
|
+
datasetId: string
|
|
128
|
+
revision: number
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface DisposeAllRequest {
|
|
132
|
+
type: 'dispose-all'
|
|
133
|
+
requestId: number
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type WorkerSamplingRequest =
|
|
137
|
+
| RegisterDatasetRequest
|
|
138
|
+
| ReplaceDatasetRequest
|
|
139
|
+
| SampleViewportRequest
|
|
140
|
+
| FindNearestPointRequest
|
|
141
|
+
| GetDatasetMetricsRequest
|
|
142
|
+
| DisposeDatasetRequest
|
|
143
|
+
| DisposeAllRequest
|
|
144
|
+
|
|
145
|
+
export interface DatasetResponse {
|
|
146
|
+
type: 'dataset-response'
|
|
147
|
+
requestId: number
|
|
148
|
+
datasetId: string
|
|
149
|
+
revision: number
|
|
150
|
+
status: WorkerSamplingStatus
|
|
151
|
+
metrics?: WorkerSamplingDatasetMetrics
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface WorkerSamplingSeriesResponse {
|
|
155
|
+
seriesId: string
|
|
156
|
+
datasetId: string
|
|
157
|
+
revision: number
|
|
158
|
+
status: WorkerSamplingStatus
|
|
159
|
+
output?: WorkerSamplingOutput
|
|
160
|
+
diagnostics: WorkerSamplingDiagnostics
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface SampleViewportResponse {
|
|
164
|
+
type: 'sample-viewport-response'
|
|
165
|
+
requestId: number
|
|
166
|
+
results: readonly WorkerSamplingSeriesResponse[]
|
|
167
|
+
/** Set by the worker when WASM initialization failed and a fallback was selected. */
|
|
168
|
+
workerError?: string
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface FindNearestPointResponse {
|
|
172
|
+
type: 'find-nearest-point-response'
|
|
173
|
+
requestId: number
|
|
174
|
+
datasetId: string
|
|
175
|
+
revision: number
|
|
176
|
+
status: WorkerSamplingStatus
|
|
177
|
+
point?: WaveformPoint
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface DisposeAllResponse {
|
|
181
|
+
type: 'dispose-all-response'
|
|
182
|
+
requestId: number
|
|
183
|
+
disposedDatasetIds: readonly string[]
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export type WorkerSamplingResponse =
|
|
187
|
+
DatasetResponse | SampleViewportResponse | FindNearestPointResponse | DisposeAllResponse
|
|
188
|
+
|
|
189
|
+
export interface WorkerSamplingResponseExpectation {
|
|
190
|
+
requestId: number
|
|
191
|
+
revisions: Readonly<Record<string, number>>
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Rejects responses from an earlier request or an earlier data revision before UI state is updated. */
|
|
195
|
+
export function isCurrentWorkerSamplingResponse(
|
|
196
|
+
response: WorkerSamplingResponse,
|
|
197
|
+
expectation: WorkerSamplingResponseExpectation,
|
|
198
|
+
): boolean {
|
|
199
|
+
if (response.requestId !== expectation.requestId) return false
|
|
200
|
+
if (response.type === 'sample-viewport-response') {
|
|
201
|
+
return response.results.every(
|
|
202
|
+
(result) => expectation.revisions[result.datasetId] === result.revision,
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
if (response.type === 'dispose-all-response') return true
|
|
206
|
+
return expectation.revisions[response.datasetId] === response.revision
|
|
207
|
+
}
|