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
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { bisector } from 'd3'
|
|
2
|
-
|
|
3
1
|
import type { WaveformAnnotation, WaveformAnnotationStyle, WaveformLineType } from '../../types'
|
|
2
|
+
import {
|
|
3
|
+
isWaveformPointSource,
|
|
4
|
+
pointSourceFromPoints,
|
|
5
|
+
type WaveformPointSource,
|
|
6
|
+
} from '../../core/waveformPointSource'
|
|
4
7
|
import type { AnnotationHit, AnnotationSeriesCandidate, AnnotationTrackLayout } from './types'
|
|
5
8
|
|
|
6
9
|
export const DEFAULT_ANNOTATION_STYLE = {
|
|
@@ -23,16 +26,26 @@ export const ANNOTATION_TEXT_GLYPH_HEIGHT = 14
|
|
|
23
26
|
export const ANNOTATION_TEXT_FONT = '12px Arial, sans-serif'
|
|
24
27
|
export const ANNOTATION_CONNECTOR_LENGTH = 32
|
|
25
28
|
|
|
26
|
-
|
|
29
|
+
type PointCollection = Array<{ x: number; y: number }> | WaveformPointSource
|
|
30
|
+
|
|
31
|
+
function sourceFor(points: PointCollection) {
|
|
32
|
+
return isWaveformPointSource(points) ? points : pointSourceFromPoints(points)
|
|
33
|
+
}
|
|
27
34
|
|
|
28
35
|
function findNearestPointOnScreen(
|
|
29
36
|
track: AnnotationTrackLayout,
|
|
30
37
|
pointerX: number,
|
|
31
38
|
pointerY: number,
|
|
32
39
|
): { point: { x: number; y: number }; screenX: number; screenY: number; distance: number } | null {
|
|
33
|
-
let nearest: {
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
let nearest: {
|
|
41
|
+
point: { x: number; y: number }
|
|
42
|
+
screenX: number
|
|
43
|
+
screenY: number
|
|
44
|
+
distance: number
|
|
45
|
+
} | null = null
|
|
46
|
+
const source = track.series.source ?? pointSourceFromPoints(track.series.points)
|
|
47
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
48
|
+
const point = source.pointAt(index)!
|
|
36
49
|
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) continue
|
|
37
50
|
const screenX = track.xScale(point.x)
|
|
38
51
|
const screenY = track.top + track.yScale(point.y)
|
|
@@ -45,33 +58,35 @@ function findNearestPointOnScreen(
|
|
|
45
58
|
}
|
|
46
59
|
|
|
47
60
|
export function findNearestPointByX(
|
|
48
|
-
points:
|
|
61
|
+
points: PointCollection,
|
|
49
62
|
xValue: number,
|
|
50
63
|
): { x: number; y: number } | null {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
|
|
64
|
+
const source = sourceFor(points)
|
|
65
|
+
if (!source.length || !Number.isFinite(xValue)) return null
|
|
66
|
+
const rightIndex = source.visibleRange([xValue, xValue]).start
|
|
67
|
+
const right = source.pointAt(Math.min(rightIndex, source.length - 1))!
|
|
68
|
+
const left = source.pointAt(Math.max(0, rightIndex - 1))!
|
|
69
|
+
return Math.abs(xValue - left.x) < Math.abs(right.x - xValue) ? left : right
|
|
56
70
|
}
|
|
57
71
|
|
|
58
72
|
export function interpolateAnnotationPoint(
|
|
59
|
-
points:
|
|
73
|
+
points: PointCollection,
|
|
60
74
|
xValue: number,
|
|
61
75
|
lineType: WaveformLineType = 'linear',
|
|
62
76
|
): { x: number; y: number } | null {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
77
|
+
const source = sourceFor(points)
|
|
78
|
+
if (!source.length || !Number.isFinite(xValue)) return null
|
|
79
|
+
const first = source.pointAt(0)!
|
|
80
|
+
const last = source.pointAt(source.length - 1)!
|
|
66
81
|
if (xValue < first.x || xValue > last.x) return null
|
|
67
|
-
if (
|
|
82
|
+
if (source.length === 1) return xValue === first.x ? { ...first } : null
|
|
68
83
|
|
|
69
|
-
const rightIndex =
|
|
70
|
-
const right =
|
|
84
|
+
const rightIndex = source.visibleRange([xValue, xValue]).start
|
|
85
|
+
const right = source.pointAt(Math.min(rightIndex, source.length - 1))!
|
|
71
86
|
if (right.x === xValue || rightIndex === 0) return { x: xValue, y: right.y }
|
|
72
87
|
if (lineType === 'none') return null
|
|
73
88
|
|
|
74
|
-
const left =
|
|
89
|
+
const left = source.pointAt(rightIndex - 1)!
|
|
75
90
|
if (lineType === 'step-start') return { x: xValue, y: right.y }
|
|
76
91
|
if (lineType === 'step-middle') {
|
|
77
92
|
return { x: xValue, y: xValue < (left.x + right.x) / 2 ? left.y : right.y }
|
|
@@ -108,7 +123,7 @@ export function findAnnotationSeriesCandidates(
|
|
|
108
123
|
]
|
|
109
124
|
}
|
|
110
125
|
const interpolatedPoint = interpolateAnnotationPoint(
|
|
111
|
-
track.series.points,
|
|
126
|
+
track.series.source ?? pointSourceFromPoints(track.series.points),
|
|
112
127
|
xValue,
|
|
113
128
|
track.series.lineType,
|
|
114
129
|
)
|
|
@@ -116,7 +131,10 @@ export function findAnnotationSeriesCandidates(
|
|
|
116
131
|
|
|
117
132
|
// Always snap to nearest actual sample point for the anchor
|
|
118
133
|
// This ensures annotations align with visible data points
|
|
119
|
-
const nearestPoint = findNearestPointByX(
|
|
134
|
+
const nearestPoint = findNearestPointByX(
|
|
135
|
+
track.series.source ?? pointSourceFromPoints(track.series.points),
|
|
136
|
+
xValue,
|
|
137
|
+
)
|
|
120
138
|
if (!nearestPoint) return []
|
|
121
139
|
|
|
122
140
|
// Use interpolated point for distance calculation to get accurate series selection
|
|
@@ -213,15 +231,16 @@ export function findNearestAnnotationPoint(
|
|
|
213
231
|
|
|
214
232
|
tracks.forEach((track) => {
|
|
215
233
|
const localY = pointerY - track.top
|
|
216
|
-
|
|
234
|
+
const source = track.series.source ?? pointSourceFromPoints(track.series.points)
|
|
235
|
+
if (localY < 0 || localY > track.height || source.length === 0) return
|
|
217
236
|
|
|
218
237
|
const xValue = track.xScale.invert(pointerX)
|
|
219
|
-
const centerIndex =
|
|
238
|
+
const centerIndex = source.visibleRange([xValue, xValue]).start
|
|
220
239
|
const firstIndex = Math.max(0, centerIndex - 2)
|
|
221
|
-
const lastIndex = Math.min(
|
|
240
|
+
const lastIndex = Math.min(source.length - 1, centerIndex + 2)
|
|
222
241
|
|
|
223
242
|
for (let candidateIndex = firstIndex; candidateIndex <= lastIndex; candidateIndex += 1) {
|
|
224
|
-
const point =
|
|
243
|
+
const point = source.pointAt(candidateIndex)!
|
|
225
244
|
const screenX = track.xScale(point.x)
|
|
226
245
|
const screenY = track.yScale(point.y) + track.top
|
|
227
246
|
const distance = Math.hypot(screenX - pointerX, screenY - pointerY)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ScaleLinear } from 'd3'
|
|
2
2
|
|
|
3
3
|
import type { WaveformAnnotation, WaveformLineType, WaveformPoint } from '../../types'
|
|
4
|
+
import type { WaveformPointSource } from '../../core/waveformPointSource'
|
|
4
5
|
|
|
5
6
|
export interface AnnotationTrackLayout {
|
|
6
7
|
index: number
|
|
@@ -11,6 +12,7 @@ export interface AnnotationTrackLayout {
|
|
|
11
12
|
unit?: string
|
|
12
13
|
lineType?: WaveformLineType
|
|
13
14
|
points: WaveformPoint[]
|
|
15
|
+
source?: WaveformPointSource
|
|
14
16
|
}
|
|
15
17
|
left?: number
|
|
16
18
|
top: number
|
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
ANNOTATION_AMBIGUITY_DISTANCE,
|
|
10
10
|
ANNOTATION_HIT_RADIUS,
|
|
11
11
|
findAnnotationSeriesCandidates,
|
|
12
|
-
findNearestPointByX,
|
|
13
12
|
} from './markup'
|
|
14
13
|
import type {
|
|
15
14
|
AnnotationEditorAnchor,
|
|
@@ -150,15 +149,15 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
|
|
|
150
149
|
item.seriesList.some((series) => series.id === seriesId),
|
|
151
150
|
)
|
|
152
151
|
const series = track?.seriesList.find((item) => item.id === seriesId)
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
)
|
|
156
|
-
const point =
|
|
157
|
-
if (!draft || !candidate || !track || !
|
|
152
|
+
const source = series?.source
|
|
153
|
+
const first = source?.pointAt(0)
|
|
154
|
+
const last = source?.pointAt((source?.length ?? 0) - 1)
|
|
155
|
+
const point = draft ? source?.nearestPoint(draft.annotation.x) : undefined
|
|
156
|
+
if (!draft || !candidate || !track || !first || !last || !point) {
|
|
158
157
|
timeError.value = '当前波形没有有效数据'
|
|
159
158
|
return
|
|
160
159
|
}
|
|
161
|
-
if (draft.annotation.x <
|
|
160
|
+
if (draft.annotation.x < first.x || draft.annotation.x > last.x) {
|
|
162
161
|
timeError.value = '时间超出当前波形范围'
|
|
163
162
|
return
|
|
164
163
|
}
|
|
@@ -183,20 +182,18 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
|
|
|
183
182
|
item.seriesList.some((series) => series.id === draft.annotation.seriesId),
|
|
184
183
|
)
|
|
185
184
|
const series = track?.seriesList.find((item) => item.id === draft.annotation.seriesId)
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
)
|
|
189
|
-
if (!
|
|
185
|
+
const source = series?.source
|
|
186
|
+
const first = source?.pointAt(0)
|
|
187
|
+
const last = source?.pointAt((source?.length ?? 0) - 1)
|
|
188
|
+
if (!source || !first || !last) {
|
|
190
189
|
timeError.value = '当前波形没有有效数据'
|
|
191
190
|
return
|
|
192
191
|
}
|
|
193
|
-
|
|
194
|
-
const lastX = validPoints[validPoints.length - 1].x
|
|
195
|
-
if (rawTime < firstX || rawTime > lastX) {
|
|
192
|
+
if (rawTime < first.x || rawTime > last.x) {
|
|
196
193
|
timeError.value = '时间超出当前波形范围'
|
|
197
194
|
return
|
|
198
195
|
}
|
|
199
|
-
const point =
|
|
196
|
+
const point = source.nearestPoint(rawTime)!
|
|
200
197
|
timeError.value = ''
|
|
201
198
|
draft.annotation = { ...draft.annotation, x: point.x, y: point.y }
|
|
202
199
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { createLatestTaskScheduler } from './latestTaskScheduler'
|
|
4
|
+
|
|
5
|
+
function deferred() {
|
|
6
|
+
let resolve!: () => void
|
|
7
|
+
const promise = new Promise<void>((done) => {
|
|
8
|
+
resolve = done
|
|
9
|
+
})
|
|
10
|
+
return { promise, resolve }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('createLatestTaskScheduler', () => {
|
|
14
|
+
it('runs one task at a time and coalesces pending work to the latest task', async () => {
|
|
15
|
+
const scheduler = createLatestTaskScheduler()
|
|
16
|
+
const first = deferred()
|
|
17
|
+
const calls: number[] = []
|
|
18
|
+
|
|
19
|
+
scheduler.schedule(async (generation) => {
|
|
20
|
+
calls.push(generation)
|
|
21
|
+
await first.promise
|
|
22
|
+
})
|
|
23
|
+
scheduler.schedule(async (generation) => {
|
|
24
|
+
calls.push(generation)
|
|
25
|
+
})
|
|
26
|
+
const latest = scheduler.schedule(async (generation) => {
|
|
27
|
+
calls.push(generation)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
expect(calls).toEqual([1])
|
|
31
|
+
expect(scheduler.isCurrent(1)).toBe(false)
|
|
32
|
+
expect(scheduler.isCurrent(latest)).toBe(true)
|
|
33
|
+
first.resolve()
|
|
34
|
+
await vi.waitFor(() => expect(calls).toEqual([1, 3]))
|
|
35
|
+
expect(scheduler.metrics).toEqual({ scheduled: 3, coalesced: 1, maxPending: 1 })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('invalidates running work and discards pending work on cancellation or disposal', async () => {
|
|
39
|
+
const scheduler = createLatestTaskScheduler()
|
|
40
|
+
const first = deferred()
|
|
41
|
+
const firstGeneration = scheduler.schedule(async () => first.promise)
|
|
42
|
+
scheduler.schedule(async () => undefined)
|
|
43
|
+
|
|
44
|
+
scheduler.cancelPending()
|
|
45
|
+
expect(scheduler.isCurrent(firstGeneration)).toBe(false)
|
|
46
|
+
first.resolve()
|
|
47
|
+
await first.promise
|
|
48
|
+
scheduler.dispose()
|
|
49
|
+
expect(scheduler.schedule(async () => undefined)).toBe(0)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export interface LatestTaskSchedulerMetrics {
|
|
2
|
+
scheduled: number
|
|
3
|
+
coalesced: number
|
|
4
|
+
maxPending: number
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function resolveAutoSelectedMode(
|
|
8
|
+
visiblePointCount: number,
|
|
9
|
+
threshold: number,
|
|
10
|
+
hysteresis: number,
|
|
11
|
+
previousSelectedMode?: 'raw' | 'sampled',
|
|
12
|
+
): 'raw' | 'sampled' {
|
|
13
|
+
if (!previousSelectedMode) return visiblePointCount <= threshold ? 'raw' : 'sampled'
|
|
14
|
+
return previousSelectedMode === 'raw'
|
|
15
|
+
? visiblePointCount <= threshold + hysteresis
|
|
16
|
+
? 'raw'
|
|
17
|
+
: 'sampled'
|
|
18
|
+
: visiblePointCount <= Math.max(0, threshold - hysteresis)
|
|
19
|
+
? 'raw'
|
|
20
|
+
: 'sampled'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ScheduledTask {
|
|
24
|
+
generation: number
|
|
25
|
+
run: (generation: number) => Promise<void>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Runs at most one task at a time and retains only the newest pending task. */
|
|
29
|
+
export function createLatestTaskScheduler() {
|
|
30
|
+
let generation = 0
|
|
31
|
+
let running = false
|
|
32
|
+
let pending: ScheduledTask | undefined
|
|
33
|
+
let disposed = false
|
|
34
|
+
const metrics: LatestTaskSchedulerMetrics = { scheduled: 0, coalesced: 0, maxPending: 0 }
|
|
35
|
+
|
|
36
|
+
const drain = async () => {
|
|
37
|
+
if (running || disposed) return
|
|
38
|
+
running = true
|
|
39
|
+
try {
|
|
40
|
+
while (pending && !disposed) {
|
|
41
|
+
const task = pending
|
|
42
|
+
pending = undefined
|
|
43
|
+
await task.run(task.generation)
|
|
44
|
+
}
|
|
45
|
+
} finally {
|
|
46
|
+
running = false
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
schedule(run: ScheduledTask['run']) {
|
|
52
|
+
if (disposed) return 0
|
|
53
|
+
generation += 1
|
|
54
|
+
metrics.scheduled += 1
|
|
55
|
+
if (pending) metrics.coalesced += 1
|
|
56
|
+
pending = { generation, run }
|
|
57
|
+
metrics.maxPending = Math.max(metrics.maxPending, 1)
|
|
58
|
+
void drain()
|
|
59
|
+
return generation
|
|
60
|
+
},
|
|
61
|
+
isCurrent(candidate: number) {
|
|
62
|
+
return !disposed && candidate === generation
|
|
63
|
+
},
|
|
64
|
+
cancelPending() {
|
|
65
|
+
generation += 1
|
|
66
|
+
pending = undefined
|
|
67
|
+
},
|
|
68
|
+
dispose() {
|
|
69
|
+
disposed = true
|
|
70
|
+
generation += 1
|
|
71
|
+
pending = undefined
|
|
72
|
+
},
|
|
73
|
+
metrics,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -57,6 +57,8 @@ export interface BuildTrackLayoutsOptions {
|
|
|
57
57
|
yAxisSplitNumber?: number
|
|
58
58
|
yAxisNice?: boolean
|
|
59
59
|
rendering: ResolvedWaveformRenderingOptions
|
|
60
|
+
/** Latest sampling result for SVG lines only; source series remain complete for interaction. */
|
|
61
|
+
linePointOverrides?: Readonly<Record<string, WaveformPoint[]>>
|
|
60
62
|
hideSecondaryLabels: boolean
|
|
61
63
|
yAxisLabelX: number
|
|
62
64
|
showCompactEmptyTracks: boolean
|
|
@@ -200,6 +202,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
|
|
200
202
|
options.rendering,
|
|
201
203
|
{
|
|
202
204
|
lineVisible: !isEmpty && trackSeries.lineType !== 'none',
|
|
205
|
+
linePointOverride: options.linePointOverrides?.[trackSeries.id],
|
|
203
206
|
pointVisible: trackSeries.pointType !== 'none',
|
|
204
207
|
errorBarVisible: trackSeries.errorBar.visible,
|
|
205
208
|
hasErrorPoints: trackSeries.hasErrorPoints,
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
WaveformPoint,
|
|
7
7
|
WaveformPointType,
|
|
8
8
|
} from '../../types'
|
|
9
|
+
import type { WaveformPointSource } from '../../core/waveformPointSource'
|
|
9
10
|
import type { NormalizedWaveformGridLineOptions } from './grid'
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -23,6 +24,7 @@ export interface DisplaySeries {
|
|
|
23
24
|
pointType: WaveformPointType
|
|
24
25
|
errorBar: ResolvedWaveformErrorBarOptions
|
|
25
26
|
points: WaveformPoint[]
|
|
27
|
+
source?: WaveformPointSource
|
|
26
28
|
xDomain: [number, number]
|
|
27
29
|
yDomain: [number, number]
|
|
28
30
|
hasErrorPoints: boolean
|
|
@@ -26,6 +26,7 @@ import { useWaveformChartLifecycle } from './useWaveformChartLifecycle'
|
|
|
26
26
|
import { usePreparedWaveformSeries } from './useWaveformData'
|
|
27
27
|
import { useWaveformLayout } from './useWaveformLayout'
|
|
28
28
|
import { useWaveformPresentation } from './useWaveformPresentation'
|
|
29
|
+
import { useWaveformRenderSampling } from './useWaveformRenderSampling'
|
|
29
30
|
import type {
|
|
30
31
|
ResolvedWaveformChartProps,
|
|
31
32
|
ViewportSelectionState,
|
|
@@ -68,6 +69,7 @@ export function useWaveformChartController(
|
|
|
68
69
|
const clipPathId = useWaveformInstanceId('waveform-clip')
|
|
69
70
|
const internalHiddenSeriesIds = ref(new Set(props.defaultHiddenSeriesIds))
|
|
70
71
|
const annotationInteraction = useWaveformAnnotationInteraction()
|
|
72
|
+
const linePointOverrides = shallowRef<Record<string, import('../../types').WaveformPoint[]>>({})
|
|
71
73
|
const editorSeriesOptions = ref<AnnotationSeriesCandidate[]>([])
|
|
72
74
|
const hoverThrottle = useAnimationFrameThrottle()
|
|
73
75
|
|
|
@@ -126,10 +128,12 @@ export function useWaveformChartController(
|
|
|
126
128
|
sharedYDomains,
|
|
127
129
|
independentYDomains,
|
|
128
130
|
annotationInteraction: markRaw(annotationInteraction),
|
|
131
|
+
linePointOverrides,
|
|
129
132
|
})
|
|
130
133
|
const {
|
|
131
134
|
chartSeries,
|
|
132
135
|
chartTracks,
|
|
136
|
+
renderingOptions,
|
|
133
137
|
trackLayouts,
|
|
134
138
|
gridOptions,
|
|
135
139
|
pageCount,
|
|
@@ -146,6 +150,16 @@ export function useWaveformChartController(
|
|
|
146
150
|
resolveSeriesYScale,
|
|
147
151
|
} = layout
|
|
148
152
|
|
|
153
|
+
useWaveformRenderSampling({
|
|
154
|
+
props,
|
|
155
|
+
emit,
|
|
156
|
+
instanceId: clipPathId,
|
|
157
|
+
preparedSeries,
|
|
158
|
+
sourceTrackLayouts: trackLayouts,
|
|
159
|
+
renderingOptions,
|
|
160
|
+
linePointOverrides,
|
|
161
|
+
})
|
|
162
|
+
|
|
149
163
|
watchEffect(() => {
|
|
150
164
|
paginationBandHeight.value =
|
|
151
165
|
gridOptions.value.showPagination && pageCount.value > 1 && chartWidth.value <= 520 ? 40 : 0
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { prepareWaveformSeries } from './useWaveformData'
|
|
4
|
+
|
|
5
|
+
describe('prepareWaveformSeries compact inputs', () => {
|
|
6
|
+
it('keeps dense typed samples as numeric columns until individual points are requested', () => {
|
|
7
|
+
const values = Float32Array.from({ length: 100_001 }, (_, index) => index / 10)
|
|
8
|
+
const snapshot = values.slice()
|
|
9
|
+
const [series] = prepareWaveformSeries({
|
|
10
|
+
kind: 'typed-samples',
|
|
11
|
+
values,
|
|
12
|
+
sampleRate: 1_000,
|
|
13
|
+
startTime: 2,
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
expect(series?.source.isCompact).toBe(true)
|
|
17
|
+
expect(series?.points).toHaveLength(values.length)
|
|
18
|
+
expect(series?.xDomain[0]).toBeLessThanOrEqual(2)
|
|
19
|
+
expect(series?.xDomain[1]).toBeGreaterThanOrEqual(102)
|
|
20
|
+
expect(series?.source.pointAt(50_000)).toEqual({ x: 52, y: 5_000 })
|
|
21
|
+
expect(values).toEqual(snapshot)
|
|
22
|
+
|
|
23
|
+
const workerDataset = series?.source.toWorkerDataset()
|
|
24
|
+
expect(workerDataset).toMatchObject({
|
|
25
|
+
kind: 'samples',
|
|
26
|
+
sampleRate: 1_000,
|
|
27
|
+
startTime: 2,
|
|
28
|
+
})
|
|
29
|
+
if (workerDataset?.kind === 'samples') {
|
|
30
|
+
expect(workerDataset).not.toHaveProperty('sourceIndexes')
|
|
31
|
+
expect(workerDataset.values.buffer).not.toBe(values.buffer)
|
|
32
|
+
expect(workerDataset.values).toEqual(values)
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('preserves invalid-sample time gaps without allocating an X column', () => {
|
|
37
|
+
const [series] = prepareWaveformSeries({
|
|
38
|
+
kind: 'typed-samples',
|
|
39
|
+
values: new Float64Array([1, Number.NaN, 3]),
|
|
40
|
+
sampleRate: 2,
|
|
41
|
+
startTime: 10,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
expect(series?.source.pointsInRange()).toEqual([
|
|
45
|
+
{ x: 10, y: 1 },
|
|
46
|
+
{ x: 11, y: 3 },
|
|
47
|
+
])
|
|
48
|
+
expect(series?.source.toWorkerDataset()).toMatchObject({
|
|
49
|
+
kind: 'samples',
|
|
50
|
+
values: new Float64Array([1, 3]),
|
|
51
|
+
sourceIndexes: new Uint32Array([0, 2]),
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('filters and stably orders compact typed points without materializing source point objects', () => {
|
|
56
|
+
const [series] = prepareWaveformSeries({
|
|
57
|
+
kind: 'typed-points',
|
|
58
|
+
x: new Float64Array([2, 1, 1, Number.NaN]),
|
|
59
|
+
y: new Float32Array([20, 10, 11, 12]),
|
|
60
|
+
error: new Float32Array([1, -1, Number.NaN, 4]),
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
expect(series?.source.isCompact).toBe(true)
|
|
64
|
+
expect(series?.source.pointsInRange()).toEqual([
|
|
65
|
+
{ x: 1, y: 10 },
|
|
66
|
+
{ x: 1, y: 11 },
|
|
67
|
+
{ x: 2, y: 20, error: 1 },
|
|
68
|
+
])
|
|
69
|
+
expect(series?.source.visibleRange([1, 1])).toEqual({ start: 0, end: 2 })
|
|
70
|
+
expect(series?.source.nearestPoint(-10)).toEqual({ x: 1, y: 10 })
|
|
71
|
+
expect(series?.source.nearestPoint(10)).toEqual({ x: 2, y: 20, error: 1 })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('retains normal array access, iteration, slicing, mapping, and enumeration semantics', () => {
|
|
75
|
+
const [series] = prepareWaveformSeries({
|
|
76
|
+
kind: 'typed-samples',
|
|
77
|
+
values: new Float32Array([2, 4, 6]),
|
|
78
|
+
sampleRate: 2,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
expect(series?.points[1]).toEqual({ x: 0.5, y: 4 })
|
|
82
|
+
expect(series?.points.at(-1)).toEqual({ x: 1, y: 6 })
|
|
83
|
+
expect([...(series?.points ?? [])]).toEqual([
|
|
84
|
+
{ x: 0, y: 2 },
|
|
85
|
+
{ x: 0.5, y: 4 },
|
|
86
|
+
{ x: 1, y: 6 },
|
|
87
|
+
])
|
|
88
|
+
expect(series?.points.slice(1)).toEqual([
|
|
89
|
+
{ x: 0.5, y: 4 },
|
|
90
|
+
{ x: 1, y: 6 },
|
|
91
|
+
])
|
|
92
|
+
expect(series?.points.map((point) => point.y)).toEqual([2, 4, 6])
|
|
93
|
+
expect(Object.keys(series?.points ?? [])).toEqual(['0', '1', '2'])
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { shallowRef, watch } from 'vue'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { normalizeWaveformSeriesSources } from '../../core/data'
|
|
4
|
+
import { lazyPointArray, type WaveformPointSource } from '../../core/waveformPointSource'
|
|
4
5
|
import type {
|
|
5
6
|
ResolvedWaveformErrorBarOptions,
|
|
6
7
|
WaveformData,
|
|
@@ -22,43 +23,33 @@ export interface PreparedWaveformSeries {
|
|
|
22
23
|
pointType: WaveformPointType
|
|
23
24
|
errorBar: ResolvedWaveformErrorBarOptions
|
|
24
25
|
points: WaveformPoint[]
|
|
26
|
+
source: WaveformPointSource
|
|
25
27
|
xDomain: [number, number]
|
|
26
28
|
yDomain: [number, number]
|
|
27
29
|
hasErrorPoints: boolean
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
function pointMetrics(
|
|
31
|
-
points: WaveformPoint[],
|
|
32
|
-
includeErrors = false,
|
|
33
|
-
): { xDomain: [number, number]; yDomain: [number, number]; hasErrorPoints: boolean } {
|
|
34
|
-
let xMinimum = Number.POSITIVE_INFINITY
|
|
35
|
-
let xMaximum = Number.NEGATIVE_INFINITY
|
|
36
|
-
let yMinimum = Number.POSITIVE_INFINITY
|
|
37
|
-
let yMaximum = Number.NEGATIVE_INFINITY
|
|
38
|
-
let hasErrorPoints = false
|
|
39
|
-
for (const point of points) {
|
|
40
|
-
if (point.x < xMinimum) xMinimum = point.x
|
|
41
|
-
if (point.x > xMaximum) xMaximum = point.x
|
|
42
|
-
if (point.y < yMinimum) yMinimum = point.y
|
|
43
|
-
if (point.y > yMaximum) yMaximum = point.y
|
|
44
|
-
if (includeErrors) {
|
|
45
|
-
const errors = resolveWaveformPointErrors(point)
|
|
46
|
-
if (errors.lower !== 0 || errors.upper !== 0) hasErrorPoints = true
|
|
47
|
-
yMinimum = Math.min(yMinimum, point.y - errors.lower)
|
|
48
|
-
yMaximum = Math.max(yMaximum, point.y + errors.upper)
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
return {
|
|
52
|
-
xDomain: paddedDomain(Number.isFinite(xMinimum) ? [xMinimum, xMaximum] : []),
|
|
53
|
-
yDomain: paddedDomain(Number.isFinite(yMinimum) ? [yMinimum, yMaximum] : []),
|
|
54
|
-
hasErrorPoints,
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
32
|
export function prepareWaveformSeries(data: WaveformData): PreparedWaveformSeries[] {
|
|
59
|
-
return
|
|
60
|
-
const
|
|
61
|
-
return {
|
|
33
|
+
return normalizeWaveformSeriesSources(data).map(({ source, ...series }) => {
|
|
34
|
+
const sourceMetrics = source.metrics(series.errorBar.visible)
|
|
35
|
+
return {
|
|
36
|
+
...series,
|
|
37
|
+
source,
|
|
38
|
+
// Existing chart code remains array-oriented; compact sources materialize individual points
|
|
39
|
+
// only when an index is read.
|
|
40
|
+
points: lazyPointArray(source),
|
|
41
|
+
xDomain: paddedDomain(
|
|
42
|
+
Number.isFinite(sourceMetrics.xMinimum)
|
|
43
|
+
? [sourceMetrics.xMinimum, sourceMetrics.xMaximum]
|
|
44
|
+
: [],
|
|
45
|
+
),
|
|
46
|
+
yDomain: paddedDomain(
|
|
47
|
+
Number.isFinite(sourceMetrics.yMinimum)
|
|
48
|
+
? [sourceMetrics.yMinimum, sourceMetrics.yMaximum]
|
|
49
|
+
: [],
|
|
50
|
+
),
|
|
51
|
+
hasErrorPoints: sourceMetrics.hasErrorPoints,
|
|
52
|
+
}
|
|
62
53
|
})
|
|
63
54
|
}
|
|
64
55
|
|
|
@@ -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 => ({
|
|
@@ -322,6 +321,7 @@ export function useWaveformLayout(context: LayoutContext) {
|
|
|
322
321
|
yAxisSplitNumber: props.axes?.y?.splitNumber,
|
|
323
322
|
yAxisNice: props.axes?.y?.nice,
|
|
324
323
|
rendering: renderingOptions.value,
|
|
324
|
+
linePointOverrides: linePointOverrides?.value,
|
|
325
325
|
hideSecondaryLabels: isCleanView.value || yAxisLayout.value.hideSecondaryLabels,
|
|
326
326
|
yAxisLabelX: yAxisMetrics.value.labelCenterX,
|
|
327
327
|
showCompactEmptyTracks: props.displayMode === 'compact' && hasWaveformData.value,
|
|
@@ -370,10 +370,10 @@ export function useWaveformLayout(context: LayoutContext) {
|
|
|
370
370
|
? props.frameNumber + trackIndex
|
|
371
371
|
: `${props.frameNumber}-${trackIndex + 1}`
|
|
372
372
|
}
|
|
373
|
-
|
|
374
373
|
return {
|
|
375
374
|
chartSeries,
|
|
376
375
|
chartTracks,
|
|
376
|
+
renderingOptions,
|
|
377
377
|
gridOptions,
|
|
378
378
|
pageCount,
|
|
379
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
|
+
})
|