waveform-analysis 0.1.47 → 0.1.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +127 -5
- package/dist/assets/workerRuntime-Dt94jbqq.js +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.js +2701 -1165
- package/dist/style.css +1 -1
- package/dist/types/components/WaveformChart.vue.d.ts +14 -0
- package/dist/types/components/annotation/WaveformAnnotationEditor.vue.d.ts +0 -1
- package/dist/types/components/annotation/markup.d.ts +5 -6
- package/dist/types/components/annotation/types.d.ts +2 -0
- package/dist/types/components/core/latestTaskScheduler.d.ts +19 -0
- package/dist/types/components/core/layout.d.ts +2 -2
- package/dist/types/components/core/trackLayoutBuilder.d.ts +4 -1
- package/dist/types/components/core/types.d.ts +2 -0
- package/dist/types/components/core/useWaveformChartController.d.ts +1 -0
- package/dist/types/components/core/useWaveformData.d.ts +2 -0
- package/dist/types/components/core/useWaveformLayout.d.ts +2 -0
- package/dist/types/components/core/useWaveformRenderSampling.d.ts +19 -0
- package/dist/types/components/core/waveformChartTypes.d.ts +8 -1
- package/dist/types/components/core/workerSamplingClient.d.ts +8 -0
- package/dist/types/components/data/types.d.ts +1 -1
- package/dist/types/components/interaction/zoomConstraints.d.ts +2 -0
- package/dist/types/components/waveform.d.ts +1 -1
- package/dist/types/core/compactSamplePointSource.d.ts +3 -0
- package/dist/types/core/data.d.ts +7 -1
- package/dist/types/core/index.d.ts +2 -1
- package/dist/types/core/rendering.d.ts +3 -1
- package/dist/types/core/renderingOptions.d.ts +11 -1
- package/dist/types/core/samplingReference.d.ts +36 -0
- package/dist/types/core/samplingReferenceValues.d.ts +4 -0
- package/dist/types/core/samplingReferenceVectors.d.ts +17 -0
- package/dist/types/core/wasmSampling.d.ts +48 -0
- package/dist/types/core/waveformPointSource.d.ts +53 -0
- package/dist/types/core/workerSampling/index.d.ts +5 -0
- package/dist/types/core/workerSampling/javascriptBackend.d.ts +3 -0
- package/dist/types/core/workerSampling/multiresolution.d.ts +29 -0
- package/dist/types/core/workerSampling/protocol.d.ts +153 -0
- package/dist/types/core/workerSampling/repository.d.ts +36 -0
- package/dist/types/core/workerSampling/repositoryDataset.d.ts +29 -0
- package/dist/types/core/workerSampling/repositorySupport.d.ts +27 -0
- package/dist/types/core/workerSampling/samplingCache.d.ts +24 -0
- package/dist/types/core/workerSampling/workerRuntime.d.ts +1 -0
- package/dist/types/index.d.ts +2 -2
- package/dist/types/types/chart.d.ts +58 -0
- package/dist/types/types/data.d.ts +20 -2
- package/dist/types/types/index.d.ts +2 -2
- package/dist/types/views/WasmSamplingDemo.vue.d.ts +3 -0
- package/package.json +5 -4
- package/src/App.test.ts +11 -7
- package/src/App.vue +2 -2
- package/src/DemoRouterApp.vue +1 -0
- package/src/components/WaveformChart.css +2 -0
- package/src/components/annotation/WaveformAnnotationEditor.css +2 -2
- package/src/components/annotation/WaveformAnnotationEditor.vue +8 -4
- package/src/components/annotation/markup.ts +45 -26
- package/src/components/annotation/types.ts +2 -0
- package/src/components/annotation/useWaveformChartAnnotations.ts +12 -15
- package/src/components/core/latestTaskScheduler.test.ts +51 -0
- package/src/components/core/latestTaskScheduler.ts +75 -0
- package/src/components/core/layout.ts +15 -6
- package/src/components/core/trackLayoutBuilder.ts +8 -2
- package/src/components/core/types.ts +2 -0
- package/src/components/core/useWaveformChartController.ts +14 -0
- package/src/components/core/useWaveformData.test.ts +95 -0
- package/src/components/core/useWaveformData.ts +23 -32
- package/src/components/core/useWaveformLayout.ts +16 -10
- package/src/components/core/useWaveformRenderSampling.test.ts +17 -0
- package/src/components/core/useWaveformRenderSampling.ts +394 -0
- package/src/components/core/waveformChartTypes.ts +13 -0
- package/src/components/core/workerSamplingClient.ts +148 -0
- package/src/components/core/yAxisNice.test.ts +174 -0
- package/src/components/data/types.ts +9 -0
- package/src/components/interaction/WaveformTooltip.test.ts +1 -3
- package/src/components/interaction/WaveformTooltip.vue +13 -12
- package/src/components/interaction/useWaveformHover.ts +7 -7
- package/src/components/interaction/zoomConstraints.ts +5 -1
- package/src/components/waveform.ts +9 -0
- package/src/components/waveformChartCases/compactAndSharedHover.test.ts +15 -4
- package/src/components/waveformChartCases/dataAndHoverPerformance.test.ts +11 -6
- package/src/components/waveformChartCases/dataLifecycle.test.ts +1 -3
- package/src/components/waveformChartCases/trackOrder.test.ts +130 -0
- package/src/components/waveformChartCases/workerSamplingIntegration.test.ts +117 -0
- package/src/components/waveformChartCases/xDomainStrategy.test.ts +14 -2
- package/src/core/compactSamplePointSource.ts +76 -0
- package/src/core/data.test.ts +114 -0
- package/src/core/data.ts +177 -75
- package/src/core/index.ts +11 -0
- package/src/core/rendering.test.ts +87 -12
- package/src/core/rendering.ts +35 -1
- package/src/core/renderingOptions.ts +124 -15
- package/src/core/samplingReference.test.ts +124 -0
- package/src/core/samplingReference.ts +243 -0
- package/src/core/samplingReferenceValues.ts +169 -0
- package/src/core/samplingReferenceVectors.ts +46 -0
- package/src/core/wasmSampling.test.ts +205 -0
- package/src/core/wasmSampling.ts +230 -0
- package/src/core/waveformPointSource.ts +395 -0
- package/src/core/workerSampling/index.ts +30 -0
- package/src/core/workerSampling/javascriptBackend.ts +15 -0
- package/src/core/workerSampling/multiresolution.test.ts +69 -0
- package/src/core/workerSampling/multiresolution.ts +300 -0
- package/src/core/workerSampling/protocol.test.ts +34 -0
- package/src/core/workerSampling/protocol.ts +207 -0
- package/src/core/workerSampling/repository.test.ts +400 -0
- package/src/core/workerSampling/repository.ts +400 -0
- package/src/core/workerSampling/repositoryCache.test.ts +163 -0
- package/src/core/workerSampling/repositoryDataset.ts +194 -0
- package/src/core/workerSampling/repositorySupport.ts +225 -0
- package/src/core/workerSampling/repositoryTypedDataset.test.ts +89 -0
- package/src/core/workerSampling/repositoryWasmDataset.test.ts +70 -0
- package/src/core/workerSampling/samplingCache.ts +94 -0
- package/src/core/workerSampling/workerRuntime.ts +40 -0
- package/src/data/simulatedWaveforms.test.ts +19 -0
- package/src/data/simulatedWaveforms.ts +11 -8
- package/src/demo/xAxisLabelControls.test.ts +1 -1
- package/src/demoRoutes.css +177 -0
- package/src/index.ts +18 -0
- package/src/router.ts +5 -0
- package/src/types/chart.ts +65 -0
- package/src/types/data.ts +24 -1
- package/src/types/index.ts +9 -0
- package/src/views/WasmSamplingDemo.test.ts +13 -0
- package/src/views/WasmSamplingDemo.vue +180 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
292
|
+
layoutTracks.value.map(Boolean),
|
|
289
293
|
yAxisLayout.value.horizontalGap,
|
|
290
294
|
)
|
|
291
|
-
return cells.map((cell, index) => ({ ...cell, series:
|
|
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
|
+
}
|