waveform-analysis 0.1.48 → 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 +120 -5
- package/dist/assets/workerRuntime-Dt94jbqq.js +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.js +2683 -1153
- 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/trackLayoutBuilder.d.ts +3 -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 +56 -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/trackLayoutBuilder.ts +3 -0
- 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 +4 -4
- 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/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/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/demoRoutes.css +177 -0
- package/src/index.ts +18 -0
- package/src/router.ts +5 -0
- package/src/types/chart.ts +63 -0
- package/src/types/data.ts +24 -1
- package/src/types/index.ts +9 -0
- package/src/views/WasmSamplingDemo.test.ts +13 -0
- package/src/views/WasmSamplingDemo.vue +180 -0
|
@@ -0,0 +1,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"
|
|
117
|
-
seriesPoint.name
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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:
|
|
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 {
|
|
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
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
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) => {
|