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,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
|
+
}
|
|
@@ -133,9 +133,13 @@ export function axisTextMetrics(
|
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
export function measureYAxisGroupClearance(
|
|
136
|
+
export function measureYAxisGroupClearance(
|
|
137
|
+
group: YAxisSeriesGroup,
|
|
138
|
+
tickCount?: number,
|
|
139
|
+
nice = true,
|
|
140
|
+
): number {
|
|
137
141
|
return (
|
|
138
|
-
axisTextMetrics(group.domain,
|
|
142
|
+
axisTextMetrics(group.domain, nice, undefined, group.seriesList[0]?.unit, tickCount)
|
|
139
143
|
.tickTextWidth +
|
|
140
144
|
Y_AXIS_TICK_PADDING +
|
|
141
145
|
Y_AXIS_LABEL_GAP +
|
|
@@ -144,9 +148,13 @@ export function measureYAxisGroupClearance(group: YAxisSeriesGroup, tickCount?:
|
|
|
144
148
|
)
|
|
145
149
|
}
|
|
146
150
|
|
|
147
|
-
function measureYAxisGroupTickClearance(
|
|
151
|
+
function measureYAxisGroupTickClearance(
|
|
152
|
+
group: YAxisSeriesGroup,
|
|
153
|
+
tickCount?: number,
|
|
154
|
+
nice = true,
|
|
155
|
+
): number {
|
|
148
156
|
return (
|
|
149
|
-
axisTextMetrics(group.domain,
|
|
157
|
+
axisTextMetrics(group.domain, nice, undefined, group.seriesList[0]?.unit, tickCount)
|
|
150
158
|
.tickTextWidth +
|
|
151
159
|
Y_AXIS_TICK_PADDING +
|
|
152
160
|
Y_AXIS_OUTER_PADDING
|
|
@@ -159,13 +167,14 @@ export function measureTrackYAxisClearance(
|
|
|
159
167
|
yDomain?: WaveformYDomain,
|
|
160
168
|
yDomains?: Record<string, WaveformYDomain>,
|
|
161
169
|
tickCount?: number,
|
|
170
|
+
nice = true,
|
|
162
171
|
): { left: number; right: number } {
|
|
163
172
|
return resolveYAxisSeriesGroups(track, overlayMode, yDomain, yDomains).reduce(
|
|
164
173
|
(clearance, group) => {
|
|
165
174
|
clearance[group.side] +=
|
|
166
175
|
overlayMode === 'multi-axis' || track.visibleSeries.length === 1
|
|
167
|
-
? measureYAxisGroupClearance(group, tickCount)
|
|
168
|
-
: measureYAxisGroupTickClearance(group, tickCount)
|
|
176
|
+
? measureYAxisGroupClearance(group, tickCount, nice)
|
|
177
|
+
: measureYAxisGroupTickClearance(group, tickCount, nice)
|
|
169
178
|
return clearance
|
|
170
179
|
},
|
|
171
180
|
{ left: 0, right: 0 },
|
|
@@ -55,7 +55,10 @@ export interface BuildTrackLayoutsOptions {
|
|
|
55
55
|
timeUnit: 's' | 'ms'
|
|
56
56
|
xAxisLabelFormatter?: WaveformXAxisLabelFormatter
|
|
57
57
|
yAxisSplitNumber?: number
|
|
58
|
+
yAxisNice?: boolean
|
|
58
59
|
rendering: ResolvedWaveformRenderingOptions
|
|
60
|
+
/** Latest sampling result for SVG lines only; source series remain complete for interaction. */
|
|
61
|
+
linePointOverrides?: Readonly<Record<string, WaveformPoint[]>>
|
|
59
62
|
hideSecondaryLabels: boolean
|
|
60
63
|
yAxisLabelX: number
|
|
61
64
|
showCompactEmptyTracks: boolean
|
|
@@ -119,7 +122,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
|
|
119
122
|
const tickCount = resolveYAxisTickCount(cell.plotHeight, options.yAxisSplitNumber)
|
|
120
123
|
const niceCount = Math.max(1, tickCount - 1)
|
|
121
124
|
const scale = scaleLinear(group.domain, [cell.plotHeight, 0])
|
|
122
|
-
scale.nice(niceCount)
|
|
125
|
+
if (options.yAxisNice !== false) scale.nice(niceCount)
|
|
123
126
|
const [axisStart, axisEnd] = scale.domain()
|
|
124
127
|
const majorTicks = Array.from(
|
|
125
128
|
{ length: tickCount },
|
|
@@ -158,7 +161,9 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
|
|
158
161
|
seriesList: group.seriesList,
|
|
159
162
|
}
|
|
160
163
|
})
|
|
161
|
-
const
|
|
164
|
+
const fallbackYScale = scaleLinear(displayTrack.yDomain, [cell.plotHeight, 0])
|
|
165
|
+
if (options.yAxisNice !== false) fallbackYScale.nice()
|
|
166
|
+
const yScale = yAxes[0]?.scale ?? fallbackYScale
|
|
162
167
|
const xMajorTicks = xScale.ticks(Math.max(2, Math.floor(cell.width / 100)))
|
|
163
168
|
const yMajorTicks = yAxes[0]?.majorTicks ?? []
|
|
164
169
|
const yAxisTickValues = yAxes[0]?.tickValues ?? []
|
|
@@ -197,6 +202,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
|
|
197
202
|
options.rendering,
|
|
198
203
|
{
|
|
199
204
|
lineVisible: !isEmpty && trackSeries.lineType !== 'none',
|
|
205
|
+
linePointOverride: options.linePointOverrides?.[trackSeries.id],
|
|
200
206
|
pointVisible: trackSeries.pointType !== 'none',
|
|
201
207
|
errorBarVisible: trackSeries.errorBar.visible,
|
|
202
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
|
|