waveform-analysis 0.1.47 → 0.1.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +127 -5
  2. package/dist/assets/workerRuntime-Dt94jbqq.js +1 -0
  3. package/dist/index.cjs +1 -1
  4. package/dist/index.js +2701 -1165
  5. package/dist/style.css +1 -1
  6. package/dist/types/components/WaveformChart.vue.d.ts +14 -0
  7. package/dist/types/components/annotation/WaveformAnnotationEditor.vue.d.ts +0 -1
  8. package/dist/types/components/annotation/markup.d.ts +5 -6
  9. package/dist/types/components/annotation/types.d.ts +2 -0
  10. package/dist/types/components/core/latestTaskScheduler.d.ts +19 -0
  11. package/dist/types/components/core/layout.d.ts +2 -2
  12. package/dist/types/components/core/trackLayoutBuilder.d.ts +4 -1
  13. package/dist/types/components/core/types.d.ts +2 -0
  14. package/dist/types/components/core/useWaveformChartController.d.ts +1 -0
  15. package/dist/types/components/core/useWaveformData.d.ts +2 -0
  16. package/dist/types/components/core/useWaveformLayout.d.ts +2 -0
  17. package/dist/types/components/core/useWaveformRenderSampling.d.ts +19 -0
  18. package/dist/types/components/core/waveformChartTypes.d.ts +8 -1
  19. package/dist/types/components/core/workerSamplingClient.d.ts +8 -0
  20. package/dist/types/components/data/types.d.ts +1 -1
  21. package/dist/types/components/interaction/zoomConstraints.d.ts +2 -0
  22. package/dist/types/components/waveform.d.ts +1 -1
  23. package/dist/types/core/compactSamplePointSource.d.ts +3 -0
  24. package/dist/types/core/data.d.ts +7 -1
  25. package/dist/types/core/index.d.ts +2 -1
  26. package/dist/types/core/rendering.d.ts +3 -1
  27. package/dist/types/core/renderingOptions.d.ts +11 -1
  28. package/dist/types/core/samplingReference.d.ts +36 -0
  29. package/dist/types/core/samplingReferenceValues.d.ts +4 -0
  30. package/dist/types/core/samplingReferenceVectors.d.ts +17 -0
  31. package/dist/types/core/wasmSampling.d.ts +48 -0
  32. package/dist/types/core/waveformPointSource.d.ts +53 -0
  33. package/dist/types/core/workerSampling/index.d.ts +5 -0
  34. package/dist/types/core/workerSampling/javascriptBackend.d.ts +3 -0
  35. package/dist/types/core/workerSampling/multiresolution.d.ts +29 -0
  36. package/dist/types/core/workerSampling/protocol.d.ts +153 -0
  37. package/dist/types/core/workerSampling/repository.d.ts +36 -0
  38. package/dist/types/core/workerSampling/repositoryDataset.d.ts +29 -0
  39. package/dist/types/core/workerSampling/repositorySupport.d.ts +27 -0
  40. package/dist/types/core/workerSampling/samplingCache.d.ts +24 -0
  41. package/dist/types/core/workerSampling/workerRuntime.d.ts +1 -0
  42. package/dist/types/index.d.ts +2 -2
  43. package/dist/types/types/chart.d.ts +58 -0
  44. package/dist/types/types/data.d.ts +20 -2
  45. package/dist/types/types/index.d.ts +2 -2
  46. package/dist/types/views/WasmSamplingDemo.vue.d.ts +3 -0
  47. package/package.json +5 -4
  48. package/src/App.test.ts +11 -7
  49. package/src/App.vue +2 -2
  50. package/src/DemoRouterApp.vue +1 -0
  51. package/src/components/WaveformChart.css +2 -0
  52. package/src/components/annotation/WaveformAnnotationEditor.css +2 -2
  53. package/src/components/annotation/WaveformAnnotationEditor.vue +8 -4
  54. package/src/components/annotation/markup.ts +45 -26
  55. package/src/components/annotation/types.ts +2 -0
  56. package/src/components/annotation/useWaveformChartAnnotations.ts +12 -15
  57. package/src/components/core/latestTaskScheduler.test.ts +51 -0
  58. package/src/components/core/latestTaskScheduler.ts +75 -0
  59. package/src/components/core/layout.ts +15 -6
  60. package/src/components/core/trackLayoutBuilder.ts +8 -2
  61. package/src/components/core/types.ts +2 -0
  62. package/src/components/core/useWaveformChartController.ts +14 -0
  63. package/src/components/core/useWaveformData.test.ts +95 -0
  64. package/src/components/core/useWaveformData.ts +23 -32
  65. package/src/components/core/useWaveformLayout.ts +16 -10
  66. package/src/components/core/useWaveformRenderSampling.test.ts +17 -0
  67. package/src/components/core/useWaveformRenderSampling.ts +394 -0
  68. package/src/components/core/waveformChartTypes.ts +13 -0
  69. package/src/components/core/workerSamplingClient.ts +148 -0
  70. package/src/components/core/yAxisNice.test.ts +174 -0
  71. package/src/components/data/types.ts +9 -0
  72. package/src/components/interaction/WaveformTooltip.test.ts +1 -3
  73. package/src/components/interaction/WaveformTooltip.vue +13 -12
  74. package/src/components/interaction/useWaveformHover.ts +7 -7
  75. package/src/components/interaction/zoomConstraints.ts +5 -1
  76. package/src/components/waveform.ts +9 -0
  77. package/src/components/waveformChartCases/compactAndSharedHover.test.ts +15 -4
  78. package/src/components/waveformChartCases/dataAndHoverPerformance.test.ts +11 -6
  79. package/src/components/waveformChartCases/dataLifecycle.test.ts +1 -3
  80. package/src/components/waveformChartCases/trackOrder.test.ts +130 -0
  81. package/src/components/waveformChartCases/workerSamplingIntegration.test.ts +117 -0
  82. package/src/components/waveformChartCases/xDomainStrategy.test.ts +14 -2
  83. package/src/core/compactSamplePointSource.ts +76 -0
  84. package/src/core/data.test.ts +114 -0
  85. package/src/core/data.ts +177 -75
  86. package/src/core/index.ts +11 -0
  87. package/src/core/rendering.test.ts +87 -12
  88. package/src/core/rendering.ts +35 -1
  89. package/src/core/renderingOptions.ts +124 -15
  90. package/src/core/samplingReference.test.ts +124 -0
  91. package/src/core/samplingReference.ts +243 -0
  92. package/src/core/samplingReferenceValues.ts +169 -0
  93. package/src/core/samplingReferenceVectors.ts +46 -0
  94. package/src/core/wasmSampling.test.ts +205 -0
  95. package/src/core/wasmSampling.ts +230 -0
  96. package/src/core/waveformPointSource.ts +395 -0
  97. package/src/core/workerSampling/index.ts +30 -0
  98. package/src/core/workerSampling/javascriptBackend.ts +15 -0
  99. package/src/core/workerSampling/multiresolution.test.ts +69 -0
  100. package/src/core/workerSampling/multiresolution.ts +300 -0
  101. package/src/core/workerSampling/protocol.test.ts +34 -0
  102. package/src/core/workerSampling/protocol.ts +207 -0
  103. package/src/core/workerSampling/repository.test.ts +400 -0
  104. package/src/core/workerSampling/repository.ts +400 -0
  105. package/src/core/workerSampling/repositoryCache.test.ts +163 -0
  106. package/src/core/workerSampling/repositoryDataset.ts +194 -0
  107. package/src/core/workerSampling/repositorySupport.ts +225 -0
  108. package/src/core/workerSampling/repositoryTypedDataset.test.ts +89 -0
  109. package/src/core/workerSampling/repositoryWasmDataset.test.ts +70 -0
  110. package/src/core/workerSampling/samplingCache.ts +94 -0
  111. package/src/core/workerSampling/workerRuntime.ts +40 -0
  112. package/src/data/simulatedWaveforms.test.ts +19 -0
  113. package/src/data/simulatedWaveforms.ts +11 -8
  114. package/src/demo/xAxisLabelControls.test.ts +1 -1
  115. package/src/demoRoutes.css +177 -0
  116. package/src/index.ts +18 -0
  117. package/src/router.ts +5 -0
  118. package/src/types/chart.ts +65 -0
  119. package/src/types/data.ts +24 -1
  120. package/src/types/index.ts +9 -0
  121. package/src/views/WasmSamplingDemo.test.ts +13 -0
  122. package/src/views/WasmSamplingDemo.vue +180 -0
@@ -1,6 +1,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
- const pointBisector = bisector((point: { x: number }) => point.x)
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: { point: { x: number; y: number }; screenX: number; screenY: number; distance: number } | null =
34
- null
35
- for (const point of track.series.points) {
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: Array<{ x: number; y: number }>,
61
+ points: PointCollection,
49
62
  xValue: number,
50
63
  ): { x: number; y: number } | null {
51
- if (!points.length || !Number.isFinite(xValue)) return null
52
- const centerIndex = pointBisector.center(points, xValue)
53
- const center = points[Math.min(centerIndex, points.length - 1)]
54
- const left = points[Math.max(0, centerIndex - 1)]
55
- return Math.abs(xValue - left.x) < Math.abs(center.x - xValue) ? left : center
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: Array<{ x: number; y: number }>,
73
+ points: PointCollection,
60
74
  xValue: number,
61
75
  lineType: WaveformLineType = 'linear',
62
76
  ): { x: number; y: number } | null {
63
- if (!points.length || !Number.isFinite(xValue)) return null
64
- const first = points[0]
65
- const last = points[points.length - 1]
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 (points.length === 1) return xValue === first.x ? { ...first } : null
82
+ if (source.length === 1) return xValue === first.x ? { ...first } : null
68
83
 
69
- const rightIndex = pointBisector.left(points, xValue)
70
- const right = points[Math.min(rightIndex, points.length - 1)]
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 = points[rightIndex - 1]
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(track.series.points, xValue)
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
- if (localY < 0 || localY > track.height || track.series.points.length === 0) return
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 = pointBisector.center(track.series.points, xValue)
238
+ const centerIndex = source.visibleRange([xValue, xValue]).start
220
239
  const firstIndex = Math.max(0, centerIndex - 2)
221
- const lastIndex = Math.min(track.series.points.length - 1, centerIndex + 2)
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 = track.series.points[candidateIndex]
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 validPoints = series?.points.filter(
154
- (item) => Number.isFinite(item.x) && Number.isFinite(item.y),
155
- )
156
- const point = validPoints && draft ? findNearestPointByX(validPoints, draft.annotation.x) : null
157
- if (!draft || !candidate || !track || !validPoints?.length) {
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 < validPoints[0].x || draft.annotation.x > validPoints.at(-1)!.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 validPoints = series?.points.filter(
187
- (point) => Number.isFinite(point.x) && Number.isFinite(point.y),
188
- )
189
- if (!validPoints?.length) {
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
- const firstX = validPoints[0].x
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 = findNearestPointByX(validPoints, rawTime)!
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(group: YAxisSeriesGroup, tickCount?: number): number {
136
+ export function measureYAxisGroupClearance(
137
+ group: YAxisSeriesGroup,
138
+ tickCount?: number,
139
+ nice = true,
140
+ ): number {
137
141
  return (
138
- axisTextMetrics(group.domain, true, undefined, group.seriesList[0]?.unit, tickCount)
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(group: YAxisSeriesGroup, tickCount?: number): number {
151
+ function measureYAxisGroupTickClearance(
152
+ group: YAxisSeriesGroup,
153
+ tickCount?: number,
154
+ nice = true,
155
+ ): number {
148
156
  return (
149
- axisTextMetrics(group.domain, true, undefined, group.seriesList[0]?.unit, tickCount)
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 yScale = yAxes[0]?.scale ?? scaleLinear(displayTrack.yDomain, [cell.plotHeight, 0]).nice()
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 { normalizeWaveformSeries, resolveWaveformPointErrors } from '../../core'
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 normalizeWaveformSeries(data).map((series) => {
60
- const metrics = pointMetrics(series.points, series.errorBar.visible)
61
- return { ...series, ...metrics }
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