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.
Files changed (122) hide show
  1. package/README.md +127 -5
  2. package/dist/assets/workerRuntime-Dt94jbqq.js +1 -0
  3. package/dist/index.cjs +1 -1
  4. package/dist/index.js +2701 -1165
  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/layout.d.ts +2 -2
  12. package/dist/types/components/core/trackLayoutBuilder.d.ts +4 -1
  13. package/dist/types/components/core/types.d.ts +2 -0
  14. package/dist/types/components/core/useWaveformChartController.d.ts +1 -0
  15. package/dist/types/components/core/useWaveformData.d.ts +2 -0
  16. package/dist/types/components/core/useWaveformLayout.d.ts +2 -0
  17. package/dist/types/components/core/useWaveformRenderSampling.d.ts +19 -0
  18. package/dist/types/components/core/waveformChartTypes.d.ts +8 -1
  19. package/dist/types/components/core/workerSamplingClient.d.ts +8 -0
  20. package/dist/types/components/data/types.d.ts +1 -1
  21. package/dist/types/components/interaction/zoomConstraints.d.ts +2 -0
  22. package/dist/types/components/waveform.d.ts +1 -1
  23. package/dist/types/core/compactSamplePointSource.d.ts +3 -0
  24. package/dist/types/core/data.d.ts +7 -1
  25. package/dist/types/core/index.d.ts +2 -1
  26. package/dist/types/core/rendering.d.ts +3 -1
  27. package/dist/types/core/renderingOptions.d.ts +11 -1
  28. package/dist/types/core/samplingReference.d.ts +36 -0
  29. package/dist/types/core/samplingReferenceValues.d.ts +4 -0
  30. package/dist/types/core/samplingReferenceVectors.d.ts +17 -0
  31. package/dist/types/core/wasmSampling.d.ts +48 -0
  32. package/dist/types/core/waveformPointSource.d.ts +53 -0
  33. package/dist/types/core/workerSampling/index.d.ts +5 -0
  34. package/dist/types/core/workerSampling/javascriptBackend.d.ts +3 -0
  35. package/dist/types/core/workerSampling/multiresolution.d.ts +29 -0
  36. package/dist/types/core/workerSampling/protocol.d.ts +153 -0
  37. package/dist/types/core/workerSampling/repository.d.ts +36 -0
  38. package/dist/types/core/workerSampling/repositoryDataset.d.ts +29 -0
  39. package/dist/types/core/workerSampling/repositorySupport.d.ts +27 -0
  40. package/dist/types/core/workerSampling/samplingCache.d.ts +24 -0
  41. package/dist/types/core/workerSampling/workerRuntime.d.ts +1 -0
  42. package/dist/types/index.d.ts +2 -2
  43. package/dist/types/types/chart.d.ts +58 -0
  44. package/dist/types/types/data.d.ts +20 -2
  45. package/dist/types/types/index.d.ts +2 -2
  46. package/dist/types/views/WasmSamplingDemo.vue.d.ts +3 -0
  47. package/package.json +5 -4
  48. package/src/App.test.ts +11 -7
  49. package/src/App.vue +2 -2
  50. package/src/DemoRouterApp.vue +1 -0
  51. package/src/components/WaveformChart.css +2 -0
  52. package/src/components/annotation/WaveformAnnotationEditor.css +2 -2
  53. package/src/components/annotation/WaveformAnnotationEditor.vue +8 -4
  54. package/src/components/annotation/markup.ts +45 -26
  55. package/src/components/annotation/types.ts +2 -0
  56. package/src/components/annotation/useWaveformChartAnnotations.ts +12 -15
  57. package/src/components/core/latestTaskScheduler.test.ts +51 -0
  58. package/src/components/core/latestTaskScheduler.ts +75 -0
  59. package/src/components/core/layout.ts +15 -6
  60. package/src/components/core/trackLayoutBuilder.ts +8 -2
  61. package/src/components/core/types.ts +2 -0
  62. package/src/components/core/useWaveformChartController.ts +14 -0
  63. package/src/components/core/useWaveformData.test.ts +95 -0
  64. package/src/components/core/useWaveformData.ts +23 -32
  65. package/src/components/core/useWaveformLayout.ts +16 -10
  66. package/src/components/core/useWaveformRenderSampling.test.ts +17 -0
  67. package/src/components/core/useWaveformRenderSampling.ts +394 -0
  68. package/src/components/core/waveformChartTypes.ts +13 -0
  69. package/src/components/core/workerSamplingClient.ts +148 -0
  70. package/src/components/core/yAxisNice.test.ts +174 -0
  71. package/src/components/data/types.ts +9 -0
  72. package/src/components/interaction/WaveformTooltip.test.ts +1 -3
  73. package/src/components/interaction/WaveformTooltip.vue +13 -12
  74. package/src/components/interaction/useWaveformHover.ts +7 -7
  75. package/src/components/interaction/zoomConstraints.ts +5 -1
  76. package/src/components/waveform.ts +9 -0
  77. package/src/components/waveformChartCases/compactAndSharedHover.test.ts +15 -4
  78. package/src/components/waveformChartCases/dataAndHoverPerformance.test.ts +11 -6
  79. package/src/components/waveformChartCases/dataLifecycle.test.ts +1 -3
  80. package/src/components/waveformChartCases/trackOrder.test.ts +130 -0
  81. package/src/components/waveformChartCases/workerSamplingIntegration.test.ts +117 -0
  82. package/src/components/waveformChartCases/xDomainStrategy.test.ts +14 -2
  83. package/src/core/compactSamplePointSource.ts +76 -0
  84. package/src/core/data.test.ts +114 -0
  85. package/src/core/data.ts +177 -75
  86. package/src/core/index.ts +11 -0
  87. package/src/core/rendering.test.ts +87 -12
  88. package/src/core/rendering.ts +35 -1
  89. package/src/core/renderingOptions.ts +124 -15
  90. package/src/core/samplingReference.test.ts +124 -0
  91. package/src/core/samplingReference.ts +243 -0
  92. package/src/core/samplingReferenceValues.ts +169 -0
  93. package/src/core/samplingReferenceVectors.ts +46 -0
  94. package/src/core/wasmSampling.test.ts +205 -0
  95. package/src/core/wasmSampling.ts +230 -0
  96. package/src/core/waveformPointSource.ts +395 -0
  97. package/src/core/workerSampling/index.ts +30 -0
  98. package/src/core/workerSampling/javascriptBackend.ts +15 -0
  99. package/src/core/workerSampling/multiresolution.test.ts +69 -0
  100. package/src/core/workerSampling/multiresolution.ts +300 -0
  101. package/src/core/workerSampling/protocol.test.ts +34 -0
  102. package/src/core/workerSampling/protocol.ts +207 -0
  103. package/src/core/workerSampling/repository.test.ts +400 -0
  104. package/src/core/workerSampling/repository.ts +400 -0
  105. package/src/core/workerSampling/repositoryCache.test.ts +163 -0
  106. package/src/core/workerSampling/repositoryDataset.ts +194 -0
  107. package/src/core/workerSampling/repositorySupport.ts +225 -0
  108. package/src/core/workerSampling/repositoryTypedDataset.test.ts +89 -0
  109. package/src/core/workerSampling/repositoryWasmDataset.test.ts +70 -0
  110. package/src/core/workerSampling/samplingCache.ts +94 -0
  111. package/src/core/workerSampling/workerRuntime.ts +40 -0
  112. package/src/data/simulatedWaveforms.test.ts +19 -0
  113. package/src/data/simulatedWaveforms.ts +11 -8
  114. package/src/demo/xAxisLabelControls.test.ts +1 -1
  115. package/src/demoRoutes.css +177 -0
  116. package/src/index.ts +18 -0
  117. package/src/router.ts +5 -0
  118. package/src/types/chart.ts +65 -0
  119. package/src/types/data.ts +24 -1
  120. package/src/types/index.ts +9 -0
  121. package/src/views/WasmSamplingDemo.test.ts +13 -0
  122. package/src/views/WasmSamplingDemo.vue +180 -0
@@ -1,6 +1,5 @@
1
1
  import { scaleLinear, type ZoomTransform } from 'd3'
2
2
  import { computed, type ComputedRef, type Ref, type ShallowRef } from 'vue'
3
-
4
3
  import { resolveWaveformRenderingOptions } from '../../core'
5
4
  import { paddedDomain } from '../../utils'
6
5
  import {
@@ -36,7 +35,6 @@ import type { PreparedWaveformSeries } from './useWaveformData'
36
35
  import type { ResolvedWaveformChartProps } from './waveformChartTypes'
37
36
  import { applyXDomainStrategy } from './xDomain'
38
37
  import type { useWaveformAnnotationInteraction } from '../annotation'
39
-
40
38
  interface LayoutContext {
41
39
  props: ResolvedWaveformChartProps
42
40
  preparedSeries: ShallowRef<PreparedWaveformSeries[]>
@@ -50,8 +48,8 @@ interface LayoutContext {
50
48
  sharedYDomains: Ref<Record<string, [number, number]>>
51
49
  independentYDomains: Ref<Record<number, [number, number]>>
52
50
  annotationInteraction: ReturnType<typeof useWaveformAnnotationInteraction>
51
+ linePointOverrides?: ShallowRef<Readonly<Record<string, import('../../types').WaveformPoint[]>>>
53
52
  }
54
-
55
53
  export function useWaveformLayout(context: LayoutContext) {
56
54
  const {
57
55
  props,
@@ -66,6 +64,7 @@ export function useWaveformLayout(context: LayoutContext) {
66
64
  sharedYDomains,
67
65
  independentYDomains,
68
66
  annotationInteraction,
67
+ linePointOverrides,
69
68
  } = context
70
69
  const chartSeries = computed<DisplaySeries[]>(() =>
71
70
  preparedSeries.value.map((series, index): DisplaySeries => ({
@@ -112,6 +111,12 @@ export function useWaveformLayout(context: LayoutContext) {
112
111
  const pagedTracks = computed(() =>
113
112
  paginateSeries(chartTracks.value, currentPage.value, gridOptions.value),
114
113
  )
114
+ const layoutTracks = computed(() => {
115
+ const tracksWithSeries = pagedTracks.value.filter((track) => track.series.length > 0)
116
+ return gridOptions.value.fillIncompleteLastRow && tracksWithSeries.length > 0
117
+ ? tracksWithSeries
118
+ : pagedTracks.value
119
+ })
115
120
  const yAxisMetrics = computed(() => {
116
121
  const axisText = chartTracks.value
117
122
  .filter((track) => track.visibleSeries.length > 0)
@@ -122,7 +127,7 @@ export function useWaveformLayout(context: LayoutContext) {
122
127
  (group) =>
123
128
  axisTextMetrics(
124
129
  group.domain,
125
- true,
130
+ props.axes?.y?.nice !== false,
126
131
  undefined,
127
132
  group.seriesList[0]?.unit,
128
133
  props.axes?.y?.splitNumber,
@@ -173,6 +178,7 @@ export function useWaveformLayout(context: LayoutContext) {
173
178
  props.yDomain,
174
179
  props.yDomains,
175
180
  props.axes?.y?.splitNumber,
181
+ props.axes?.y?.nice !== false,
176
182
  )
177
183
  return {
178
184
  left: Math.max(maximum.left, clearance.left),
@@ -223,9 +229,7 @@ export function useWaveformLayout(context: LayoutContext) {
223
229
  const hasChartArea = computed(() => innerWidth.value > 0 && innerHeight.value > 0)
224
230
  const resolvedXLabel = computed(() => props.xLabel ?? `时间(${props.timeUnit})`)
225
231
  const activeInteractionMode = computed(() => props.interactionMode)
226
- const isZoomMode = computed(
227
- () => activeInteractionMode.value === 'zoom' || activeInteractionMode.value === undefined,
228
- )
232
+ const isZoomMode = computed(() => activeInteractionMode.value !== 'annotation')
229
233
  const sharedXDomain = computed(() => {
230
234
  const values: number[] = []
231
235
  chartTracks.value.forEach((track) => {
@@ -285,10 +289,10 @@ export function useWaveformLayout(context: LayoutContext) {
285
289
  innerHeight.value,
286
290
  gridOptions.value,
287
291
  props.displayMode,
288
- pagedTracks.value.map(Boolean),
292
+ layoutTracks.value.map(Boolean),
289
293
  yAxisLayout.value.horizontalGap,
290
294
  )
291
- return cells.map((cell, index) => ({ ...cell, series: pagedTracks.value[index] }))
295
+ return cells.map((cell, index) => ({ ...cell, series: layoutTracks.value[index] }))
292
296
  })
293
297
  const trackLayouts = computed<TrackLayout[]>(() =>
294
298
  buildTrackLayouts({
@@ -315,7 +319,9 @@ export function useWaveformLayout(context: LayoutContext) {
315
319
  timeUnit: props.timeUnit,
316
320
  xAxisLabelFormatter: props.axes?.x?.labelFormatter,
317
321
  yAxisSplitNumber: props.axes?.y?.splitNumber,
322
+ yAxisNice: props.axes?.y?.nice,
318
323
  rendering: renderingOptions.value,
324
+ linePointOverrides: linePointOverrides?.value,
319
325
  hideSecondaryLabels: isCleanView.value || yAxisLayout.value.hideSecondaryLabels,
320
326
  yAxisLabelX: yAxisMetrics.value.labelCenterX,
321
327
  showCompactEmptyTracks: props.displayMode === 'compact' && hasWaveformData.value,
@@ -364,10 +370,10 @@ export function useWaveformLayout(context: LayoutContext) {
364
370
  ? props.frameNumber + trackIndex
365
371
  : `${props.frameNumber}-${trackIndex + 1}`
366
372
  }
367
-
368
373
  return {
369
374
  chartSeries,
370
375
  chartTracks,
376
+ renderingOptions,
371
377
  gridOptions,
372
378
  pageCount,
373
379
  pagedTracks,
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { resolveAutoSelectedMode } from './latestTaskScheduler'
4
+
5
+ describe('resolveAutoSelectedMode', () => {
6
+ it('uses the exact threshold when no interaction selection is supplied', () => {
7
+ expect(resolveAutoSelectedMode(1_000, 1_000, 200)).toBe('raw')
8
+ expect(resolveAutoSelectedMode(1_001, 1_000, 200)).toBe('sampled')
9
+ })
10
+
11
+ it('keeps the previous mode inside the configured interaction hysteresis band', () => {
12
+ expect(resolveAutoSelectedMode(1_100, 1_000, 200, 'raw')).toBe('raw')
13
+ expect(resolveAutoSelectedMode(1_201, 1_000, 200, 'raw')).toBe('sampled')
14
+ expect(resolveAutoSelectedMode(900, 1_000, 200, 'sampled')).toBe('sampled')
15
+ expect(resolveAutoSelectedMode(800, 1_000, 200, 'sampled')).toBe('raw')
16
+ })
17
+ })
@@ -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
+ }