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,394 @@
1
+ import { computed, onScopeDispose, shallowRef, watch, type ComputedRef, type ShallowRef } from 'vue'
2
+
3
+ import {
4
+ type SampleViewportResponse,
5
+ type WorkerSamplingDiagnostics,
6
+ type WorkerSamplingSeriesRequest,
7
+ } from '../../core/workerSampling'
8
+ import type { WaveformPoint, WaveformSamplingError } from '../../types'
9
+ import type { ResolvedWaveformRenderingOptions } from '../../core'
10
+ import { pointSourceFromPoints, type WaveformPointSource } from '../../core/waveformPointSource'
11
+ import type { PreparedWaveformSeries } from './useWaveformData'
12
+ import type { DisplaySeries, TrackLayout } from './types'
13
+ import type { ResolvedWaveformChartProps, WaveformChartEmit } from './waveformChartTypes'
14
+ import { createLatestTaskScheduler, resolveAutoSelectedMode } from './latestTaskScheduler'
15
+ import {
16
+ createWorkerSamplingClient,
17
+ isDatasetResponse,
18
+ type WorkerSamplingClient,
19
+ } from './workerSamplingClient'
20
+
21
+ interface SamplingTarget {
22
+ series: DisplaySeries
23
+ source: WaveformPointSource
24
+ request: WorkerSamplingSeriesRequest
25
+ visibleRange: { start: number; end: number }
26
+ }
27
+ interface SamplingContext {
28
+ props: ResolvedWaveformChartProps
29
+ emit: WaveformChartEmit
30
+ instanceId: string
31
+ preparedSeries: ShallowRef<PreparedWaveformSeries[]>
32
+ sourceTrackLayouts: ComputedRef<TrackLayout[]>
33
+ renderingOptions: ComputedRef<ResolvedWaveformRenderingOptions>
34
+ linePointOverrides: ShallowRef<Record<string, WaveformPoint[]>>
35
+ }
36
+
37
+ const AUTO_MODE_SETTLE_DELAY_MS = 120
38
+
39
+ function sourcePoints(
40
+ result: SampleViewportResponse['results'][number],
41
+ source: WaveformPointSource,
42
+ ) {
43
+ const output = result.output
44
+ if (!output) return undefined
45
+ if (output.kind === 'aggregates') {
46
+ return Array.from(output.x, (x, index) => ({ x, y: output.y[index]! }))
47
+ }
48
+ return Array.from(output.sourceIndexes, (index) => source.pointAt(index)).filter(
49
+ (point): point is WaveformPoint => point !== undefined,
50
+ )
51
+ }
52
+
53
+ function rawDiagnostics(target: SamplingTarget, requestId: number): WorkerSamplingDiagnostics {
54
+ const sampling = target.request
55
+ const visiblePointCount = target.visibleRange.end - target.visibleRange.start
56
+ return {
57
+ seriesId: sampling.seriesId,
58
+ datasetId: sampling.datasetId,
59
+ mode: sampling.mode,
60
+ selectedMode: 'raw',
61
+ backend: 'raw',
62
+ strategy: sampling.strategy === 'auto' ? 'peak' : sampling.strategy,
63
+ sourcePointCount: target.source.length,
64
+ visiblePointCount,
65
+ renderedPointCount: visiblePointCount,
66
+ durationMs: 0,
67
+ cacheHit: false,
68
+ requestId,
69
+ revision: sampling.revision,
70
+ rawPointLimitExceeded:
71
+ visiblePointCount > target.request.rawPointLimit! && target.request.mode === 'raw',
72
+ scheduledRequestCount: 0,
73
+ coalescedRequestCount: 0,
74
+ maxPendingRequestCount: 0,
75
+ }
76
+ }
77
+
78
+ function fallbackPoints(target: SamplingTarget, maxPointsPerPixel: number) {
79
+ const { source } = target
80
+ const { start, end } = target.visibleRange
81
+ const count = end - start
82
+ const targetCount = Math.max(1, Math.floor(target.request.plotWidth * maxPointsPerPixel))
83
+ if (count <= targetCount) return source.pointsInRange(start, end)
84
+ if (targetCount === 1) return source.pointAt(start) ? [source.pointAt(start)!] : []
85
+ const points: WaveformPoint[] = []
86
+ for (let index = 0; index < targetCount; index += 1) {
87
+ const offset = Math.round((index * (count - 1)) / (targetCount - 1))
88
+ const point = source.pointAt(start + offset)
89
+ if (point) points.push(point)
90
+ }
91
+ return points
92
+ }
93
+
94
+ function errorPayload(
95
+ message: string,
96
+ mode: WaveformSamplingError['mode'],
97
+ fallback: WaveformSamplingError['fallback'],
98
+ seriesIds: string[],
99
+ ): WaveformSamplingError {
100
+ return { message, mode, fallback, seriesIds }
101
+ }
102
+
103
+ export function useWaveformRenderSampling(context: SamplingContext) {
104
+ const dataEpoch = shallowRef(0)
105
+ let client: WorkerSamplingClient | undefined
106
+ let requestId = 0
107
+ const revisions = new Map<string, number>()
108
+ const backendBySeries = new Map<string, WorkerSamplingDiagnostics['backend']>()
109
+ const selectedModeBySeries = new Map<string, 'raw' | 'sampled'>()
110
+ const emittedErrors = new Set<string>()
111
+ const pendingDiagnostics = new Map<string, WorkerSamplingDiagnostics>()
112
+ const samplingScheduler = createLatestTaskScheduler()
113
+ let diagnosticsTimer: ReturnType<typeof setTimeout> | undefined
114
+ let autoModeSettleTimer: ReturnType<typeof setTimeout> | undefined
115
+ let hasInitialSamplingRun = false
116
+
117
+ const targets = computed(() => {
118
+ const sampling = context.renderingOptions.value.sampling
119
+ return context.sourceTrackLayouts.value.flatMap((track) => {
120
+ const domain = track.xScale.domain() as [number, number]
121
+ return track.seriesList.flatMap((series) => {
122
+ if (series.lineType === 'none' || track.width <= 0) return []
123
+ const source = series.source ?? pointSourceFromPoints(series.points)
124
+ const range = source.visibleRange(domain)
125
+ const datasetId = `${context.instanceId}:${series.id}`
126
+ return [
127
+ {
128
+ series,
129
+ source,
130
+ visibleRange: range,
131
+ request: {
132
+ seriesId: series.id,
133
+ datasetId,
134
+ revision: revisions.get(datasetId) ?? 1,
135
+ xDomain: domain,
136
+ visibleStartIndex: range.start,
137
+ visibleEndIndex: range.end,
138
+ plotWidth: track.width,
139
+ mode: sampling.mode,
140
+ autoThreshold: sampling.autoThreshold,
141
+ autoHysteresis: sampling.autoHysteresis,
142
+ strategy: sampling.strategy,
143
+ maxPointsPerPixel: sampling.maxPointsPerPixel,
144
+ rawPointLimit: sampling.rawPointLimit,
145
+ wasmFailureFallback: sampling.wasmFailureFallback,
146
+ lineType: series.lineType,
147
+ pointType: series.pointType,
148
+ errorBarVisible: series.errorBar.visible,
149
+ pointMinSpacing: context.renderingOptions.value.pointMinSpacing,
150
+ errorBarMinSpacing: context.renderingOptions.value.errorBarMinSpacing,
151
+ },
152
+ } satisfies SamplingTarget,
153
+ ]
154
+ })
155
+ })
156
+ })
157
+ const targetSignature = computed(
158
+ () =>
159
+ `${dataEpoch.value}:${targets.value
160
+ .map(
161
+ (target) =>
162
+ `${target.request.datasetId}:${target.request.xDomain.join(',')}:${target.request.plotWidth}:${target.visibleRange.start}:${target.visibleRange.end}:${target.request.mode}:${target.request.autoThreshold}:${target.request.autoHysteresis}:${target.request.strategy}:${target.request.maxPointsPerPixel}:${target.request.rawPointLimit}:${target.request.wasmFailureFallback}:${target.request.lineType}:${target.request.pointType}:${target.request.errorBarVisible}:${target.request.pointMinSpacing}:${target.request.errorBarMinSpacing}`,
163
+ )
164
+ .join('|')}`,
165
+ )
166
+
167
+ const flushDiagnostics = () => {
168
+ diagnosticsTimer = undefined
169
+ pendingDiagnostics.forEach((diagnostic) => {
170
+ const previousBackend = backendBySeries.get(diagnostic.seriesId)
171
+ if (previousBackend && previousBackend !== diagnostic.backend) {
172
+ context.emit('sampling-backend-change', {
173
+ seriesId: diagnostic.seriesId,
174
+ previous: previousBackend,
175
+ current: diagnostic.backend,
176
+ })
177
+ }
178
+ backendBySeries.set(diagnostic.seriesId, diagnostic.backend)
179
+ context.emit('sampling-complete', diagnostic)
180
+ })
181
+ pendingDiagnostics.clear()
182
+ }
183
+ const emitDiagnostics = (diagnostics: WorkerSamplingDiagnostics[]) => {
184
+ diagnostics.forEach((diagnostic) =>
185
+ pendingDiagnostics.set(diagnostic.seriesId, {
186
+ ...diagnostic,
187
+ scheduledRequestCount: samplingScheduler.metrics.scheduled,
188
+ coalescedRequestCount: samplingScheduler.metrics.coalesced,
189
+ maxPendingRequestCount: samplingScheduler.metrics.maxPending,
190
+ }),
191
+ )
192
+ if (!diagnosticsTimer) diagnosticsTimer = setTimeout(flushDiagnostics, 100)
193
+ }
194
+
195
+ const emitErrorOnce = (payload: WaveformSamplingError) => {
196
+ const key = `${payload.mode}:${payload.fallback}:${payload.message}:${payload.seriesIds.join(',')}`
197
+ if (emittedErrors.has(key)) return
198
+ emittedErrors.add(key)
199
+ context.emit('sampling-error', payload)
200
+ }
201
+
202
+ const registerTargets = async (
203
+ workerClient: WorkerSamplingClient,
204
+ batch: SamplingTarget[],
205
+ token: number,
206
+ ) => {
207
+ await Promise.all(
208
+ batch.map(async (target) => {
209
+ if (revisions.has(target.request.datasetId)) return
210
+ const response = await workerClient.send({
211
+ type: 'register-dataset',
212
+ requestId: ++requestId,
213
+ datasetId: target.request.datasetId,
214
+ revision: 0,
215
+ dataset: target.source.toWorkerDataset(),
216
+ })
217
+ if (
218
+ !samplingScheduler.isCurrent(token) ||
219
+ !isDatasetResponse(response) ||
220
+ (response.status !== 'ok' && response.status !== 'stale-revision')
221
+ ) {
222
+ return
223
+ }
224
+ revisions.set(target.request.datasetId, response.revision)
225
+ }),
226
+ )
227
+ }
228
+
229
+ const runSampling = async (token: number, useAutoHysteresis = false) => {
230
+ const currentTargets = targets.value
231
+ const nextOverrides: Record<string, WaveformPoint[]> = { ...context.linePointOverrides.value }
232
+ const sampledTargets: SamplingTarget[] = []
233
+ const diagnostics: WorkerSamplingDiagnostics[] = []
234
+ currentTargets.forEach((target) => {
235
+ const visiblePointCount = target.visibleRange.end - target.visibleRange.start
236
+ const previousSelectedMode = useAutoHysteresis
237
+ ? selectedModeBySeries.get(target.series.id)
238
+ : undefined
239
+ const autoRaw =
240
+ target.request.mode === 'auto' &&
241
+ resolveAutoSelectedMode(
242
+ visiblePointCount,
243
+ target.request.autoThreshold!,
244
+ target.request.autoHysteresis ?? 0,
245
+ previousSelectedMode,
246
+ ) === 'raw'
247
+ if (target.request.mode === 'raw' || autoRaw) {
248
+ nextOverrides[target.series.id] = target.source.pointsInRange(
249
+ target.visibleRange.start,
250
+ target.visibleRange.end,
251
+ )
252
+ selectedModeBySeries.set(target.series.id, 'raw')
253
+ diagnostics.push(rawDiagnostics(target, token))
254
+ } else {
255
+ if (!nextOverrides[target.series.id]?.length) {
256
+ nextOverrides[target.series.id] = fallbackPoints(
257
+ target,
258
+ context.renderingOptions.value.sampling.maxPointsPerPixel,
259
+ )
260
+ }
261
+ sampledTargets.push({
262
+ ...target,
263
+ request: { ...target.request, previousSelectedMode },
264
+ })
265
+ }
266
+ })
267
+ if (!sampledTargets.length) {
268
+ if (!samplingScheduler.isCurrent(token)) return
269
+ context.linePointOverrides.value = nextOverrides
270
+ emitDiagnostics(diagnostics)
271
+ return
272
+ }
273
+
274
+ context.linePointOverrides.value = nextOverrides
275
+ client ??= createWorkerSamplingClient()
276
+ await registerTargets(client, sampledTargets, token)
277
+ if (!samplingScheduler.isCurrent(token)) return
278
+ const batch = sampledTargets.map((target) => ({
279
+ ...target.request,
280
+ revision: revisions.get(target.request.datasetId) ?? 1,
281
+ }))
282
+ let response: SampleViewportResponse
283
+ try {
284
+ const result = await client.send({
285
+ type: 'sample-viewport',
286
+ requestId: ++requestId,
287
+ series: batch,
288
+ })
289
+ if (result.type !== 'sample-viewport-response') return
290
+ response = result
291
+ } catch (error) {
292
+ if (!samplingScheduler.isCurrent(token)) return
293
+ emitErrorOnce(
294
+ errorPayload(
295
+ error instanceof Error ? error.message : String(error),
296
+ context.renderingOptions.value.sampling.mode,
297
+ 'javascript',
298
+ sampledTargets.map((target) => target.series.id),
299
+ ),
300
+ )
301
+ return
302
+ }
303
+ if (!samplingScheduler.isCurrent(token)) return
304
+ const sourceById = new Map(sampledTargets.map((target) => [target.series.id, target.source]))
305
+ response.results.forEach((result) => {
306
+ const source = sourceById.get(result.seriesId)
307
+ const selected = source ? sourcePoints(result, source) : undefined
308
+ if (selected && (result.status === 'ok' || result.status === 'wasm-unavailable')) {
309
+ nextOverrides[result.seriesId] = selected
310
+ }
311
+ diagnostics.push(result.diagnostics)
312
+ selectedModeBySeries.set(result.seriesId, result.diagnostics.selectedMode)
313
+ if (result.status === 'wasm-unavailable') {
314
+ emitErrorOnce(
315
+ errorPayload(
316
+ response.workerError ?? 'WASM sampling is unavailable.',
317
+ result.diagnostics.mode,
318
+ result.diagnostics.backend === 'javascript' ? 'javascript' : 'none',
319
+ [result.seriesId],
320
+ ),
321
+ )
322
+ }
323
+ })
324
+ if (response.workerError) {
325
+ emitErrorOnce(
326
+ errorPayload(
327
+ response.workerError,
328
+ context.renderingOptions.value.sampling.mode,
329
+ 'javascript',
330
+ sampledTargets.map((target) => target.series.id),
331
+ ),
332
+ )
333
+ }
334
+ if (
335
+ client.workerFailureReason &&
336
+ response.results.every((result) => result.status !== 'wasm-unavailable')
337
+ ) {
338
+ emitErrorOnce(
339
+ errorPayload(
340
+ client.workerFailureReason,
341
+ context.renderingOptions.value.sampling.mode,
342
+ 'javascript',
343
+ sampledTargets.map((target) => target.series.id),
344
+ ),
345
+ )
346
+ }
347
+ context.linePointOverrides.value = { ...nextOverrides }
348
+ emitDiagnostics(diagnostics)
349
+ }
350
+
351
+ watch(
352
+ targetSignature,
353
+ () => {
354
+ if (autoModeSettleTimer) clearTimeout(autoModeSettleTimer)
355
+ const useAutoHysteresis = hasInitialSamplingRun
356
+ hasInitialSamplingRun = true
357
+ samplingScheduler.schedule((token) => runSampling(token, useAutoHysteresis))
358
+ if (
359
+ useAutoHysteresis &&
360
+ context.renderingOptions.value.sampling.mode === 'auto' &&
361
+ context.renderingOptions.value.sampling.autoHysteresis > 0
362
+ ) {
363
+ autoModeSettleTimer = setTimeout(() => {
364
+ autoModeSettleTimer = undefined
365
+ samplingScheduler.schedule((token) => runSampling(token, false))
366
+ }, AUTO_MODE_SETTLE_DELAY_MS)
367
+ }
368
+ },
369
+ { immediate: true },
370
+ )
371
+ watch(
372
+ context.preparedSeries,
373
+ () => {
374
+ samplingScheduler.cancelPending()
375
+ dataEpoch.value += 1
376
+ context.linePointOverrides.value = {}
377
+ revisions.clear()
378
+ backendBySeries.clear()
379
+ selectedModeBySeries.clear()
380
+ emittedErrors.clear()
381
+ client?.dispose()
382
+ client = undefined
383
+ },
384
+ { flush: 'sync' },
385
+ )
386
+ onScopeDispose(() => {
387
+ if (diagnosticsTimer) clearTimeout(diagnosticsTimer)
388
+ if (autoModeSettleTimer) clearTimeout(autoModeSettleTimer)
389
+ samplingScheduler.dispose()
390
+ client?.dispose()
391
+ })
392
+
393
+ return { linePointOverrides: context.linePointOverrides }
394
+ }
@@ -10,6 +10,9 @@ import type {
10
10
  WaveformPlotMargin,
11
11
  WaveformPoint,
12
12
  WaveformRenderingOptions,
13
+ WaveformSamplingBackend,
14
+ WaveformSamplingDiagnostics,
15
+ WaveformSamplingError,
13
16
  WaveformTitleOptions,
14
17
  WaveformXDomainStrategy,
15
18
  WaveformZeroLineOptions,
@@ -97,6 +100,16 @@ export interface WaveformChartEmit {
97
100
  (event: 'annotation-update', annotation: WaveformAnnotation, previous: WaveformAnnotation): void
98
101
  (event: 'annotation-delete', annotation: WaveformAnnotation): void
99
102
  (event: 'page-change', page: number, pageCount: number): void
103
+ (event: 'sampling-complete', diagnostics: WaveformSamplingDiagnostics): void
104
+ (
105
+ event: 'sampling-backend-change',
106
+ payload: {
107
+ seriesId: string
108
+ previous: WaveformSamplingBackend
109
+ current: WaveformSamplingBackend
110
+ },
111
+ ): void
112
+ (event: 'sampling-error', error: WaveformSamplingError): void
100
113
  }
101
114
 
102
115
  interface ViewportSelectionBase {
@@ -0,0 +1,148 @@
1
+ import { WorkerSamplingRepository } from '../../core/workerSampling'
2
+ import type {
3
+ DatasetResponse,
4
+ WorkerSamplingRequest,
5
+ WorkerSamplingResponse,
6
+ } from '../../core/workerSampling'
7
+ import type { WaveformPoint } from '../../types'
8
+ import type { WorkerSamplingDataset } from '../../core/waveformPointSource'
9
+
10
+ interface StoredDataset {
11
+ points?: readonly WaveformPoint[]
12
+ dataset?: WorkerSamplingDataset
13
+ revision: number
14
+ }
15
+
16
+ interface PendingRequest {
17
+ request: WorkerSamplingRequest
18
+ resolve: (response: WorkerSamplingResponse) => void
19
+ reject: (error: Error) => void
20
+ }
21
+
22
+ export interface WorkerSamplingClient {
23
+ readonly workerFailureReason: string | undefined
24
+ send(request: WorkerSamplingRequest): Promise<WorkerSamplingResponse>
25
+ dispose(): void
26
+ }
27
+
28
+ function workerFailureMessage(event?: ErrorEvent) {
29
+ return event?.message || 'Web Worker is unavailable; JavaScript sampling is being used.'
30
+ }
31
+
32
+ export function createWorkerSamplingClient(): WorkerSamplingClient {
33
+ let worker: Worker | undefined
34
+ let fallback: WorkerSamplingRepository | undefined
35
+ let workerFailureReason: string | undefined
36
+ const pending = new Map<number, PendingRequest>()
37
+ const datasets = new Map<string, StoredDataset>()
38
+
39
+ const fallbackRepository = () => {
40
+ fallback ??= new WorkerSamplingRepository()
41
+ for (const [datasetId, dataset] of datasets) {
42
+ fallback.handle({
43
+ type: 'register-dataset',
44
+ requestId: 0,
45
+ datasetId,
46
+ revision: 0,
47
+ points: dataset.points,
48
+ dataset: dataset.dataset,
49
+ })
50
+ for (let revision = 1; revision < dataset.revision; revision += 1) {
51
+ fallback.handle({
52
+ type: 'replace-dataset',
53
+ requestId: 0,
54
+ datasetId,
55
+ revision,
56
+ points: dataset.points,
57
+ dataset: dataset.dataset,
58
+ })
59
+ }
60
+ }
61
+ return fallback
62
+ }
63
+
64
+ const recordDatasetRequest = (request: WorkerSamplingRequest) => {
65
+ if (request.type === 'register-dataset') {
66
+ datasets.set(request.datasetId, {
67
+ points: request.points,
68
+ dataset: request.dataset,
69
+ revision: 1,
70
+ })
71
+ }
72
+ if (request.type === 'replace-dataset') {
73
+ datasets.set(request.datasetId, {
74
+ points: request.points,
75
+ dataset: request.dataset,
76
+ revision: request.revision + 1,
77
+ })
78
+ }
79
+ if (request.type === 'dispose-dataset') datasets.delete(request.datasetId)
80
+ if (request.type === 'dispose-all') datasets.clear()
81
+ }
82
+
83
+ const resolveFallbackRequests = () => {
84
+ const outstanding = [...pending.values()]
85
+ pending.clear()
86
+ const repository = fallbackRepository()
87
+ outstanding.forEach(({ request, resolve }) => resolve(repository.handle(request)))
88
+ }
89
+
90
+ const fallBackToJavascript = (event?: ErrorEvent) => {
91
+ if (fallback) return
92
+ workerFailureReason = workerFailureMessage(event)
93
+ worker?.terminate()
94
+ worker = undefined
95
+ resolveFallbackRequests()
96
+ }
97
+
98
+ try {
99
+ if (typeof Worker === 'undefined') fallBackToJavascript()
100
+ else {
101
+ worker = new Worker(new URL('../../core/workerSampling/workerRuntime.ts', import.meta.url), {
102
+ type: 'module',
103
+ })
104
+ worker.onmessage = (event: MessageEvent<WorkerSamplingResponse>) => {
105
+ const request = pending.get(event.data.requestId)
106
+ if (!request) return
107
+ pending.delete(event.data.requestId)
108
+ request.resolve(event.data)
109
+ }
110
+ worker.onerror = (event) => fallBackToJavascript(event)
111
+ worker.onmessageerror = () => fallBackToJavascript()
112
+ }
113
+ } catch {
114
+ fallBackToJavascript()
115
+ }
116
+
117
+ return {
118
+ get workerFailureReason() {
119
+ return workerFailureReason
120
+ },
121
+ send(request) {
122
+ recordDatasetRequest(request)
123
+ if (fallback) return Promise.resolve(fallbackRepository().handle(request))
124
+ return new Promise<WorkerSamplingResponse>((resolve, reject) => {
125
+ pending.set(request.requestId, { request, resolve, reject })
126
+ try {
127
+ worker?.postMessage(request)
128
+ } catch {
129
+ pending.delete(request.requestId)
130
+ fallBackToJavascript()
131
+ resolve(fallbackRepository().handle(request))
132
+ }
133
+ })
134
+ },
135
+ dispose() {
136
+ worker?.terminate()
137
+ worker = undefined
138
+ pending.forEach(({ reject }) => reject(new Error('Sampling client was disposed.')))
139
+ pending.clear()
140
+ datasets.clear()
141
+ fallback = undefined
142
+ },
143
+ }
144
+ }
145
+
146
+ export function isDatasetResponse(response: WorkerSamplingResponse): response is DatasetResponse {
147
+ return response.type === 'dataset-response'
148
+ }
@@ -14,6 +14,12 @@ export type {
14
14
  WaveformZoomResetPayload,
15
15
  WaveformAnnotationStyle,
16
16
  WaveformAnnotation,
17
+ WaveformSamplingMode,
18
+ WaveformSamplingStrategy,
19
+ WaveformSamplingOptions,
20
+ WaveformSamplingBackend,
21
+ WaveformSamplingDiagnostics,
22
+ WaveformSamplingError,
17
23
  WaveformRenderingOptions,
18
24
  WaveformPlotMargin,
19
25
  WaveformTitleTextStyle,
@@ -30,6 +36,9 @@ export type {
30
36
  WaveformPointType,
31
37
  WaveformErrorBarOptions,
32
38
  ResolvedWaveformErrorBarOptions,
39
+ WaveformTypedValues,
40
+ TypedSampleData,
41
+ TypedPointData,
33
42
  WaveformSeries,
34
43
  WaveformData,
35
44
  NormalizedWaveformSeries,
@@ -188,9 +188,7 @@ describe('WaveformTooltip', () => {
188
188
  },
189
189
  })
190
190
 
191
- const values = wrapper
192
- .findAll('.waveform-tooltip__value')
193
- .map((value) => value.text())
191
+ const values = wrapper.findAll('.waveform-tooltip__value').map((value) => value.text())
194
192
  expect(values).toEqual(['(x:1 y:12)', '(x:1.25 y:8)', '无数据'])
195
193
  })
196
194
  })
@@ -90,14 +90,10 @@ const tooltipStyle = computed(() => {
90
90
  ...horizontalStyle,
91
91
  top: `${Math.max(
92
92
  8,
93
- Math.min(
94
- props.position.y - 18,
95
- props.containerHeight - estimateTooltipHeight(maxWidth) - 8,
96
- ),
93
+ Math.min(props.position.y - 18, props.containerHeight - estimateTooltipHeight(maxWidth) - 8),
97
94
  )}px`,
98
95
  }
99
96
  })
100
-
101
97
  </script>
102
98
 
103
99
  <template>
@@ -113,12 +109,15 @@ const tooltipStyle = computed(() => {
113
109
  >
114
110
  <i :style="{ backgroundColor: seriesPoint.color }" />
115
111
  <span class="waveform-tooltip__series-content">
116
- <span class="waveform-tooltip__series-label">{{ seriesPoint.shotNo?.trim() || '未配置炮号' }}: {{
117
- seriesPoint.name
118
- }}<template v-if="seriesPoint.unit">({{ seriesPoint.unit }})</template></span>
119
- <span v-if="seriesPoint.point" class="waveform-tooltip__value">(x:{{
120
- formatTooltipTime(seriesPoint.point.x, timeUnit)
121
- }} y:{{ formatTooltipNumber(seriesPoint.point.y) }})</span>
112
+ <span class="waveform-tooltip__series-label"
113
+ >{{ seriesPoint.shotNo?.trim() || '未配置炮号' }}: {{ seriesPoint.name
114
+ }}<template v-if="seriesPoint.unit">({{ seriesPoint.unit }})</template></span
115
+ >
116
+ <span v-if="seriesPoint.point" class="waveform-tooltip__value"
117
+ >(x:{{ formatTooltipTime(seriesPoint.point.x, timeUnit) }} y:{{
118
+ formatTooltipNumber(seriesPoint.point.y)
119
+ }})</span
120
+ >
122
121
  <span v-else class="waveform-tooltip__value waveform-tooltip__value--missing">无数据</span>
123
122
  </span>
124
123
  </span>
@@ -136,7 +135,9 @@ const tooltipStyle = computed(() => {
136
135
  max-width: min(560px, calc(100% - 16px));
137
136
  padding: 9px 12px;
138
137
  color: #505050;
139
- font: 14px/1.35 Arial, sans-serif;
138
+ font:
139
+ 14px/1.35 Arial,
140
+ sans-serif;
140
141
  pointer-events: none;
141
142
  background: #fff;
142
143
  border: 1px solid #e3e7eb;
@@ -1,7 +1,8 @@
1
- import { bisector, pointer } from 'd3'
1
+ import { pointer } from 'd3'
2
2
  import type { ComputedRef, Ref, ShallowRef } from 'vue'
3
3
 
4
4
  import type { WaveformPoint } from '../data/types'
5
+ import { pointSourceFromPoints } from '../../core/waveformPointSource'
5
6
  import type {
6
7
  DisplaySeries,
7
8
  HoveredSeriesPoint,
@@ -28,8 +29,6 @@ interface HoverContext {
28
29
  resolveTrackAtPointer: (pointerX: number, pointerY: number) => TrackLayout | undefined
29
30
  }
30
31
 
31
- const pointBisector = bisector<WaveformPoint, number>((point) => point.x)
32
-
33
32
  export function useWaveformHover(context: HoverContext) {
34
33
  const {
35
34
  emit,
@@ -110,10 +109,11 @@ export function useWaveformHover(context: HoverContext) {
110
109
  return true
111
110
  }
112
111
  const nearestPoint = (series: DisplaySeries, xValue: number): WaveformPoint | undefined => {
113
- const firstPoint = series.points[0]
114
- const lastPoint = series.points.at(-1)
115
- if (!firstPoint || !lastPoint || xValue < firstPoint.x || xValue > lastPoint.x) return undefined
116
- return series.points[pointBisector.center(series.points, xValue)]
112
+ const source = series.source ?? pointSourceFromPoints(series.points)
113
+ const first = source.pointAt(0)
114
+ const last = source.pointAt(source.length - 1)
115
+ if (!first || !last || xValue < first.x || xValue > last.x) return undefined
116
+ return source.nearestPoint(xValue)
117
117
  }
118
118
 
119
119
  const handleIndependentPointerMove = (event: PointerEvent, trackIndex: number) => {