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,194 @@
|
|
|
1
|
+
import type { WaveformPoint } from '@/types'
|
|
2
|
+
import type { WorkerSamplingDataset } from '../waveformPointSource'
|
|
3
|
+
import type { WasmDatasetSamplingBackend } from '../wasmSampling'
|
|
4
|
+
import { MultiResolutionSamplingIndex } from './multiresolution'
|
|
5
|
+
import type { WorkerSamplingBackend, WorkerSamplingDatasetMetrics } from './protocol'
|
|
6
|
+
import { copyFinitePoints, metricsFor, metricsForValues } from './repositorySupport'
|
|
7
|
+
|
|
8
|
+
export interface StoredDataset {
|
|
9
|
+
x?: Float64Array
|
|
10
|
+
y: Float64Array
|
|
11
|
+
sampleStartTime?: number
|
|
12
|
+
sampleRate?: number
|
|
13
|
+
sampleSourceIndexes?: Uint32Array
|
|
14
|
+
points?: WaveformPoint[]
|
|
15
|
+
revision: number
|
|
16
|
+
metrics: WorkerSamplingDatasetMetrics
|
|
17
|
+
index?: MultiResolutionSamplingIndex
|
|
18
|
+
wasmDatasetHandle?: number
|
|
19
|
+
wasmIndexBytes?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function datasetLength(dataset: StoredDataset | undefined) {
|
|
23
|
+
return dataset?.y.length ?? 0
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function datasetXAt(dataset: StoredDataset, index: number) {
|
|
27
|
+
if (dataset.x) return dataset.x[index]!
|
|
28
|
+
return (
|
|
29
|
+
dataset.sampleStartTime! + (dataset.sampleSourceIndexes?.[index] ?? index) / dataset.sampleRate!
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function datasetVisibleRange(dataset: StoredDataset, domain: [number, number]) {
|
|
34
|
+
const lowerBound = (value: number, inclusive: boolean) => {
|
|
35
|
+
let start = 0
|
|
36
|
+
let end = datasetLength(dataset)
|
|
37
|
+
while (start < end) {
|
|
38
|
+
const middle = start + Math.floor((end - start) / 2)
|
|
39
|
+
if (inclusive ? datasetXAt(dataset, middle) <= value : datasetXAt(dataset, middle) < value) {
|
|
40
|
+
start = middle + 1
|
|
41
|
+
} else end = middle
|
|
42
|
+
}
|
|
43
|
+
return start
|
|
44
|
+
}
|
|
45
|
+
const startX = Math.min(domain[0], domain[1])
|
|
46
|
+
const endX = Math.max(domain[0], domain[1])
|
|
47
|
+
return { start: lowerBound(startX, false), end: lowerBound(endX, true) }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function datasetXRange(dataset: StoredDataset, start: number, end: number) {
|
|
51
|
+
if (dataset.x) return dataset.x.subarray(start, end)
|
|
52
|
+
return Float64Array.from({ length: end - start }, (_, offset) =>
|
|
53
|
+
datasetXAt(dataset, start + offset),
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function isWasmDatasetBackend(
|
|
58
|
+
backend: WorkerSamplingBackend | undefined,
|
|
59
|
+
): backend is WasmDatasetSamplingBackend {
|
|
60
|
+
return (
|
|
61
|
+
backend?.kind === 'wasm' &&
|
|
62
|
+
'registerDataset' in backend &&
|
|
63
|
+
'disposeDataset' in backend &&
|
|
64
|
+
'disposeAllDatasets' in backend &&
|
|
65
|
+
'datasetIndexBytes' in backend &&
|
|
66
|
+
'registerSamples' in backend &&
|
|
67
|
+
'sampleDataset' in backend
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function copyFiniteNumericValues(sourceX: Float64Array, sourceY: Float32Array | Float64Array) {
|
|
72
|
+
const indexes: number[] = []
|
|
73
|
+
const length = Math.min(sourceX.length, sourceY.length)
|
|
74
|
+
for (let index = 0; index < length; index += 1) {
|
|
75
|
+
if (Number.isFinite(sourceX[index]) && Number.isFinite(sourceY[index])) indexes.push(index)
|
|
76
|
+
}
|
|
77
|
+
indexes.sort((left, right) => sourceX[left]! - sourceX[right]! || left - right)
|
|
78
|
+
return {
|
|
79
|
+
x: Float64Array.from(indexes, (index) => sourceX[index]!),
|
|
80
|
+
y: Float64Array.from(indexes, (index) => sourceY[index]!),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function createStoredDataset(
|
|
85
|
+
input: readonly WaveformPoint[] | undefined,
|
|
86
|
+
numeric: WorkerSamplingDataset | undefined,
|
|
87
|
+
revision: number,
|
|
88
|
+
indexMaxBytes: number | undefined,
|
|
89
|
+
): StoredDataset {
|
|
90
|
+
if (numeric?.kind === 'typed') {
|
|
91
|
+
const normalized = copyFiniteNumericValues(numeric.x, numeric.y)
|
|
92
|
+
return {
|
|
93
|
+
x: normalized.x,
|
|
94
|
+
y: normalized.y,
|
|
95
|
+
revision,
|
|
96
|
+
metrics: metricsForValues(
|
|
97
|
+
normalized.x,
|
|
98
|
+
normalized.y,
|
|
99
|
+
Math.max(numeric.x.length, numeric.y.length),
|
|
100
|
+
),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (numeric?.kind === 'samples') {
|
|
104
|
+
const validMetadata =
|
|
105
|
+
Number.isFinite(numeric.sampleRate) &&
|
|
106
|
+
numeric.sampleRate > 0 &&
|
|
107
|
+
Number.isFinite(numeric.startTime) &&
|
|
108
|
+
(!numeric.sourceIndexes || numeric.sourceIndexes.length === numeric.values.length)
|
|
109
|
+
if (!validMetadata) {
|
|
110
|
+
return {
|
|
111
|
+
y: new Float64Array(),
|
|
112
|
+
sampleStartTime: 0,
|
|
113
|
+
sampleRate: 1,
|
|
114
|
+
revision,
|
|
115
|
+
metrics: {
|
|
116
|
+
inputPointCount: numeric.values.length,
|
|
117
|
+
validPointCount: 0,
|
|
118
|
+
xDomain: null,
|
|
119
|
+
yDomain: null,
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const indexes = Array.from({ length: numeric.values.length }, (_, index) => index).filter(
|
|
124
|
+
(index) => Number.isFinite(numeric.values[index]),
|
|
125
|
+
)
|
|
126
|
+
const sourceIndexAt = (index: number) => numeric.sourceIndexes?.[index] ?? index
|
|
127
|
+
indexes.sort((left, right) => sourceIndexAt(left) - sourceIndexAt(right) || left - right)
|
|
128
|
+
const y = Float64Array.from(indexes, (index) => numeric.values[index]!)
|
|
129
|
+
const sourceIndexes = Uint32Array.from(indexes, sourceIndexAt)
|
|
130
|
+
const xAt = (index: number) => numeric.startTime + sourceIndexes[index]! / numeric.sampleRate
|
|
131
|
+
let yMinimum = Number.POSITIVE_INFINITY
|
|
132
|
+
let yMaximum = Number.NEGATIVE_INFINITY
|
|
133
|
+
for (const value of y) {
|
|
134
|
+
yMinimum = Math.min(yMinimum, value)
|
|
135
|
+
yMaximum = Math.max(yMaximum, value)
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
y,
|
|
139
|
+
sampleStartTime: numeric.startTime,
|
|
140
|
+
sampleRate: numeric.sampleRate,
|
|
141
|
+
sampleSourceIndexes:
|
|
142
|
+
sourceIndexes.length === numeric.values.length &&
|
|
143
|
+
sourceIndexes.every((value, index) => value === index)
|
|
144
|
+
? undefined
|
|
145
|
+
: sourceIndexes,
|
|
146
|
+
revision,
|
|
147
|
+
metrics: {
|
|
148
|
+
inputPointCount: numeric.values.length,
|
|
149
|
+
validPointCount: y.length,
|
|
150
|
+
xDomain: y.length ? [xAt(0), xAt(y.length - 1)] : null,
|
|
151
|
+
yDomain: y.length ? [yMinimum, yMaximum] : null,
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const copied = copyFinitePoints(input ?? numeric?.points ?? [])
|
|
156
|
+
const x = Float64Array.from(copied, (point) => point.x)
|
|
157
|
+
const y = Float64Array.from(copied, (point) => point.y)
|
|
158
|
+
return {
|
|
159
|
+
x,
|
|
160
|
+
y,
|
|
161
|
+
points: copied,
|
|
162
|
+
revision,
|
|
163
|
+
metrics: metricsFor(copied, input?.length ?? numeric?.points.length ?? 0),
|
|
164
|
+
index: new MultiResolutionSamplingIndex(copied, { maxBytes: indexMaxBytes }),
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function registerWasmDataset(
|
|
169
|
+
backend: WorkerSamplingBackend | undefined,
|
|
170
|
+
dataset: StoredDataset,
|
|
171
|
+
indexMaxBytes: number | undefined,
|
|
172
|
+
) {
|
|
173
|
+
if (!isWasmDatasetBackend(backend) || dataset.wasmDatasetHandle !== undefined) return
|
|
174
|
+
dataset.wasmDatasetHandle = dataset.x
|
|
175
|
+
? backend.registerDataset(dataset.x, dataset.y, indexMaxBytes)
|
|
176
|
+
: backend.registerSamples(
|
|
177
|
+
dataset.y,
|
|
178
|
+
dataset.sampleSourceIndexes,
|
|
179
|
+
dataset.sampleStartTime!,
|
|
180
|
+
dataset.sampleRate!,
|
|
181
|
+
indexMaxBytes,
|
|
182
|
+
)
|
|
183
|
+
dataset.wasmIndexBytes = backend.datasetIndexBytes(dataset.wasmDatasetHandle)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function disposeDatasetIndexes(
|
|
187
|
+
backend: WorkerSamplingBackend | undefined,
|
|
188
|
+
dataset: StoredDataset,
|
|
189
|
+
) {
|
|
190
|
+
dataset.index?.dispose()
|
|
191
|
+
if (dataset.wasmDatasetHandle !== undefined && isWasmDatasetBackend(backend)) {
|
|
192
|
+
backend.disposeDataset(dataset.wasmDatasetHandle)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { bisector } from 'd3'
|
|
2
|
+
|
|
3
|
+
import type { WaveformPoint } from '@/types'
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_WORKER_SAMPLING_AUTO_THRESHOLD,
|
|
6
|
+
DEFAULT_WORKER_SAMPLING_MAX_POINTS_PER_PIXEL,
|
|
7
|
+
type WorkerSamplingBackendKind,
|
|
8
|
+
type WorkerSamplingDatasetMetrics,
|
|
9
|
+
type WorkerSamplingDiagnostics,
|
|
10
|
+
type WorkerSamplingOutput,
|
|
11
|
+
type WorkerSamplingSeriesRequest,
|
|
12
|
+
type WorkerSamplingStatus,
|
|
13
|
+
} from './protocol'
|
|
14
|
+
|
|
15
|
+
const pointBisector = bisector<WaveformPoint, number>((point) => point.x)
|
|
16
|
+
|
|
17
|
+
export function copyFinitePoints(points: readonly WaveformPoint[]) {
|
|
18
|
+
const copied: WaveformPoint[] = []
|
|
19
|
+
let sorted = true
|
|
20
|
+
let previousX = Number.NEGATIVE_INFINITY
|
|
21
|
+
for (const point of points) {
|
|
22
|
+
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) continue
|
|
23
|
+
if (point.x < previousX) sorted = false
|
|
24
|
+
previousX = point.x
|
|
25
|
+
copied.push({
|
|
26
|
+
x: point.x,
|
|
27
|
+
y: point.y,
|
|
28
|
+
...(Number.isFinite(point.error) ? { error: point.error } : {}),
|
|
29
|
+
...(Number.isFinite(point.lowerError) ? { lowerError: point.lowerError } : {}),
|
|
30
|
+
...(Number.isFinite(point.upperError) ? { upperError: point.upperError } : {}),
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
if (!sorted) copied.sort((left, right) => left.x - right.x)
|
|
34
|
+
return copied
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function metricsFor(
|
|
38
|
+
points: readonly WaveformPoint[],
|
|
39
|
+
inputPointCount: number,
|
|
40
|
+
): WorkerSamplingDatasetMetrics {
|
|
41
|
+
let xMinimum = Number.POSITIVE_INFINITY
|
|
42
|
+
let xMaximum = Number.NEGATIVE_INFINITY
|
|
43
|
+
let yMinimum = Number.POSITIVE_INFINITY
|
|
44
|
+
let yMaximum = Number.NEGATIVE_INFINITY
|
|
45
|
+
for (const point of points) {
|
|
46
|
+
xMinimum = Math.min(xMinimum, point.x)
|
|
47
|
+
xMaximum = Math.max(xMaximum, point.x)
|
|
48
|
+
yMinimum = Math.min(yMinimum, point.y)
|
|
49
|
+
yMaximum = Math.max(yMaximum, point.y)
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
inputPointCount,
|
|
53
|
+
validPointCount: points.length,
|
|
54
|
+
xDomain: points.length ? [xMinimum, xMaximum] : null,
|
|
55
|
+
yDomain: points.length ? [yMinimum, yMaximum] : null,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function metricsForValues(
|
|
60
|
+
x: Float64Array,
|
|
61
|
+
y: Float32Array | Float64Array,
|
|
62
|
+
inputPointCount = x.length,
|
|
63
|
+
): WorkerSamplingDatasetMetrics {
|
|
64
|
+
let xMinimum = Number.POSITIVE_INFINITY
|
|
65
|
+
let xMaximum = Number.NEGATIVE_INFINITY
|
|
66
|
+
let yMinimum = Number.POSITIVE_INFINITY
|
|
67
|
+
let yMaximum = Number.NEGATIVE_INFINITY
|
|
68
|
+
for (let index = 0; index < x.length; index += 1) {
|
|
69
|
+
xMinimum = Math.min(xMinimum, x[index]!)
|
|
70
|
+
xMaximum = Math.max(xMaximum, x[index]!)
|
|
71
|
+
yMinimum = Math.min(yMinimum, y[index]!)
|
|
72
|
+
yMaximum = Math.max(yMaximum, y[index]!)
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
inputPointCount,
|
|
76
|
+
validPointCount: x.length,
|
|
77
|
+
xDomain: x.length ? [xMinimum, xMaximum] : null,
|
|
78
|
+
yDomain: x.length ? [yMinimum, yMaximum] : null,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function statusFor<T extends { revision: number }>(
|
|
83
|
+
dataset: T | undefined,
|
|
84
|
+
revision: number,
|
|
85
|
+
): WorkerSamplingStatus {
|
|
86
|
+
if (!dataset) return 'not-found'
|
|
87
|
+
return dataset.revision === revision ? 'ok' : 'stale-revision'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function selectedStrategy(strategy: WorkerSamplingSeriesRequest['strategy']) {
|
|
91
|
+
return strategy === 'auto' ? 'peak' : strategy
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function targetPointCount(request: WorkerSamplingSeriesRequest) {
|
|
95
|
+
const width = Number.isFinite(request.plotWidth) ? Math.max(0, request.plotWidth) : 0
|
|
96
|
+
const density = Number.isFinite(request.maxPointsPerPixel)
|
|
97
|
+
? Math.max(0, request.maxPointsPerPixel ?? 0)
|
|
98
|
+
: DEFAULT_WORKER_SAMPLING_MAX_POINTS_PER_PIXEL
|
|
99
|
+
return Math.max(1, Math.floor(width * density))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function shouldUseRaw(request: WorkerSamplingSeriesRequest, visiblePointCount: number) {
|
|
103
|
+
if (request.mode === 'raw') return true
|
|
104
|
+
if (request.mode !== 'auto') return false
|
|
105
|
+
const threshold = Number.isFinite(request.autoThreshold)
|
|
106
|
+
? Math.max(1, Math.floor(request.autoThreshold ?? 0))
|
|
107
|
+
: DEFAULT_WORKER_SAMPLING_AUTO_THRESHOLD
|
|
108
|
+
if (!request.previousSelectedMode) return visiblePointCount <= threshold
|
|
109
|
+
const hysteresis = Number.isFinite(request.autoHysteresis)
|
|
110
|
+
? Math.max(0, Math.floor(request.autoHysteresis ?? 0))
|
|
111
|
+
: 0
|
|
112
|
+
return request.previousSelectedMode === 'raw'
|
|
113
|
+
? visiblePointCount <= threshold + hysteresis
|
|
114
|
+
: visiblePointCount <= Math.max(0, threshold - hysteresis)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function visibleRange(points: readonly WaveformPoint[], domain: [number, number]) {
|
|
118
|
+
const startX = Math.min(domain[0], domain[1])
|
|
119
|
+
const endX = Math.max(domain[0], domain[1])
|
|
120
|
+
return { start: pointBisector.left(points, startX), end: pointBisector.right(points, endX) }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function visibleValueRange(x: Float64Array, domain: [number, number]) {
|
|
124
|
+
const startX = Math.min(domain[0], domain[1])
|
|
125
|
+
const endX = Math.max(domain[0], domain[1])
|
|
126
|
+
let start = 0
|
|
127
|
+
let end = x.length
|
|
128
|
+
while (start < end) {
|
|
129
|
+
const middle = start + Math.floor((end - start) / 2)
|
|
130
|
+
if (x[middle]! < startX) start = middle + 1
|
|
131
|
+
else end = middle
|
|
132
|
+
}
|
|
133
|
+
const rangeStart = start
|
|
134
|
+
end = x.length
|
|
135
|
+
while (start < end) {
|
|
136
|
+
const middle = start + Math.floor((end - start) / 2)
|
|
137
|
+
if (x[middle]! <= endX) start = middle + 1
|
|
138
|
+
else end = middle
|
|
139
|
+
}
|
|
140
|
+
return { start: rangeStart, end: start }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function nearestPoint(points: readonly WaveformPoint[], x: number) {
|
|
144
|
+
const first = points[0]
|
|
145
|
+
const last = points.at(-1)
|
|
146
|
+
return Number.isFinite(x) && first && last && x >= first.x && x <= last.x
|
|
147
|
+
? points[pointBisector.center(points, x)]
|
|
148
|
+
: undefined
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function outputCount(output: WorkerSamplingOutput | undefined) {
|
|
152
|
+
if (!output) return 0
|
|
153
|
+
return output.kind === 'source-indexes' ? output.sourceIndexes.length : output.x.length
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function outputWithGlobalIndexes(
|
|
157
|
+
output: WorkerSamplingOutput,
|
|
158
|
+
offset: number,
|
|
159
|
+
): WorkerSamplingOutput {
|
|
160
|
+
if (output.kind === 'aggregates') return output
|
|
161
|
+
return {
|
|
162
|
+
kind: 'source-indexes',
|
|
163
|
+
sourceIndexes: Uint32Array.from(output.sourceIndexes, (index) => index + offset),
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function cacheKey(
|
|
168
|
+
request: WorkerSamplingSeriesRequest,
|
|
169
|
+
range: { start: number; end: number },
|
|
170
|
+
strategy: ReturnType<typeof selectedStrategy>,
|
|
171
|
+
target: number,
|
|
172
|
+
backend: WorkerSamplingBackendKind,
|
|
173
|
+
) {
|
|
174
|
+
return [
|
|
175
|
+
request.datasetId,
|
|
176
|
+
request.revision,
|
|
177
|
+
range.start,
|
|
178
|
+
range.end,
|
|
179
|
+
request.plotWidth,
|
|
180
|
+
target,
|
|
181
|
+
request.maxPointsPerPixel,
|
|
182
|
+
request.mode,
|
|
183
|
+
backend,
|
|
184
|
+
strategy,
|
|
185
|
+
request.lineType ?? 'linear',
|
|
186
|
+
request.pointType ?? 'none',
|
|
187
|
+
request.errorBarVisible === true ? 1 : 0,
|
|
188
|
+
request.pointMinSpacing ?? 0,
|
|
189
|
+
request.errorBarMinSpacing ?? 0,
|
|
190
|
+
].join('\u0000')
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function diagnosticsFor(
|
|
194
|
+
request: WorkerSamplingSeriesRequest,
|
|
195
|
+
sourcePointCount: number,
|
|
196
|
+
visiblePointCount: number,
|
|
197
|
+
backend: WorkerSamplingBackendKind,
|
|
198
|
+
selectedMode: 'raw' | 'sampled',
|
|
199
|
+
durationMs: number,
|
|
200
|
+
renderedPointCount: number,
|
|
201
|
+
cacheHit: boolean,
|
|
202
|
+
): WorkerSamplingDiagnostics {
|
|
203
|
+
return {
|
|
204
|
+
seriesId: request.seriesId,
|
|
205
|
+
datasetId: request.datasetId,
|
|
206
|
+
mode: request.mode,
|
|
207
|
+
selectedMode,
|
|
208
|
+
backend,
|
|
209
|
+
strategy: selectedStrategy(request.strategy),
|
|
210
|
+
sourcePointCount,
|
|
211
|
+
visiblePointCount,
|
|
212
|
+
renderedPointCount,
|
|
213
|
+
durationMs,
|
|
214
|
+
cacheHit,
|
|
215
|
+
requestId: 0,
|
|
216
|
+
revision: request.revision,
|
|
217
|
+
rawPointLimitExceeded:
|
|
218
|
+
selectedMode === 'raw' &&
|
|
219
|
+
Number.isFinite(request.rawPointLimit) &&
|
|
220
|
+
visiblePointCount > (request.rawPointLimit ?? Number.POSITIVE_INFINITY),
|
|
221
|
+
scheduledRequestCount: 0,
|
|
222
|
+
coalescedRequestCount: 0,
|
|
223
|
+
maxPendingRequestCount: 0,
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { WorkerSamplingRepository } from './repository'
|
|
4
|
+
|
|
5
|
+
describe('WorkerSamplingRepository typed datasets', () => {
|
|
6
|
+
it('copies, filters, and stably orders typed columns without materializing point objects', () => {
|
|
7
|
+
const repository = new WorkerSamplingRepository()
|
|
8
|
+
const x = Float64Array.from([2, Number.NaN, 1, 1, 3])
|
|
9
|
+
const y = Float32Array.from([20, 99, 10, 11, Number.POSITIVE_INFINITY])
|
|
10
|
+
repository.handle({
|
|
11
|
+
type: 'register-dataset',
|
|
12
|
+
requestId: 80,
|
|
13
|
+
datasetId: 'typed',
|
|
14
|
+
revision: 0,
|
|
15
|
+
dataset: { kind: 'typed', x, y },
|
|
16
|
+
})
|
|
17
|
+
x[0] = 99
|
|
18
|
+
y[2] = 99
|
|
19
|
+
|
|
20
|
+
const sampled = repository.handle({
|
|
21
|
+
type: 'sample-viewport',
|
|
22
|
+
requestId: 81,
|
|
23
|
+
series: [
|
|
24
|
+
{
|
|
25
|
+
seriesId: 'typed',
|
|
26
|
+
datasetId: 'typed',
|
|
27
|
+
revision: 1,
|
|
28
|
+
xDomain: [1, 2],
|
|
29
|
+
plotWidth: 10,
|
|
30
|
+
mode: 'raw',
|
|
31
|
+
strategy: 'peak',
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
})
|
|
35
|
+
const belowRange = repository.handle({
|
|
36
|
+
type: 'find-nearest-point',
|
|
37
|
+
requestId: 82,
|
|
38
|
+
datasetId: 'typed',
|
|
39
|
+
revision: 1,
|
|
40
|
+
x: 0,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
expect(sampled).toMatchObject({
|
|
44
|
+
results: [
|
|
45
|
+
{
|
|
46
|
+
output: { kind: 'source-indexes', sourceIndexes: Uint32Array.from([0, 1, 2]) },
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
})
|
|
50
|
+
expect(belowRange).toMatchObject({ status: 'ok', point: undefined })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('derives sample X values from compact metadata and preserves source-time gaps', () => {
|
|
54
|
+
const repository = new WorkerSamplingRepository()
|
|
55
|
+
const values = new Float32Array([2, 6])
|
|
56
|
+
repository.handle({
|
|
57
|
+
type: 'register-dataset',
|
|
58
|
+
requestId: 90,
|
|
59
|
+
datasetId: 'samples',
|
|
60
|
+
revision: 0,
|
|
61
|
+
dataset: {
|
|
62
|
+
kind: 'samples',
|
|
63
|
+
values,
|
|
64
|
+
sampleRate: 2,
|
|
65
|
+
startTime: 10,
|
|
66
|
+
sourceIndexes: new Uint32Array([0, 2]),
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
values[0] = 99
|
|
70
|
+
|
|
71
|
+
expect(
|
|
72
|
+
repository.handle({
|
|
73
|
+
type: 'find-nearest-point',
|
|
74
|
+
requestId: 91,
|
|
75
|
+
datasetId: 'samples',
|
|
76
|
+
revision: 1,
|
|
77
|
+
x: 10.9,
|
|
78
|
+
}),
|
|
79
|
+
).toMatchObject({ point: { x: 11, y: 6 } })
|
|
80
|
+
expect(
|
|
81
|
+
repository.handle({
|
|
82
|
+
type: 'get-dataset-metrics',
|
|
83
|
+
requestId: 92,
|
|
84
|
+
datasetId: 'samples',
|
|
85
|
+
revision: 1,
|
|
86
|
+
}),
|
|
87
|
+
).toMatchObject({ metrics: { validPointCount: 2, xDomain: [10, 11], yDomain: [2, 6] } })
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { beforeAll, describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { createInitializedWasmSamplingBackend, initializeWasmSampling } from '../wasmSampling'
|
|
7
|
+
import { WorkerSamplingRepository } from './repository'
|
|
8
|
+
|
|
9
|
+
describe('WorkerSamplingRepository WASM datasets', () => {
|
|
10
|
+
beforeAll(async () => {
|
|
11
|
+
await initializeWasmSampling(
|
|
12
|
+
await readFile(resolve(process.cwd(), 'wasm/pkg/waveform_sampling_wasm_bg.wasm')),
|
|
13
|
+
)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('registers typed columns in WASM, reports lazy index bytes, and releases handles on replace', () => {
|
|
17
|
+
const repository = new WorkerSamplingRepository({
|
|
18
|
+
wasmBackend: createInitializedWasmSamplingBackend(),
|
|
19
|
+
indexMaxBytes: 1024 * 1024,
|
|
20
|
+
})
|
|
21
|
+
const register = repository.handle({
|
|
22
|
+
type: 'register-dataset',
|
|
23
|
+
requestId: 1,
|
|
24
|
+
datasetId: 'typed',
|
|
25
|
+
revision: 0,
|
|
26
|
+
dataset: {
|
|
27
|
+
kind: 'typed',
|
|
28
|
+
x: Float64Array.from({ length: 65 }, (_, index) => index / 100),
|
|
29
|
+
y: Float32Array.from({ length: 65 }, (_, index) => (index === 31 ? 50 : index)),
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
const sampled = repository.handle({
|
|
33
|
+
type: 'sample-viewport',
|
|
34
|
+
requestId: 2,
|
|
35
|
+
series: [
|
|
36
|
+
{
|
|
37
|
+
seriesId: 'typed',
|
|
38
|
+
datasetId: 'typed',
|
|
39
|
+
revision: 1,
|
|
40
|
+
xDomain: [0.03, 0.61],
|
|
41
|
+
plotWidth: 4,
|
|
42
|
+
mode: 'wasm',
|
|
43
|
+
strategy: 'peak',
|
|
44
|
+
maxPointsPerPixel: 2,
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
expect(register).toMatchObject({ status: 'ok', metrics: { indexBytes: 0 } })
|
|
50
|
+
expect(sampled).toMatchObject({ results: [{ diagnostics: { backend: 'wasm' } }] })
|
|
51
|
+
expect(repository.resourceMetrics.indexBytes).toBeGreaterThan(0)
|
|
52
|
+
|
|
53
|
+
const replaced = repository.handle({
|
|
54
|
+
type: 'replace-dataset',
|
|
55
|
+
requestId: 3,
|
|
56
|
+
datasetId: 'typed',
|
|
57
|
+
revision: 1,
|
|
58
|
+
dataset: {
|
|
59
|
+
kind: 'typed',
|
|
60
|
+
x: Float64Array.from([0, 1]),
|
|
61
|
+
y: Float32Array.from([1, 2]),
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
expect(replaced).toMatchObject({ status: 'ok', revision: 2, metrics: { indexBytes: 0 } })
|
|
66
|
+
expect(repository.resourceMetrics.indexBytes).toBe(0)
|
|
67
|
+
repository.handle({ type: 'dispose-all', requestId: 4 })
|
|
68
|
+
expect(repository.resourceMetrics).toMatchObject({ datasetCount: 0, indexBytes: 0 })
|
|
69
|
+
})
|
|
70
|
+
})
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { WorkerSamplingOutput } from './protocol'
|
|
2
|
+
|
|
3
|
+
export interface SamplingCacheMetrics {
|
|
4
|
+
entries: number
|
|
5
|
+
bytes: number
|
|
6
|
+
maxEntries: number
|
|
7
|
+
maxBytes: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface CacheEntry {
|
|
11
|
+
output: WorkerSamplingOutput
|
|
12
|
+
bytes: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SamplingOutputCacheOptions {
|
|
16
|
+
maxEntries?: number
|
|
17
|
+
maxBytes?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_MAX_ENTRIES = 96
|
|
21
|
+
const DEFAULT_MAX_BYTES = 16 * 1024 * 1024
|
|
22
|
+
|
|
23
|
+
function outputBytes(output: WorkerSamplingOutput) {
|
|
24
|
+
return output.kind === 'source-indexes'
|
|
25
|
+
? output.sourceIndexes.byteLength
|
|
26
|
+
: output.x.byteLength + output.y.byteLength
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A bounded LRU for immutable Worker sampling outputs. */
|
|
30
|
+
export class SamplingOutputCache {
|
|
31
|
+
private readonly entries = new Map<string, CacheEntry>()
|
|
32
|
+
private bytes = 0
|
|
33
|
+
private readonly maxEntries: number
|
|
34
|
+
private readonly maxBytes: number
|
|
35
|
+
|
|
36
|
+
constructor(options: SamplingOutputCacheOptions = {}) {
|
|
37
|
+
this.maxEntries = Number.isFinite(options.maxEntries)
|
|
38
|
+
? Math.max(0, Math.floor(options.maxEntries ?? 0))
|
|
39
|
+
: DEFAULT_MAX_ENTRIES
|
|
40
|
+
this.maxBytes = Number.isFinite(options.maxBytes)
|
|
41
|
+
? Math.max(0, Math.floor(options.maxBytes ?? 0))
|
|
42
|
+
: DEFAULT_MAX_BYTES
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get metrics(): SamplingCacheMetrics {
|
|
46
|
+
return {
|
|
47
|
+
entries: this.entries.size,
|
|
48
|
+
bytes: this.bytes,
|
|
49
|
+
maxEntries: this.maxEntries,
|
|
50
|
+
maxBytes: this.maxBytes,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get(key: string) {
|
|
55
|
+
const entry = this.entries.get(key)
|
|
56
|
+
if (!entry) return undefined
|
|
57
|
+
this.entries.delete(key)
|
|
58
|
+
this.entries.set(key, entry)
|
|
59
|
+
return entry.output
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
set(key: string, output: WorkerSamplingOutput) {
|
|
63
|
+
const bytes = outputBytes(output)
|
|
64
|
+
if (bytes > this.maxBytes || this.maxEntries === 0) return
|
|
65
|
+
const existing = this.entries.get(key)
|
|
66
|
+
if (existing) {
|
|
67
|
+
this.bytes -= existing.bytes
|
|
68
|
+
this.entries.delete(key)
|
|
69
|
+
}
|
|
70
|
+
this.entries.set(key, { output, bytes })
|
|
71
|
+
this.bytes += bytes
|
|
72
|
+
while (this.entries.size > this.maxEntries || this.bytes > this.maxBytes) {
|
|
73
|
+
const oldest = this.entries.entries().next().value
|
|
74
|
+
if (!oldest) break
|
|
75
|
+
const [oldestKey, oldestEntry] = oldest
|
|
76
|
+
this.entries.delete(oldestKey)
|
|
77
|
+
this.bytes -= oldestEntry.bytes
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
deleteDataset(datasetId: string) {
|
|
82
|
+
const prefix = `${datasetId}\u0000`
|
|
83
|
+
for (const [key, entry] of this.entries) {
|
|
84
|
+
if (!key.startsWith(prefix)) continue
|
|
85
|
+
this.entries.delete(key)
|
|
86
|
+
this.bytes -= entry.bytes
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
clear() {
|
|
91
|
+
this.entries.clear()
|
|
92
|
+
this.bytes = 0
|
|
93
|
+
}
|
|
94
|
+
}
|