waveform-analysis 0.1.48 → 0.1.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +120 -5
  2. package/dist/assets/workerRuntime-Dt94jbqq.js +1 -0
  3. package/dist/index.cjs +1 -1
  4. package/dist/index.js +2683 -1153
  5. package/dist/style.css +1 -1
  6. package/dist/types/components/WaveformChart.vue.d.ts +14 -0
  7. package/dist/types/components/annotation/WaveformAnnotationEditor.vue.d.ts +0 -1
  8. package/dist/types/components/annotation/markup.d.ts +5 -6
  9. package/dist/types/components/annotation/types.d.ts +2 -0
  10. package/dist/types/components/core/latestTaskScheduler.d.ts +19 -0
  11. package/dist/types/components/core/trackLayoutBuilder.d.ts +3 -1
  12. package/dist/types/components/core/types.d.ts +2 -0
  13. package/dist/types/components/core/useWaveformChartController.d.ts +1 -0
  14. package/dist/types/components/core/useWaveformData.d.ts +2 -0
  15. package/dist/types/components/core/useWaveformLayout.d.ts +2 -0
  16. package/dist/types/components/core/useWaveformRenderSampling.d.ts +19 -0
  17. package/dist/types/components/core/waveformChartTypes.d.ts +8 -1
  18. package/dist/types/components/core/workerSamplingClient.d.ts +8 -0
  19. package/dist/types/components/data/types.d.ts +1 -1
  20. package/dist/types/components/interaction/zoomConstraints.d.ts +2 -0
  21. package/dist/types/components/waveform.d.ts +1 -1
  22. package/dist/types/core/compactSamplePointSource.d.ts +3 -0
  23. package/dist/types/core/data.d.ts +7 -1
  24. package/dist/types/core/index.d.ts +2 -1
  25. package/dist/types/core/rendering.d.ts +3 -1
  26. package/dist/types/core/renderingOptions.d.ts +11 -1
  27. package/dist/types/core/samplingReference.d.ts +36 -0
  28. package/dist/types/core/samplingReferenceValues.d.ts +4 -0
  29. package/dist/types/core/samplingReferenceVectors.d.ts +17 -0
  30. package/dist/types/core/wasmSampling.d.ts +48 -0
  31. package/dist/types/core/waveformPointSource.d.ts +53 -0
  32. package/dist/types/core/workerSampling/index.d.ts +5 -0
  33. package/dist/types/core/workerSampling/javascriptBackend.d.ts +3 -0
  34. package/dist/types/core/workerSampling/multiresolution.d.ts +29 -0
  35. package/dist/types/core/workerSampling/protocol.d.ts +153 -0
  36. package/dist/types/core/workerSampling/repository.d.ts +36 -0
  37. package/dist/types/core/workerSampling/repositoryDataset.d.ts +29 -0
  38. package/dist/types/core/workerSampling/repositorySupport.d.ts +27 -0
  39. package/dist/types/core/workerSampling/samplingCache.d.ts +24 -0
  40. package/dist/types/core/workerSampling/workerRuntime.d.ts +1 -0
  41. package/dist/types/index.d.ts +2 -2
  42. package/dist/types/types/chart.d.ts +56 -0
  43. package/dist/types/types/data.d.ts +20 -2
  44. package/dist/types/types/index.d.ts +2 -2
  45. package/dist/types/views/WasmSamplingDemo.vue.d.ts +3 -0
  46. package/package.json +5 -4
  47. package/src/App.test.ts +11 -7
  48. package/src/App.vue +2 -2
  49. package/src/DemoRouterApp.vue +1 -0
  50. package/src/components/WaveformChart.css +2 -0
  51. package/src/components/annotation/WaveformAnnotationEditor.css +2 -2
  52. package/src/components/annotation/WaveformAnnotationEditor.vue +8 -4
  53. package/src/components/annotation/markup.ts +45 -26
  54. package/src/components/annotation/types.ts +2 -0
  55. package/src/components/annotation/useWaveformChartAnnotations.ts +12 -15
  56. package/src/components/core/latestTaskScheduler.test.ts +51 -0
  57. package/src/components/core/latestTaskScheduler.ts +75 -0
  58. package/src/components/core/trackLayoutBuilder.ts +3 -0
  59. package/src/components/core/types.ts +2 -0
  60. package/src/components/core/useWaveformChartController.ts +14 -0
  61. package/src/components/core/useWaveformData.test.ts +95 -0
  62. package/src/components/core/useWaveformData.ts +23 -32
  63. package/src/components/core/useWaveformLayout.ts +4 -4
  64. package/src/components/core/useWaveformRenderSampling.test.ts +17 -0
  65. package/src/components/core/useWaveformRenderSampling.ts +394 -0
  66. package/src/components/core/waveformChartTypes.ts +13 -0
  67. package/src/components/core/workerSamplingClient.ts +148 -0
  68. package/src/components/data/types.ts +9 -0
  69. package/src/components/interaction/WaveformTooltip.test.ts +1 -3
  70. package/src/components/interaction/WaveformTooltip.vue +13 -12
  71. package/src/components/interaction/useWaveformHover.ts +7 -7
  72. package/src/components/interaction/zoomConstraints.ts +5 -1
  73. package/src/components/waveform.ts +9 -0
  74. package/src/components/waveformChartCases/compactAndSharedHover.test.ts +15 -4
  75. package/src/components/waveformChartCases/dataAndHoverPerformance.test.ts +11 -6
  76. package/src/components/waveformChartCases/dataLifecycle.test.ts +1 -3
  77. package/src/components/waveformChartCases/workerSamplingIntegration.test.ts +117 -0
  78. package/src/components/waveformChartCases/xDomainStrategy.test.ts +14 -2
  79. package/src/core/compactSamplePointSource.ts +76 -0
  80. package/src/core/data.test.ts +114 -0
  81. package/src/core/data.ts +177 -75
  82. package/src/core/index.ts +11 -0
  83. package/src/core/rendering.test.ts +87 -12
  84. package/src/core/rendering.ts +35 -1
  85. package/src/core/renderingOptions.ts +124 -15
  86. package/src/core/samplingReference.test.ts +124 -0
  87. package/src/core/samplingReference.ts +243 -0
  88. package/src/core/samplingReferenceValues.ts +169 -0
  89. package/src/core/samplingReferenceVectors.ts +46 -0
  90. package/src/core/wasmSampling.test.ts +205 -0
  91. package/src/core/wasmSampling.ts +230 -0
  92. package/src/core/waveformPointSource.ts +395 -0
  93. package/src/core/workerSampling/index.ts +30 -0
  94. package/src/core/workerSampling/javascriptBackend.ts +15 -0
  95. package/src/core/workerSampling/multiresolution.test.ts +69 -0
  96. package/src/core/workerSampling/multiresolution.ts +300 -0
  97. package/src/core/workerSampling/protocol.test.ts +34 -0
  98. package/src/core/workerSampling/protocol.ts +207 -0
  99. package/src/core/workerSampling/repository.test.ts +400 -0
  100. package/src/core/workerSampling/repository.ts +400 -0
  101. package/src/core/workerSampling/repositoryCache.test.ts +163 -0
  102. package/src/core/workerSampling/repositoryDataset.ts +194 -0
  103. package/src/core/workerSampling/repositorySupport.ts +225 -0
  104. package/src/core/workerSampling/repositoryTypedDataset.test.ts +89 -0
  105. package/src/core/workerSampling/repositoryWasmDataset.test.ts +70 -0
  106. package/src/core/workerSampling/samplingCache.ts +94 -0
  107. package/src/core/workerSampling/workerRuntime.ts +40 -0
  108. package/src/data/simulatedWaveforms.test.ts +19 -0
  109. package/src/data/simulatedWaveforms.ts +11 -8
  110. package/src/demoRoutes.css +177 -0
  111. package/src/index.ts +18 -0
  112. package/src/router.ts +5 -0
  113. package/src/types/chart.ts +63 -0
  114. package/src/types/data.ts +24 -1
  115. package/src/types/index.ts +9 -0
  116. package/src/views/WasmSamplingDemo.test.ts +13 -0
  117. 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
+ }